ᕕ( ᐛ )ᕗ Jimyag's Blog

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。文中的结论以这个快照为准,不把主分支实现当成所有历史版本的共同语义。

先给出结论:

  1. Secret 的核心是 map[string][]byte;Base64 只是字节数组的 JSON 序列化表示,不提供机密性。
  2. 是否加密存储不由 Secret 类型决定,而由 kube-apiserver 的 EncryptionConfiguration 决定;默认未配置时写入 etcd 不加密。
  3. Pod 不直接访问 etcd。kubelet 根据 Pod 引用从 API Server 获取 Secret,再投递给环境变量、volume 或镜像拉取凭据。
  4. volume 用内存型 emptyDir 和原子符号链接切换,可最终更新;环境变量只在生成容器配置时解析一次,更新不触达已运行进程。
  5. 自动挂载的 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type Secret struct {
    metav1.TypeMeta   `json:""`
    metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`

    Immutable *bool `json:"immutable,omitempty" protobuf:"varint,5,opt,name=immutable"`
    Data map[string][]byte `json:"data,omitempty" protobuf:"bytes,2,rep,name=data"`
    StringData map[string]string `json:"stringData,omitempty" protobuf:"bytes,4,rep,name=stringData"`
    Type SecretType `json:"type,omitempty" protobuf:"bytes,3,opt,name=type,casttype=SecretType"`
}

const MaxSecretSize = 1 * 1024 * 1024

真正持久化的业务数据是 Data map[string][]byte。因为 JSON 不能直接表达任意字节,Go 的 []byte 在 JSON 中被编码成 Base64 字符串。因此下面两个字段表达的是同一份字节内容:

1
2
data:
  password: czNjcjN0
1
2
stringData:
  password: s3cr3t

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 专用数据库逻辑,而是把 SecretSecretList、匹配函数和增删改策略交给通用的 genericregistry.Store

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
store := &genericregistry.Store{
    NewFunc:                  func() runtime.Object { return &api.Secret{} },
    NewListFunc:              func() runtime.Object { return &api.SecretList{} },
    PredicateFunc:            secret.Matcher,
    // ... SingularQualifiedResource、TableConvertor 等字段省略
    DefaultQualifiedResource: api.Resource("secrets"),
    CreateStrategy:           secret.Strategy,
    UpdateStrategy:           secret.Strategy,
    DeleteStrategy:           secret.Strategy,
}

secret.Strategy 声明 Secret 是 namespace-scoped 资源,并在创建、更新时调用校验函数。核心校验位于 ValidateSecret

  1. 校验对象名称、namespace 等通用 metadata。
  2. 校验 data 的键名,只允许字母、数字、-_.
  3. 累加所有 value 的原始字节数,超过 1 MiB 时拒绝。
  4. 根据内置 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 要求 usernamepassword 至少存在一个。
  • kubernetes.io/tls 只检查 tls.crttls.key 两个键存在,不解析证书,也不验证公私钥是否匹配。

因此 type 主要提供约定和有限校验,不代表 API Server 已经验证凭据可用。

immutable 的实际语义

ValidateSecretUpdate 里有两层不可变约束:

  • 无论 immutable 是否设置,type 字段都不能修改。
  • 当旧对象的 immutable 已经是 true 时,额外禁止两类操作:
    • immutable 改回 false 或删除该字段。
    • 修改 data

metadata 仍然可以更新。watch 型 Secret manager 也会识别 immutable,不再为这种对象维持无意义的持续 watch。它是 API 不可变性和节点侧资源优化,不是加密开关。

写入 etcd 前发生了什么

通用 etcd3 存储的创建路径位于 store.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
data, err := runtime.Encode(s.codec, obj)
// ...
newData, err := s.transformer.TransformToStorage(
    ctx,
    data,
    authenticatedDataString(preparedKey),
)
// ...
txnResp, err := s.client.Kubernetes.OptimisticPut(
    ctx, preparedKey, newData, 0, kubernetes.PutOptions{LeaseID: lease},
)

顺序是:

  1. 把内部对象编码为存储版本。
  2. 调用当前资源对应的 storage transformer。
  3. 把转换结果写入 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 链。当前源码支持 AESGCMAESCBCSecretboxKMSIdentity;其中 KMS 有 v1、v2 两个 API 版本,v1 已弃用,新配置应使用 v2。构造逻辑在 prefixTransformersAndProbes

一个简化配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
    providers:
      - kms:
          apiVersion: v2
          name: example-kms
          endpoint: unix:///var/run/kmsplugin/socket.sock
      - identity: {}

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

1
2
3
4
5
type Manager interface {
    GetSecret(namespace, name string) (*v1.Secret, error)
    RegisterPod(pod *v1.Pod)
    UnregisterPod(pod *v1.Pod)
}

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.secretRefenv[].valueFrom.secretKeyRef

  1. 通过 Secret manager 获取对象。
  2. envFrom 遍历全部 secret.Data,把字节转换成字符串。
  3. secretKeyRef 读取指定 key。
  4. 生成最终的容器环境变量列表并交给容器运行时。

环境变量属于进程启动参数的一部分。容器启动后,即使 kubelet 缓存收到新的 Secret,原进程的环境也不会被修改。应用若依赖环境变量,需要通过重建 Pod 或应用自己的重载机制使用新值。

另外,环境变量会把字节按字符串处理,不适合承载含不可打印字节的二进制数据,这类内容应优先走 volume。

这条路径还意味着 Secret 会进入容器运行时配置和进程环境的可见边界。调试输出、崩溃报告、子进程继承等环节都需要避免泄露。

2. Secret volume:内存介质与原子切换

Secret volume plugin 定义在 pkg/volume/secret/secret.go。它不是直接把 API 对象 bind mount 进容器,而是包装一个 medium: MemoryemptyDir

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func wrappedVolumeSpec() volume.Spec {
    return volume.Spec{
        Volume: &v1.Volume{
            VolumeSource: v1.VolumeSource{
                EmptyDir: &v1.EmptyDirVolumeSource{
                    Medium: v1.StorageMediumMemory,
                },
            },
        },
    }
}

挂载时的 SetUpAt 依次执行:

  1. 从 Secret manager 获取对象;非 optional Secret 不存在时返回错误。
  2. MakePayloaddata 映射成文件内容、路径和 mode。
  3. 建立内存型 emptyDir
  4. AtomicWriter 写入文件并处理 fsGroup 权限。

AtomicWriter 不会逐个覆盖容器正在读取的可见文件。它先创建新的时间戳目录,写完全部内容和权限后,把 ..data_tmp 原子 rename 为 ..data。可见文件则是到 ..data/<name> 的符号链接。实现与目录结构说明在 atomic_writer.go

1
2
3
4
5
6
7
/mounted/secret/
├── username -> ..data/username
├── password -> ..data/password
├── ..data -> ..2026_08_13_17_00_00.123456789/
└── ..2026_08_13_17_00_00.123456789/
    ├── username
    └── password

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.crt ConfigMap 中的 CA;
  • downward API 提供的 namespace。

projected volume plugin 不会读取一个 token Secret,而是在 collectDataServiceAccountToken 分支(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、权限控制入口和受控投递机制。要形成完整保护,还需要:

  1. secrets 配置静态数据加密,优先考虑由外部 KMS 管理主密钥。
  2. getlistwatch 分别实施最小权限;listwatch 同样能取得 Secret 内容。
  3. 限制创建 Pod、Deployment 等工作负载的权限。能在 namespace 中创建使用任意 Secret 的 Pod,通常就能间接读取该 namespace 的 Secret。
  4. 限制每个容器的挂载范围,不要因为同属一个 Pod 就把 Secret 暴露给所有 sidecar。
  5. 避免把 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:

1
2
3
4
5
kubectl create secret generic secret-demo \
  --from-literal=username=demo \
  --from-literal=password=not-a-real-password

kubectl get secret secret-demo -o jsonpath='{.data.password}' | base64 -d

这只能证明 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 后分别观察:

1
2
3
kubectl patch secret secret-demo \
  --type merge \
  -p '{"stringData":{"password":"rotated-test-value"}}'

预期结果是: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 对象。

#Kubernetes #Secret #Kube-Apiserver #Kubelet #Etcd #安全