Kubernetes Secret 实现原理:从 API Server 到 Pod
Kubernetes Secret 看起来只是一个带有 data 字段的 API 对象,但从提交 YAML 到容器读到数据,中间跨过了 API 类型转换、准入与校验、etcd 序列化、kubelet 缓存和 volume 投射等多层逻辑。
本文基于 Kubernetes 源码 commit 52ba90138eb40cab0987dac73e05c838149bdd1c 分析这条链路。该 commit 在本地仓库中描述为 v1.37.0-alpha.1-1973-g52ba90138eb。文中的结论以这个快照为准,不把主分支实现当成所有历史版本的共同语义。
先给出结论:
- Secret 的核心是
map[string][]byte;Base64 只是字节数组的 JSON 序列化表示,不提供机密性。 - 是否加密存储不由 Secret 类型决定,而由 kube-apiserver 的
EncryptionConfiguration决定;默认未配置时写入 etcd 不加密。 - Pod 不直接访问 etcd。kubelet 根据 Pod 引用从 API Server 获取 Secret,再投递给环境变量、volume 或镜像拉取凭据。
- volume 用内存型
emptyDir和原子符号链接切换,可最终更新;环境变量只在生成容器配置时解析一次,更新不触达已运行进程。 - 自动挂载的 ServiceAccount token 走
TokenRequest+ projected volume,与kubernetes.io/service-account-token旧式 Secret 是两套机制。
完整链路
一次普通 Secret 的流转可以概括为:
flowchart LR
Client["kubectl / API client"]
Convert["API decode + version conversion\nstringData -> data"]
Validate["Secret strategy + validation"]
Registry["generic registry Store"]
Transform["storage transformer\nidentity / AES / Secretbox / KMS"]
Etcd[("etcd")]
Kubelet["kubelet Secret Manager\nWatch / TTL / Get"]
EnvVolume["env / volume"]
Container["container process"]
PullSecret["imagePullSecrets"]
ImageService["CRI ImageService / PullImage"]
Client --> Convert --> Validate --> Registry
Registry --> Transform --> Etcd
Etcd --> Transform --> Registry
Registry --> Kubelet
Kubelet --> EnvVolume --> Container
Kubelet --> PullSecret --> ImageService
这张图里有两个重要边界:
- API Server 负责对象语义、授权和静态数据加密;Secret 自身不是加密容器。
- kubelet 负责把 API 对象转换成节点上的具体消费形式;容器运行时不会自行查询 Secret API。边界在 CRI:镜像拉取凭据由 kubelet 转成
PullImage的 credentials 参数传入,环境变量和卷则通过容器创建配置传入,运行时拿到的都是转换后的结果。
Secret API 对象保存了什么
Secret 的公开类型定义在 staging/src/k8s.io/api/core/v1/types.go:
|
|
真正持久化的业务数据是 Data map[string][]byte。因为 JSON 不能直接表达任意字节,Go 的 []byte 在 JSON 中被编码成 Base64 字符串。因此下面两个字段表达的是同一份字节内容:
|
|
|
|
stringData 是只写便利字段。版本转换函数 Convert_v1_Secret_To_core_Secret 把字符串转换为字节并写入 data;同名键存在时,stringData 覆盖 data。读取 API 时不会再返回 stringData。
所以 Base64 的作用只是序列化。任何能读取 Secret API、etcd 明文或 Secret manifest 的主体都能恢复原始值。Kubernetes 官方的 Secret 良好实践 也明确把 Base64 和加密区分开。
创建和更新时如何校验
Secret 的 REST 存储入口在 pkg/registry/core/secret/storage/storage.go。它没有实现一套 Secret 专用数据库逻辑,而是把 Secret、SecretList、匹配函数和增删改策略交给通用的 genericregistry.Store:
|
|
secret.Strategy 声明 Secret 是 namespace-scoped 资源,并在创建、更新时调用校验函数。核心校验位于 ValidateSecret:
- 校验对象名称、namespace 等通用 metadata。
- 校验
data的键名,只允许字母、数字、-、_、.。 - 累加所有 value 的原始字节数,超过 1 MiB 时拒绝。
- 根据内置
type做最低限度的结构校验。
不同 Secret 类型的校验强度并不相同。例如:
Opaque不增加数据格式约束。kubernetes.io/service-account-token要求metadata.annotations["kubernetes.io/service-account.name"]非空。kubernetes.io/dockerconfigjson要求.dockerconfigjson存在并且能解析为 JSON,但不会验证它是否真是可用的 Docker 配置。kubernetes.io/basic-auth要求username或password至少存在一个。kubernetes.io/tls只检查tls.crt和tls.key两个键存在,不解析证书,也不验证公私钥是否匹配。
因此 type 主要提供约定和有限校验,不代表 API Server 已经验证凭据可用。
immutable 的实际语义
ValidateSecretUpdate 里有两层不可变约束:
- 无论
immutable是否设置,type字段都不能修改。 - 当旧对象的
immutable已经是true时,额外禁止两类操作:- 把
immutable改回false或删除该字段。 - 修改
data。
- 把
metadata 仍然可以更新。watch 型 Secret manager 也会识别 immutable,不再为这种对象维持无意义的持续 watch。它是 API 不可变性和节点侧资源优化,不是加密开关。
写入 etcd 前发生了什么
通用 etcd3 存储的创建路径位于 store.go:
|
|
顺序是:
- 把内部对象编码为存储版本。
- 调用当前资源对应的 storage transformer。
- 把转换结果写入 etcd。
读取时则反过来:先从 etcd 取出字节,执行 TransformFromStorage,再 decode 成 API 对象。etcd 不理解 Secret,也不负责选择加密算法。
默认为什么不是加密存储
kube-apiserver 只有设置 --encryption-provider-config 后,才会加载资源 transformer。源码 maybeApplyResourceTransformers 在配置路径为空时直接返回。
未加密路径使用 identity 语义。其 TransformToStorage 原样返回输入字节,不提供机密性。它只会在读取时拒绝带有 k8s:enc: 前缀的加密数据,防止把密文误当成普通序列化对象。
这也是「Secret 默认不以明文显示」和「Secret 默认在 etcd 中加密」不能画等号的原因。kubectl 的表格输出不显示 value,并不改变底层存储。
EncryptionConfiguration 如何工作
配置静态数据加密后,每类资源会得到一个 transformer 链。当前源码支持 AESGCM、AESCBC、Secretbox、KMS 和 Identity;其中 KMS 有 v1、v2 两个 API 版本,v1 已弃用,新配置应使用 v2。构造逻辑在 prefixTransformersAndProbes。
一个简化配置如下:
|
|
provider 顺序有实际语义:
- 写入只使用第一个 provider。
- 读取会依次尝试能够识别现有数据前缀的 provider。
- 如果不是第一个 provider 完成了解密,数据会标记为 stale,后续更新可用首选 provider 重写。
这让密钥轮换和从明文迁移到密文成为可能,但只改配置不会自动重写全部历史对象。既有 Secret 仍需要迁移。另外,本快照的 kube-apiserver 支持 --encryption-provider-config-automatic-reload,配置文件的变更可以在运行中热重载,无需重启 API Server。完整配置和迁移步骤应以官方的 静态数据加密文档 为准。
sequenceDiagram
participant API as kube-apiserver REST storage
participant Codec as storage codec
participant Transformer as resource transformer
participant Etcd as etcd
API->>Codec: encode Secret
Codec-->>API: serialized bytes
API->>Transformer: TransformToStorage(bytes, etcd key)
Transformer-->>API: plaintext or k8s:enc:* ciphertext
API->>Etcd: OptimisticPut
Etcd-->>API: stored bytes
API->>Transformer: TransformFromStorage(bytes, etcd key)
Transformer-->>API: serialized plaintext
API->>Codec: decode
Codec-->>API: Secret object
etcd key 作为 authenticated data 传入 transformer。这意味着支持认证加密的实现不仅保护内容,还把密文和它应当所属的存储路径绑定,降低把一条密文搬到另一条 key 下仍被接受的风险。
kubelet 如何只管理 Pod 引用的 Secret
Pod 调度到节点后,kubelet 通过 Secret manager 取得它需要的对象。接口位于 pkg/kubelet/secret/secret_manager.go:
|
|
RegisterPod 通过 VisitPodSecretNames 收集 Pod 里的 Secret 引用。watch 和 TTL manager 只为已经注册的 Pod 维护引用计数与缓存;最后一个 Pod 解除引用后,对象会从本地管理范围移除。
kubelet 支持三种变更检测策略,选择逻辑在 pkg/kubelet/kubelet.go:
| 策略 | 获取方式 | 新鲜度语义 |
|---|---|---|
Watch |
为被引用对象建立 watch,本地缓存返回结果 | 由 watch 传播更新,默认策略 |
Cache |
本地 TTL 缓存,过期后重新 GET | TTL 来自 Node 的 node.alpha.kubernetes.io/ttl 注解,未设置时兜底 1 分钟 |
Get |
每次需要时直接 GET API Server | 不保留该层对象缓存 |
默认值在 defaults.go 中设为 Watch。
这里的「只管理引用对象」不等于只靠 kubelet 代码就能形成安全边界。API Server 侧还需要 Node authorizer。它把 Secret 到 Pod、Pod 到 Node 建成关系图,并限制节点只读与分配到该节点的 Pod 有关的 Secret。对应图边在 plugin/pkg/auth/authorizer/node/graph.go,授权入口在 node_authorizer.go。集群没有正确启用相应授权机制时,不能只根据 kubelet 的缓存范围推断权限已经最小化。
kubelet 消费 Secret 的三条主要路径
1. 环境变量:创建容器时求值一次
kubelet 在 makeEnvironmentVariables 中处理 envFrom.secretRef 和 env[].valueFrom.secretKeyRef:
- 通过 Secret manager 获取对象。
envFrom遍历全部secret.Data,把字节转换成字符串。secretKeyRef读取指定 key。- 生成最终的容器环境变量列表并交给容器运行时。
环境变量属于进程启动参数的一部分。容器启动后,即使 kubelet 缓存收到新的 Secret,原进程的环境也不会被修改。应用若依赖环境变量,需要通过重建 Pod 或应用自己的重载机制使用新值。
另外,环境变量会把字节按字符串处理,不适合承载含不可打印字节的二进制数据,这类内容应优先走 volume。
这条路径还意味着 Secret 会进入容器运行时配置和进程环境的可见边界。调试输出、崩溃报告、子进程继承等环节都需要避免泄露。
2. Secret volume:内存介质与原子切换
Secret volume plugin 定义在 pkg/volume/secret/secret.go。它不是直接把 API 对象 bind mount 进容器,而是包装一个 medium: Memory 的 emptyDir:
|
|
挂载时的 SetUpAt 依次执行:
- 从 Secret manager 获取对象;非 optional Secret 不存在时返回错误。
MakePayload把data映射成文件内容、路径和 mode。- 建立内存型
emptyDir。 - 用
AtomicWriter写入文件并处理fsGroup权限。
AtomicWriter 不会逐个覆盖容器正在读取的可见文件。它先创建新的时间戳目录,写完全部内容和权限后,把 ..data_tmp 原子 rename 为 ..data。可见文件则是到 ..data/<name> 的符号链接。实现与目录结构说明在 atomic_writer.go。
|
|
Secret volume 的 RequiresRemount 返回 true,因此 kubelet 的 volume reconcile 会重新执行投射。更新是最终一致的,延迟由 kubelet 同步周期和所选缓存策略共同决定。
应用侧仍需正确读取:长期持有已经打开的文件描述符可能继续指向旧 inode,监听更新时应关注 ..data 符号链接切换并重新打开文件。通过 subPath 挂载单个 Secret 文件时也不会自动收到更新,这是 Secret 官方文档 明确列出的限制。
普通 Secret 也可以放进 projected volume(projected.sources[].secret)。它复用同一个 AtomicWriter 机制,RequiresRemount 同样返回 true,更新语义与 secret volume 一致,区别只在声明方式。
内存型 volume 减少了 Secret 写入节点持久化存储的机会,但不等于数据从此不会出现在其他位置。节点 swap、进程内存、core dump、日志和应用复制出来的文件仍属于单独的安全边界。
3. imagePullSecrets:交给镜像管理链路
对于 pod.spec.imagePullSecrets,kubelet 的 getPullSecretsForPod 逐个读取 Secret,再把结果交给镜像拉取逻辑。缺失对象会被记录,随后镜像拉取可能失败。
这类 Secret 通常是 kubernetes.io/dockerconfigjson。它不会自动作为文件或环境变量出现在业务容器内,但会进入 kubelet、凭据解析和 CRI 镜像拉取的调用链。
ServiceAccount token 为什么需要单独解释
SecretTypeServiceAccountToken 仍然存在,token controller 也能为这种 Secret 补充 token、CA 和 namespace 数据。但它表达的是持久化的长期凭据机制。
当前 ServiceAccount admission plugin 在 Pod 允许自动挂载时,会注入名为 kube-api-access-* 的 projected volume,挂载到 /var/run/secrets/kubernetes.io/serviceaccount。源码在 mountServiceAccountToken。这个投射同时包含:
serviceAccountToken:短期 token;kube-root-ca.crtConfigMap 中的 CA;- downward API 提供的 namespace。
projected volume plugin 不会读取一个 token Secret,而是在 collectData 的 ServiceAccountToken 分支(L322-L355)中构造绑定当前 Pod UID 的 TokenRequest。kubelet token manager 通过 serviceaccounts/token 子资源取得 token,相关获取与缓存流程在 token_manager.go。刷新时机由 requiresRefresh 决定:token 生命周期消耗 80%,或已存活接近 24 小时时,提前获取新 token 并更新投影。
因此,看到容器内的 .../serviceaccount/token 文件,不能据此推断集群里一定存在对应的 Secret 对象。现代默认路径是按需签发、绑定 Pod、可轮换的 projected token。
更新传播为什么因消费方式而不同
更新和删除都由通用 etcd3 store 做乐观并发控制,但具体操作不同:更新经过 GuaranteedUpdate,最终用 OptimisticPut 比较 etcd revision;最终物理删除则由 Delete 中的 OptimisticDelete 比较原对象 revision。变更成功后,etcd 的记录会沿 watch 链路转换成 API watch 事件。
kubelet 的两种缓存也不能混为一谈:Watch 策略通过 watch 更新本地对象;Cache 策略不消费对象 watch,而是在 TTL 过期后重新 GET API Server。
| 消费方式 | 首次取值位置 | Secret 更新后的行为 |
|---|---|---|
env / envFrom |
kubelet 生成容器配置 | 已运行进程不更新,需要重建或重启 |
| Secret volume | volume plugin SetUpAt |
kubelet 最终一致地重新投射,应用需重新打开文件 |
Secret volume + subPath |
单文件 bind mount | 不自动更新 |
imagePullSecrets |
kubelet 发起镜像拉取前 | 后续拉取可重新读取;已经拉取并运行的镜像不受影响 |
| projected ServiceAccount token | kubelet TokenRequest + token cache | 到刷新窗口后获取新 token,并通过 projected volume 更新 |
这组差异不是 Secret controller 的统一「热更新策略」,而是各消费入口的生命周期不同。
安全边界与常见误解
Secret 不等于安全存储
Secret 提供的是标准 API、权限控制入口和受控投递机制。要形成完整保护,还需要:
- 为
secrets配置静态数据加密,优先考虑由外部 KMS 管理主密钥。 - 对
get、list、watch分别实施最小权限;list和watch同样能取得 Secret 内容。 - 限制创建 Pod、Deployment 等工作负载的权限。能在 namespace 中创建使用任意 Secret 的 Pod,通常就能间接读取该 namespace 的 Secret。
- 限制每个容器的挂载范围,不要因为同属一个 Pod 就把 Secret 暴露给所有 sidecar。
- 避免把 Base64 manifest、解码后的值、环境变量或文件内容写入日志、Git、诊断包和监控标签。
Secret type 不会自动验证凭据
TLS Secret 的键存在不代表证书有效,Docker config 能解析为 JSON 不代表 registry 登录一定成功。业务 controller 或创建工具仍需做语义校验。
加密 etcd 也不是终点
静态数据加密主要防止 etcd 数据文件、快照或备份被直接读取。API Server 解密后仍会把 Secret 返回给获授权主体;kubelet 和使用 Secret 的进程最终也要接触明文。威胁模型必须分别覆盖控制面主机、KMS、节点和应用。
使用限制与注意事项
大小与数量
1 MiB 上限是 API 层硬约束:ValidateSecret 累加 data 所有 value 的字节数,超过 MaxSecretSize 即拒绝创建或更新,不区分 Secret 类型。官方 Secret 文档给出的主要原因是避免大型 Secret 耗尽 API Server 和 kubelet 内存。
这项校验只统计 data value,不统计 metadata,也不能据此推断完整对象一定低于 etcd 的请求大小限制。annotation、label、序列化开销和 etcd 自身的请求上限是另外的边界。
走环境变量消费时还会叠加操作系统限制:Linux 上单进程环境总量受 ARG_MAX 约束(x86-64 默认约 2 MiB),单个环境变量长度也有独立上限(默认 128 KiB)。大量 Secret 注入 env 时,容器创建可能先被这一层拒绝。
数量没有类似的硬限制,但每个被 Pod 引用的 Secret 都会产生 kubelet 的 watch 或缓存开销,并放大 API Server 与 etcd 的压力。应清理不再被引用的对象,而不是放任堆积。
namespace 边界与轮换
Secret 是 namespace-scoped 资源,env 和 volume 只能引用同 namespace 的对象。跨 namespace 共享没有内建机制:复制到目标 namespace 会多一份拷贝、扩大暴露面;长期共享应交给外部 secret 管理方案。
immutable: true 的对象不能原地更新,只能删除重建;删除重建后,通过 env 消费的 Pod 仍需重建才能拿到新值。因此 immutable 应配合「新名字 / 新对象」的轮换流程使用,而不是当作降低更新频率的手段。
运维与安全依赖
- KMS provider 是可用性依赖,但故障影响取决于 API 版本和缓存状态。KMS v2 已缓存的 DEK 可以持续用于解密;插件进入错误状态后,当前 DEK 或 seed 还能短暂用于写入,本快照中的
kmsv2PluginWriteDEKSourceMaxTTL为 3 分钟。故障持续超过窗口后,新写入会失败;缓存未命中的解密也可能需要 KMS 恢复后才能完成。 - 热重载只重载配置:增删或重排 provider 前,先确认旧数据的前缀仍有 provider 可解,再按官方迁移流程操作。
- etcd 快照与备份和存储层同源:未配置加密时备份即明文,配置后才是密文,备份链路应纳入威胁模型。
- 官方 Secret 良好实践 建议按需考虑第三方 Secret store,并可通过 Secrets Store CSI Driver 把外部数据挂载给获授权的 Pod。是否使用 Kubernetes Secret 应根据威胁模型、访问控制和轮换要求判断,不宜按“高、中、低敏感度”简单分类。Vault 等系统可以作为外部凭据来源;SOPS 主要保护 Git 中的加密配置,解密后的结果仍可能被写成 Kubernetes Secret,它不等同于运行时外部 Secret store。
如何验证自己的集群
下面的检查应在隔离测试集群中进行。直接读取 etcd 或调整加密配置需要控制面权限,操作前应确认备份与回滚路径。
1. 验证 API 表示
创建一个不含真实凭据的测试 Secret:
|
|
这只能证明 API 字段是可逆的 Base64,不代表 etcd 是否加密。
2. 验证静态数据加密
先检查每个 kube-apiserver 是否设置 --encryption-provider-config,再确认 secrets 的第一个 provider 不是 identity。最后从 etcd 读取测试对象的原始值,检查是否以 k8s:enc: 开头(如 k8s:enc:aesgcm:v1:、k8s:enc:kms:v2:);未配置加密时原始值没有该前缀。
只检查启动参数仍不够:旧对象可能是在启用加密前写入的。应按官方迁移流程重写历史对象,并抽样验证 etcd 原始数据。
3. 验证 volume 与 env 更新差异
让测试 Pod 同时通过环境变量和 volume 使用同一个 Secret,更新 Secret 后分别观察:
|
|
预期结果是:volume 文件最终变化,原进程的环境变量保持旧值。测试时不要用 subPath,否则 volume 也不会自动更新。
总结
Kubernetes Secret 的实现并不是「把字符串 Base64 后挂载进容器」这么简单。API 类型用 map[string][]byte 保存内容,stringData 只在写入转换时提供便利;registry 负责 namespace、类型和大小校验;API Server 在写 etcd 前执行可配置的 transformer,未配置时没有静态数据加密;kubelet 只管理 Pod 引用的对象,按 Watch、TTL 或直接 GET 策略保持新鲜度;环境变量、volume、镜像凭据和 ServiceAccount token 各自的解析与更新生命周期不同。
理解这些边界后,许多生产问题会更容易分类:数据是否加密要查 API Server 的 storage transformer;文件是否更新要查 kubelet 缓存和 volume reconcile;环境变量是否变化要查容器是否重建;ServiceAccount token 是否轮换要查 TokenRequest 和 projected volume,而不是只搜索 Secret 对象。