Status().Update() 触发 Reconcile 死循环?用 GenerationChangedPredicate 按 generation 过滤
写 Operator 时最容易埋雷的地方往往不在业务逻辑,而在于:每当你对自己 watch 的 CR 写入一次状态,就会把自己重新唤醒。controller-runtime 的 Reconcile 是 level-triggered 的,它不关心这次触发来自哪里,只拿到一个 namespace/name 就重新对账。于是 reconcile → Status().Update() → watch 事件 → 再次 reconcile 这条环一旦形成,轻则单个 Pod CPU 打满,重则把 kube-apiserver 当高频 MQ 压垮。
项目与源码:
controller-runtime由 k8s SIG API Machinery 维护,Kubebuilder 与 Operator SDK 都基于它。
仓库:https://github.com/kubernetes-sigs/controller-runtime
包文档:https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/predicate
先看一条真实的雪崩
某生产集群某次核心链路大面积超时,排查发现 kube-apiserver CPU 直接打满、频繁 OOM 重启,Controller Manager / Scheduler 连不上 API Server 跟着假死。抓审计日志和指标后结论哭笑不得:业务线的自定义 Operator 在 Reconcile 里用 client.Update() 直接改 CR 的 Annotations 记录同步状态,且没配任何 EventFilter。每更新一次,Informer 就产生新事件,形成无延迟死循环,硬生生把 apiserver 拖垮。
GitHub 上也有一堆同类现象:
- #2831:status 更新后 Reconcile 被再次触发。
- #1613:资源超过一定数量后 Reconcile 无限循环、无 rate-limit 狂吃 CPU,最后定位到「每个对象都往 status 里写东西,又触发了一次 update」。
- #3240:官方是否加一个「忽略 status 更新的 predicate」的讨论。
这类问题几乎都不是框架 bug,而是自己写出来的非幂等热循环。
机制:generation / resourceVersion / status 三者要分清
先理清 K8s 元数据里两个版本号的区别,这是整个问题的地基。
| 字段 | 什么时候变 | 说明 |
|---|---|---|
metadata.resourceVersion | 任何写操作都变(spec、status、metadata、annotations、labels 都算) | 每次落 etcd 都自增,Informer 靠它感知变化 |
metadata.generation | 只写 spec(以及部分内置资源的 annotations,见下)才自增 | 是 API server 对「意图变化」的计数 |
Controller 里每次 update 都会把 resourceVersion 顶上去,所以 Informer 一定能收到这条 update 事件。而 generation 不同:只有写 spec 才会 bump。
这就是为什么 controller-runtime 的 watch 链路需要一层过滤——predicate 的作用就是决定「这条 update 值不值得入队」:
For() 指定主 watch 资源
└─ informer watch 到资源变化(create/update/delete)
└─ eventHandler 把 namespace/name 丢进 workqueue
└─ Reconcile(ctx, req) 拿到 key 重新对账
predicate 卡在 eventHandler 这一环:不满足就直接丢弃,根本不入队。因此它不会消耗一次 Reconcile,是最便宜的刹车。
为什么 Status().Update() 会形成死循环
关键在于你写了什么。API server 对 /status 的写入做了特殊处理:不会 bump generation(只有 spec 变更才 bump),但 resourceVersion 照样自增。
于是有两条子路径:
路径 A(写 timestamp / 计数器 / lastSyncTime 这种每次都在变的字段)——纯死循环:
- Reconcile 里
client.Status().Update()写 status; - resourceVersion 自增,Informer 收到 update 事件,把该 CR 再次入队;
- Reconcile 再次执行,又写入一个更新的
lastSyncTime(时间变了); - resourceVersion 又自增……无延迟永动机。
路径 B(status 内容稳定、写进去和现状一样)——只触发一两次就停。因为 API server 会做对象比对,写的内容没变化时 resourceVersion 不会变,事件自然终止(这正是 #2831 里「为什么只跑两三次而不是无限」的解释)。但只要你多写一个每次必变的字段(时间戳、计数器、状态字符串里夹了环境名等),就退回路径 A。
正确姿势一:状态走 status 子资源
CRD 要启用 /status 子资源,代码里禁止用 client.Update() 回写状态——不管是写在 status 里,还是「图省事」塞进 annotations/labels。理由有二:
- 语义正确:spec 是期望状态,status 是控制器反馈,两者混着写违背 K8s 控制循环范式。
- 机制正确:
Status().Update()不会 bump generation,是配合 predicate 降噪的前提。
代码 review 时全局搜 client.Update(),凡是操作对象里夹了状态回写的一律打回。
正确姿势二:挂 GenerationChangedPredicate
这是刹热循环最廉价有效的一道墙:
import (
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/predicate"
)
func (r *DataJobReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&batchv1.DataJob{},
builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Complete(r)
}
它的实现就是比对新旧对象的 GetGeneration():
func (GenerationChangedPredicate) Update(e event.UpdateEvent) bool {
if e.ObjectOld == nil || e.ObjectNew == nil {
return false
}
return e.ObjectNew.GetGeneration() != e.ObjectOld.GetGeneration()
}
spec 变了 generation 才变 → 入队;只是 status/无关 metadata 变了 → generation 相同 → 丢弃。你 Status().Update() 带来的事件恰好被它拦掉,死循环就此切断。
它挡不住的三类情况
不要「挂了就高枕无忧」,GenerationChangedPredicate 有明显边界:
- 改 labels / annotations 不 bump generation。 如果控制器要响应 label/annotation 变更(比如某些 alpha 期 API 用 annotation 当开关),得组合用:
predicate.Or(
predicate.GenerationChangedPredicate{},
predicate.LabelChangedPredicate{},
)
不是所有资源 generation 都只随 spec 变。 对 Deployment 这类内置资源,写 annotations 也会 bump generation。对内置资源(非 CRD)一定要先验证哪些字段会触发 generation 自增,别拿 CRD 的假设硬套。
如果你在 Reconcile 里改了 spec 本身,generation 会永远自增,predicate 也救不了你。 出现这种情况说明对账逻辑写出了「改自己期望状态」的 bug,先修逻辑而不是加 predicate。
别忘了写 status 前的幂等守卫
predicate 拦在入队前,但入队了之后仍可能有重复的 status 写。再补一层:只在 status 真的变化时才写。CRD 开启 status 子资源后,controller-runtime 通常不把 status 放进主对象的 spec 更新,配合 equality.Semantic.DeepEqual 或对条件做字段级比较,能进一步减少无谓写入:
if !equality.Semantic.DeepEqual(cr.Status.LastSyncTime, wantTime) {
return ctrl.Result{}, r.Status().Update(ctx, cr)
}
return ctrl.Result{}, nil
事件过滤的三层,别只在 predicate 死磕
降噪其实有三层,越靠前越省:
- cache 层(
cache.Options.ByObject,label/namespace selector):informer 根本不 watch 你不关心的对象,最省内存和 watch CPU。适合过滤条件稳定(固定 label 或已知 namespace 列表)的场景。 - predicate 层(
WithEventFilter/builder.WithPredicates):适合「丢掉 status-only 更新」这种按 watch 粒度裁剪的需求,因为 cache 仍需要保留最新对象供r.Get()返回。 - Reconcile 层:对账逻辑本身幂等、level-triggered。
记一句判断标准:先让 Reconcile 幂等、别误改 spec、status 只在变化时写、predicate 处理 status-only 更新;调 MaxConcurrentReconciles 和 workqueue rate limit 是最后一步,是在停止制造无意义事件之后才考虑的事。 事件源头掐不断,并发再高也是白搭。