Kubernetes 基础体系 · 第 48/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Kubernetes Secret 治理:etcd 加密、KMS、External Secrets、轮换和泄漏

Kubernetes Secret 用于保存密码、令牌、私钥、证书等小型敏感数据。它解决的是“如何把敏感数据作为 Kubernetes API 对象分发给 Pod”,并不等于“数据已经安全保存”或“应用已经具备安全的密钥管理能力”。

完整的治理链路至少包含以下边界:

外部密钥系统
    │
    │ External Secrets 控制器读取
    ▼
Kubernetes API Server ──加密存储──> etcd
    │
    │ 鉴权、授权、审计、准入
    ▼
Kubelet ──临时卷 / 环境变量──> Pod
    │
    ▼
应用进程、日志、调试工具、下游服务

每一段都有不同的风险:

  • etcd 加密主要保护“静态存储中的 Secret”;
  • KMS 解决的是“用于加密 Secret 的密钥如何由独立密钥系统保护”;
  • External Secrets 解决的是“外部密钥系统中的值如何同步为 Kubernetes Secret”;
  • 轮换解决的是“旧凭据如何失效、新凭据如何传播以及应用如何重新加载”;
  • 泄漏治理则要覆盖 API、etcd、节点、容器、日志、CI/CD 和外部密钥系统,而不是只检查 Secret manifest。

1. Secret 的真实数据模型:编码不是加密

一个典型的 Secret 如下:

apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
  namespace: payments
type: Opaque
stringData:
  username: app
  password: "correct-horse-battery-staple"

stringData 只是客户端提交 Secret 的便利字段。API Server 接收后会把它转换为 data,而 data 的值使用 Base64 表示:

apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
  namespace: payments
type: Opaque
data:
  username: YXBw
  password: Y29ycmVjdC1ob3JzZS1iYXR0ZXJ5LXN0YXBsZQ==

Base64 只是一种编码:

printf '%s' 'Y29ycmVjdC1ob3JzZS1iYXR0ZXJ5LXN0YXBsZQ==' | base64 -d
# correct-horse-battery-staple

因此,下列命令并没有解密 Secret:

kubectl get secret db-credentials -n payments \
  -o jsonpath='{.data.password}' | base64 -d

它只是:

  1. 通过 Kubernetes API 读取 Secret;
  2. 经过 RBAC 授权;
  3. 取出 data.password
  4. 做 Base64 解码。

如果一个主体具有 get 权限,那么是否使用 data 还是 stringData 不影响其读取能力。

1.1 Secret 存在哪里

Secret 是 Kubernetes API 对象。典型写入路径是:

kubectl / controller / kubelet
        │ HTTPS
        ▼
API Server
        │ 序列化并写入
        ▼
etcd

没有配置静态加密时,Secret 的序列化内容通常可以在 etcd 中被直接恢复。即使 Kubernetes API 对外强制使用 TLS,也不能推出“etcd 中的 Secret 已经加密”:

  • TLS 保护传输链路;
  • etcd 加密保护存储内容;
  • RBAC 保护 API 访问;
  • 节点和容器运行时还存在另一组访问路径。

这些控制彼此不能替代。

1.2 Secret 不是任意大文件存储

Kubernetes Secret 面向密码、令牌、证书等小型数据。API Server 默认受对象大小限制影响,实际部署还会受到 etcd、网络、序列化和控制器处理成本的约束。大文件、证书包或二进制密钥材料不应因为可以 Base64 就全部塞进 Secret。

对于大型或高价值密钥,通常应只在 Kubernetes 中保存短期访问凭据、引用或解密所需的最小材料,让应用直接访问专用密钥系统。


2. Secret 的安全边界:谁能看到它

Secret 的安全性首先由 Kubernetes 的认证和授权模型决定。

2.1 getlistwatch 不是同一种风险

一个主体能够读取单个对象,需要类似以下权限:

rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["db-credentials"]
    verbs: ["get"]

但实际授权还需要考虑:

  • list secrets 可以批量获得命名空间中的所有 Secret;
  • watch secrets 可以持续观察对象变化;
  • listwatch 往往是控制器正常工作的必要权限;
  • 跨命名空间的 ClusterRoleClusterRoleBinding 会扩大影响范围;
  • 能创建或修改 Pod 的主体,可能通过 Pod 的 secret 卷、环境变量或 envFrom 间接取得 Secret。

