AI时代的Kubernetes教程36招-第27招-Pod Certificates和ClusterTrustBundle怎么建立工作负载身份?
Kubernetes 1.37 Pod Certificates 与 ClusterTrustBundle:为数字人服务建立原生 mTLS 身份
证书生命周期的每一次错位,都会变成线上一次信任崩塌。
[读者] 平台工程师 / 负责多集群工作负载身份
[痛点] 证书靠外部组件灌注,轮换与 Pod 生命周期脱节
[现在读] 1.37 把 Pod 证书与信任锚做进原生 API
[读完] 能判断何时换成原生方案,并落地一版可验证的 mTLS
[旧方案] cert-manager 签发 + Secret 挂载 + 手工分发 CA
|
v
[新需求] 短生命周期、随 Pod 生死、按身份隔离
|
v
[冲突] 外部签发链路长、信任锚多源、失效与 Pod 不同步
|
v
[后果] 轮换失败即大面积 mTLS 握手失败
我是老李,在一家做数字人的公司负责平台架构。我们的服务不算多,但每个都敏感:渲染、语音驱动、口型对齐、对话编排、资产库,任何两个服务之间说话都要双向 TLS。
过去两年,这套身份体系一直放在 deploy/security/workload-identity/ 目录下:cert-manager 负责签发,Secret 负责落地,ConfigMap 负责分发根 CA,应用启动时读一次就再也不管。
直到我们把证书寿命从 90 天压到几个小时,Pod 也开始按秒级滚动,老链路忽然就不够用了。
当证书寿命短过 Pod 的生命周期,签发链路还站得住吗?
信任锚到底应该是集群级资源,还是每个应用自己维护的一份配置?
如果这两件事选错了,代价会落在哪一层?
01、故事
我们最初的需求很朴素:让数字人内部服务之间能确认对方不是冒名顶替者。V1 方案选的是最常见的那条路——cert-manager 监听 Certificate 资源,签发后写入 Secret,Pod 通过 volume 把 Secret 挂进来,应用启动时读私钥和证书,再读一份根 CA。
这套方案在 8 个服务、证书 90 天有效期的时候非常舒服。变更少、链路短、出问题一眼能看出来。
变化从去年开始。我们把服务拆到了 40 多个,数字人直播和实时对话的链路要求 Pod 随时可以滚动、扩缩容。安全团队同时提了一个很硬的要求:工作负载证书寿命不得超过数小时,私钥不能长期落盘在 Secret 里。
任务是为每一个 Pod 提供一份随它创建、随它消失、还能自动轮换的身份材料,并让所有服务信任同一套根。
冲突立刻暴露:旧方案里,证书和 Pod 没有关系,它属于 Secret;Secret 更新了,Pod 里的文件不一定更新;文件更新了,进程内存里的旧证书也不一定更新。
身份跟着资源走
信任跟着版本走
轮换跟着运气走
故障跟着感觉走
02、问题
旧方案失效的第一个信号,是轮换窗口里的握手失败。
业务侧看到的是数字人直播链路中断、口型与语音不同步;技术侧看到的是日志里成片的 x509: certificate has expired or is not yet valid 和 x509: certificate signed by unknown authority。
技术表现可以归成三类:一是证书文件更新后,进程仍在用启动时加载的旧证书;二是不同命名空间的信任锚版本不一致,A 服务信的新根,B 服务还没加载;三是 Pod 重建后,新证书和旧私钥短暂不匹配,mTLS 直接拒绝。
我们给自己定了可验证的完成标准:单个 Pod 从创建到具备 mTLS 能力全自动;证书到期前自动刷新且进程无感;信任锚只有一个权威来源;不依赖任何外部签发组件在关键路径上在线。
问题不在签发慢
而在交付断
断在生命周期
断在信任源
03、原理
Kubernetes 1.37 把 Pod Certificates 与 ClusterTrustBundle 推到了 GA。它的核心思路,是把工作负载身份从“配置”变成“运行时属性”。
按官方博客和文档描述,Kubelet 会代表 Pod 创建 PodCertificateRequest,由 Signer Controller 完成签发与轮换,最终以私钥、证书链和信任锚的形式投影进容器。ClusterTrustBundle 则承担集群级信任锚的分发职责。具体字段、准入与刷新时序需按当前官方文档核验。
反直觉判断:证书轮换的难点从来不是签发速度,而是“谁持有私钥”和“谁负责把新证书原子地交给进程”。签发再快,如果读取方不刷新,轮换就是失败的。
另一个关键点是身份的表达方式。传统做法把身份绑在 DNS 名称上,Pod 证书更接近 SPIFFE 风格的工作负载身份:身份由 signerName 和 Pod 上下文共同决定,而不是由某个 Secret 的名字决定。这意味着未来迁移到 SPIFFE URI SAN 时,路径是连续的。
签发只是起点
刷新才是终点
私钥不出节点
信任不落多处
04、架构
[输入] Pod 调度到节点,声明 projected volume
|
v
[模块] Kubelet / PodCertificateRequest / Signer Controller
|
v
[数据/状态] 节点本地私钥、已签发 X.509、ClusterTrustBundle
|
v
[处理] 投影挂载 + 到期前刷新 + 信任锚同步
|
v
[输出] 容器内私钥/证书/CA 文件,供 mTLS 使用
这条链路的边界很清楚:Kubelet 负责请求与投影,Signer Controller 负责签发,ClusterTrustBundle 负责信任锚分发,应用只负责读取文件并热加载。任何一环把责任推给下一环,链路都会断。
收益是生命周期一致:Pod 在,证书在;Pod 走,证书走。代价是控制面压力上升,尤其是短寿命证书带来的高频请求;同时应用必须支持热加载,否则再好的刷新机制也白搭。
适用条件是集群版本已升级到 1.37,且 Signer Controller 与集群准入策略已就绪。如果集群仍在旧版本,需按当前官方文档核验可用的替代路径。
链路由谁管
责任要分明
边界画得清
故障才可寻
05、实战一次
环境是 Kubernetes 1.37 集群,Pod Certificates 与 ClusterTrustBundle 已 GA。依赖是集群内已部署自定义 Signer Controller,并已创建 ClusterTrustBundle 作为根信任锚。以下清单中的字段名与准入规则需按当前官方文档核验。
先创建信任锚对象,示意如下:
apiVersion: certificates.k8s.io/v1beta1
kind: ClusterTrustBundle
metadata:
name: digital-human-root-ca
trustBundle:
signerName: digital-human.internal/signer
bundle: |
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
再让 Pod 通过 projected volume 同时挂载证书与信任锚:
apiVersion: v1
kind: Pod
metadata:
name: avatar-identity-demo
namespace: digital-human
spec:
serviceAccountName: avatar-renderer
containers:
- name: app
image: nginx:1.27-alpine
volumeMounts:
- name: identity
mountPath: /var/run/identity
readOnly: true
volumes:
- name: identity
projected:
sources:
- podCertificate:
signerName: digital-human.internal/signer
keyType: ED25519
credentialBundlePath: credential-bundle.pem
keyPath: private.key
certificateChainPath: cert-chain.pem
- clusterTrustBundle:
name: digital-human-root-ca
path: ca-bundle.pem
应用启动后,/var/run/identity 下应出现私钥、证书链与信任锚。未在当前环境实测,以下为预期结果:
$ kubectl exec -n digital-human avatar-identity-demo -- ls -l /var/run/identity
total 12
-rw------- 1 root root 119 Jul 10 08:00 private.key
-rw-r--r-- 1 root root 892 Jul 10 08:00 credential-bundle.pem
-rw-r--r-- 1 root root 892 Jul 10 08:00 cert-chain.pem
-rw-r--r-- 1 root root 1024 Jul 10 08:00 ca-bundle.pem
示例输出之后立刻验证证书链:
$ kubectl exec -n digital-human avatar-identity-demo -- \
openssl verify -CAfile /var/run/identity/ca-bundle.pem \
/var/run/identity/cert-chain.pem
/var/run/identity/cert-chain.pem: OK
V1 到这里能跑通,但它留下了两个问题:进程只读一次文件,以及信任锚更新与证书更新不同步。
先跑通一步
再看清裂口
不急着优化
证据先到手
06、排查
第一条诊断链来自一次直播链路中断。现象是 Pod 运行约 30 分钟后,服务间握手开始间歇性失败。怀疑是证书没有刷新。检查挂载目录的文件时间戳和进程日志,证据是文件已经是新证书,但进程日志显示它仍在使用启动时加载的旧证书。根因是应用只读一次文件,没有监听投影卷的更新。修复方式是引入文件变更监听,在证书替换后重新加载。
这里我犯过一个错。错误尝试: 把证书寿命从数小时调回 7 天,想用长寿命换取稳定。为什么错误:这等于用扩大泄露窗口来掩盖读取方不刷新的问题,一旦证书泄露,攻击窗口从小时级变成周级,而且下次寿命再压缩时问题会原样复发。
第二条诊断链出现在跨命名空间调用。现象是 A 服务访问 B 服务时报 unknown authority。怀疑是 ClusterTrustBundle 没有正确分发。检查两个命名空间里挂载的 ca-bundle.pem,证据是 B 服务的信任锚已经是新版本,A 服务仍是旧版本,而进程缓存的是旧根。根因是信任锚刷新与证书刷新是两条独立的时间线,应用只监听了一个文件。修复方式是同时监听证书目录和信任锚文件,并在重载时做一次版本一致性检查。
现象先入眼
证据再开口
根因不靠猜
修复才长久
07、优化
严格基于第 06 章的证据,V2 只改两件事。
第一个根因是应用只读一次文件。修改方式是用文件系统事件监听挂载目录,捕获投影卷的原子替换,然后在内存中重新加载证书与私钥。原因是投影卷的更新是文件级替换,监听目录比监听单个文件更可靠。新行为是证书替换后秒级感知,旧连接继续完成,新连接使用新证书。验证方式是手动触发一次轮换,观察日志里重载记录与后续握手是否成功。
第二个根因是信任锚与证书刷新不同步。修改方式是把证书目录与信任锚文件放在同一个投影卷中,重载时先读信任锚再读证书,确保二者来自同一次快照。原因是 mTLS 握手要求证书链与信任锚版本一致,任何一侧超前都会报未知权威。新行为是握手使用一致版本,验证方式是并发建立连接,观察是否出现 unknown authority。这里只写成验证方案,不写成已经发生的压测结果。
应用侧的核心加载逻辑示意如下:
func loadIdentity(dir string) (*tls.Certificate, error) {
certPEM, err := os.ReadFile(filepath.Join(dir, "credential-bundle.pem"))
if err != nil {
return nil, err
}
keyPEM, err := os.ReadFile(filepath.Join(dir, "private.key"))
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, err
}
return &cert, nil
}
根因定方向
改动不越界
行为可观测
验证再收口
08、演进
[同一输入]
|
+--[V1] cert-manager + Secret 挂载 / 长寿命、外部依赖强
|
+--[V2] 原生 Pod Certificates + ClusterTrustBundle / 短寿命、随 Pod 生死
|
[Trade-off]
得到:生命周期一致、信任锚统一、私钥不出节点
失去:控制面请求上升、应用需支持热加载、调试面更复杂
正确性上,V2 把证书与 Pod 生命周期绑定,消除了 Pod 重建后旧证书复用的窗口。稳定性上,V2 依赖控制面与 Kubelet 的刷新时序,需要监控 PodCertificateRequest 的失败率,而不是只盯应用日志。
复杂度上,V1 的问题集中在外部组件,V2 的问题分散在控制面、节点和应用三处,排查路径更长。成本上,短寿命证书会增加签发与刷新频率,需要在控制面容量上留出余量。适用范围上,V2 适合 Pod 频繁滚动、身份敏感的服务;对长期驻留且变更极少的边缘负载,旧方案仍然可用。
遗留问题有两个:SPIFFE 风格的 URI SAN 还没有完全落地,跨集群信任锚联邦也尚未纳入。
旧路有旧价
新路有新债
取舍看场景
不问对与错
09、洞见
9.1 身份是运行时属性,不是配置
反直觉判断:把证书当配置管理,资源会越管越多;把证书当运行时属性,管理面反而更小。V1 里每个 Secret 都是一份需要同步的状态,V2 里身份由 Pod 上下文推导,状态从配置文件里消失,转移到了控制面。
9.2 轮换的瓶颈在读取方
反直觉判断:签发不是瓶颈,读取才是。第 06 章的证据已经说明,证书早就换好了,是进程不肯换。任何身份方案如果不解决读取方的热加载,短寿命只会放大故障频率。
9.3 信任锚必须只有一个权威
ClusterTrustBundle 的价值不在于少写几行配置,而在于把信任锚从各服务自维护的副本,收敛成集群级对象。多源信任锚在 V1 里还能靠人工同步撑住,服务数量一上来就必然分叉。
9.4 迁移要留兼容期
新旧证书体系切换时,必须允许一段时间双信任锚并存,否则滚动发布会把旧 Pod 和新 Pod 拆成两个互不信任的世界。兼容期不是技术债,而是迁移的必需品。
状态别外挂
读取要重构
信任归一源
迁移留余地
10、系统落地
原来 deploy/security/workload-identity/ 目录下放着 cert-manager 的 Certificate 清单、CA ConfigMap 和挂载配置,身份材料由外部组件写入 Secret。
本篇新增了三样东西:projected volume 中的 podCertificate 源、指向 ClusterTrustBundle 的信任锚挂载、以及应用侧的文件监听与重载逻辑。
现在能做的事是:单个 Pod 创建后自动获得短寿命身份,通过投影卷拿到私钥、证书链和信任锚,并完成一次可验证的 mTLS 握手;证书到期前由 Kubelet 与 Signer Controller 驱动刷新。
还缺三块:SPIFFE 风格的 URI SAN 表达、跨集群信任锚联邦、以及基于身份的准入与授权策略。下一步是把当前基于 DNS 名称的校验逐步迁到 URI SAN,再把 ClusterTrustBundle 与授权策略串起来,让身份不仅可用,还能被策略消费。
旧底先保留
新层再叠加
能力可交付
缺口要标清
11、小结
Q1 → 证书寿命短过 Pod 生命周期后,旧签发链路必须换成与 Pod 同生命周期、由 Kubelet 与 Signer Controller 驱动的机制
Q2 → 信任锚应由集群级 ClusterTrustBundle 统一分发,而不是每个应用维护一份副本
Q3 → 选错会把问题从签发转移到运行时读取和信任同步,代价是间歇性握手失败
状态 → 一套基于原生 Pod 证书的 mTLS 身份可落地,并能在不重启 Pod 的前提下持续轮换
三问有回应
一路有证据
身份可轮换
信任有归处
12、作业
12.1 理解题:为什么证书轮换的瓶颈常在读取方?
参考答案:签发只决定证书什么时候可用,读取方决定证书什么时候生效。进程如果只在启动时加载一次证书,投影卷更新得再快也不会进入握手路径,因此短寿命证书会把原本隐藏的读取问题放大成高频故障。
12.2 实战题:写一个监听投影卷并热加载证书的最小实现
参考答案:监听挂载目录,而不是单个文件;在事件到达后重新读取证书链与私钥并构建 tls.Certificate;用原子变量替换证书对象;在重载失败时保留旧证书并上报事件,避免把进程打挂。验证时手动触发一次轮换,观察日志与后续握手。
12.3 排障题:服务间报 unknown authority,如何排查?
参考答案:先确认双方挂载的信任锚内容是否一致,再确认进程是否加载了最新版本,最后确认证书链是否由同一签发者签发。若两侧都正确仍报错,检查是否处于迁移期的双信任锚阶段,以及进程是否缓存了旧根。
12.4 架构判断题:什么情况下不该用原生 Pod 证书?
参考答案:当集群版本尚未支持、Signer Controller 无法维护、或应用完全不具备热加载能力且无法改造时,不宜强行切换。对变更极少、寿命要求宽松的边缘负载,保留原有签发链路也是合理选择。
题题有落点
答答有依据
会做也会判
才算真掌握
13、思考
回到最初那个冲突:证书寿命短过 Pod 生命周期时,问题就不再是签发够不够快,而是身份材料和信任锚能不能跟着 Pod 一起走、一起换。
我最后形成的工程判断是:把身份当运行时属性,把信任锚收敛成唯一权威,把读取方的热加载当成方案的一部分而不是附赠品。只要这三件事有证据、有验证、有边界,原生 Pod 证书就不是一个新玩具,而是一条能让 mTLS 身份长期稳定运行的路径。
身份随行走
信任归一源
读取须重构
证据定取舍

浙公网安备 33010602011771号