Kubernetes 编程 / Operator 专题【左扬精讲】—— 生产级 Operator 最佳实践:并发安全、资源清理与高可用设计

Kubernetes 编程 / Operator 专题【左扬精讲】—— 生产级 Operator 最佳实践:并发安全、资源清理与高可用设计

当一个 application-operator 跑在开发环境、单副本、本地 kubeconfig 上时,它通常看不出任何问题。但一旦部署到生产环境——三副本 Leader 选举、APIServer 短暂抖动、etcd 偶发慢响应、Pod 因 OOM 被驱逐——一系列"开发时根本遇不到"的故障就会集中爆发:同一个对象被并发处理、Finalizer 没释放导致 CR 无法删除、控制器脑裂导致两个 Pod 同时写 Status、WorkQueue 被异常事件堆积撑爆内存……

这一篇我们专门讨论"从能跑到跑得稳"。在前面几篇《Reconcile 循环》、《workqueue》、《Informer 全链路》的基础上,把视角从"单条调用栈"切换到"生产环境下的整条控制回路"。我们会沿着 k8s 1.36.1 的 client-go 与 sample-controller 两条权威主线,把 并发安全、资源清理、限速重试、Leader 选举、优雅退出、观测体系 这六个最容易翻车的能力点讲透。每个能力点都配套源码、并发场景复现、可直接 copy-paste 的代码片段。

读完后你将能够:① 回答"为什么我的 Operator 跑三个月突然把集群搞挂了";② 正确使用 Client-go 的限速队列与 Leader 选举;③ 写出能在 1000+ CR 规模下仍稳定运行的 Reconcile 函数;④ 接入结构化日志、Prometheus 指标、pprof 三大观测手段。

Kubernetes 1.36.1 client-go Operator 生产实践 Leader 选举 并发安全

🔓 学习重点提示  — 建议先通读全文,再重点回顾标注内容

★ 重点掌握(必须)
   • Reconcile 幂等性三原则:状态读全、乐观锁、命名空间隔离
   • WorkQueue 限速器家族:DefaultTypedRateLimiter 的五种算法组合
   • Leader 选举 + 优雅退出:resourceLock 选型与 OnStoppedLeading 钩子
   • Finalizer 资源清理:foregroundDeletion 与反孤儿保护

☆ 次重点(了解即可)
   • Prometheus 指标自注册(ControllerRuntimeMetricsProvider)
   • pprof 在 Operator 中的开启方式
   • Structured Logging 的 zap adapter 接入


目录

  1. 一、生产化 Operator 的六大能力全景图
  2. 二、Reconcile 幂等性:并发安全的地基
  3. 三、WorkQueue 限速器:防止异常事件击垮控制器
  4. 四、Leader 选举:多副本下的脑裂防护
  5. 五、Finalizer 资源清理:CR 删除时的兜底逻辑
  6. 六、优雅退出:让 Pod 重启不再丢事件
  7. 七、观测体系:日志、指标、Profiling 三件套
  8. 八、常见生产事故与排查清单
  9. 九、FAQ(20+ 高频问题)

一、生产化 Operator 的六大能力全景图

我们先把"生产化 Operator"拆成六块能力。开发环境的 Operator 通常只关心"能不能调通 Reconcile",而生产环境关心的是"在各种异常下系统能否自愈"。这六块能力是有层次关系的——下层是上层的依赖,缺一层上层就会塌。

┌──────────────────────────────────────────────────────────┐
│ ① 幂等性(Reconcile 多次跑结果一致) │ ← 并发安全的地基
│ ▼ │
│ ② WorkQueue 限速(异常事件排队,重试退避) │ ← 防止雪崩
│ ▼ │
│ ③ Finalizer 清理(CR 删除前先做收尾) │ ← 资源回收
│ ▼ │
│ ④ Leader 选举(多副本只让一个干活) │ ← 防脑裂
│ ▼ │
│ ⑤ 优雅退出(Pod 关闭时排空队列再退出) │ ← 升级不丢事件
│ ▼ │
│ ⑥ 观测体系(日志+指标+Profiling) │ ← 故障可定位
└──────────────────────────────────────────────────────────┘

