CRD状态更新引发的死循环:从Controller-Runtime源码拆解Informer机制

下午两点半,监控大屏准时弹出了一条飞书告警。K8S集群的 API Server 请求延迟突增,apiserver_request_total 指标显示 UPDATEWATCH 请求的 QPS 在过去十分钟内翻了近一百倍。 顺着链路查下去,源头是一个业务团队刚灰度上线的自定义 Operator。查看该 Pod 的资源消耗,CPU 已经被打满到 4 核,内存也在持续攀升。 抓了个 pprof 现场:

go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30

在火焰图中,CPU 时间大量消耗在 k8s.io/apimachinery/pkg/util/json.Unmarshal 以及 k8s.io/client-go/tools/cache.(*DeltaFIFO).Pop 上。这症状太典型了——标准的 Controller Reconcile 死循环引发的自杀式 DDoS。 把他们的代码拉下来看了一眼,核心逻辑精简后如下:

func (r *MyCustomReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var instance myv1.MyCRD
    if err := r.Get(ctx, req.NamespacedName, &instance); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }
    // ... 执行业务逻辑,比如创建 Deployment 等 ...
    // 更新状态
    instance.Status.Phase = "Running"
    instance.Status.LastUpdateTime = metav1.Now() // 罪魁祸首之一
    if err := r.Status().Update(ctx, &instance); err != nil {
        return ctrl.Result{}, err
    }
    return ctrl.Result{}, nil
}

这段代码看起来符合直觉:“获取对象 -> 处理逻辑 -> 更新状态”。但在 K8S 声明式 API 和 Informer 机制的底层运作下,这就是一颗定时炸弹。

拆解死循环的底层逻辑

controller-runtime 的框架下,Controller 的运作高度依赖 Informer 机制。开发者很容易将其当作一个普通的 CRUD 模型,却忽略了底层的事件流。 当 r.Status().Update 被调用时,究竟发生了什么?

  1. API Server 的响应:更新 Status 成功后,API Server 会持久化这个对象到 etcd。在这个过程中,对象的 metadata.resourceVersion 会不可避免地发生递增。

  2. Reflector 捕获变更:kube-apiserver 会通过 chunked 编码的 HTTP 长连接,将 Watch Event 推送给 Informer 的 Reflector。Reflector 看到 resourceVersion 变了,认为对象发生了更新。

  3. DeltaFIFO 入队:Reflector 将一个 Updated 类型的 Delta 压入 DeltaFIFO

  4. 触发 Event Handler:Informer 的 Controller(不是你写的 Reconciler,是 client-go 内部的 controller)从 DeltaFIFO Pop 出这个 Delta,更新本地的 ThreadSafeStore(也就是你平时 r.Get 命中的本地缓存),并回调注册的 ResourceEventHandler。

  5. WorkQueue 积压controller-runtime 默认的 Handler 会将这个发生变更的对象的 NamespacedName 压入限速队列(RateLimitingQueue)。

  6. Reconcile 再次执行:你的 Reconciler 从队列取出这个请求,再次执行逻辑。因为代码里每次都无条件更新 LastUpdateTime,导致 Status 再次发生实质性改变。 这就形成了一个完美的闭环:Reconcile -> Update Status -> ResourceVersion++ -> Watch Event -> Enqueue -> Reconcile。 每秒几百次的循环,不仅把 Operator 自身的 CPU 榨干,还连带把 API Server 的带宽和处理能力吃爆。

阻断风暴:Generation 与 Predicate 机制

K8S 在设计之初就考虑到了这个问题。为了区分“用户期望状态的变更(Spec)”和“系统当前状态的变更(Status)”,引入了 metadata.generation 这个元数据。 按照规范,只有当 CRD 的 Spec 部分发生变更时,API Server 才会递增 generation。而 Status 的更新,只会导致 resourceVersion 递增,generation 保持不变。 前提是你必须在 CRD 的定义中显式启用了 status 子资源:

//+kubebuilder:subresource:status
type MyCRD struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
    Spec   MyCRDSpec   `json:"spec,omitempty"`
    Status MyCRDStatus `json:"status,omitempty"`
}

如果没有启用 /status 子资源,r.Status().Update 底层会回退到全量更新对象,这会导致 generation 错误地递增,问题会更难排查。 要阻断上述死循环,最标准的第一道防线是使用 GenerationChangedPredicate。在 Controller Setup 时进行过滤:

import "sigs.k8s.io/controller-runtime/pkg/predicate"
func (r *MyCustomReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&myv1.MyCRD{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
        Complete(r)
}

我们翻开 GenerationChangedPredicate 的源码看看它是怎么工作的:

func (GenerationChangedPredicate) Update(e event.UpdateEvent) bool {
    if e.ObjectOld == nil {
        return false
    }
    if e.ObjectNew == nil {
        return false
    }
    // 只有当对象的 generation 发生变化时,才允许事件进入 WorkQueue
    return e.ObjectNew.GetGeneration() != e.ObjectOld.GetGeneration()
}

通过这个拦截器,单纯的 Status 更新引发的 Watch Event,在推送到 WorkQueue 之前就会被直接丢弃。

状态机的最终一致性:Semantic Equality

仅仅加了 Predicate 就可以高枕无忧了吗?并非如此。 在真实的业务场景中,我们可能不仅要监听自己的 CRD,还会 Owns() 其他资源(比如 Deployment)。当底层的 Deployment 状态变化时,我们需要将其反映到 CRD 的 Status 中。此时,更新 Status 依然可能导致不必要的 API Server 写入压力。 正确的做法是,在向 API Server 发起更新请求之前,比较内存中计算出的期望 Status 与本地缓存(或者当前获取到)的 Status 是否存在语义上的差异。 这里不要用标准的 reflect.DeepEqual,因为 K8S 对象在序列化和反序列化过程中,可能会产生一些微小的不可见变化(比如空切片变 nil)。应当使用 apimachinery 提供的 apiequality.Semantic.DeepEqual

import "k8s.io/apimachinery/pkg/api/equality"
// ... 业务逻辑执行完毕 ...
newStatus := myv1.MyCRDStatus{
    Phase: "Running",
    // 尽量避免将时间戳直接写在每次更新的逻辑中,除非业务状态确实发生了改变
}
if !equality.Semantic.DeepEqual(instance.Status, newStatus) {
    instance.Status = newStatus
    if err := r.Status().Update(ctx, &instance); err != nil {
        // 这里需要注意冲突处理,Update 可能抛出 Conflict 错误
        return ctrl.Result{}, err
    }
}

结合这两种手段:入口处用 Predicate 阻挡无效事件,出口处用 Semantic Equality 掐断无意义的写放大,才能算是一个合格的 Kubernetes 控制器。 把这段修复逻辑提交、构建、重新部署之后,看着 Grafana 上 APIServer 的 QPS 曲线像瀑布一样跌回正常水位,Pod 的 CPU 使用率回落到 5m,内存停止了泄漏。 K8S 的架构设计确实优雅,但这种优雅建立在极其严密的声明式边界之上。在 Operator 开发中,当你敲下 Update() 或者 List() 的时候,脑子里必须浮现出背后那条从 Apiserver 到 ETCD,再流转回 Informer DeltaFIFO 的漫长链路。任何对状态的无节制修改,最终都会在系统层面上成倍地偿还。