最后一点很重要:即使某个用户不能 get secret,如果他能在目标命名空间创建一个 Pod,并且知道目标 Secret 名称,他可能可以让 Kubelet 把 Secret 注入自己的 Pod。因而 Secret 读取权限不能只通过 secrets 资源规则判断,还必须检查 Pod 创建、Deployment 修改、Job 创建等间接能力。

2.2 etcd 管理权限是更高层的边界

能够读取 etcd 数据库文件、执行 etcd 查询或取得 etcd 快照的人,通常绕过了 Kubernetes API 的普通 RBAC。此时是否安全取决于:

  1. Secret 是否启用了 API Server 的静态加密;
  2. 加密密钥是否与 etcd 数据分离;
  3. etcd 快照和备份是否也被保护;
  4. 恢复环境是否同样配置了加密密钥;
  5. 运维人员和备份系统是否具有访问密钥的权限。

因此,kubectl auth can-i get secrets 只能回答 Kubernetes API 授权问题,不能回答 etcd、节点磁盘、备份平台和 KMS 的授权问题。


3. etcd 静态加密:API Server 如何保护 Secret

Kubernetes 的静态加密由 API Server 的 --encryption-provider-config 配置启用。它作用于 API Server 写入存储层的数据,常见目标是 secrets

一个使用本地 AES-CBC 密钥的示例:

apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
    providers:
      - aescbc:
          keys:
            - name: key1
              secret: <base64-encoded-32-byte-key>
      - identity: {}

其中:

  • resources 指定要加密的资源;
  • aescbc 是一种本地加密提供者;
  • secret 是 Base64 编码后的密钥材料,不是任意字符串;
  • identity 表示不加密、原样存储;
  • providers 的顺序同时影响写入和读取。

3.1 写入和读取规则

对于一个资源,API Server 通常按下面的规则工作:

  1. 写入新对象时,选择列表中第一个可用的 provider;
  2. 读取对象时,按 provider 顺序尝试解密;
  3. 旧 provider 仍然保留在配置中,才能读取尚未迁移的旧数据;
  4. 只有重新写入对象,旧密文才会转换成新 provider 生成的密文。

这意味着下面的配置存在明显风险:

providers:
  - identity: {}
  - aescbc:
      keys:
        - name: key1
          secret: ...

因为 identity 排在第一位,新写入的数据会优先以明文存储。即使后面配置了 aescbc,也不能改变这个写入结果。

更安全的迁移形态是:

providers:
  - aescbc:
      keys:
        - name: key2
          secret: <new-key>
  - aescbc:
      keys:
        - name: key1
          secret: <old-key>
  - identity: {}

这里的 identity 只作为读取旧明文对象的回退项。待所有对象完成重写后,应移除 identity,否则未来误写或新资源配置都可能重新产生明文对象。

3.2 读取加密配置不能只看配置文件

配置文件存在不代表功能已经生效。实际部署中需要检查:

  • 所有 API Server 实例是否使用同一份有效配置;
  • 配置文件是否挂载到正确路径;
  • API Server 是否已经重启或动态加载成功;
  • 配置中的密钥是否完整、格式正确;
  • 高可用集群中是否有旧实例仍使用旧配置;
  • 旧对象是否真的完成了重写。

如果多个 API Server 的配置不一致,可能出现以下状态:

  • 一个实例能读取对象,另一个实例无法读取;
  • 新写入对象由不同实例使用不同密钥;
  • 轮换后部分对象仍只能由旧实例解密;
  • API Server 启动失败或反复重启。

3.3 如何验证 etcd 中是否已经加密

不要直接把生产 etcd 快照导出到普通工作站。验证时应使用受控环境,并注意命令输出本身可能包含敏感数据。

一个常见的验证思路是:

  1. 创建一个只用于测试的 Secret;
  2. 读取 API Server 返回的 Secret,确认业务端仍可正常使用;
  3. 在受控的 etcd 检查环境中搜索对应对象;
  4. 检查 etcd 中是否出现明文值;
  5. 检查加密后的对象是否带有加密 provider 的存储标记。

不同 Kubernetes 版本和 provider 的内部存储前缀可能不同,不应把某个前缀硬编码为跨版本合规判断。更可靠的验证是使用官方支持的加密配置和迁移流程,并在备份、恢复环境中做实际恢复测试。


4. 加密 provider 与密钥轮换

4.1 aescbcaesgcmsecretboxkms

Kubernetes 提供多个静态加密 provider。它们的差异主要在于:

  • 加密操作在 API Server 本地完成还是交给 KMS;
  • 密钥材料是否存放在 API Server 主机;
  • provider 的安全性质、运维方式和版本支持情况;
  • 是否需要外部进程和 Unix Domain Socket。