我们先建立一个心智模型:Operator 是一个"持续对账"系统。它不断地问集群"我期望的状态 = 你实际的状态吗?",如果不一致就改实际状态向期望状态靠拢。"持续对账"决定了 Reconcile 必然是可重入的:同一个对象、同一个 Reconcile 函数、被调用 N 次,最终结果应该和调用 1 次一致。这就是 ① 幂等性 的本质。

② 限速是幂等性的护城河。如果一个本该被 APIServer 限速的爆炸性事件直接灌进 Reconcile(比如 CR 被外部脚本批量 Patch 1000 次),没有限速队列时 Reconcile 会被打爆,进而引发 OOM 雪崩。WorkQueue 的限速器家族就是为这种场景设计。

③ Finalizer④ Leader 选举⑤ 优雅退出⑥ 观测 都是"非功能需求",但每一条都会决定你的 Operator 能否在 7×24 环境下稳定运行。下面我们一条一条拆。


二、Reconcile 幂等性:并发安全的地基

2.1 为什么 Reconcile 必须是幂等的

很多人写完第一个 Operator 后,会觉得"Reconcile 反正只被叫一次吧?"——这是开发环境的错觉。生产环境里 Reconcile 可能因为以下任一原因被并发或重复调用:

  • APIServer 短暂抖动 → Watch 断流重连:会触发 Relist + 全量入队,Reconcile 跑多次
  • Leader 选举切换:旧 Leader 可能在退出前还在处理某个 key,新 Leader 又会重新入队
  • Reconcile 函数内部出错返回 error:workqueue 退避后重试,相同 key 多次入队
  • Worker 数 > 1:不同 Goroutine 同时从队列取不同 key,并发处理
  • 同 key 被多个事件源触发:Spec 变化、Status 变化、Owner 资源变化、Finalizer 清理,4 种事件可能都指向同一个 CR

幂等性的反面是"线性副作用"——比如 Reconcile 里有这样的逻辑:if !exists { create() }。当两个 Reconcile 并发跑这段逻辑时,A 判断不存在后还没来得及 Create,B 也判断不存在,就重复 Create 了。Kubernetes API Server 会拒绝第二个 Create(资源已存在),但错误处理不当会让 Reconcile 死循环重试。

2.2 幂等性三原则

我把它总结成三条原则:状态读全、乐观锁、命名空间隔离。每一条都对应一个具体的代码模式。

原则 ① 状态读全
每次进入 Reconcile 都从 APIServer Get 一次最新的对象,不要依赖本地缓存(lister)做关键判断。原因:lister 有 1-2 秒延迟,多个 Reconcile 看到的状态可能不一样。Get 操作会带 ResourceVersion,APIServer 会拒绝过期写入。

原则 ② 乐观锁(Optimistic Concurrency)
Update/Patch 时一定要带 ResourceVersion。APIServer 收到 Update 请求后会做 CAS:if rv != obj.resourceVersion { return Conflict },让冲突者重试。这是 k8s 并发控制的灵魂。

原则 ③ 命名空间隔离
Operator 持有的子资源(Deployment/Service/ConfigMap)必须放在与 CR 相同的 namespace,且命名采用 {cr-name}-{component} 模式(如 my-app-deployment、my-app-service)。这样即使 CR 重建,旧的子资源也不会和新的重名冲突。

2.3 源码:sample-controller 里的幂等模式

// staging/src/k8s.io/sample-controller/controller.go

func (c *Controller) syncDeployment(ctx context.Context, deployment *appsv1.Deployment) error {
    // 1) 读全:从 APIServer 拿最新状态
    existing, err := c.kubeclientset.AppsV1().Deployments(deployment.Namespace).Get(ctx, deployment.Name, metav1.GetOptions{})
    if err != nil && !errors.IsNotFound(err) {
        return err
    }

    if errors.IsNotFound(err) {
        // 不存在 → Create
        _, err := c.kubeclientset.AppsV1().Deployments(deployment.Namespace).Create(ctx, deployment, metav1.CreateOptions{})
        return err
    }

    // 2) 存在 → 比对,必要时 Update
    if !reflect.DeepEqual(existing.Spec, deployment.Spec) {
        // 把期望的 ResourceVersion 拷过来做乐观锁
        deployment.ResourceVersion = existing.ResourceVersion
        _, err := c.kubeclientset.AppsV1().Deployments(deployment.Namespace).Update(ctx, deployment, metav1.UpdateOptions{})
        return err
    }
    return nil
}

