


















当一个 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 接入
我们先把"生产化 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 环境下稳定运行。下面我们一条一条拆。
很多人写完第一个 Operator 后,会觉得"Reconcile 反正只被叫一次吧?"——这是开发环境的错觉。生产环境里 Reconcile 可能因为以下任一原因被并发或重复调用:
幂等性的反面是"线性副作用"——比如 Reconcile 里有这样的逻辑:if !exists { create() }。当两个 Reconcile 并发跑这段逻辑时,A 判断不存在后还没来得及 Create,B 也判断不存在,就重复 Create 了。Kubernetes API Server 会拒绝第二个 Create(资源已存在),但错误处理不当会让 Reconcile 死循环重试。
我把它总结成三条原则:状态读全、乐观锁、命名空间隔离。每一条都对应一个具体的代码模式。
原则 ① 状态读全
每次进入 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 重建,旧的子资源也不会和新的重名冲突。
// 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 重试整条调用链。
把 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 子资源。
client-go 的 workqueue 包提供 5 种限速器,它们以装饰器方式组合使用:
| 限速器 | 行为 | 典型场景 |
|---|---|---|
| DefaultTypedRateLimiter | 5 种算法组合(默认) | 通用 Operator |
| BucketRateLimiter | 令牌桶,全局速率限制 | 写入外部系统 |
| ItemExponentialFailureRateLimiter | 按失败次数指数退避 | APIServer 临时错误 |
| ItemFastSlowRateLimiter | 前 N 次快重试,之后慢重试 | 区分"瞬时错误"和"持续错误" |
| MaxOfRateLimiter | 取多个限速器中"最慢"的那个 | 多重约束并存 |
// 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 设小了。
// 假设我们用 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 压力。
如果 Operator 是 Deployment 跑 3 副本,每个 Pod 都跑 Reconciler,会出现什么问题?
Leader 选举(Leader Election)就是"同一时间只让一个 Pod 干活,其他 Pod 待机"的机制。它由 k8s 内置组件 k8s.io/client-go/tools/leaderelection 提供,底层用 Lease 对象(一个轻量级 CR,由 kube-controller-manager 自身用来做选举)做分布式锁。
// 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。
// 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)
}
代码里几个易踩坑的点:
| 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 是 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 就能持续看到它、知道要清理。
// 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 为空后才真正删除。
// 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)
}
注意几个易错点:
| 维度 | backgroundDeletion(默认) | foregroundDeletion |
|---|---|---|
| kubectl 行为 | kubectl delete 立刻返回,APIServer 后台异步删 | kubectl delete 阻塞到 Finalizer 全部移除 |
| 对象何时真正消失 | Finalizer 移除后,下一次 GC 周期 | Finalizer 移除后立即删除 |
| Operator 拿到的事件 | UPDATE(DeletionTimestamp 变化) | UPDATE + DELETE 都被监听到 |
| 使用建议 | 大多数场景(开发、测试、简单清理) | 外部资源(云 LB、数据库)必须同步清理 |
Operator 升级时 Pod 会被 SIGTERM 干掉。如果不优雅退出,会出现:① WorkQueue 里的事件没处理完;② Leader Lease 没释放;③ Watch 连接没断干净,APIServer 看到异常 log。k8s.io/apimachinery/pkg/util/wait 和 context.Context 是两大利器。
// 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。
生产事故的根因排查,80% 依赖日志/指标/Profiling 三件套。如果 Operator 出问题只能靠重启解决,说明观测能力不足。
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))
}()
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)
当 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 三步法 |
▼ 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
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。