对生产治理而言,关键问题不是只选择某个算法名称,而是回答:

加密密钥本身由谁持有?API Server 主机失陷后,攻击者能否同时取得密文和解密密钥?

如果加密密钥直接写在 API Server 使用的配置文件中,那么攻击者取得该文件后可能解密 etcd 中的 Secret。该方案仍然有价值,因为它能防止“只有 etcd 数据或快照泄漏”的场景,但不能把它描述为密钥隔离。

4.2 KMS 的定义

KMS 是 Key Management Service,密钥管理服务。Kubernetes 的 KMS provider 让 API Server 通过 KMS 插件访问外部密钥系统,而不是把主加密密钥直接作为普通配置存储在 API Server 上。

典型数据流如下:

API Server
   │
   │ KMS gRPC / Unix socket
   ▼
KMS 插件
   │
   │ 云 KMS、HSM 或其他外部密钥服务
   ▼
外部 KEK

这里通常涉及两层密钥:

  • KEK:Key Encryption Key,用于保护数据加密密钥;
  • DEK:Data Encryption Key,用于加密具体的 Secret 数据。

抽象过程可以写成:

密文 = Encrypt(DEK, Secret)
封装后的 DEK = Encrypt(KMS-KEK, DEK)
存储 = 密文 + 封装后的 DEK + 版本信息

读取时,API Server 或 KMS 插件先恢复 DEK,再解密 Secret。具体封装格式和缓存行为由 Kubernetes 版本及 KMS 插件实现决定,但治理上的关键点不变:etcd 不应单独拥有恢复 Secret 所需的完整密钥材料。

4.3 KMS v1 和 KMS v2

Kubernetes KMS provider 使用插件协议。KMS v1 和 KMS v2 在缓存、协议行为和插件交互上存在差异。

  • KMS v1 已经进入弃用路径,具体可用性取决于 Kubernetes 版本;
  • KMS v2 是当前应优先评估的方向;
  • 云厂商托管控制平面可能不允许用户自行配置 API Server 的 provider;
  • 某些发行版会提供自己的 KMS 集成,配置参数和升级策略不完全相同。

因此,不能把某个云厂商的 kms 配置片段直接当作所有 Kubernetes 集群通用配置。应以目标 Kubernetes 版本的官方文档、发行版文档和 KMS 插件版本说明为准。

无论使用哪一种协议,KMS 插件故障都会影响 API Server:

  • 新建或更新 Secret 可能失败;
  • API Server 读取已加密对象可能失败;
  • 控制器可能持续重试;
  • Pod 使用 Secret 的新调度或重建可能无法完成;
  • 如果 KMS 配置阻止 API Server 启动,影响范围会扩大到整个 API 服务。

KMS 不是一个“零成本开关”。必须验证:

  1. KMS 插件进程和 Unix socket 权限;
  2. KMS 对象和密钥版本;
  3. API Server 到插件的连通性;
  4. 插件到外部 KMS 的网络和身份;
  5. KMS 限流、配额和不可用时的行为;
  6. 集群恢复时能否访问原有密钥;
  7. 密钥禁用、轮换和删除的审批流程。

4.4 静态加密密钥轮换的完整步骤

假设当前使用 key1,要切换到 key2,安全过程不是简单替换配置:

第一步:把新 provider 放在第一位

providers:
  - aescbc:
      keys:
        - name: key2
          secret: <new-key>
  - aescbc:
      keys:
        - name: key1
          secret: <old-key>
  - identity: {}

新写入对象使用 key2,旧对象仍可使用 key1identity 读取。

第二步:逐步重写目标对象

最简单的概念性操作是重新提交对象,使 API Server 产生一次写入:

kubectl get secret db-credentials -n payments -o yaml \
  | kubectl replace -f -

这条命令的风险包括:

  • YAML 可能包含敏感数据;
  • 管道进程、终端历史、调试日志可能暴露内容;
  • replace 可能因为 resourceVersion 过期而失败;
  • 过大的批量操作可能给 API Server、etcd 和控制器造成压力;
  • 对象中不适合由客户端回写的字段可能引发冲突;
  • 误处理 kubernetes.io/service-account-token 等系统对象会扩大影响。

生产环境应采用分批、限速、可重试的迁移程序,并记录对象名称、版本和失败原因,而不是把所有 Secret 一次性导出到文件。迁移程序还必须避免把 data 写入普通日志。

第三步:确认旧格式已清空