上面这段代码是幂等性的教科书实现。三步走:Get → 分支 → Create/Update。注意第二步把 existing.ResourceVersion 拷给期望对象再 Update——这就是乐观锁。如果在你 Get 和 Update 之间有人改了资源,APIServer 会返回 409 Conflict,sample-controller 的处理方式是把 error 返回给 Reconcile,由 workqueue 重试整条调用链。

2.4 进阶:Status 子资源与并发安全

把 Spec 和 Status 分离到 /status 子资源后,只有开启 status 子资源,Update Status 才不会破坏 ResourceVersion 的乐观锁机制。原因:开启 status 子资源后,Update Status 是 PUT /status 路径,Update Spec 是 PUT / 路径,两者使用不同的 ResourceVersion 链,互不干扰。

💡 注意
没有开启 status 子资源时,Update Status 实际上走的是 PUT / 路径,会同时改 Spec 和 Status。这种实现下,Reconcile 里"Update Spec 后立刻 Update Status"会触发 Conflict(因为 Update Spec 改变了 ResourceVersion)。生产 CRD 必须 开启 status 子资源。


三、WorkQueue 限速器:防止异常事件击垮控制器

3.1 限速器家族谱

client-go 的 workqueue 包提供 5 种限速器,它们以装饰器方式组合使用:

限速器行为典型场景
DefaultTypedRateLimiter 5 种算法组合(默认) 通用 Operator
BucketRateLimiter 令牌桶,全局速率限制 写入外部系统
ItemExponentialFailureRateLimiter 按失败次数指数退避 APIServer 临时错误
ItemFastSlowRateLimiter 前 N 次快重试,之后慢重试 区分"瞬时错误"和"持续错误"
MaxOfRateLimiter 取多个限速器中"最慢"的那个 多重约束并存

3.2 源码:DefaultTypedRateLimiter 的内部组合

// staging/src/k8s.io/client-go/util/workqueue/default_rate_limiters.go

func defaultTypedRateLimiter[T comparable]() RateLimiter[T] {
    return NewDefaultTypedRateLimiter[T](
        // 1) 每秒 10 QPS 的令牌桶
        NewItemExponentialFailureRateLimiter[T](5*time.Millisecond, 1000*time.Second),
        // 2) 全局 Bucket:每秒 10 个,最多 100 个突发
        NewBucketRateLimiter[T](10, 100),
    )
}

读这段代码要知道:1) 决定了"同 key 失败后多久重试",2) 决定了"队列整体消费速率"。两个一组合,就同时约束了"对单对象的耐心"和"对整个集群的礼貌"。当你看到 Operator 调外部 API 报 429 Too Many Requests 时,绝大多数情况是 NewBucketRateLimiter 的 QPS 设小了。

3.3 自定义限速策略

// 假设我们用 controller-runtime 的 client.Builder 暴露 queue

import (
    "k8s.io/client-go/util/workqueue"
    "time"
)

// 生产级:前 3 次快重试(5ms 起,1s 封顶),之后慢重试(10s 起,1h 封顶)
rateLimiter := workqueue.NewItemFastSlowRateLimiter(5*time.Millisecond, 10*time.Second, 3)

queue := workqueue.NewTypedRateLimitingQueueWithConfig(rateLimiter,
    workqueue.TypedRateLimitingQueueConfig[string]{
        Name: "application-controller",
    })

ItemFastSlowRateLimiter 在生产中非常实用:开发环境里 3 次重试就能成功(开发环境 APIServer 慢请求 99% 是 jitter),到第 4 次仍未成功时切换到慢重试(10s 起步),避免无谓的 APIServer 压力。


四、Leader 选举:多副本下的脑裂防护

4.1 为什么要 Leader 选举

如果 Operator 是 Deployment 跑 3 副本,每个 Pod 都跑 Reconciler,会出现什么问题?

  • 重复创建子资源:3 个 Pod 同时 Reconcile 同一个 CR,每个都 Get → 发现不存在 → Create。结果 3 个 Create 中 2 个被 APIServer 拒绝,但 controller 不知道是 "Rejected because already exists" 还是 "Rejected because conflict",统统重试
  • 重复写 Status:3 个 Pod 同时 Update Status,最后一个写入者覆盖前两个。Status 字段会闪烁(flapping)
  • 重复调用外部 API:调云厂商的 SLB、CDN API,3 倍费用 + 触发厂商 Rate Limit
  • 重复打日志/打指标:监控数据噪点成倍增加

