标签: Workqueue

  • 深入 K8S Operator 陷阱排查:Reconcile 同步阻塞调用引发的 Workqueue 饥饿与 Finalizer 级联死锁实战

    在编写 K8S Operator 时,严禁在 Reconcile 循环中执行长时间的同步阻塞调用。这种设计会导致 controller-runtime 的 worker 线程被迅速耗尽,Workqueue 深度持续飙升,最终引发全局事件处理停滞与 Finalizer 无法卸载的级联死锁。本文将剖析同步 I/O 引发的队列饥饿问题,并给出基于异步状态机的重构方案。

    案发现场:诡异的 Terminating 堆积

    某次排查生产环境(K8S v1.28,基于 controller-runtime v0.16.3 开发的定制 Operator)故障时,发现大量自定义资源(CR)陷入 Terminating 状态无法回收:

    $ kubectl get mycr -n prod-ns
    NAME         STATUS        AGE
    cluster-01   Terminating   2d
    cluster-02   Terminating   1d
    cluster-03   Pending       12h
    cluster-04   Pending       10h
    

    不仅删除操作卡死,新建的 CR 也一直处于 Pending 状态,没有任何调谐(Reconcile)日志输出。查看 Operator 所在 Pod 的资源使用率,CPU 消耗接近 0,内存曲线平稳,APIServer 也没有明显限流报错。系统表现得就像是“睡着了”。

    深度排查:Metrics 与 Goroutine 现场取证

    既然资源指标正常,说明不是死循环导致的 OOM 或 CPU 打满,大概率是发生了死锁或线程耗尽。

    首先暴露并检查 controller-runtime 内置的 Prometheus metrics:

    # 查看队列深度
    $ curl -s http://localhost:8080/metrics | grep workqueue_depth
    workqueue_depth{name="mycr-controller"} 154
    
    # 查看正在处理的元素数
    $ curl -s http://localhost:8080/metrics | grep workqueue_unfinished_work_seconds
    workqueue_unfinished_work_seconds{name="mycr-controller"} 84600.5
    

    指标显示,队列中积压了 154 个事件,而 unfinished_work 的时间长得离谱。这说明 controller 的 Worker 拿到了事件,但迟迟没有返回(Done() 未被调用)。

    立即通过 pprof 抓取 Goroutine 堆栈:

    $ curl -s http://localhost:8080/debug/pprof/goroutine?debug=2 > goroutine.txt
    

    分析 goroutine.txt,发现核心 Worker 线程全部阻塞在底层的 HTTP 读等待上:

    goroutine 123 [IO wait, 84600 minutes]:
    net/http.(*persistConn).readLoop(0xc000456780)
        /usr/local/go/src/net/http/transport.go:2100 +0x2c0
    ...
    github.com/my-org/my-operator/controllers.(*MyCRReconciler).Reconcile(0xc000213450, {0xcontext_addr, 0xcontext_val}, {{"prod-ns", "cluster-01"}})
        /workspace/controllers/mycr_controller.go:85 +0x1a4
    sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).Reconcile(0xc000567890, {0xcontext_addr, 0xcontext_val}, {{"prod-ns", "cluster-01"}})
    ...
    

    源码 /workspace/controllers/mycr_controller.go:85 对应的逻辑是:Operator 在删除 CR 时,通过 Finalizer 拦截了删除请求,并调用外部云厂商的 API 去销毁底层的云数据库实例。由于该销毁 API 不是异步接口,且底层的 HTTP Client 没有设置 Timeout,导致 Worker 永远卡在 readLoop 中。

    为什么默认配置下的 Controller 会因为单个异常 CR 陷入全局停滞?

    要理解这个现象,必须认清 controller-runtime 的底层工作模型。

    在 controller-runtime 中,Controller 的默认并发数 MaxConcurrentReconciles 被硬编码为 1。这意味着,如果你在 SetupWithManager 时没有显式修改并发数,Controller 只有一个 Worker 协程在默默处理 Workqueue 中的元素。

    // sigs.k8s.io/controller-runtime/pkg/builder/controller.go 默认配置
    func (blder *Builder) Build(r reconcile.Reconciler) (controller.Controller, error) {
        if blder.opts.MaxConcurrentReconciles == 0 {
            blder.opts.MaxConcurrentReconciles = 1
        }
        ...
    }
    

    当某一个 CR(例如 cluster-01)触发了删除事件,Worker 从队列中 Pop 出该事件并进入 Reconcile 函数。此时,因为外部 API 调用阻塞(没有 Timeout),这个唯一的 Worker 协程被挂起。

    Workqueue 的机制是:一个元素在被 Done() 标记前,不会被再次加入处理逻辑;但更致命的是,由于唯一的 Worker 被卡死,队列中后续追加的所有其他 CR 的事件(无论是 Create 还是 Update)都无法被消费,积压在队列中,导致全局雪崩。

    同时,由于该 CR 带有 Finalizer:

    1. 用户执行 kubectl delete。

    2. K8S APIServer 给对象打上 DeletionTimestamp,但因为 Finalizer 还在,对象转入 Terminating 状态。

    3. Reconcile 阻塞,永远走不到移除 Finalizer 的代码。

    4. K8S 中的资源发生死锁,堆积如山。

    防御性编程重构:异步状态机与超时控制

    要彻底根治此类问题,必须在 Operator 开发中贯彻“防御性编程”思想:Reconcile 必须是快速、幂等且非阻塞的。

    1. HTTP 客户端必须强制超时

    永远不要相信外部 API。所有涉及外部调用的 HTTP Client 必须设置严格的 Timeout。

    // 错误示例:默认 Client,可能无限阻塞
    // client := &http.Client{} 
    
    // 正确示例:严格的超时控制
    client := &http.Client{
        Timeout: 10 * time.Second,
    }
    

    2. 拥抱异步状态机与 RequeueAfter

    将同步等待外部资源就绪/销毁的逻辑,改为异步查询并主动将事件重新放回队列(Requeue)。

    重构后的 Reconcile 核心逻辑如下:

    func (r *MyCRReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        var mycr v1alpha1.MyCR
        if err := r.Get(ctx, req.NamespacedName, &mycr); err != nil {
            return ctrl.Result{}, client.IgnoreNotFound(err)
        }
    
        // 处理 DeletionTimestamp 非空的逻辑 (Finalizer 拦截)
        if !mycr.ObjectMeta.DeletionTimestamp.IsZero() {
            if controllerutil.ContainsFinalizer(&mycr, "mycr.example.com/finalizer") {
                // 发起异步销毁请求,而不是在这里等
                status, err := r.CloudClient.AsyncDelete(mycr.Spec.InstanceID)
                if err != nil {
                    return ctrl.Result{}, err // 触发 Workqueue 的默认指数退避重试
                }
    
                // 外部资源还没删除完毕,等待 10 秒后重新入队检查
                if status != "DELETED" {
                    return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
                }
    
                // 外部资源确认销毁,移除 Finalizer
                controllerutil.RemoveFinalizer(&mycr, "mycr.example.com/finalizer")
                if err := r.Update(ctx, &mycr); err != nil {
                    return ctrl.Result{}, err
                }
            }
            // Finalizer 已被移除,CR 会被 K8S 自动回收
            return ctrl.Result{}, nil
        }
    
        // ... 其他创建/更新逻辑 ...
        return ctrl.Result{}, nil
    }
    

    3. 适当调高并发度

    对于确实有大量 I/O 交互的 Controller,不应局限于单 Worker 模型。在初始化 Controller 时显式调高并发处理数,提升系统的吞吐能力及容错性。

    func (r *MyCRReconciler) SetupWithManager(mgr ctrl.Manager) error {
        return ctrl.NewControllerManagedBy(mgr).
            For(&v1alpha1.MyCR{}).
            // 提升并发至 10,避免单一 CR 的慢查询卡死整个 Controller
            WithOptions(controller.Options{MaxConcurrentReconciles: 10}).
            Complete(r)
    }
    

    常见问题 (FAQ)

    Q1:为什么在 Reconcile 中刚更新了 CR 的 Status,又立刻触发了再一次的 Reconcile? K8S 的 Informer 会监听所有资源变动。更新 Status 同样会修改对象的 ResourceVersion,从而触发一次 Update 事件被推入 Workqueue。为了避免死循环,应在 SetupWithManager 时使用 Predicate 进行事件过滤,比如 predicate.GenerationChangedPredicate{},确保只有在 Spec 变动(导致 Generation 递增)时才触发 Reconcile,而 Status 的变动被忽略。

    Q2:使用 RequeueAfter 返回时,Workqueue 的指数退避 (Exponential Backoff) 是如何生效的? 如果你返回 ctrl.Result{RequeueAfter: 5*time.Second}, nil,元素会在精确的 5 秒后再次入队,此时不会触发指数退避。指数退避(默认从 5ms 起步,最大 1000s)仅在你返回 error 时由底层的 ItemExponentialFailureRateLimiter 触发。区分好“业务等待”(用 RequeueAfter)和“异常重试”(返回 error),是避免 APIServer 遭受请求风暴的关键。

    Q3:如何避免 Informer 缓存未同步导致在 Reconcile 中刚更新完数据,下一次 Requeue 却读到旧数据? 由于 APIServer 到 Informer Cache 存在微秒/毫秒级延迟,高频更新时 r.Get() 可能读到过期数据,进而引发乐观锁冲突(the object has been modified)。除了解冲突重试外,如果必须读取绝对最新的状态,可以使用 APIReader(通过 mgr.GetAPIReader() 获取)绕过 Cache 直接查 APIServer,但这会增加 APIServer 的压力,只建议在强一致性要求的兜底检查中使用。