需要确认所有目标对象都已经由新 provider 写入。不能只检查最近创建的 Secret,因为旧对象可能一直未被修改。

第四步:移除旧 provider

迁移完成后,才可以删除旧密钥和 identity 回退:

providers:
  - aescbc:
      keys:
        - name: key2
          secret: <new-key>

先删除旧 provider 再重写对象,会导致仍使用旧密钥或仍为明文的对象无法读取。


5. External Secrets:把外部密钥系统同步为 Kubernetes Secret

External Secrets 通常指 External Secrets Operator(ESO)及类似控制器提供的 CRD 和控制循环。它不是 Kubernetes 核心 API 的内建资源,而是一个第三方扩展。不同项目、版本和云厂商 provider 的字段可能不同。

它解决的不是 etcd 加密,而是数据来源问题:

外部密钥系统
  ├─ AWS Secrets Manager
  ├─ Google Secret Manager
  ├─ Azure Key Vault
  ├─ Vault
  └─ 其他 provider
          │
          ▼
External Secrets Controller
          │ 创建或更新
          ▼
Kubernetes Secret
          │
          ▼
Pod

5.1 三类对象及其职责

以 ESO 为例,常见对象包括:

  • SecretStore:定义某个命名空间内如何访问外部系统;
  • ClusterSecretStore:集群范围复用的外部系统连接定义;
  • ExternalSecret:声明从外部系统读取哪些键,以及生成哪个 Kubernetes Secret。

一个简化示例:

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: payments-store
  namespace: payments
spec:
  provider:
    aws:
      service: SecretsManager
      region: ap-southeast-1
      auth:
        jwt:
          serviceAccountRef:
            name: eso-reader
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-credentials
  namespace: payments
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: payments-store
    kind: SecretStore
  target:
    name: db-credentials
    creationPolicy: Owner
  data:
    - secretKey: username
      remoteRef:
        key: prod/payments/database
        property: username
    - secretKey: password
      remoteRef:
        key: prod/payments/database
        property: password

这段配置的含义是:

  1. 控制器使用 payments-store 定义的身份访问外部 Secrets Manager;
  2. 读取 prod/payments/database
  3. 取出其中的 usernamepassword 属性;
  4. 创建或维护命名空间 payments 中名为 db-credentials 的 Kubernetes Secret;
  5. 每隔一段时间检查外部值是否变化。

apiVersion、provider 字段和认证方式受 ESO 版本影响。部署前必须检查目标 ESO 版本支持的 CRD。不能只安装 CRD 而不安装控制器,也不能假设 Kubernetes 自带这些资源。

5.2 External Secrets 并没有绕过 etcd

External Secrets 通常会创建普通 Kubernetes Secret。因此数据仍然可能经历:

外部系统明文
    │ 控制器读取
    ▼
控制器内存
    │ API 写入
    ▼
Kubernetes Secret
    │
    ▼
etcd 中的密文或明文

如果没有启用 etcd 静态加密,同步到 Kubernetes 后仍可能以未加密形式保存在 etcd 中。即使启用了 KMS,Secret 仍会出现在:

  • API Server 的处理路径;
  • 控制器的内存;
  • Kubelet 的缓存和临时卷;
  • Pod 进程;
  • 应用日志和错误报告。

因此 External Secrets 和 etcd 加密是互补关系:

  • External Secrets 管理“来源和同步”;
  • etcd 加密管理“API 存储中的静态保护”;
  • RBAC 管理“谁能通过 API 读取”;
  • 应用和节点安全管理“Secret 注入后的生命周期”。

5.3 控制器的状态和错误路径

External Secrets 是控制循环,不是一次性导入命令。它通常会反复执行:

  1. 读取 ExternalSecret
  2. 解析引用的 SecretStore
  3. 使用控制器身份访问外部系统;
  4. 获取远程值;
  5. 计算目标 Kubernetes Secret;
  6. 创建或更新目标 Secret;
  7. 写回同步状态和事件;
  8. 等待下一次 refresh 或外部触发。

常见失败表现包括:

kubectl describe externalsecret db-credentials -n payments
kubectl get externalsecret db-credentials -n payments -o yaml
kubectl logs -n external-secrets deploy/external-secrets

可能看到的错误类型:

  • SecretStore 不存在或引用类型错误;
  • ServiceAccount 没有访问外部系统的权限;
  • 云端身份绑定错误;
  • 外部键不存在;
  • provider API 限流或网络不可达;
  • 目标 Secret 被人工修改,控制器随后恢复为期望值;
  • 目标 Secret 设置为不可变,后续同步失败;
  • 控制器无法更新目标 Secret 的资源版本。