Leader 选举(Leader Election)就是"同一时间只让一个 Pod 干活,其他 Pod 待机"的机制。它由 k8s 内置组件 k8s.io/client-go/tools/leaderelection 提供,底层用 Lease 对象(一个轻量级 CR,由 kube-controller-manager 自身用来做选举)做分布式锁。

4.2 源码:leaderelection.Run 的核心结构

// staging/src/k8s.io/client-go/tools/leaderelection/leaderelection.go

type LeaderCallbacks struct {
    OnStartedLeading func(context.Context)
    OnStoppedLeading func()
    OnNewLeader      func(string)
}

func Run(ctx context.Context, lec LeaderElectionConfig) error {
    // 1) 周期性地 renew lease
    // 2) 拿到 lease 后回调 OnStartedLeading
    // 3) 失去 lease 时回调 OnStoppedLeading
    // 4) 退出/出错时整体退出
}

OnStartedLeading 是在你"赢得选举"后被调用的——通常在这里启动 Reconciler(也可能是 cache.WaitForCacheSync + 启动 workers)。OnStoppedLeading 是在你"失去选举"时调用的——必须在这里做反向收尾:停止 Reconciler、释放外部连接、清空 WorkQueue。

4.3 实战:给 application-operator 加上 Leader 选举

// cmd/manager/main.go(伪代码)

func main() {
    // ... 构造 kubeclient、informerFactory 等
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    lec := leaderelection.LeaderElectionConfig{
        Lock: &resourcelock.LeaseLock{
            LeaseMeta: metav1.ObjectMeta{
                Name:      "application-operator-leader",
                Namespace: "application-operator-system",
            },
            Client: kubeclient.CoordinationV1(),
            LockConfig: resourcelock.ResourceLockConfig{
                Identity: os.Getenv("POD_NAME"),
            },
        },
        ReleaseOnCancel: true,
        LeaseDuration:   15 * time.Second,
        RenewDeadline:   10 * time.Second,
        RetryPeriod:     2 * time.Second,
        Callbacks: leaderelection.LeaderCallbacks{
            OnStartedLeading: func(ctx context.Context) {
                log.Info("我赢得选举,开始干活")
                if err := runApplicationController(ctx); err != nil {
                    log.Error(err, "控制器退出")
                    cancel()  // ← 关键:内部退出时主动 cancel
                }
            },
            OnStoppedLeading: func() {
                log.Info("我失去选举,准备退出")
                cancel()  // ← 关键:失去选举主动 cancel 整个进程
            },
        },
    }
    leaderelection.Run(ctx, lec)
}

代码里几个易踩坑的点:

  • LeaseDuration > 3 × RenewDeadline:每个续约周期 = RetryPeriod;3 次重试失败才认为失联。这是 k8s 的"3 次失败"惯例
  • ReleaseOnCancel: true:让进程退出时主动释放 Lease,避免新 Leader 还要等 LeaseDuration 才能接班
  • Identity 必须唯一:用 POD_NAME(Downward API 注入),不要用 hostname——StatefulSet 里 hostname 会稳定,但 Deployment 里每个 Pod 的 hostname 是随机的,用 hostname 会让 Lease 更新时 identity 乱变

4.4 resourceLock 选型对比

resourceLock底层资源优缺点
LeaseLock coordination.k8s.io/v1.Lease ✅ 官方推荐;✅ 性能最好;✅ 不被 etcd 配额限制
ConfigMapsLock ConfigMap ⚠️ 兼容老版本;⚠️ ConfigMap 1MB 限制
EndpointsLock Endpoints ❌ 已废弃;用 LeaseLock 替代

🌟 实用技巧
k8s 1.36 默认要求 resourceLock: leases,老版本 ConfigMapsLock 在 1.36 里仍兼容但已不推荐。当你的 Operator 要兼容 1.20 之前的旧集群时,可以读取环境变量 K8S_VERSION 动态选择 lock 类型。


五、Finalizer 资源清理:CR 删除时的兜底逻辑

5.1 什么是 Finalizer

Finalizer 是 metadata.finalizers 字段里的字符串数组,每个元素是"一个需要在该资源删除前完成的事情"。当一个对象有 Finalizer 时,kubectl delete 不会真正删除它——APIServer 只会把 deletionTimestamp 字段写上,把删除请求挂起,直到所有 Finalizer 都被移除。

