使用 External Secrets Operator 安全管理 Kubernetes Secrets
作者 | 祝祥
来源 | 新钛云服(ID:newtyun)
转载请联系授权(微信ID:zlm935177782)
关键要点
- Kubernetes Secret 管理有助于将 Secret 与应用程序代码分离,并在需要时在集群中启用它们。
- 默认情况下,Secret 以不安全的 base64 形式存储,这是一种编码方法而不是加密。
- 第三方 Secret 管理系统是拥有集中、强大的 Secret 管理机制的更好的选择。
- ESO 是一个 Kubernetes Operator,它集成了外部 Secret 管理系统,例如 AWS Secrets Manager、HashiCorp Vault、Google Secret Manager、Azure Key Vault 等。
- External Secrets Operator 的目标是将来自外部 API 的 Secret 同步到 Kubernetes。
Kubernetes Secret 未加密地存储在 API 服务器的底层数据存储(etcd)中。任何拥有 API 访问权限的人都可以检索或修改 Secret,任何有权访问 etcd 的人也可以。Secret 对象类型用来保存敏感信息,例如密码、OAuth 令牌和 ssh key。
将这些信息放在 Secret 中比放在 pod 的定义中或者 docker 镜像中来说更加安全和灵活。然而,Kubernetes 尚不具备管理 Secret 生命周期的能力,因此有时我们需要外部系统来管理这些敏感信息。一旦我们需要管理的 Secret 信息量增加,我们可能需要额外的工具来简化和更好地管理流程。在本文中,我们将详细介绍其中一个工具 External Secrets Operator (https://external-secrets.io/)。
什么是Secret?
Secret 是用于管理用户到应用程序和应用程序到应用程序级别的访问权限的数字凭证。它们可以是密码、加密密钥、令牌等形式。
什么是Secrets Management?
Secret Management 是关于安全地管理数字凭证的创建、存储、轮换和撤销,同时消除或至少最大限度地减少人为参与并减少潜在的风险。
什么是 Kubernetes Secret?
容器需要访问敏感数据才能执行基本操作,例如与数据库、API 和其他系统集成。就 Kubernetes 而言,Secret 是一个包含数字凭证的对象,例如密码、令牌或密钥。使用Secret消除了将敏感信息存储在 pod 规范或容器映像中的需要。
问题
我们都知道使用 Secret 连接到外部服务的典型模式。下面是一个简单的解决方案架构,展示了我们如何使用 Secret 连接到数据库。
在这种情况下,我们有一个使用用户名和密码连接到数据库的微服务(或单体应用)。
当您开始支持多个环境(如开发、测试和生产)时,管理和同步所有这些 Secret 会变得有点困难。
现在,想象一下将您的应用程序拆分为多个服务,每个服务都有自己的外部依赖项,例如数据库、第三方 API 等,从而导致架构更加复杂。
具有多种服务和环境的设置(例如上述 Kubernetes 中的设置)会带来很多挑战,包括:
- 您可能需要管理数百个 Secret。
- 管理 Secret 的生命周期变得很困难,例如创建、存储、轮换和撤销。
- 加入新服务和具有特定访问权限的人员变得困难。
- 您必须考虑安全分发 Secret。
由于上述原因,您可以考虑选择第三方 Secret 管理工具来卸载与控制 Kubernetes Secret 相关的一些工作。
一些流行的工具和 Provider 可以实现更好的 Secret 安全性和可用性,如下所示:
- 云提供的工具:AWS Secrets Manager、Google Secret Manager、Azure Key Vault、IBM Cloud Secrets Manager、Oracle Key Vault
- 开源工具:HashiCorp Vault
因此,我们需要一个简单的解决方案,至少可以解决其中一些问题,它将存储在外部 Secret 管理工具中的 Secret 带入我们的集群,并继续在我们的应用程序中使用 Kubernetes Secret。这意味着我们需要一个组件来将外部 Secret 同步到我们的集群中。这就是 External Secrets Operator 为我们做的事情。
Operator设计模式
在我们仔细研究 External Secrets Operator (https://external-secrets.io/) 之前,让我们快速回顾一下Kubernetes Operators是什么。
我们已经知道,每个 Kubernetes 集群都有一个期望的状态。该状态决定了应该运行哪些工作负载(pod、部署等),这些工作负载应该使用哪些镜像,以及这些工作负载应该可以使用哪些其他资源。控制器是集群中的控制循环,用于监控对象的当前状态,将其与所需状态进行比较,并根据需要进行更改。我们还将这些控制循环称为协调循环。
下面是这个过程如何工作的总图。
这种使用声明性状态和控制器管理应用程序和基础设施资源的过程称为 Operator 设计模式。
Kubernetes 为自动化而生。无需任何修改,你即可以从 Kubernetes 核心中获得许多内置的自动化功能。你可以使用 Kubernetes 自动化部署和运行工作负载, 甚至 可以自动化 Kubernetes 自身。
Kubernetes 的 Operator 模式概念允许你在不修改 Kubernetes 自身代码的情况下,通过为一个或多个自定义资源关联控制器 来扩展集群的能力。Operator 是 Kubernetes API 的客户端,充当 自定义资源 的控制器。
使用 Operator 可以自动化的事情包括:
- 按需部署应用
- 获取/还原应用状态的备份
- 处理应用代码的升级以及相关改动。例如,数据库 schema 或额外的配置设置
- 发布一个 service,要求不支持 Kubernetes API 的应用也能发现它
- 模拟整个或部分集群中的故障以测试其稳定性
- 在没有内部成员选举程序的情况下,为分布式应用选择首领角色
什么是External Secrets Operator (ESO)?
ESO 是一个 Kubernetes Operator,它连接到我们上面提到的外部 Secret 管理系统,并读取 Secret 信息并将值注入 Kubernetes Secret。它是自定义 API 资源的集合,为外部 API 提供用户友好的抽象,为我们管理Secret的生命周期。
External Secrets Operator的结构
与所有其他 Kubernetes Operator一样,ESO 由一些主要组件组成:
- 自定义资源定义 (CRD):这些定义了 Operator 可用设置的数据架构,在我们的例子中是SecretStore (https://github.com/external-secrets/external-secrets/blob/main/config/crds/bases/external-secrets.io_secretstores.yaml)和 ExternalSecret (https://github.com/external-secrets/external-secrets/blob/main/config/crds/bases/external-secrets.io_externalsecrets.yaml) 定义。
- 程序结构:这些结构使用选择的编程语言定义与上述 CRD 相同的数据模式,在我们的例子中是 (https://github.com/external-secrets/external-secrets/tree/main/pkg/controllers) Go。
- 自定义资源 (CR):它们保存由 CRD 定义的设置的值,并描述 Operator 的配置。
- 控制器:这是实际工作发生的地方。控制器作用于自定义资源并负责创建和管理资源。它们可以用任何编程语言创建,ESO 控制器是用 Go 创建的。
External Secret Providers
ESO 使用不同的 Provider 连接到外部 Secret 管理系统并将 Secret 拉入集群。这些 Provider 程序由 SecretStore 和 ExternalSecret 资源配置,我们稍后会介绍。您可以在此处找到当前实现的提供程序的源代码。
Secret Provider 的结构实际上非常简单:
type Provider interface{
//NewClient constructs a SecretsManagerProvider
NewClient(ctx context.Context, store GenericStore, kube client.Client, namespace string) (SecretsClient, error)
//ValidateStore checks if the provided store is valid
ValidateStore(store GenericStore) error
}
正如您在上面看到的,每个 Provider 程序都具有验证存储配置和实例化将执行实际工作的 SecretsClient 对象的功能。
SecretsClient实例负责验证 Secre t配置并以各种形式提取 Secret:
type SecretsClient interface{
GetSecret(ctx context.Context, ref ExternalSecretDataRemoteRef) ([]byte, error)
Validate() (ValidationResult, error)
GetSecretMap(ctx context.Context, ref ExternalSecretDataRemoteRef) (map[string][]byte, error)
GetAllSecrets(ctx context.Context, ref ExternalSecretFind) (map[string][]byte, error)
Close(ctx context.Context) error
}
让我们看一下我们提到的资源类型,并了解它们如何协同工作以同步外部 Secret。
SecretStore 资源
SecretStore 资源允许您通过指定身份验证所需的配置来配置要访问的外部 Secret 管理服务以及如何访问它。
以下是访问 AWS Secrets Manager 的示例配置:
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: secretstore-sample
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
secretRef:
accessKeyIDSecretRef:
name: awssm-secret
key: access-key
secretAccessKeySecretRef:
name: awssm-secret
key: secret-access-key
ExternalSecret资源
正如 SecretStore 定义如何访问 Secret 一样,ExternalSecret 资源定义应该检索什么。它具有对 SecretStore 的引用,以便 ESO Operator 的控制器可以使用 ExternalSecret 资源通过使用 SecretStore 资源指定的配置来创建 Kubernetes Secret。
这是您使用secretStoreRef属性连接两个资源的方式:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: example
spec:
refreshInterval: 1h
secretStoreRef:
name: secretstore-sample
kind: SecretStore
target:
name: secret-to-be-created
creationPolicy: Owner
data:
- secretKey: secret-key-to-be-managed
remoteRef:
key: provider-key
version: provider-key-version
property: provider-key-property
dataFrom:
- extract:
key: remote-key-in-the-provider
一旦应用程序运行,所有的 Provider 都会将自己注册到 ESO。注册只是简单地将 Provider 对象及其规范添加到 map 中。每当 ESO 控制器需要访问 Secret 存储时,它都会使用此映射按名称查找存储。在创建自己的Secret Provider 时,我们将遵循相同的路线。
ESO 如何同步 secrets?
正如我们在上面的 Operator Design Pattern 部分中所展示的,控制器负责通过循环运行来协调集群的当前状态和期望状态之间的变更。ESO 控制器也不例外。在每个循环期间,外部 Secret 控制器执行以下操作:
- 循环不停的读取外部 Secret 配置
- 通过 secretStoreRef 属性检索外部密钥配置引用的 SecretStore
- 使用 store 规范中的 Provider 名称查找上述 Provider 映射以查找与 Secret 关联的 Provider
- 使用 store Provider 名称实例化 Secret 客户端
- 使用 Secret 客户端从外部系统检索 Secret 数据
- 如果没有返回 secret 数据并且删除策略设置为 Delete,则从集群中删除 secret。如果删除策略设置为保留,则密钥保持原样。
- 假设已成功检索到外部密钥,则会在集群中创建 Kubernetes 密钥,并应用指定的模板。
创建一个简单的 ESO Provider
我们在本节中的目标是创建一个非常简单的 ESO Provider 程序。请记住,我们在这里所做的绝对不适合生产。对于更优雅和生产就绪的解决方案,您可以在了解添加新 Provider 程序的工作流程后随时查看其他 Provider 程序的源代码。
以下是将新的 Secret Provider 添加到 ESO 的步骤:
- 为新的 Secret Provider 添加配置模式
- 创建类型定义以将 CRD 定义映射到 Go 结构
- 添加 Provider 实现
- 更新 register.go 以包含新的 Provider
- 创建和部署
一个简单的Secrets-Management服务
为了使教程尽可能简单,并且考虑到 ESO 已经涵盖了大多数用于管理 Secret 的常见外部系统,我们将使用 Node.js Express 作为本教程的 Secret 服务器。
下面是服务的实现:
const express = require('express');
const router = express.Router();
const keys = [];
/* GET keys listing as a JSON array */
router.get('/', (req, res, next) => {
res.send(keys);
});
/* GET a single key as a JSON object. */
router.get('/:key', (req, res) => {
const key = keys.find(k => k.key === req.params.key);
res.send(key);
})
module.exports = router;
添加新的 CRD 定义
我们需要让 Kubernetes 知道新 Provider 的配置。这是自定义资源的最小定义:
express:
description: Configuration to sync secrets using Express provider
properties:
host:
type: string
required:
- host
type: object
此定义应与其他 CRD 一起添加到 deploy/crds/bundle.yaml。新的 Provider 只有一个配置属性:host,它告诉 Provider Secret 服务在哪里运行。
为 Provider 配置创建类型
为了让 Provider 从控制器中获取其配置,我们还需要添加必要的类型,以便将配置转换为 Go 结构。
package v1beta1
type ExpressProvider struct {
Host string `json:"host"`
}
如您所见,CRD 的配置和上面的结构匹配。在运行时,Provider 以上述结构的形式接收配置。
实现 Provider
我们需要实现 Provider 和 SecretClient 接口以使我们的 Provider 程序工作。基本上,我们需要创建一个 SecretClient 并返回它。我们需要实现的最重要的功能是 SecretClient 的 GetSecret 功能。我们还可以添加验证来检查 store 的配置是否正确。下面是 Provider 的基本实现和每个决策的解释:
package express
import (
"context"
"encoding/json"
"fmt"
esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
"io/ioutil"
"log"
"net/http"
"net/url"
"sigs.k8s.io/controller-runtime/pkg/client"
"time"
)
const (
errNilStore = "nil store found"
errMissingStoreSpec = "store is missing spec"
errMissingProvider = "storeSpec is missing provider"
errInvalidProvider = "invalid provider spec. Missing express field in store %s"
errInvalidExpressHostURL = "invalid express host URL"
)
// this struct will hold the keys that the service returns
type keyValue struct {
Key string `json:"key"`
Value string `json:"value"`
}
type Provider struct {
config *esv1beta1.ExpressProvider
hostUrl string
}
// NewClient this is where we initialize the SecretClient and return it for the controller to use
func (p *Provider) NewClient(ctx context.Context, store esv1beta1.GenericStore, kube client.Client, namespace string) (esv1beta1.SecretsClient, error) {
config := store.GetSpec().Provider.Express
return &Provider{
config: config,
hostUrl: config.Host,
}, nil
}
func (p *Provider) GetAllSecrets(ctx context.Context, ref esv1beta1.ExternalSecretFind) (map[string][]byte, error) {
return nil, fmt.Errorf("GetAllSecrets not implemented")
}
// GetSecret reads the secret from the Express server and returns it. The controller uses the value here to
// create the Kubernetes secret
func (p *Provider) GetSecret(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) {
expressClient := http.Client{
Timeout: time.Second * 5,
}
req, err := http.NewRequest(http.MethodGet, p.hostUrl+"/keys/"+ref.Key, nil)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Sending request to: %s\n", p.hostUrl+"/keys/"+ref.Key)
res, getErr := expressClient.Do(req)
if getErr != nil {
return nil, fmt.Errorf("error getting the secret %s", ref.Key)
}
if res.Body != nil {
defer res.Body.Close()
}
body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
return nil, fmt.Errorf("error reading secret %s", ref.Key)
}
fmt.Printf("body: %s\n", body)
secret := keyValue{}
jsonErr := json.Unmarshal(body, &secret)
if jsonErr != nil {
return nil, fmt.Errorf("bad key format: %s", ref.Key)
}
return []byte(secret.Value), nil
}
// ValidateStore validates the store configuration to prevent unexpected errors
func (p *Provider) ValidateStore(store esv1beta1.GenericStore) error {
if store == nil {
return fmt.Errorf(errNilStore)
}
spec := store.GetSpec()
if spec == nil {
return fmt.Errorf(errMissingStoreSpec)
}
if spec.Provider == nil {
return fmt.Errorf(errMissingProvider)
}
provider := spec.Provider.Express
if provider == nil {
return fmt.Errorf(errInvalidProvider, store.GetObjectMeta().String())
}
hostUrl, err := url.Parse(provider.Host)
if err != nil {
return fmt.Errorf(errInvalidExpressHostURL)
}
if hostUrl.Host == "" {
return fmt.Errorf(errInvalidExpressHostURL)
}
return nil
}
// registers the provider object to process on each reconciliation loop
func init() {
esv1beta1.Register(&Provider{}, &esv1beta1.SecretStoreProvider{
Express: &esv1beta1.ExpressProvider{},
})
}
将 Provider 注册到 Provider 列表
下一步是在 register.go 中导入 Provider 程序模块,以便调用其初始化函数:
package register
import (
…
_ "github.com/external-secrets/external-secrets/pkg/provider/express"
…
)
部署 ESO 进行测试
ESO 文档中描述了将 ESO 部署到 Kubernetes 集群所需的步骤。但是,由于我们在本地工作,我们可以通过手动运行 Makefile 中定义的任务来加快开发和测试过程。
首先让我们部署 CRD:
make crds.install
然后在本地运行 ESO:
make run
使用 Secret 测试 Provider
为了测试 Provider,我们需要将 SecretStore 和 ExternalSecret 配置部署到集群。SecretStore 配置将指向 Express 服务器,ExternalSecret 配置将存储在 Express 服务器中的Secret映射到 Kubernetes Secret。
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: secretstore-express
spec:
provider:
express:
host: http://express-secrets-service
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: express-external-secret
spec:
refreshInterval: 1h
secretStoreRef:
kind: SecretStore
name: secretstore-express
target:
name: my-express-secret
creationPolicy: Owner
data:
- secretKey: secretKey # Key given to the secret to be created on the cluster
remoteRef:
key: my-secret-key
部署上面的清单:
kubectl apply -f secret.yaml
如果一切按计划进行,则应该在 Kubernetes 集群中创建 secret。:
kubectl get secret my-express-secret -o yaml
下面是我们从 Kubernetes API 得到的输出:
apiVersion: v1
data:
secretKey: dGhpcy1pcy1hLXNlY3JldA==
immutable: false
kind: Secret
结论
在本文中,我们解释了对 External Secrets Operator 的需求,并展示了如何开始开发外部 Secret Provider 程序。External Secrets Operator 是一个强大的工具,用于在多租户和多服务环境中管理Secret,很多公司或者组织(https://github.com/external-secrets/external-secrets/blob/main/ADOPTERS.md) 都在在生产中使用该工具。