同步失败时,旧的 Kubernetes Secret 可能仍然存在。应用会继续使用旧凭据,直到旧凭据在外部系统中失效;也可能因为控制器删除并重建目标对象而导致挂载或引用短暂异常。具体行为取决于 creationPolicy、删除策略和控制器版本,必须在预生产环境验证,而不能只看 refreshInterval


6. Secret 注入方式决定轮换能否生效

Secret 常见注入方式有两种:环境变量和卷。

6.1 环境变量是进程启动时的快照

env:
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: db-credentials
        key: password

容器启动时,Kubelet 将 Secret 值设置到容器环境中。之后 Kubernetes Secret 更新,不会自动修改已经运行进程的环境变量。

因此轮换流程通常是:

Secret 更新
    │
    ├─ 应用读取卷文件:可能在后续读取中看到新值
    │
    └─ 应用读取环境变量:必须重启 Pod 才能看到新值

envFrom 也有相同限制。

6.2 Secret 卷通常可以最终收敛到新值

volumes:
  - name: db-credentials
    secret:
      secretName: db-credentials

containers:
  - name: app
    image: example/app:1.0
    volumeMounts:
      - name: db-credentials
        mountPath: /var/run/secrets/db
        readOnly: true

Kubelet 会通过缓存和同步机制更新 Secret 卷的内容。更新不是强同步事务,应用可能需要等待一段时间。应用如果只在启动时读取文件,仍然不会自动使用新凭据。

subPath 是一个重要反例:

volumeMounts:
  - name: db-credentials
    mountPath: /etc/app/password
    subPath: password

通过 subPath 挂载的单个文件不会随着 Secret 卷更新而自动更新。此时即使 Secret 和普通卷都已更新,应用看到的文件仍可能是旧内容。

6.3 应用必须具备重新加载能力

即使文件已经更新,应用也可能:

  • 把密码缓存到内存;
  • 只在启动时建立数据库连接;
  • 连接池继续使用旧凭据;
  • 证书库只初始化一次;
  • 不能在凭据变更时安全地重新认证。

因此“Secret 已更新”不等于“业务已经完成轮换”。需要明确:

  1. Secret 的新值何时写入;
  2. Kubelet 何时投影;
  3. 应用何时读取;
  4. 旧连接何时关闭;
  5. 外部服务何时撤销旧凭据;
  6. 失败时是否可以回滚或双凭据并行。

7. Secret 轮换:先区分谁在轮换

“轮换 Secret”可能表示三件不同的事。

7.1 轮换 Kubernetes 静态加密密钥

目标是改变 etcd 中 Secret 的保护密钥:

旧 DEK / 旧 provider
        │ 重写
        ▼
新 DEK / 新 provider

它不改变数据库密码,也不改变应用看到的业务凭据。它解决的是静态存储保护。

7.2 轮换外部系统中的业务凭据

例如数据库密码从 old-password 改为 new-password。这需要外部系统支持安全变更。一个不安全的顺序是:

先撤销 old-password
    │
    ▼
再写入 Kubernetes Secret
    │
    ▼
应用最终得到 new-password

在传播延迟期间,应用会立即失效。

更稳妥的“双凭据”流程是:

1. 在数据库创建 new-password
2. 外部密钥系统同时保存 old 和 new,或保存新版本
3. 同步 Kubernetes Secret
4. 应用加载并验证 new-password
5. 确认所有实例完成切换
6. 撤销 old-password
7. 删除旧值并记录轮换完成

如果数据库只允许一个密码,必须使用维护窗口、连接排空、快速回滚或服务端兼容策略。

7.3 轮换 Secret 对象或版本引用

External Secrets 常用外部系统的版本、标签或路径作为引用。轮换时可能是:

  • 原地修改同一外部键;
  • 写入新版本并让控制器读取“当前版本”;
  • 改变 remoteRef
  • 创建新 Kubernetes Secret,再切换 Deployment;
  • 使用不同 Secret 名称实现蓝绿切换。

这里的关键是定义一致性条件。设:

  • TsT_s:外部系统写入新凭据的时间;
  • TeT_e:External Secrets 控制器同步完成时间;
  • TkT_k:Kubelet 将新值投影到 Pod 的时间;
  • TaT_a:应用完成重新加载的时间;
  • TrT_r:旧凭据被撤销时间。

如果应用只能接受新凭据,则安全可用的轮换顺序应满足:

TsTeTkTa<TrT_s \leq T_e \leq T_k \leq T_a < T_r