为什么要这个机制?因为 Operator 创建了外部子资源(Deployment、Service、Cloud LB、数据库 schema…)——这些资源在 CR 删除时不会自动跟着删,Operator 需要一段"收尾逻辑"把它们清掉。如果 CR 被 APIServer 立即删了,Operator 后续就找不到这个 CR,外部子资源就成了孤儿。Finalizer 就是为这个场景设计:只要 Finalizer 不被移除,CR 就会保留在 APIServer 里,Operator 就能持续看到它、知道要清理。

5.2 源码:apimachinery 中的 Finalizer 协议

// staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/types.go

type ObjectMeta struct {
    // ...
    // Finalizers 是删除前的兜底钩子
    Finalizers []string `json:"finalizers,omitempty" protobuf:"bytes,12,rep,name=finalizers"`

    // 当 DeletionTimestamp 不为空且 Finalizers 仍非空时,对象处于"待删除"状态
    DeletionTimestamp *metav1.Time `json:"deletionTimestamp,omitempty" ...`
    // ...
}

APIServer 行为规则:当一个对象有非空 Finalizers 时:① PUT 删除请求不会真的删除对象;② 只会把 metadata.deletionTimestamp 字段填上;③ 同时给对象加一个 "foregroundDeletion" 标签(取决于 spec.foregroundDeletionFinalizer)。Operator 看到 deletionTimestamp != nil 时就进入"清理模式",做完清理后从 finalizers 数组里移除自己的标识,APIServer 看到 finalizers 为空后才真正删除。

5.3 实战代码:application-operator 的 Finalizer 收尾

// pkg/controller/application_controller.go(节选)

const applicationFinalizer = "application.example.com/finalizer"

func (r *ApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    app := &appv1.Application{}
    if err := r.Get(ctx, req.NamespacedName, app); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // ① 检查是否处于删除流程
    if !app.DeletionTimestamp.IsZero() {
        if controllerutil.ContainsFinalizer(app, applicationFinalizer) {
            // ② 执行清理
            if err := r.cleanupExternalResources(ctx, app); err != nil {
                return ctrl.Result{}, err
            }
            // ③ 移除 Finalizer(让 APIServer 真正删对象)
            controllerutil.RemoveFinalizer(app, applicationFinalizer)
            return ctrl.Result{}, r.Update(ctx, app)
        }
        return ctrl.Result{}, nil
    }

    // ④ 正常流程:确保 Finalizer 存在
    if !controllerutil.ContainsFinalizer(app, applicationFinalizer) {
        controllerutil.AddFinalizer(app, applicationFinalizer)
        return ctrl.Result{}, r.Update(ctx, app)
    }

    // ⑤ 主对账逻辑
    return r.reconcileNormal(ctx, app)
}

注意几个易错点:

  • 移除 Finalizer 前必须重新 Get:因为 ForegroundDeletion 期间对象可能被别人修改过
  • Finalizer 字符串必须是 domain-prefixed:application.example.com/finalizer 而不是 finalizer,避免与系统 finalizer 冲突
  • 清理逻辑必须有超时:否则 Operator 永远卡在 Finalizer 里,对象永远删不掉

5.4 backgroundDeletion vs foregroundDeletion

维度backgroundDeletion(默认)foregroundDeletion
kubectl 行为 kubectl delete 立刻返回,APIServer 后台异步删 kubectl delete 阻塞到 Finalizer 全部移除
对象何时真正消失 Finalizer 移除后,下一次 GC 周期 Finalizer 移除后立即删除
Operator 拿到的事件 UPDATE(DeletionTimestamp 变化) UPDATE + DELETE 都被监听到
使用建议 大多数场景(开发、测试、简单清理) 外部资源(云 LB、数据库)必须同步清理

六、优雅退出:让 Pod 重启不再丢事件

Operator 升级时 Pod 会被 SIGTERM 干掉。如果不优雅退出,会出现:① WorkQueue 里的事件没处理完;② Leader Lease 没释放;③ Watch 连接没断干净,APIServer 看到异常 log。k8s.io/apimachinery/pkg/util/wait 和 context.Context 是两大利器。

6.1 优雅退出代码模板

// cmd/manager/main.go

