说了通过、却照样把我们搞垮的准入 webhook
我们运营一个身份认证服务,所以"刚刚启动的那个容器,真的是我们构建的那个容器吗?"这个问题绝不是纸上谈兵。我们的答案是签名:在 CI 里给每个镜像签名,部署前先验证,然后再加一层,在集群里放一个 webhook,好让哪怕是手敲的 kubectl 也无法运行任何未签名的东西。它是在某个周二下午上线的。到了傍晚,我们在 dev 集群上的认证 Deployment 已经堆积了 2,242 个 ReplicaSet,大约每三秒就铸出一个新的,报告 Available=False,什么都没提供。
最显而易见的假设是,新 webhook 在拒绝我们的镜像。并非如此。它的日志对每一个请求都写着 allowed: true。整个晚上,我们发给它的一切它都放行,而它放行的那个 Deployment 却在分崩离析。
我们正在加上的那一层
流水线本来就已经在签名和验证了。每个镜像在构建时都会拿到一个无密钥签名,绑定到 GitHub Actions 的 OIDC 身份,而部署任务会在任何东西到达集群之前,针对该身份做一次验证。那道关卡是 fail-closed 的,它才是真正的控制点。
集群侧那一层是纵深防御:sigstore 的 policy-controller,以准入 webhook 的形式运行,持有两条 policy。一条说,凡是匹配我们自己的镜像仓库路径的镜像,都必须带有来自我们工作流身份的无密钥签名。另一条是一个兜底规则,放行其余一切,因为"没有 policy 匹配"意味着拒绝,而没有这个兜底规则,集群就会失去它的 Vault agent、它的 CSI driver,以及每一个不是它自己构建的 sidecar。安装、给 namespace 打标签、完成。标签就是那个开关:没有标签,就不强制执行。
第一次宕机,平平无奇
用 failurePolicy: Fail 加上 chart 默认的十秒超时把它打开,几乎立刻就把 dev 搞垮了,而且正是每个人都预料准入 webhook 会把你搞垮的那种方式。一个冷启动的 controller 必须联系 Fulcio 和 Rekor,去验证一个它此前没见过的签名。冷启动状态下,这活儿十秒钟干不完。fail-closed 加上超过了截止时间,意味着 pod 创建被拒,意味着 rollout 无法放置 pod,意味着服务没有任何副本。
这种失效模式有充分的文档记载,而修复办法也正是文档里写的那个:把 webhook 的超时提高到三十秒,并设置 failurePolicy: Ignore。Ignore 听起来像是放弃,在单层设计里它确实就是放弃。但在我们的设计里,流水线关卡才是 fail-closed 的主控制,集群这一层的存在是为了兜住那些压根没走过流水线的东西。一个永远不会把集群搞垮的 webhook,对我们来说比一个能兜住最后百分之一的 webhook 更有价值,因为那最后百分之一在上游早已被兜住了。
我们在 17:40 发布了那个改动。它彻底修复了第一次宕机。它也制造了第二次,而这才是值得一读的部分。
Mutating,不只是 validating
每个人都把准入 webhook 想成一个门卫:它检查对象,然后返回是或否。policy-controller 不只是这样。它是一个 mutating webhook,而它所改写的,正是它刚刚验证过的那个东西。当它放行一个用 tag 引用镜像的 pod spec 时,它会把那个引用改写成包含它解析并核对过的 digest。myregistry.io/authagonal-auth:abc123 进去。myregistry.io/authagonal-auth:abc123@sha256:... 出来。
这真的是个好主意。tag 是一个可变的指针,所以先验证一个 tag、之后又让 kubelet 重新解析它,会留下一个两者可能不一致的窗口。在准入时把 digest 钉死,就把这个窗口关上了。运行的那个对象,就是被验证过的那个对象。
现在把这一点和 Deployment 的工作方式摆在一起看。Deployment controller 会对你的 pod 模板做哈希,而这个哈希正是用来标识拥有这些 pod 的那个 ReplicaSet。它哈希的是你声明的那个模板。webhook 改写的是它所放行的那个 ReplicaSet 上的模板。所以如果你 Deployment 的模板写的是 :abc123,它自己的子 ReplicaSet 写的却是 :abc123@sha256:...,两者就不再一致了。
controller 进行 reconcile,对模板做哈希,寻找一个带有该哈希的 ReplicaSet,却找到一个模板不同的。在 Kubernetes 里,这只意味着一件事:哈希碰撞,两个不同的模板落到了同一个哈希上。controller 按它该有的方式处理碰撞。它给 collisionCount 加一,这会扰动哈希,然后创建一个新的 ReplicaSet。三秒之后它再次 reconcile,而这个新的 ReplicaSet 也已经被改写过了。这个循环不会收敛,因为它试图消解的那个分歧,每次它一动手,就被 webhook 重新制造出来。
两千二百四十二个 ReplicaSet,就是当你在傍晚而不是清晨才撞见一个无限循环时它的模样。
为什么只有一个 Deployment 坏了
那个 namespace 里跑着四个 workload。一个陷入了螺旋。三个完全没事,而正是这个细节让我们一直在错的地方找,因为一个系统性的配置错误不该是挑挑拣拣的。
failurePolicy: Ignore 就是原因。当 webhook 变慢或处于冷启动、某个请求超时时,Ignore 意味着对象会被不加改写地放行。某一次 apply 究竟是带着烙进去的 digest 回来,还是带着它进去时的裸 tag 回来,取决于 webhook 有没有及时应答。那三个健康的 Deployment 是在它已经热起来的时候 apply 的,所以它们的模板本就带着 digest,webhook 的改写只是一个与原样相符的 no-op。那个陷入螺旋的,是在 webhook 还冷着的时候 apply 的,模板里保留了裸 tag,随后每一个子 ReplicaSet 都在它脚下被改写掉了。
所以触发因素是一场竞态,每次 apply 各自判定,四个里的任何一个在任何一次部署都可能输掉。Ignore 并没有造成这个 bug。它把一个确定性的 bug 变成了一个时有时无的 bug,并把它藏在三个健康的 workload 背后。
修复办法是一个不动点
本能是去阻止 webhook 改写。更好的本能是:别给它任何可改写的东西。如果我们提交的 pod 模板已经恰好就是 webhook 会产出的样子,那么改写什么也改变不了,哈希保持稳定,循环也就无从开始。
所以现在部署任务会在 apply 之前自己先把 digest 解析出来。对每个镜像,它都会问镜像仓库这个 tag 当前指向什么,然后把完全钉死的引用写进 overlay:
digest=$(az acr repository show --name "$acr" \
--image "authagonal-${img}:${tag}" --query digest -o tsv)
kustomize edit set image "${ref}=${ref}:${tag}@${digest}"
最终发布出去的是 :tag@sha256:...,tag 和 digest 在一起。tag 留着是为了让人易读,digest 才是真正被解析的东西。webhook 验证它,发现没什么可改写的,原样返回这个对象。从那以后 collisionCount 一直是平的。
这件事的尾巴上还藏着一个小陷阱。因为写 digest 的是 CI,从笔记本上手动 apply overlay,发出去的反而是基础 manifest 里的占位 tag,而 policy 现在会正确地以"must be an image digest"拒绝它。集群在对你说实话:你刚敲进去的那个东西,从来没被验证过。
mutating webhook 是第二个写入者,不是一道检查关卡
一个 mutating admission webhook 不是一道检查关卡。它是一个位于控制循环内部的写入者,而这个循环本来就在比较你所要求的和现有的。两个写入者对同一个字段各执一词,这不是一个 policy 问题,而是一个分布式系统问题,而唯一能让它变安全的是幂等性:你声明的状态必须是那次改写的一个不动点,这样把改写作用在它身上,产出的还是它自己。
这种重新框定的视角能推广到 sigstore 之外。任何在准入时改写你 spec 的东西,不管它是注入 sidecar、添加默认资源限制,还是规范化镜像引用,都处在同样的位置,同样的问题也就适用。如果你把自己的 manifest 喂进这个东西,你拿回来的还会是你自己的 manifest 吗?如果不是,就会有某个东西一直不停地注意到那个差异。它会很有耐心,而且它会比你快得多。
还有那个更小的教训,就是那晚我们真正不得不推翻重学的:allowed: true 并不意味着 webhook 不是问题所在。我们花了一个小时把那些日志当成不在场证明。webhook 自始至终都在说实话。是我们在问它错的问题,因为我们一直只把它当成一个会说不的东西。
如果你更希望你的身份提供方一开始就把它的供应链论证清楚,Authagonal 是一个托管的认证服务,它的镜像在 CI 里签名、部署前验证、并在准入这一刻按 digest 钉死,而我们能写下这句话,是因为我们已经为它赔上了一个晚上。