也就是说,旧凭据撤销必须晚于应用确认新凭据已经生效。若无法证明 Ta<TrT_a < T_r,就不应直接撤销旧凭据,而应采用双凭据或重叠有效期。


8. Secret 泄漏:加密后仍可能从哪些地方出现

etcd 加密只覆盖特定的存储路径,不能防止所有泄漏。

8.1 API 和 RBAC 泄漏

危险权限包括:

resources:
  - secrets
verbs:
  - get
  - list
  - watch

尤其应审查:

  • system:masters
  • 集群管理员和平台运维角色;
  • 可读取所有命名空间 Secret 的 ClusterRole
  • 控制器使用的 ServiceAccount;
  • CI/CD、GitOps 和备份系统;
  • 能创建 Pod 或修改 Deployment 的主体。

审计日志应记录 Secret 读取、列表、更新和删除行为,但审计策略不应把 Secret 的完整 data 内容写入日志。审计日志本身也属于敏感资产。

8.2 Manifest、Git 和 CI/CD 泄漏

以下写法容易将凭据提交到 Git:

stringData:
  password: real-production-password

即使使用 data

data:
  password: cmVhbC1wcm9kdWN0aW9uLXBhc3N3b3Jk

也没有改善,因为 Base64 可以还原。

需要重点检查:

  • Git 历史,而不仅是当前分支;
  • CI 构建日志;
  • set -x 输出;
  • Helm 渲染结果;
  • 临时目录和制品包;
  • GitOps 控制器缓存;
  • 代码评审机器人和聊天系统;
  • Terraform state 或其他状态文件。

一旦凭据进入 Git 历史,删除当前文件不足以完成修复。应先撤销和轮换凭据,再清理历史和限制访问。

8.3 Pod、节点和进程泄漏

Secret 注入后,风险边界扩大到:

  • 容器内拥有读取权限的其他进程;
  • 容器调试和 kubectl exec
  • 节点上具有足够权限的用户;
  • Kubelet 和容器运行时;
  • /proc/<pid>/environ
  • core dump;
  • 应用错误日志;
  • 诊断接口、线程转储和指标标签;
  • 临时文件、交换区和节点备份。

环境变量尤其容易通过调试输出和崩溃诊断暴露。对于证书和文件型凭据,卷挂载能减少环境变量泄漏,但不能防止同一容器内的进程访问,也不能替代节点安全。

8.4 Webhook、控制器和中间件泄漏

API Server 处理 Secret 时,相关对象可能经过:

  • 准入 Webhook;
  • 策略控制器;
  • GitOps 控制器;
  • 备份控制器;
  • External Secrets 控制器;
  • 自定义 Operator。

Webhook 是否能看到完整对象取决于其订阅的资源和准入阶段,但设计时不能假定“Webhook 永远看不到 Secret”。应避免把完整对象打印到日志,并限制 Webhook 的服务账号、网络范围和保存策略。


9. 不可变 Secret 与轮换的冲突

Secret 支持:

apiVersion: v1
kind: Secret
metadata:
  name: static-certificate
  namespace: payments
immutable: true
type: Opaque
stringData:
  certificate: "..."

immutable: true 的含义是 Secret 的数据不可再修改。它适合内容确实不应改变,或希望阻止意外修改、减少 Kubelet 监听压力的场景。

但它与原地轮换冲突:

  • External Secrets 不能更新该对象的数据;
  • 证书续期无法直接写入;
  • 应用不会得到新值;
  • 需要删除并重建 Secret,或者切换到新名称。

删除重建还有引用和时序风险。Deployment 是否会因 Secret 对象重建自动重启,取决于工作负载模板是否发生变化;仅删除并创建同名 Secret 不应被当作可靠的滚动发布机制。

更明确的做法是使用版本化名称:

db-credentials-v41
db-credentials-v42

然后通过 Deployment 模板、挂载路径或配置引用切换版本。这会增加发布协调成本,但轮换状态更可观察,也更容易回滚。


10. ServiceAccount Token 不应再按旧 Secret 模型设计

ServiceAccount 凭据是 Secret 治理中经常被误判的一类。

现代 Kubernetes 工作负载通常使用:

  • TokenRequest API;
  • Projected ServiceAccount Token;
  • 明确的 audience
  • 有限的过期时间;
  • 自动轮换;
  • 云厂商 Workload Identity 或类似联邦身份。

Projected Token 示例:

apiVersion: v1
kind: Pod
metadata:
  name: api-client
  namespace: payments