func main() {
    // ① context 控制整条生命周期
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // ② 监听 SIGTERM/SIGINT
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
    go func() {
        sig :=

SIGTERM 到来的瞬间,Kubernetes 默认给 30 秒(terminationGracePeriodSeconds)。这 30 秒里我们要做:① 取消 context(让 Informer 停止接事件);② 让正在跑的 Reconcile 跑完(不要硬中断);③ 释放 Lease;④ 关闭 HTTP server(如果有 metrics server)。如果 30 秒内没做完,k8s 就会发 SIGKILL 强杀。

⚠️ 警告
不要在 Reconcile 中加无限循环。即使加了 ctx 取消机制,外部 RPC 调用(云厂商 SDK)往往不遵守 ctx 取消。生产级做法:把 Reconcile 的执行时间用 defer recover() + try-catch 包好,对外调用统一加 30 秒超时 ctx。


七、观测体系:日志、指标、Profiling 三件套

生产事故的根因排查,80% 依赖日志/指标/Profiling 三件套。如果 Operator 出问题只能靠重启解决,说明观测能力不足。

7.1 结构化日志(klog v2)

k8s 1.36 默认用 klog v2,输出 JSON 格式结构化日志。开发期可用 --v=4 看 detail,生产期用 --v=2 + zap adapter 输出到 stdout 由 Loki 收集。

// 在 Reconcile 入口用 logr 包装

log := ctrl.Log.WithValues("application", req.NamespacedName)
log.Info("开始 Reconcile", "resourceVersion", app.ResourceVersion)
defer func() {
    log.Info("Reconcile 结束", "elapsed", time.Since(start))
}()

7.2 Prometheus 指标(controller-runtime metrics)

controller-runtime 默认开启 controller_runtime_reconcile_total、controller_runtime_reconcile_errors_total、controller_runtime_workqueue_depth 三个核心指标。Scrape 路径 /metrics,需要 Operator 暴露 HTTP server:

// 在 main.go 启动 metrics server

metricsServer := metricsserver.NewServer(metricsserver.Options{
    BindAddress: "0.0.0.0:8080",
    FilterProvider: filters.WithAuthenticationAndAuthorization,
})
mgr, _ := ctrl.NewManager(cfg, ctrl.Options{
    Metrics:                metricsServer,
    HealthProbeBindAddress: ":8081",
})
mgr.Start(ctx)

7.3 pprof 性能分析

当 Operator 出现内存泄漏、CPU 飙升时,pprof 是救命稻草。net/http/pprof 标准库默认就支持,启动时一行 import:

// cmd/manager/main.go

import _ "net/http/pprof"

go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
}()

// 使用:go tool pprof http://localhost:6060/debug/pprof/heap
// 使用:curl http://localhost:6060/debug/pprof/profile?seconds=30 > cpu.prof

🌟 实用技巧
pprof 端口 不要对外暴露。用 kubectl port-forward 转发到本地再分析。生产环境通过 Service Monitor 自动采集 heap、goroutine、block profile 三个 profile 类型。


八、常见生产事故与排查清单

症状根因排查命令 / 修复
CR 一直 Terminating,删不掉 Finalizer 卡住,Reconcile 死循环 kubectl edit 手动删 finalizers(应急)
Operator 内存持续增长 WorkQueue 大量堆积 / Informer 缓存无界 检查 queue_depth 指标 + pprof heap
Reconcile 大量 Conflict 错误 没有用乐观锁,Spec/Status 同时改 开启 status 子资源 + 携带 ResourceVersion
两个 Pod 同时干同一件事 Leader 选举未启用 leaderelection.Run + LeaseLock
Operator 重启后 Status 闪烁 Reconcile 非幂等,Create/Update 重复 Get → 分支 → Create/Update 三步法

九、FAQ(20+ 高频问题)

▼ Q1: 同一个 CR 在 1 秒内被 Reconcile 30 次,正常吗?

A: 不正常。常见根因是 Reconcile 失败后未返回 error,导致 workqueue 立即重新入队;或者是 Informer 注册了多个 Watch,且对象 Owner 链上的多个资源都在变化。解决:让 Reconcile 在错误时显式 return ctrl.Result{}, err,让 workqueue 自动按限速器退避重试。


▼ Q2: Leader 选举切换时,旧的 Reconcile 还在跑,会有问题吗?

A: 通常不会。OnStoppedLeading 触发后,新 Leader 会重新入队所有 key,旧 Leader 在 OnStoppedLeading 回调返回后会被 ctx 取消,正在跑的 Reconcile 会收到 ctx.Done() 终止。即使旧 Reconcile 已经跑了 90% 的写操作,由于它携带的是过时的 ResourceVersion,APIServer 会返回 Conflict,新 Leader 重试就能拿到正确状态。


▼ Q3: WorkQueue 里堆积了 10 万个 key,怎么清理?

A: 优先看为什么堆积:① 是不是 Reconcile 死循环?② 是不是 APIServer 持续报错导致重试?③ 是不是 EventSource 重复注册?临时缓解:kubectl exec -it pod -- curl localhost:6060/debug/pprof/heap 抓 heap profile 看有没有内存泄漏。终极方案:重启 Operator 让队列清空。


▼ Q4: 为什么我 Update Status 一直报 Conflict?

A: 99% 是没开启 status 子资源,或者在 Update Spec 后立刻 Update Status。开启 status 子资源后,Update Status 走 PUT /status,不会和 PUT / 冲突;或者在 Update Status 前重新 Get 一次对象,带上最新的 ResourceVersion。


▼ Q5: Finalizer 卡住导致 CR 删不掉,有没有办法手动删?

A: 应急方案:kubectl patch application my-app -p '{"metadata":{"finalizers":[]}}' --type=merge,或者 kubectl edit application my-app 直接编辑元数据删 finalizers。但这会导致 Operator 跳过清理逻辑,留下孤儿资源。生产环境应在 Operator 修复后让自动清理走完整流程。


▼ Q6: 我用 controller-runtime 还需要自己写 Leader 选举吗?

A: 需要。controller-runtime 的 Manager 本身不做 Leader 选举(默认所有副本都跑 Controller)。生产级做法是 ManagerOptions.LeaderElection = true,让 Manager 自动用 leaderelection 库做选举。


▼ Q7: WorkQueue 的限速器 QPS 设多少合适?

A: 没有标准答案。经验值:① 单 Operator 操作本集群资源,QPS 20 即可;② 调外部云 API,根据云厂商 Rate Limit 设定;③ 调 k8s APIServer,QPS 50-100 不会触发 APIServer 限流。生产环境务必在指标里看 429 数量,按需调小。


▼ Q8: 怎么让 Reconcile 在 30 秒后超时?

A: 在 Reconcile 入口用 context.WithTimeout(ctx, 30*time.Second) 包一层。注意:timeout 触发的 ctx 取消会让 ctx.Done() 通知所有下游操作,前提是下游用了 http.NewRequestWithContext 或 grpc.WithContext。Go 1.26 之前的第三方库不传 ctx 的话无法被取消。


▼ Q9: Prometheus 抓不到 /metrics 端点,怎么排查?

A: 三步排查:① kubectl port-forward pod 8080:8080,本地 curl localhost:8080/metrics 看是否有内容;② 检查 ServiceMonitor CR 是否正确 selector 到了 Operator Pod;③ 检查 Operator 容器是否暴露了 8080 端口(spec.containers.ports)。


▼ Q10: 我的 Operator 启动后 1 分钟内一直打 "cache not synced",正常吗?

A: 在大集群(5000+ Pod)上是正常的——Informer 需要 List 一次全量资源。开发集群小,List 瞬间完成。生产环境通过 cache.WaitForCacheSync 等待缓存同步完成,再启动 Reconciler,否则会处理过期数据。


▼ Q11: LeaderLease 我看系统帮自动建了,但 OwnerReferences 指向了过期的 Pod,怎么处理?

A: 这是个常见误解:Lease 的 ownerReferences 通常不设置。即使设置了,旧 Pod 死后 k8s GC 会清掉 Lease 引用,但 Lease 对象本身会保留(被新 Pod 续约)。如果设置了 BlockOwnerDeletion=true 而 Owner 资源已删除,Lease 会卡在删除流程里。


▼ Q12: 为什么我配置了 MaxOfRateLimiter,但重试间隔没有按预期生效?

A: MaxOfRateLimiter 取的是"max"——即多个限速器中"最严"的那个。如果其中一个限速器报错或抛 panic,MaxOf 会回退到默认值。检查每个内部限速器都正确初始化,并打开 klog --v=4 看到底是哪个限速器在决定重试间隔。


▼ Q13: 把 Operator 从单副本改 3 副本后,CR 处理变慢 3 倍,怎么破?

A: 说明没启用 Leader 选举。3 个 Pod 都跑 Reconcile,APIServer 被 3 倍流量打,触发 429 限流,整体反而变慢。开启 Leader 选举后只有 1 个 Pod 跑,其他 2 个热备,性能和单副本一致。


▼ Q14: 我希望 Operator 重启时,把未处理的事件保留到重启后再处理,怎么做?

A: 这是个反模式。WorkQueue 队列是内存中的,重启会丢失。k8s 的设计假设是:Operator 重启后,Informer 会从断点续传,APIServer 会重新发 UPDATE 事件,所有"未完成"的事件会自然重新入队。如果担心重启丢事件,应提高 ReplicaSet 重启间隔、调大 terminationGracePeriodSeconds,而不是用磁盘队列。


▼ Q15: 同时操作 1000 个 CR,最佳实践是用 Patch 还是 Update?

A: 用 Strategic Merge Patch 或 Server-Side Apply。Update 需携带完整对象,传输量大;Patch 只传 diff,且不依赖 ResourceVersion,并发安全性更好。controller-runtime 中 client.Patch(ctx, obj, client.MergeFrom(original)) 一行解决。


▼ Q16: 怎么测试 Finalizer 收尾逻辑?

A: 单元测试里 mock APIServer 响应,模拟对象带 DeletionTimestamp 非空 + 含 finalizer,断言 Reconcile 调用了清理函数并移除 finalizer。集成测试用 envtest 启动真 etcd + kube-apiserver,kubectl apply -f cr.yaml 后 kubectl delete,用 wait 验证子资源被清理。


▼ Q17: 为什么我引入 leaderelection 后 Pod 启动慢了一半?

A: 正常。leaderelection.Run 启动时不会等选举成功,所以启动慢不来自选举。慢的原因通常是 Manager 启动时 WaitForCacheSync 耗时——大集群 List 一次全量对象可能要 10-30 秒。可通过 --feature-gates=APIPriorityAndFairness=true 启用 PriorityAndFairness 缓解。


▼ Q18: Operator 的 goroutine 数量有什么经验值?

A: controller-runtime 默认 MaxConcurrentReconciles = 1,即每个 Controller 一个 worker。生产中通常调成 2-4。worker 太多会导致 APIServer 429 限流;太少则无法压榨单机性能。一般 4 核 8G 机器,2-4 worker 是甜蜜点。


▼ Q19: 我用 HPA 弹性伸缩 Operator 副本,伸缩时会不会有问题?

A: 通常没问题。HPA 缩容会按 k8s 规则优雅终止 Pod,触发 OnStoppedLeading 回调,新副本会接手 Lease。问题场景:缩容速度太快(--scale-down 窗口太短),新 Pod 还没拿到 Lease,旧 Pod 已经被 k8s 强杀,导致中间出现 Leader 真空期(最长 = LeaseDuration + RenewDeadline)。生产级:HPA stabilizationWindow 至少 60 秒。


▼ Q20: k8s 1.36 引入了什么新特性影响 Operator 实践?

A: 1.36 的几个重要变化:① JobPodFailurePolicy GA,Operator 创建 Job 时可以更精确控制失败策略;② StorageVersionMigrator GA,自动迁移 etcd 数据;③ CustomResourceValidationExpressions(CEL)正式版,让 CRD schema 验证更强大;④ SidecarContainers GA,可在 Pod 内跑 webserver/agent sidecar 而不影响主容器生命周期。


▼ Q21: Operator 调云厂商 API 报 429,怎么和 WorkQueue 配合限速?

A: 常见做法是用 ClientLimiter 模式:① 在外部 SDK 调用层加 golang.org/x/time/rate 令牌桶;② 在 Reconcile 层捕获 429 错误并返回,让 workqueue 走退避重试;③ 在限速器配置里把 BucketRateLimiter 的 QPS 调小到 1/2 倍外部厂商限流值。

Kubernetes 编程 / Operator 专题【左扬精讲】—— 生产级 Operator 最佳实践 · 基于 k8s 1.36.1 + client-go + controller-runtime

posted @ 2026-06-15 10:57  左扬  阅读(0)  评论(0)    收藏  举报