sealed-secrets 实战指南:用 SealedSecret 与 kubeseal 在 Kubernetes 中安全托管加密 Secret
Sealed Secrets 是运行在 Kubernetes 集群内的控制器(controller/operator)与客户端工具 kubeseal 的组合:kubeseal 使用非对称加密将普通 Secret 加密为 SealedSecret 自定义资源,只有目标集群中的控制器能够解密还原出原始 Secret,连加密者本人都无法反解。这意味着你可以放心地把加密后的 SealedSecret 提交进 Git 仓库(哪怕仓库是公开的),实现"GitOps 管一切,除了 Secret"这一经典痛点的闭环。读完本文,你将掌握 SealedSecret 的资源结构、安装方式、加密/解封/校验/合并/重加密的完整 CLI 操作,以及密封密钥轮换(key renewal)背后的设计原理与最佳实践。
Overview:Sealed Secrets 是什么
Sealed Secrets 由两部分组成(源码见 cmd/controller/main.go 与 cmd/kubeseal/main.go):
- 集群侧控制器 / 运维组件(controller / operator):运行在集群内,持有 RSA 私钥,负责把
SealedSecret解封为普通Secret; - 客户端工具
kubeseal:使用非对称加密把Secret加密成只有控制器才能解密的SealedSecret。
kubeseal 加密得到的 SealedSecret 资源本质是一份"生成 Secret 的配方"。一个典型的 SealedSecret 如下:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: mysecret
namespace: mynamespace
spec:
encryptedData:
foo: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq.....
一旦控制器解封,会在集群中生成一个等效的普通 Secret:
apiVersion: v1
kind: Secret
metadata:
name: mysecret
namespace: mynamespace
data:
foo: YmFy # <- base64 编码的 "bar"
这个普通 Kubernetes Secret)可以看到,SealedSecret 由 Spec(含 encryptedData 与可选的 template)和可选的 Status 组成,status.conditions 中的 Synced 条件用于反映解封是否成功,kubectl get 时也会以 Status / Synced 列直接展示。
SealedSecrets 作为 Secret 的模板
前面的例子只关注了加密数据项本身,但 SealedSecret 自定义资源与其解封出的 Secret 之间的关系,在很多方面(并非所有方面)类似于熟悉的 Deployment 与 Pod 的关系。
特别需要注意的是:SealedSecret 资源上的注解(annotations)和标签(labels)并不等同于由它生成出的 Secret 上的注解和标签。为了区分这一点,SealedSecret 对象包含一个 template 段,用来编码你希望控制器写入解封后 Secret 的所有字段:
- 模板函数:除了 Go 标准库 Text Template 函数外,还可用 Sprig 函数库(但
env、expandenv、getHostByName被排除); - metadata:原样复制(
ownerReference字段会被更新,除非显式跳过,见下文"跳过 owner reference"); - 其他字段:
type和immutable字段会被复制;data字段可用来在 Secret 上模板化复杂值(对应源码 types.go 中的SecretTemplateSpec);其余字段目前会被忽略。
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: mysecret
namespace: mynamespace
annotations:
"kubectl.kubernetes.io/last-applied-configuration": ....
spec:
encryptedData:
.dockerconfigjson: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq.....
template:
type: kubernetes.io/dockerconfigjson
immutable: true
# 以下是要添加到输出 Secret 的标签和注解示例
metadata:
labels:
"jenkins.io/credentials-type": usernamePassword
annotations:
"jenkins.io/credentials-description": credentials from Kubernetes
控制器会将其解封为类似下面的 Secret:
apiVersion: v1
kind: Secret
metadata:
name: mysecret
namespace: mynamespace
labels:
"jenkins.io/credentials-type": usernamePassword
annotations:
"jenkins.io/credentials-description": credentials from Kubernetes
ownerReferences:
- apiVersion: bitnami.com/v1alpha1
controller: true
kind: SealedSecret
name: mysecret
uid: 5caff6a0-c9ac-11e9-881e-42010aac003e
type: kubernetes.io/dockerconfigjson
immutable: true
data:
.dockerconfigjson: ewogICJjcmVk...
如你所见,生成的 Secret 是 SealedSecret 的"依赖对象"(dependent object),因此当 SealedSecret 被更新或删除时,它也会被相应更新或删除(Kubernetes 的 ownerReference 垃圾回收机制,参见控制器 pkg/controller/controller.go 中关于 GC 的注释)。
公钥 / 证书
用于加密的密钥证书(公钥部分)需要在任何使用 kubeseal 的地方可用。证书不是机密信息,但你需要确保使用的是正确的那份。
kubeseal默认在运行时从控制器拉取证书(这需要能够安全访问 Kubernetes API server)。这种交互式用法很方便,但在特殊配置的集群中可能很脆弱,例如私有 GKE 集群这种控制平面与节点之间有防火墙的场景。- 另一种工作流是把证书存到某处(如本地磁盘):先用
kubeseal --fetch-cert >mycert.pem抓取,再离线使用kubeseal --cert mycert.pem。控制器启动时也会把证书打印到日志中。 - 自 v0.9.x 起,证书每 30 天自动续期。建议你和团队定期更新离线证书。自 v0.9.2 起,
kubeseal也接受 URL,你可以搭建内部自动化,把证书发布到你信任的位置:
kubeseal --cert https://your.intranet.company.com/sealed-secrets/your-cluster.cert
它还识别 SEALED_SECRETS_CERT 环境变量(专业提示:可配合 direnv 使用)。
从源码看,证书抓取有两条路径(pkg/kubeseal/kubeseal.go):未指定 --cert 时,OpenCert 会通过 Kubernetes API 代理请求控制器的 /v1/cert.pem 端点(服务端实现见 pkg/controller/server.go);指定了本地文件或 URL 时则直接读取文件或发起 HTTP 请求。注意 ParseKey 会校验证书是否过期,过期证书将直接报错拒绝加密。
作用域(Scopes)
从终端用户的角度看,SealedSecrets 是一种"只写"设备:SealedSecret 只能被目标集群里的控制器解密,其他人(包括原作者本人)都无法从 SealedSecret 反推出原始 Secret。
用户可能拥有也可能没有对目标集群的直接访问权;更具体地说,用户可能能或不能读取控制器解封出的 Secret。Kubernetes 的 RBAC 配置方式很多,但常见做法是禁止低权限用户读取 Secret,同时给用户分配一个或多个拥有更高权限(可创建/读取 Secret、可创建引用这些 Secret 的 Deployment)的命名空间。
加密的 SealedSecret 被设计为即使被查看也不会泄露其掩盖的 Secret 的任何信息。这意味着我们不能允许用户读取一个针对其无权访问命名空间的 SealedSecret,然后把它复制推送到一个他们能读取 Secret 的命名空间中。因此 Sealed Secrets 的行为表现得好像每个命名空间都有自己独立的加密密钥——一旦你为某个命名空间密封了一个 Secret,它就无法被挪到另一个命名空间并在此解密。
技术上我们并没有为每个命名空间使用独立的私钥,而是在加密过程中把命名空间名称纳入加密,从而获得同样的效果(在 pkg/crypto/crypto.go 的 HybridEncrypt 中,命名空间/名称会作为 OAEP 的 label 参与加密)。此外,命名空间并非 RBAC 决定谁可见哪个 Secret 的唯一层级:用户可能在某个命名空间里只能访问名为 foo 的 Secret 而不能访问其他 Secret。因此默认情况下也不能让用户随意重命名 SealedSecret,否则恶意用户只需把该命名空间的任意 SealedSecret 改名覆盖掉自己有权访问的那个 Secret 即可解密。我们使用与纳入命名空间相同的机制,把Secret 名称也纳入加密。
当然,有很多场景你并不在意这一层保护。例如你的集群只有管理员能访问,或者任何人都读不了任何 Secret 资源;你可能有把密封密文挪到其他命名空间的用例(比如事先不知道命名空间名),也可能不知道 Secret 的名称(比如名称包含基于内容哈希的唯一后缀)。以下是可选的作用域:
strict(默认):Secret 必须以完全相同的名称和命名空间密封。名称和命名空间成为加密数据的一部分,更改名称和/或命名空间将导致"解密错误"(decryption error);namespace-wide:可以在给定命名空间内自由重命名密封密文;cluster-wide:可以在任意命名空间解封,也可以使用任意名称。
与名称和命名空间受限相反,Secret 的数据项(即 JSON 对象键,如 spec.encryptedData.my-key)可以随意重命名而不会影响解密能力。
作用域通过 --scope 标志选择:
kubeseal --scope cluster-wide <secret.yaml >sealed-secret.json
也可以通过传递给 kubeseal 的输入 Secret 上的注解来请求作用域:
sealedsecrets.bitnami.com/namespace-wide: "true"->namespace-widesealedsecrets.bitnami.com/cluster-wide: "true"->cluster-wide
没有任何此类注解即表示 strict 模式。如果两者同时设置,cluster-wide 优先。
NOTE:下一个版本将把这些统一收敛为单个
sealedsecrets.bitnami.com/scope注解。
Installation:安装控制器与 kubeseal
最新发布版本与详细的安装说明请参见项目 Releases 页面。云平台相关的特殊说明见 GKE。
受限环境(无 RBAC)安装
在缺少创建集群级 RBAC 资源(如 ClusterRole)权限的环境中,可以使用 Releases 页面提供的 controller-norbac.yaml 清单。它是最小化的部署,只包含 Deployment、Service 和 CustomResourceDefinition,刻意省略了 ServiceAccount、ClusterRole 和 ClusterRoleBinding。
前提条件:
- 集群管理员必须已经安装好 SealedSecret 的 CRD;
- 你必须有一个已分配的 Service Account 来运行该 Deployment。
控制器(Controller)
部署清单后,它会创建 SealedSecret 资源,并把控制器安装到 kube-system 命名空间,同时创建 Service Account 和所需的 RBAC 角色。片刻之后控制器启动、生成密钥对并准备就绪。如果没就绪,请检查控制器日志。
Kustomize
官方控制器清单的安装机制就是一个 YAML 文件。某些场景下你可能需要做自定义(如设置自定义命名空间或环境变量)。kubectl 原生支持这一点,参见 kustomize。
Helm Chart
Sealed Secrets 的 Helm Chart 现已由官方支持并托管在本仓库中:
helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
NOTE:Helm Chart 的版本号方案与 sealed secrets 项目自身的版本号方案不同。Chart 最初由社区维护,第一个版本采用了主版本号 1,而项目本身仍处于主版本 0。Chart 的版本不必与应用版本一致,但确实容易混淆,因此当前版本规则是:
SealedSecret控制器版本方案:0.X.Y- Helm Chart 版本方案:1.X.Y-rZ
因此可以有多个 Chart 修订版,只修 Chart 自身而不影响静态 YAML 清单或控制器镜像。
NOTE:Helm Chart 默认以名称
sealed-secrets安装控制器,而kubesealCLI 默认尝试访问名为sealed-secrets-controller的控制器。你可以显式给 CLI 传--controller-name:
kubeseal --controller-name sealed-secrets <args>
或者,在安装 Chart 时设置 fullnameOverride 覆盖名称。另外注意 kubeseal 默认假定控制器安装在 kube-system 命名空间。所以如果你想不用每次传控制器名称和命名空间,可以这样安装 Chart:
helm install sealed-secrets -n kube-system --set-string fullnameOverride=sealed-secrets-controller sealed-secrets/sealed-secrets
受限环境下的 Helm Chart
有些公司可能只给你单个命名空间的权限,而不是整个集群。最受限的环境通常是:
- 分配了一个
namespace和某个service account; - 无法访问集群其他部分,甚至无法访问集群级 CRD;
- 甚至可能无法在自己的命名空间里创建更多 service account 或角色;
- 所有 Deployment 都必须包含资源限制(resource limits)。
即便如此你仍然可以安装 sealed secrets Helm Chart,只有一个前提:集群必须已经安装好 sealed secrets 的 CRD。管理员装好 CRD 后(如果之前没有的话),你可以准备这样一个 YAML 配置文件来安装 Chart:
serviceAccount:
create: false
name: {allocated-service-account}
rbac:
create: false
clusterRole: false
resources:
limits:
cpu: 150m
memory: 256Mi
注意:
- 不创建任何 service account,而是使用分配给你的那个(
{allocated-service-account}是你被分配的 service account 名称); - 不在命名空间或集群内创建任何 RBAC 角色;
- 必须指定资源限制(上面是示例值,通常可用,但建议针对你的环境复核)。
假设文件命名为 config.yaml,现在可以这样安装:
helm install sealed-secrets -n {allocated-namespace} sealed-secrets/sealed-secrets --skip-crds -f config.yaml
其中 {allocated-namespace} 是你被分配的命名空间名称。
Chart 的更多可调参数(keyrenewperiod、keyttl、keycutofftime、additionalNamespaces、maxRetries、rateLimit 等)可以在 helm/sealed-secrets/values.yaml 中查看。
Kubeseal(客户端)
Homebrew
brew install kubeseal
MacPorts
port install kubeseal
Nixpkgs
nix-env -iA nixpkgs.kubeseal
(免责声明:Nixpkgs 包并非由 Bitnami 维护)
Linux
KUBESEAL_VERSION='' # 例如设置为 KUBESEAL_VERSION='0.23.0'
curl -OL "https://github.com/bitnami/sealed-secrets/releases/download/v${KUBESEAL_VERSION:?}/kubeseal-${KUBESEAL_VERSION:?}-linux-amd64.tar.gz"
tar -xvzf kubeseal-${KUBESEAL_VERSION:?}-linux-amd64.tar.gz kubeseal
sudo install -m 755 kubeseal /usr/local/bin/kubeseal
如果机器上装有 curl 和 jq,可以动态获取最新版本号(适合自动化环境):
# 通过 GitHub API 获取最新的 sealed-secrets 版本
KUBESEAL_VERSION=$(curl -s https://api.github.com/repos/bitnami/sealed-secrets/tags | jq -r '.[0].name' | cut -c 2-)
# 检查版本是否获取成功
if [ -z "$KUBESEAL_VERSION" ]; then
echo "Failed to fetch the latest KUBESEAL_VERSION"
exit 1
fi
curl -OL "https://github.com/bitnami/sealed-secrets/releases/download/v${KUBESEAL_VERSION}/kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz"
tar -xvzf kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz kubeseal
sudo install -m 755 kubeseal /usr/local/bin/kubeseal
其中 KUBESEAL_VERSION 是你想用的 kubeseal 发布版本的版本标签,例如 v0.18.0。
从源码安装
如果只要最新的客户端工具,可以用以下命令安装到 $GOPATH/bin:
go install github.com/bitnami/sealed-secrets/cmd/kubeseal@main
可以用发布标签或 commit SHA 代替 main。go install 会把 kubeseal 二进制放到 $GOPATH/bin:
$(go env GOPATH)/bin/kubeseal
Upgrade:升级与版本兼容性
升级客户端工具和/或控制器前,请务必查看发布说明,了解可能的破坏性变更(breaking changes)。
支持的版本
目前仅最新版本的 Sealed Secrets 受支持用于生产环境。
与 Kubernetes 版本的兼容性
Sealed Secrets 控制器通过依赖稳定的 Kubernetes API 来保证与不同版本 Kubernetes 的兼容性。通常 Kubernetes 1.16 以上版本视为兼容。不过官方支持的是当前推荐的 Kubernetes 版本。此外,1.24 以上的版本在每次发布时都会通过我们的 CI 流程进行充分验证。
Usage:加密、部署与管理 Secret
核心用法:
# 以某种方式创建一个 json/yaml 编码的 Secret:
# (注意使用 `--dry-run` —— 这只是本地文件!)
echo -n bar | kubectl create secret generic mysecret --dry-run=client --from-file=foo=/dev/stdin -o json >mysecret.json
# 关键一步:
kubeseal -f mysecret.json -w mysealedsecret.json
# 此时 mysealedsecret.json 已可安全上传到 Github、发到 Twitter 等。
# 最终:
kubectl create -f mysealedsecret.json
# 完成!
kubectl get secret mysecret
注意 SealedSecret 和 Secret 必须具有相同的命名空间和名称。这是为了防止集群内其他用户复用你的密封密文(详见上文 Scopes 部分)。
kubeseal 读取命名空间的顺序是:输入 Secret 中指定的命名空间 → 显式传入的 --namespace 参数 → kubectl 默认命名空间。原 Secret 上的标签、注解等会被保留,但不会自动反映到 SealedSecret 中。
从设计上看,该方案不认证用户身份。换句话说,任何人都可以创建包含任意 Secret 内容的 SealedSecret(只要命名空间/名称匹配)。这需要依赖你现有的配置管理工作流、集群 RBAC 规则等来确保只有预期的 SealedSecret 被上传到集群。与原有 Kubernetes 相比,唯一的变化是 Secret 的内容在集群之外被隐藏了。
管理已存在的 Secret
如果你希望 Sealed Secrets 控制器管理一个已存在的 Secret,可以给该 Secret 打上 sealedsecrets.bitnami.com/managed: "true" 注解。当解封同名称同命名空间的 SealedSecret 时,现有 Secret 会被覆盖,并且 SealedSecret 会取得该 Secret 的所有权(删除 SealedSecret 时 Secret 也会被删除)。
修补(Patch)已存在的 Secret
自 v0.23.0 新增
有些场景你不想整体替换 Secret,只想新增或修改其中的部分键。此时可以给 Secret 打上 sealedsecrets.bitnami.com/patch: "true" 注解。使用该注解后,Secret 中那些不存在于 SealedSecret 的密钥、标签和注解不会被删除,而 SealedSecret 中存在的项会被添加到 Secret 上(两边都有的密钥、标签和注解会被 SealedSecret 的值修改)。
该注解不会让 SealedSecret 取得 Secret 的所有权。你可以同时加上 patch 和 managed 两个注解,在获得修补行为的同时取得所有权。控制器源码中对应的实现逻辑见 pkg/controller/controller.go:patch 模式下只合并 Data、Labels、Annotations 而保留原有键,非 patch 模式则整体替换。
密封时跳过设置 owner references
如果你希望 SealedSecret 和 Secret 相互独立(删除 SealedSecret 时 Secret 不会跟着消失),需要在执行上述 Usage 步骤之前,给 Secret 打上 sealedsecrets.bitnami.com/skip-set-owner-references: "true" 注解。你仍然可以同时给 Secret 加 sealedsecrets.bitnami.com/managed: "true",这样 SealedSecret 更新时 Secret 也会被更新。
更新已存在的 Secret
如果要在不掌握其他数据项明文的情况下新增或更新密封密文,可以把新的加密数据项复制粘贴合并进已有的 sealed secret 中。注意新密封的数据项必须使用兼容的名称和命名空间(参考上文关于作用域的说明)。
如果你不想复制粘贴,可以使用 --merge-into 命令更新已有的 sealed secret:
echo -n bar | kubectl create secret generic mysecret --dry-run=client --from-file=foo=/dev/stdin -o json \
| kubeseal > mysealedsecret.json
echo -n baz | kubectl create secret generic mysecret --dry-run=client --from-file=bar=/dev/stdin -o json \
| kubeseal --merge-into mysealedsecret.json
原始模式(Raw mode,实验性)
先用 kubectl 创建一个临时 Secret 再丢进管道交给 kubeseal,这种体验并不友好。项目正在重构 CLI 体验。在此期间提供一种替代模式:kubeseal 只负责把一个值加密后输出到 stdout,由你自己把它放进 SealedSecret 资源(与任何其他 k8s 资源无异)。它也可以作为编辑器/IDE 集成的构建块。
缺点是必须自己小心保持密封作用域、命名空间和名称的一致性(参见 Scopes)。
strict 作用域(默认):
$ echo -n foo | kubeseal --raw --namespace bar --name mysecret
AgBChHUWLMx...
namespace-wide 作用域:
$ echo -n foo | kubeseal --raw --namespace bar --scope namespace-wide
AgAbbFNkM54...
同时需要在 SealedSecret 中加上 sealedsecrets.bitnami.com/namespace-wide 注解:
metadata:
annotations:
sealedsecrets.bitnami.com/namespace-wide: "true"
cluster-wide 作用域:
$ echo -n foo | kubeseal --raw --scope cluster-wide
AgAjLKpIYV+...
同时需要在 SealedSecret 中加上 sealedsecrets.bitnami.com/cluster-wide 注解:
metadata:
annotations:
sealedsecrets.bitnami.com/cluster-wide: "true"
从源码看(cmd/kubeseal/main.go),--raw 模式下 strict/namespace-wide 作用域必须提供命名空间,strict 还要求提供 --name;数据可来自 stdin 或单个 --from-file(例如 /dev/stdin 读取管道输入)。
校验一个 Sealed Secret
kubeseal 提供了 --validate 标志来校验已有的密封密文。假设文件 sealed-secrets.yaml 内容如下:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: mysecret
namespace: mynamespace
spec:
encryptedData:
foo: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq.....
你可以校验该密封密文是否被正确创建:
$ cat sealed-secrets.yaml | kubeseal --validate
如果密封密文无效,kubeseal 会显示:
$ cat sealed-secrets.yaml | kubeseal --validate
error: unable to decrypt sealed secret
Secret Rotation:密钥轮换与 Secret 轮换
你应该始终轮换你的 Secret。但由于你的 Secret 是用另一把密钥加密的,你需要理解这两层之间的关系才能做出正确决策。
TL;DR:
如果密封私钥被泄露,你需要先按下面"Early key renewal"一节的说明操作,再轮换任何真实 Secret 值。
SealedSecret 的密钥续期(key renewal)和重加密功能不能替代对你真实 Secret 值的定期轮换。
密封密钥续期(Sealing key renewal)
密封密钥每 30 天自动续期。也就是说,会生成一把新密钥并追加到控制器可用于解封 SealedSecret 资源的活跃密钥集合中。最新生成的密钥用于你使用 kubeseal 时密封新 Secret,也是 kubeseal --fetch-cert 下载的证书对应的密钥。
30 天是合理的默认值,但可以通过 SealedSecret 控制器 Pod 模板中的 --key-renew-period=<value> 标志按需调整,该值可用 Go duration 格式给出(例如 720h30m)。假设已把 Sealed Secrets 安装到 kube-system 命名空间,用下面的命令编辑控制器 Deployment 并添加 --key-renew-period 参数。关闭编辑器后 Deployment 会被修改,新 Pod 会自动替换旧 Pod:
kubectl edit deployment/sealed-secrets-controller --namespace=kube-system
值为 0 将停用自动密钥续期。当然,停用自动续期可能也有合理用例,但经验表明新用户往往在完全理解 sealed secrets 工作原理之前就急于认为需要控制密钥续期。更多内容参见下文关于密钥续期的常见误解。
注意:不能用例如 "d" 作为天数的单位,因为 Go 标准库不支持。与其捂脸,不如借此机会思考一下程序员对时间的错误认知。
一个常见误解是把密钥续期当作一种密钥轮换(rotation):旧密钥不仅过时了,而且有害,因此想把它清除。这个功能历史上确实被叫做 "key rotation",更增加了混淆。Sealed secrets 不会自动轮换,生成新密钥时旧密钥也不会被删除。旧的 SealedSecret 资源仍然可以解密(因为旧密封密钥没有被删除)。
密钥注册表初始化优先级顺序(Key registry init priority order)
控制器启动时会初始化密钥注册表(key registry),最新密钥用于密封 Secret。默认情况下,该证书根据证书的 NotBefore 属性选择。如果想改变注册表中密钥的优先级顺序,可以使用 --key-order-priority 标志:
CertNotBefore:(默认)密钥注册表基于密钥证书的NotBefore属性排序;SecretCreationTimestamp:密钥注册表基于 Secret 的创建时间戳排序。
该标志影响用于加密 Secret 的公钥,以及 kubeseal --fetch-cert 获取的证书。对应实现见 pkg/controller/main.go 的 registryNewKeyWithSecret 与 getKeyOrderPriority 函数。
用户 Secret 轮换(User secret rotation)
密封密钥续期和 SealedSecret 轮换不能替代轮换你的真实 Secret。
这个工具的核心价值主张是:
把你的 Secret 加密成 SealedSecret,它可以安全存储——哪怕在公共仓库里。
如果你把任何东西存进版本控制系统(尤其是公开仓库),就必须假定你永远无法真正删除那条信息。
如果某把密封密钥以某种方式泄露到集群之外,你必须把用该密钥加密的所有 SealedSecret 资源都视为已泄露。在集群内做多少密封密钥轮换,甚至对现有 SealedSecrets 文件做重加密,都无法改变这一点。
最佳实践是定期轮换你的所有真实 Secret(例如修改密码)并且用这些新 Secret 制作新的 SealedSecret 资源。但如果 SealedSecret 控制器没有在续期密封密钥,那次轮换就毫无意义,因为攻击者同样能解密新 Secret。所以两者都要做:定期续期密封密钥,并轮换你的真实 Secret!
提前密钥续期(Early key renewal)
如果你知道或怀疑某把密封密钥已被泄露,应该在开始密封新轮换出的 Secret 之前尽快续期密钥,否则等于把新 Secret 也暴露给攻击者。
可以通过向控制器传入 --key-cutoff-time 标志或 SEALED_SECRETS_KEY_CUTOFF_TIME 环境变量,把当前时间戳传给控制器来提前生成一把新密钥。期望格式为 RFC1123,可以用 date -R 生成。对应的解析逻辑见 pkg/controller/main.go 与 initKeyRenewal(main.go):当最近密钥的生成时间早于 cutoff time 时,会立即生成新密钥。
常见误解(关于密钥续期)
Sealed secrets 的密封密钥不是访问控制密钥(如密码)。它更像你用来阅读加密邮件的 GPG 密钥。沿用邮件类比:
假设你有理由相信自己的 GPG 私钥可能泄露了。如果第一件事就是删除私钥,那将得不偿失:之前用那把密钥加密的所有邮件你都读不了了(除非有解密副本),朋友们还不知道要改用新密钥,他们发给你的新邮件也读不了。
当然,那些加密邮件的内容已经不安全,攻击者现在可能能解密它们,但木已成舟。突然失去读信能力并不会挽回损失;相反更糟,因为你不再确切知道攻击者掌握了什么秘密。你真正要做的是确保朋友停止使用你的旧密钥,从此以后所有通信都用新密钥对加密(即朋友必须知道新公钥)。
同样的逻辑适用于 SealedSecrets。最终目标是保护你真正的"用户"Secret。"密封"密钥只是机制、是"信封"。如果某个 Secret 泄露了,没有回头路,木已成舟。
第一步是确保新 Secret 不再用那把已泄露的旧密钥加密(在邮件类比中就是:生成新密钥对并把新公钥发给所有朋友)。第二步是消除损失,这取决于 Secret 的性质。简单的例子是数据库密码:如果不小心泄露了数据库密码,正确做法是直接修改数据库密码(在数据库上改!并吊销旧的!)并且用新密码更新 SealedSecret 资源(即再次运行 kubeseal)。这两个步骤在前面章节都有描述,现在你对底层原理有了更深入的理解,不妨重读一遍。
手动密钥管理(高级)
SealedSecret 控制器及相关工作流被设计为保留旧密封密钥并定期添加新密钥。除非你清楚自己在做什么,否则不应删除旧密钥。
当然,如果你愿意,也可以手动管理(创建、移动、删除)密封密钥。它们只是普通的 k8s secrets,存在于 SealedSecret 控制器所在的命名空间(通常是 kube-system,可配置)。通过创造性地管理密封密钥可以解决一些高级用例。例如,可以在几个集群间共享同一把密封密钥,从而在多个集群中应用完全相同的 sealed secret。由于密封密钥只是普通 k8s secrets,你甚至可以用 sealed secrets 本身 + GitOps 工作流来管理你的密封密钥(在多个集群间共享同一把密钥时非常有用)!
给密封密钥 secret 打上除 active 以外的任何标签值,实际上就会把该密钥从 SealedSecret 控制器中删除,但它仍保留在 k8s 中,必要时可用于手动加密/解密。从源码看(pkg/controller/keys.go),控制器通过 sealedsecrets.bitnami.com/sealed-secrets-key: active 标签来发现密钥,密钥以 TLS secret 形式存储(tls.key + tls.crt),启动时用 pkg/controller/main.go 的 initKeyRegistry 按标签拉取并注册。
注意:SealedSecret 控制器目前不会自动拾取手动创建、删除或重新打标签的密封密钥。管理员必须重启控制器后效果才会生效。
重加密(Re-encryption,高级)
在清除某些旧密封密钥之前,你需要用最新的私钥重加密你的 SealedSecrets:
kubeseal --re-encrypt <my_sealed_secret.json >tmp.json \
&& mv tmp.json my_sealed_secret.json
上面的调用会生成一份用最新密钥全新加密的 sealed secret 文件,且整个过程 Secret 明文不会离开集群到达客户端。之后你可以把该文件保存到你的版本控制系统(kubeseal --re-encrypt 不会更新集群内的对象)。目前旧密钥不会自动垃圾回收。建议定期重加密你的 SealedSecrets。但正如上文所说,不要自欺欺人地陷入虚假安全感:你必须假定旧版本的 SealedSecret 资源(即你用认为已"死亡"的密钥加密的那份)仍可能存在于某处并对攻击者可见。也就是说,重加密不能替代定期轮换你的真实 Secret。
Details(高级):控制器内部机制
该控制器新增了一个 SealedSecret 自定义资源。SealedSecret 的核心是一个 base64 编码的、非对称加密的 Secret。控制器把一组私钥/公钥对以 kubernetes secrets 的形式维护。密钥带有 sealedsecrets.bitnami.com/sealed-secrets-key 标签,标签值标识为 active 或 compromised。控制器启动时会:
- 搜索这些密钥,若标记为
active则加入本地存储; - 创建一把新密钥;
- 启动密钥轮换周期。
keySelector 的定义与启动流程见 pkg/controller/main.go 和 initKeyRenewal。控制器还会监听 /v1/cert.pem、/v1/verify、/v1/rotate 等 HTTP 端点(pkg/controller/server.go),分别用于证书下发、解封校验和手动轮换触发。
Crypto(加密原理)
更多加密细节参见 docs/developer/crypto.md。从 pkg/crypto/crypto.go 可以看出,HybridEncrypt 采用的是一次性 AES-GCM + RSA-OAEP 混合加密:随机生成 32 字节会话密钥(session key)用于 AES-GCM 加密明文,会话密钥再用 RSA-OAEP(SHA-256)加密;输出字节串格式为「RSA 密文长度(2 字节大端) ‖ RSA 密文 ‖ AES 密文」。由于会话密钥只用一次,GCM 的 nonce 可以全零。解密时 HybridDecrypt 会遍历控制器持有的全部私钥逐一尝试(crypto.go),这也解释了为什么旧密钥不删除时旧 SealedSecrets 依然能解封。
Developing:开发指南
开发指南见 Developer Guide。
FAQ
能否在一个 YAML / JSON 文件中一次加密多个 Secret?
可以!把任意多个 secret 放进一个文件即可。YAML 用 --- 分隔,JSON 则作为多个独立对象。
如果我不再能访问集群,还能解密吗?
不能。私钥只存储于控制器管理的 Secret 中(除非你有 k8s 对象的其他备份)。没有后门——没有加密某份 SealedSecrets 所用的私钥,就无法解密。如果你既拿不到含加密密钥的 Secrets,也拿不到集群中存活的解密版 Secrets,那么只能为所有东西重新生成密码,再用新密封密钥重新密封,等等。
如何备份我的 SealedSecrets?
如果你确实想备份加密私钥,用有合适权限的账户很容易做到:
kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml >main.key
echo "---" >> main.key
kubectl get secret -n kube-system sealed-secrets-key -o yaml >>main.key
NOTE:只有当你的集群安装过早于 0.9.x 的 sealed-secrets 时,才需要第二条语句。
NOTE:该文件包含控制器的公钥 + 私钥,请务必妥善保管(omg-safe)!
NOTE:密封密钥续期后应重新制作备份,否则备份将无法解密新的密封密文。
灾难后要从备份恢复,只需在启动控制器前把这些 secrets 放回去;如果控制器已启动,则替换掉新建的 secrets 并重启控制器:
-
Helm 部署方式:
kubectl apply -f main.key kubectl delete pod -n kube-system -l app.kubernetes.io/name=sealed-secrets -
通过
controller.yaml清单部署:kubectl apply -f main.key kubectl delete pod -n kube-system -l name=sealed-secrets-controller
能否用备份密钥离线解密我的 Secret?
虽然不建议把 sealed-secrets 当作长期存储系统,但确实有些人的合理需求是:集群宕机时能恢复 Secret,而把备份恢复到新的 SealedSecret 控制器部署中又不现实。如果你备份了一把或多把私钥(见上一问),可以用 kubeseal --recovery-unseal --recovery-private-key file1.key,file2.key,... 命令解密 sealed secrets 文件。该命令支持 PEM 编码私钥,也支持备份出来的 json/yaml 编码的控制器 secret(及 v1.List),见 cmd/kubeseal/main.go。
kubeseal 有哪些可用的 flag?
可以使用 kubeseal --help 查看全部可用标志。常见标志(cmd/kubeseal/main.go)包括:--cert(证书文件/URL)、--controller-namespace(默认 kube-system)、--controller-name(默认 sealed-secrets-controller)、-o/--format(json 或 yaml)、-w/--sealed-secret-file、-f/--secret-file、--fetch-cert、--allow-empty-data、--validate、--merge-into、--raw、--name、--from-file、--scope、--re-encrypt、--recovery-unseal、--recovery-private-key 等。
如何更新已用 sealed secrets 加密的 JSON/YAML/TOML/… 文件中的部分内容?
一个 Kubernetes Secret 资源包含多个数据项,本质是键/值对的扁平映射。SealedSecrets 在这一层操作,不关心值里放的是什么。换句话说,它无法理解你放进 secret 里的任何结构化配置文件,因此也无法帮你更新其中的单个字段。由于这是一个常见问题(尤其是面对遗留应用时),我们提供了一个示例展示可能的变通方案。
能否使用自己(预先生成的)证书?
可以,你可以给控制器提供自己的证书,它会直接使用。请参考 docs/bring-your-own-certificates.md 中的变通方案。
控制器不在 kube-system 命名空间时如何使用 kubeseal?
如果你把控制器安装在非默认的 kube-system 命名空间,需要给 kubeseal 命令行工具提供该命名空间。有两个办法:
- 通过命令行选项
--controller-namespace <namespace>:
kubeseal --controller-namespace sealed-secrets <mysecret.json >mysealedsecret.json
- 通过环境变量
SEALED_SECRETS_CONTROLLER_NAMESPACE:
export SEALED_SECRETS_CONTROLLER_NAMESPACE=sealed-secrets
kubeseal <mysecret.json >mysealedsecret.json
如何校验镜像?
我们的镜像使用 cosign 签名,签名保存在 GitHub Container Registry 中。v0.20.2(含)之前的镜像使用 Cosign v1 签名,新镜像用 Cosign v2 签名。
# 导出 COSIGN_REPOSITORY 变量,指向 GHCR signs 路径
export COSIGN_REPOSITORY=ghcr.io/bitnami/sealed-secrets-controller/signs
# 校验上传到 GHCR 的镜像
cosign verify --key .github/workflows/cosign.pub ghcr.io/bitnami/sealed-secrets-controller:latest
# 校验上传到 Dockerhub 的镜像
cosign verify --key .github/workflows/cosign.pub docker.io/bitnami/sealed-secrets-controller:latest
如何让一个控制器只管理部分命名空间?
如果你想让一个控制器管理多个(但不是全部)命名空间,可以用命令行标志 --additional-namespaces=<namespace1>,<namespace2>,<...> 提供额外命名空间。请确保在目标命名空间中提供了合适的 roles 和 rolebindings,以便控制器管理其中的 secrets(cmd/controller/main.go)。
能否配置控制器解封重试次数?
可以,用 --max-unseal-retries 标志配置,默认值为 5(见 cmd/controller/main.go)。该标志控制解封 Sealed Secrets 时的最大重试次数。
如何在集群范围或指定命名空间范围内管理 SealedSecrets?
默认情况下,控制器使用 --all-namespaces 标志(默认值为 true)监听所有命名空间中的 SealedSecret 资源。如果需要限制控制器范围,有两个选项:
- 监听部分命名空间:用
--additional-namespaces=<ns1>,<ns2>提供逗号分隔的命名空间列表; - 只监听本地命名空间:设置
--all-namespaces=false(或环境变量SEALED_SECRETS_ALL_NAMESPACES=false)。这在多租户集群中很有用,每个命名空间可以有独立密封密钥的隔离控制器。
Community:社区与相关项目
- Kubernetes Slack 上的 #sealed-secrets 频道(可到 http://slack.k8s.io 注册 Kubernetes Slack org)。
相关项目(社区生态):
kseal:kubeseal 的伴侣工具(Kubeseal Companion)kubeseal-convert:转换工具- Visual Studio Code 扩展:
codecontemplator.kubeseal - WebSeal:在浏览器中生成 secrets
- HybridEncrypt TypeScript 实现:
aes-gcm-rsa-oaep - [已废弃] Sealed Secrets Operator
延伸阅读:CRD 定义见 helm/sealed-secrets/crds/bitnami.com_sealedsecrets.yaml 与 schema-v1alpha1.yaml;类型定义与注解常量见 pkg/apis/sealedsecrets/v1alpha1/types.go;控制器核心解封/合并逻辑见 pkg/controller/controller.go;加密实现见 pkg/crypto/crypto.go;kubeseal 业务逻辑见 pkg/kubeseal/kubeseal.go。