spec:
  serviceAccountName: payments-api
  containers:
    - name: app
      image: example/app:1.0
      volumeMounts:
        - name: token
          mountPath: /var/run/secrets/tokens
          readOnly: true
  volumes:
    - name: token
      projected:
        sources:
          - serviceAccountToken:
              path: cloud-token
              audience: sts.example.com
              expirationSeconds: 3600

audience 限制令牌的预期接收方。令牌签发给服务 A,不应被服务 B 接受。expirationSeconds 控制有效期,具体允许范围受 Kubernetes 版本和 API Server 配置影响。

应用不能假设令牌文件永远不变。它应在文件更新后重新读取,而不是只在启动时加载一次。

旧模式是手动创建长期有效的 ServiceAccount Token Secret:

apiVersion: v1
kind: Secret
metadata:
  name: legacy-token
  annotations:
    kubernetes.io/service-account.name: payments-api
type: kubernetes.io/service-account-token

这种方式会产生长期凭据,并把令牌作为 Secret 对象存储。现代集群通常应优先使用短期 projected token;如果使用云端身份联邦,则应让 Pod 通过短期 Kubernetes Token 换取云端临时凭据,而不是把长期云访问密钥放进 Secret。

这也说明一个边界:

etcd 加密不能替代短期令牌、受限 audience、最小权限和 Workload Identity。

它只能保护令牌在 Kubernetes 存储中的静态表示。


11. 故障诊断:先定位断在哪一段

当应用报告“凭据无效”时,不应立即重新生成 Secret。先区分故障发生在数据源、同步、存储、投影还是应用重载。

11.1 检查 Kubernetes 对象状态

kubectl get secret db-credentials -n payments \
  -o jsonpath='{.metadata.creationTimestamp}{"\n"}{.metadata.resourceVersion}{"\n"}'

kubectl describe externalsecret db-credentials -n payments
kubectl get events -n payments --sort-by=.lastTimestamp

不要在排障命令中直接打印 .data。如果必须验证内容,应在受控终端中读取单个键,并避免复制到工单、聊天或日志。

检查 ExternalSecret 时重点看:

  • Ready 或等价状态是否为成功;
  • refreshTime 是否前进;
  • SecretStore 是否可用;
  • 控制器事件是否显示权限、网络或远程键错误;
  • 目标 Secret 的更新时间是否随外部值变化。

11.2 检查 Pod 看到的值

对于文件卷,检查文件修改时间和内容摘要比直接打印明文更安全:

kubectl exec -n payments deploy/payments-api -- \
  sh -c 'stat /var/run/secrets/db/password && sha256sum /var/run/secrets/db/password'

如果应用使用环境变量,检查 Pod 是否在 Secret 更新后重建:

kubectl get pods -n payments -l app=payments-api \
  -o custom-columns=NAME:.metadata.name,START:.status.startTime

如果使用 subPath,应优先怀疑挂载不会更新。若应用只在启动时读取文件,则需要显式重启或让应用实现热加载。

11.3 检查 API Server 和 KMS

KMS 相关故障通常首先出现在 API Server 日志中。应检查:

  • KMS socket 是否存在且权限正确;
  • KMS 插件是否运行;
  • 外部 KMS 身份是否有效;
  • 密钥是否被禁用或删除;
  • provider 配置是否在所有 API Server 一致;
  • 旧密钥是否仍保留到迁移完成。

不要在排障时直接删除旧密钥。静态加密轮换中的旧密钥可能仍是读取历史对象所必需的。


12. 生产环境中的取舍

12.1 只使用 Kubernetes Secret

优点是结构简单、组件少、与原生 API 集成好。缺点是:

  • 凭据来源和轮换依赖 Kubernetes 侧流程;
  • 运维人员可能直接接触 Secret;
  • etcd、备份和 API 权限必须自己完整治理;
  • 应用若不支持热加载,轮换需要发布。

适合凭据数量有限、已有成熟 API Server 加密和轮换流程的环境。

12.2 使用 External Secrets 加外部 KMS

优点是:

  • 外部系统可以作为凭据事实来源;
  • 云端或 Vault 的审计、版本和轮换能力可以复用;
  • Kubernetes manifest 中不必保存明文凭据;
  • 可以按命名空间和 ServiceAccount 限制读取范围。

代价是:

  • 引入控制器、CRD、provider 和身份联邦;
  • 需要处理同步延迟和控制器故障;
  • 外部系统和 Kubernetes Secret 之间存在副本;
  • 同步后的 Secret 仍必须保护;
  • 外部凭据权限设计变得更加重要。

