编程 Status().Update() 触发 Reconcile 死循环?用 GenerationChangedPredicate 按 generation 过滤

2026-09-04 00:05:36

Status().Update() 触发 Reconcile 死循环?用 GenerationChangedPredicate 按 generation 过滤

写 Operator 时最容易埋雷的地方往往不在业务逻辑,而在于:每当你对自己 watch 的 CR 写入一次状态,就会把自己重新唤醒。controller-runtimeReconcile 是 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 这种每次都在变的字段)——纯死循环:

  1. Reconcile 里 client.Status().Update() 写 status;
  2. resourceVersion 自增,Informer 收到 update 事件,把该 CR 再次入队;
  3. Reconcile 再次执行,又写入一个更新的 lastSyncTime(时间变了);
  4. resourceVersion 又自增……无延迟永动机。

路径 B(status 内容稳定、写进去和现状一样)——只触发一两次就停。因为 API server 会做对象比对,写的内容没变化时 resourceVersion 不会变,事件自然终止(这正是 #2831 里「为什么只跑两三次而不是无限」的解释)。但只要你多写一个每次必变的字段(时间戳、计数器、状态字符串里夹了环境名等),就退回路径 A。

正确姿势一:状态走 status 子资源

CRD 要启用 /status 子资源,代码里禁止用 client.Update() 回写状态——不管是写在 status 里,还是「图省事」塞进 annotations/labels。理由有二:

  1. 语义正确:spec 是期望状态,status 是控制器反馈,两者混着写违背 K8s 控制循环范式。
  2. 机制正确: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 有明显边界:

  1. 改 labels / annotations 不 bump generation。 如果控制器要响应 label/annotation 变更(比如某些 alpha 期 API 用 annotation 当开关),得组合用:
predicate.Or(
	predicate.GenerationChangedPredicate{},
	predicate.LabelChangedPredicate{},
)
  1. 不是所有资源 generation 都只随 spec 变。 对 Deployment 这类内置资源,写 annotations 也会 bump generation。对内置资源(非 CRD)一定要先验证哪些字段会触发 generation 自增,别拿 CRD 的假设硬套。

  2. 如果你在 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 是最后一步,是在停止制造无意义事件之后才考虑的事。 事件源头掐不断,并发再高也是白搭。

推荐文章

程序员茄子在线接单