这不是“把 Secret 移出 Kubernetes 就没有泄漏”,而是把泄漏面从单一系统扩展成两个系统之间的同步链路。

12.3 不把 Secret 同步到 Kubernetes

某些应用可以直接使用 Workload Identity 访问外部密钥系统,或通过 CSI 类驱动将外部值挂载到文件系统。这样可以减少 Kubernetes Secret 对象和 etcd 副本,但仍要考虑:

  • 节点和 Pod 是否能读取挂载文件;
  • 驱动是否会创建同步的 Kubernetes Secret;
  • 应用是否支持动态更新;
  • 外部系统不可用时应用如何启动;
  • 节点缓存和临时文件的清理;
  • provider、驱动和云厂商的版本差异。

只有在应用和平台都能正确处理身份、挂载、轮换和故障时,这种架构才有意义。


13. 一份可执行的治理检查路径

可以按下面顺序建立最小可验证闭环。

第一步:确认授权边界

kubectl auth can-i get secrets \
  --as=system:serviceaccount:payments:payments-api \
  -n payments

kubectl auth can-i list secrets \
  --as=system:serviceaccount:payments:payments-api \
  -n payments

如果应用只需要使用挂载后的文件,通常不应授予它直接读取 Kubernetes Secret 的 API 权限。Kubelet 根据 Pod 规范完成挂载,不要求应用 ServiceAccount 直接拥有 get secrets

第二步:确认 Secret 没有以明文进入仓库

git grep -n -i -E 'password|token|private.?key|client.?secret' -- \
  '*.yaml' '*.yml' '*.json' '*.env'

这只是初筛,不能替代历史扫描。扫描结果也可能包含误报,不能因为扫描通过就认为凭据安全。

第三步:确认 etcd 加密配置和迁移状态

检查 API Server 启动参数、配置文件来源、所有控制平面实例的配置一致性,并在受控环境中验证测试对象在 etcd 中的存储状态。确认轮换时遵循:

新 provider 第一位
    ▼
保留旧 provider 读取
    ▼
分批重写所有对象
    ▼
验证旧格式清空
    ▼
删除旧 provider 和 identity

第四步:确认轮换传播路径

为应用记录以下可观察信号:

  • 外部系统新凭据版本;
  • ExternalSecret 最近同步时间;
  • Kubernetes Secret resourceVersion 或更新时间;
  • Pod 文件的修改时间;
  • 应用重新加载时间;
  • 新旧凭据认证成功率;
  • 旧凭据撤销时间。

没有这些信号时,轮换失败往往只表现为“某些 Pod 偶尔认证失败”,很难判断是同步延迟、缓存、连接池还是应用未重载。

第五步:做故障演练

至少验证以下场景:

  1. KMS 暂时不可用;
  2. 外部密钥系统返回权限错误;
  3. External Secrets 控制器重启;
  4. Secret 更新但应用不重启;
  5. 应用使用 subPath 挂载;
  6. 旧业务凭据被提前撤销;
  7. 从 etcd 备份恢复到新集群;
  8. API Server 高可用实例配置不一致。

故障演练的目标不是证明系统永远不会失败,而是确认失败时不会静默使用错误凭据、不会把明文写进日志,并且能够恢复到已知状态。


结语

Kubernetes Secret 治理可以分解为四个不同问题:

  1. 存储保护:Secret 在 etcd、备份和磁盘上是否加密;
  2. 密钥保护:用于加密 Secret 的密钥是否与 etcd 分离,并能可靠轮换;
  3. 来源与分发:凭据是否由外部密钥系统管理,如何同步到 Kubernetes;
  4. 生命周期与泄漏:凭据如何注入、重新加载、轮换、撤销和审计。

最容易出现的错误,是把其中一个问题当成全部安全边界:

  • Base64 不等于加密;
  • TLS 不等于 etcd 静态加密;
  • etcd 加密不等于 API 访问控制;
  • External Secrets 不等于凭据不进入 Kubernetes;
  • Secret 更新不等于应用已使用新值;
  • projected ServiceAccount Token 不等于长期 Token Secret;
  • 删除 Git 中的文件不等于泄漏凭据已经失效。

一个可验证的安全闭环应当是:使用最小 RBAC,启用并验证 etcd 静态加密,优先使用受控 KMS,按需采用 External Secrets,设计有重叠窗口的凭据轮换流程,让应用支持安全重载,并对 API、控制器、节点、日志、备份和外部密钥系统同时进行泄漏审查。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。