首页 / 资讯中心 / 文章详情

Kubernetes控制器模式与控制循环:手写自定义控制器调谐器实战

Kubernetes控制器模式与控制循环:手写自定义控制器调谐器实战 ★ FEATURED ARTICLE
1. 控制器模式的核心控制循环如何驱动集群状态1.1 声明式API下的“对账”机制说到 Kubernetes Controller很多刚接触 Kubernetes 的人第一反应是这不就是运维同学在集群里跑的那些自动修复脚本吗等你自己动手写一个控制器之后才会意识到这个判断只对了一小半。控制器不是某一段具体的自动化流程而是 Kubernetes 里一整套驱动“当前状态”向“期望状态”收敛的运行机制。我最初被一个副本数反复抖动的问题逼着去翻控制器源码啃完之后最大的感受是如果不理解控制循环你看到的 Deployment 副本数变化就像魔术理解了之后它就变成了一个很朴素的“对账工具”——任何资源都会被这套机制自动纠正。先把最容易混淆的点说透你提交的 Deployment YAML 不是“要执行的任务”而是“希望最终出现的状态”。比如你写replicas: 3系统不关心你为什么要 3 个它只负责保证任何时刻都尽量有 3 个可用的 Pod。用生活化的类比就是你给家政服务下的单写的是“今晚 8 点前家里保持整洁、阳台植物有水分、猫粮碗不空”而不是列具体步骤“先擦桌子、再浇花、再倒猫粮”。系统自己想办法完成而且它永远在巡查不是做完一次就收工。这个“巡查—对比—纠偏—再巡查”的过程就是控制器模式里的控制循环Control Loop。为什么这套循环如此重要因为节点宕机、容器被杀、有人手抖改了副本数、镜像拉取失败、网络分区……这些都会让实际状态偏离期望状态。没有任何一个一次性脚本能覆盖所有的偏差来源但一个永不停止的循环可以。Kubernetes 自愈能力的本质就是大量控制器叠加后的结果Deployment Controller 管 Pod 数量Node Controller 管节点状态Endpoint Controller 管 Service 后端列表它们各自负责一个或几个领域靠同一套模式运转。这里还有一个非常关键、也容易被忽略的细节控制器在发现差异之后并不会直接去操作容器运行时。它只是通过 API Server 写一个新的“意图”比如更新 ReplicaSet 的期望副本数或者移除某个 Finalizer。真正去拉镜像、起容器的是 kubelet 那一层。这个分层设计解释了为什么控制器很克制——它只负责声明意图和对账不直接干脏活累活。1.2 控制器三大件Informer、WorkQueue、Reconciler写自定义控制器之前先建立三个基础组件的直觉它们分别回答“如何被通知”“如何排队”“如何干活”。Informer负责监听 API Server 上某个资源的变化。它先做一次全量 List 把对象拉到本地之后通过 Watch 增量同步并且始终维护一份本地缓存。这样每个 Reconcile 都读缓存而不是频繁打 API Server。WorkQueueInformer 收到事件后不会直接调业务逻辑而是把对象的namespace/name业界通常叫 key放进一个并发安全队列。这个队列能做去重同一对象短时间内来十个事件队列里可能只保留一个待处理项并且它还支持延迟重试。Reconciler这才是业务逻辑所在。worker 协程从队列取出一个 key读缓存里的最新对象对比期望状态有差异就通过 API Server 调整然后继续拿下一个 key。常说的“控制器”在 Kubernetes 里有两层含义一层是kube-controller-manager里内置的那批控制器集合比如 Node Controller、Deployment Controller、Endpoint Controller另一层就是用户自己写的自定义控制器也就是本文要重点拆解的对象。两者的骨架几乎完全一样区别只在 Reconciler 里的期望状态怎么算、差异怎么纠。2. 从零组装一个自定义控制器手写调谐器的完整过程2.1 为什么我选择手写而不是直接套框架当时身边不少人建议我直接用脚手架框架理由很充分它能自动生成 Informer、WorkQueue、Leader Election 这些基础设施你只需要专注写 Reconcile 方法。我确实先试了那条路线结果被一个问题卡了两天某个更新操作反复触发 Reconcile日志每小时刷几千行最后定位到是框架自动管理的缓存对象被我无意中修改了。这个问题逼着我下决心把 client-go 的完整链路手写一遍把每个环节亲手搭出来。手写控制器的缺点很明显事件处理要自己注册队列要自己创建缓存同步要自己等待选主要自己接。但优点是每个环节都是显式的数据从哪来、往哪去、谁会碰同一个对象全在你掌控之内。我的观点是学习阶段一定要手写一遍如果是给生产环境做复杂 CRD 控制器基于 controller-runtime 的上层脚手架框架会更稳妥。这跟“先理解汇编再学高级语言”是一个道理底层链路只要通一次后面用任何框架都踏实很多。维度手写 client-go直接套脚手架框架学习价值高底层链路全暴露低细节被封装开发效率低要自己处理大量基础组件高生成代码直接跑排障体验上手慢但问题定位路径清晰出问题往往要往框架内部钻适用场景学习、小工具、机制验证生产级 CRD 控制器2.2 骨架代码事件注册、入队、出队与调谐先准备环境Go 1.21 以上一个可用的 Kubernetes 集群或本地模拟集群配置好 kubeconfig。项目结构不用复杂一个main.go、一个controller.go足够。main.go里做三件事加载配置、创建 Informer 工厂、注册事件处理函数。package main import ( context flag log os path/filepath time k8s.io/client-go/informers k8s.io/client-go/kubernetes k8s.io/client-go/tools/cache k8s.io/client-go/tools/clientcmd k8s.io/client-go/util/workqueue ) func main() { kubeconfig : flag.String(kubeconfig, filepath.Join(os.Getenv(HOME), .kube, config), ) flag.Parse() cfg, err : clientcmd.BuildConfigFromFlags(, *kubeconfig) if err ! nil { log.Fatalf(加载 kubeconfig 失败: %v, err) } client : kubernetes.NewForConfigOrDie(cfg) factory : informers.NewSharedInformerFactory(client, 30*time.Second) informer : factory.Apps().V1().Deployments() queue : workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter()) c : Controller{ client: client, lister: informer.Lister(), queue: queue, } informer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: c.enqueue, UpdateFunc: func(old, new interface{}) { c.enqueue(new) }, DeleteFunc: c.enqueue, }) factory.Start(context.Background().Done()) if !cache.WaitForCacheSync(context.Background().Done(), informer.Informer().HasSynced) { log.Fatal(等待 informer 缓存同步失败) } c.Run(context.Background(), 2) }controller.go里定义 Controller 结构和调谐主循环。Reconcile 目前只打印日志相当于一个“能感知世界变化但还没有动作”的控制器。package main import ( context log time k8s.io/apimachinery/pkg/api/errors k8s.io/apimachinery/pkg/util/wait k8s.io/client-go/kubernetes appsv1listers k8s.io/client-go/listers/apps/v1 k8s.io/client-go/tools/cache k8s.io/client-go/util/workqueue ) type Controller struct { client kubernetes.Interface lister appsv1listers.DeploymentLister queue workqueue.RateLimitingInterface } func (c *Controller) enqueue(obj interface{}) { key, err : cache.DeletionHandlingMetaNamespaceKeyFunc(obj) if err ! nil { return } c.queue.Add(key) } func (c *Controller) Run(ctx context.Context, workers int) { for i : 0; i workers; i { go wait.UntilWithContext(ctx, c.worker, time.Second) } -ctx.Done() } func (c *Controller) worker(ctx context.Context) { for c.processNextItem(ctx) { } } func (c *Controller) processNextItem(ctx context.Context) bool { key, shutdown : c.queue.Get() if shutdown { return false } defer c.queue.Done(key) if err : c.reconcile(ctx, key.(string)); err ! nil { c.queue.AddRateLimited(key) return true } c.queue.Forget(key) return true } func (c *Controller) reconcile(ctx context.Context, key string) error { namespace, name, err : cache.SplitMetaNamespaceKey(key) if err ! nil { return nil } dep, err : c.lister.Deployments(namespace).Get(name) if err ! nil { if errors.IsNotFound(err) { return nil } return err } log.Printf(Deployment %s/%s 当前副本数: %d, namespace, name, *dep.Spec.Replicas) return nil }这段代码里有几个值得强调的点。informers.NewSharedInformerFactory(client, 30*time.Second)的第二个参数是 resync 周期它让控制器每隔一段时间把队列里的所有对象重新入队一次即使没有任何新事件也能触发全量对账。这是声明式系统补偿事件丢失的兜底机制不是可有可无的性能负担。cache.DeletionHandlingMetaNamespaceKeyFunc专门处理删除事件因为删除时 handler 收到的可能是一个对象指针或一个DeletionFinalStateUnknown标记这个函数能统一提取 key 且不会 panic。wait.UntilWithContext启动多个 worker 协程每个 worker 持续从队列取 key直到 ctx 取消。2.3 本地调试一个真实案例的过程写完骨架后建议先做一次端到端自测把链路跑通再写复杂业务逻辑否则后面排错会非常痛苦。我的流程是把 kubeconfig 指向测试集群本地直接go run .启动控制器然后开第二个终端操作执行kubectl create deployment test-ctl --imagenginx --replicas2观察控制器日志确认 Add 事件被打印执行kubectl scale deployment test-ctl --replicas3再次确认日志更新手动删掉其中一个 Pod这时候要特别注意Deployment Controller 也会自动重建 Pod所以如果只注册了 Deployment 的 handler你会看到一条日志如果同时注册了 Pod 的 handler就会看到两条以上。这里有一个容易误导新手的点手写控制器不会自动收到集群里所有对象的通知Informer 工厂只会监听你在代码里显式创建的 informer。所以排查“为什么我的控制器没反应”时第一件事永远是检查事件处理函数到底注册在哪个 informer 上。我见过好几个项目把两个资源的 handler 写反结果数据错乱到完全没法解释。3. Reconcile 调谐逻辑的设计声明式思维与命令式思维的分水岭3.1 “对账”而不是“执行步骤”定时缩容实例的迭代设计一个具体例子我希望某个 Deployment 在每天 21:00 到次日 7:00 之间副本数自动缩到 0其他时间恢复为某个固定值。这个场景很适合用来演示控制器因为时间敏感、事件驱动和定时对账必须配合。如果按命令式思维你会写“到 21 点执行 scale 到 0到 7 点执行 scale 到 N”。这个方案听起来简单但控制器如果在 21 点零 1 分宕机重启它根本不知道“上一次循环已经执行过什么”可能直接错过整个缩容窗口。在命令式体系里下一次操作的依据是上一次留下的内存状态而内存状态一重启就全没了。换声明式写法就完全不同Reconcile 每一轮都重新从“当前时间 期望配置”推导目标。规则定义成如果此刻在夜间窗口内期望副本数就是 0否则期望副本数取自 Deployment 上标注的固定配置。无论控制器重启多少次只要 resync 一到它必然能发现“当前副本数是 3、期望副本数是 0”这个差异然后把状态纠正回来。实现步骤大致为先从 Deployment 上读取固定配置比如schedule-night.svc/on: true和schedule-night.svc/expected: 3然后判断当前时间是否在夜间窗口内算出期望副本数最后比较*dep.Spec.Replicas和期望值不一致就调用 Update 或 Scale 接口一致就直接返回 nil。这段逻辑里没有“上次处理过没有”这个概念。事件只是在告诉控制器“你可能需要再看一眼”而不是“请你执行下一步”。这种思考方式是整个 Reconcile 设计里最难转变、也最值钱的地方。3.2 幂等、重试与状态回写一个可靠的 Reconcile 长什么样把上面的逻辑结构化一个可靠的 Reconcile 函数骨架大概长这样func (c *Controller) reconcile(ctx context.Context, key string) error { namespace, name, _ : cache.SplitMetaNamespaceKey(key) dep, err : c.lister.Deployments(namespace).Get(name) if errors.IsNotFound(err) { return nil } if err ! nil { return err } expected : desiredReplicas(dep) // 根据时间和注解计算 if *dep.Spec.Replicas expected { return nil } copy : dep.DeepCopy() copy.Spec.Replicas expected if _, err : c.client.AppsV1().Deployments(namespace).Update(ctx, copy, metav1.UpdateOptions{}); err ! nil { return err } return nil }写代码时我一般坚持五个原则。第一只接受 key 作为输入不依赖外部变量记录“上次的进度”任何一次调谐都从当前状态重新推导。第二对象不存在时直接返回 nil因为对象已经删了说明期望状态已经实现不需要再折腾。第三所有写操作都要先 DeepCopy绝对不能直接改 Informer 缓存里的对象原因下一节详细展开。第四避免在 Reconcile 里做长等待比如等 Pod Running 再继续下一步这会占住 worker让其他对象的调谐全部排队。第五使用 Status 子资源记录结果例如上次缩容时间、当前应用的期望值都写进status字段这样 API Server 会保留这份历史重启后依然可查。重试策略同样值得重视。Reconcile 返回 error 时workqueue 的限速器会决定多久后重试返回 nil 且没有改变任何东西本次调谐就结束控制器等下一个事件或下一个 resync。在业务代码里要能清楚区分“暂时失败”和“最终确定”因为两者的日志、监控、事件上报方式完全不一样。把“带重试的临时错误”写成“成功但没做事”会让故障永远无法自愈。4. 踩坑实录与性能优化把控制器从“能用”调到“稳”4.1 缓存未同步的竞态为什么启动后要先等 cache sync我第一次写控制器时启动后立刻执行了一个“把副本数超过 10 的 Deployment 统一改成 5”的批量操作结果发现有一批对象根本没有被处理。排查了很久才发现问题出在启动时序factory.Start()只是启动了 Informer 的 goroutine但首次全量 List 可能还没有完成本地缓存完全是空的。此时 Reconcile 从 lister 里读不到对象就会走IsNotFound分支直接返回看起来像“对象被删了”实际上对象还好好地在集群里。修复方式很简单就是启动后必须等待缓存同步factory.Start(ctx.Done()) if !cache.WaitForCacheSync(ctx.Done(), informer.Informer().HasSynced) { log.Fatal(等待 informer 缓存同步失败) }HasSynced表示该 Informer 已完成首次 List 并把数据放进本地缓存。只有等它返回 truelister 里的数据才可信worker 才可以开始消费队列。这等于告诉所有 worker“别急先把地形摸清楚再动手。”除了启动竞态还有类似的坑是“Watch 还没建立时来的事件会丢”。Informer 通过先 List 后 Watch 的机制保证了数据完整比你自己裸调 API 然后靠 watch 补事件要可靠得多。所以这段等待不是防御性代码而是正确性的前提。4.2 共享对象指针的陷阱必须 DeepCopy 的底层原因这是我在 2.1 里提到的那次两天排障的核心原因。当时我给 Deployment 打一个 Annotation 做标记直接在dep上修改然后调 Update运行一段时间后状态值开始不断抖动今天明明把 annotation 改成了done过一阵再看缓存发现又变回旧值。后来定位到问题出处Informer 的 EventHandler 抛给业务逻辑的obj其实和本地缓存里的是同一个对象指针。你在dep.Annotations[sync] done时改的不仅是手头这个变量还污染了 Informer 缓存里的对象。下一次 Reconcile 读这个对象时看到的是被修改过的状态于是“是否已标记”的判断完全错乱。正确的做法是先拷贝再改copy : dep.DeepCopy() copy.Annotations[controller.example.com/synced] true _, err : c.client.AppsV1().Deployments(copy.Namespace).Update(ctx, copy, metav1.UpdateOptions{}) if err ! nil { return err }DeepCopy会递归复制整个对象包括嵌套的 Spec、Annotations 和 Status。之后修改才安全。这个认知让我后来养成一个习惯凡是做 Update 或 Patch 之前看到原对象直接传入的一律打回重写。很多人都是在线上出过诡异状态才真正记住这条规则希望读到这里的你不用重复踩。4.3 事件风暴与限速重试别把调谐做成打地鼠默认限速器DefaultControllerRateLimiter的设计很全面针对单个 key 的重复失败重试间隔会从 5ms 开始指数退避最终稳定在 1000s同时整体还有令牌桶机制避免极端突发打爆 API Server。但即便有这样的保护业务代码还是能在不经意间触发事件风暴。我遇到过一次典型情况Reconcile 逻辑里调了外部系统的接口外部接口持续返回 500而我没有区分“这是临时故障还是永久配置错误”导致每来一个相关事件就全量重试一个晚上 API Server 的 Read 量翻了几倍集群整体响应变慢。后来改成对外部临时错误返回 error 走指数退避对确定性的配置错误直接记录 Event 然后返回 nil不再反复重试。同时用workqueue.NewItemExponentialFailureRateLimiter(5*time.Millisecond, 300*time.Second)把最大重试间隔限制在 5 分钟避免无限退避。另一个实用技巧是过滤无操作日志。默认情况下resync 会周期性触发大量“没有差异”的 Reconcile如果每次都打 Info 日志一天能刷出几十万行。我给这类无操作的调谐统一用log.V(4).Info或直接不打日志只记录真正执行了写操作的行为。日志量下降之后排障时反而更容易找到关键信息。4.4 多 worker 并发与 Leader Election从单实例到多副本多个 worker 并发消费 workqueue 时官方队列保证同一个 key 不会被两个 worker 同时取走——前一个 key 没有Done之前队列不会把它再次交给别人。所以并发安全隐患不在于同一个对象被重复处理而在于两个不同的 key 可能对应同一个底层目标。比如两个 Deployment 都引用了同一份 ConfigMap而你的调谐逻辑会修改这个 ConfigMap这时候就需要在业务里对目标加锁或者把这类操作收敛到单 worker 执行。真正容易忽略的是多副本部署时的选主问题。当你把控制器用 Deployment 方式部署成两个副本每个副本都有自己的 Informer 缓存和 workqueue它们会独立收到同样的事件各自执行调谐。如果没有选主机制两个副本同时更新同一个 Deployment后写方会覆盖先写方轻则产生无意义的写冲突重则让状态来回翻转。解决方案是使用 Kubernetes 原生的 Lease 选主机制。核心思路是启动时先抢一个 Lease抢到的一方成为 Leader 并启动 worker另一方进入等待状态。Leader 失联或租约过期后其他副本再重新选举。骨架代码大致如下leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{ Lock: resourcelock.LeaseLock{ LeaseMeta: metav1.ObjectMeta{Namespace: kube-system, Name: my-controller}, Client: client.CoordinationV1(), }, LeaseDuration: 15 * time.Second, RenewDeadline: 10 * time.Second, RetryPeriod: 2 * time.Second, ReleaseOnCancel: true, Callbacks: leaderelection.LeaderCallbacks{ OnStartedLeading: func(ctx context.Context) { controller.Run(ctx, workers) }, OnStoppedLeading: func() { log.Fatal(选主丢失进程退出) }, }, })如果用的是 controller-runtime 这类框架选主通常只是一个启动参数看起来简单很多。但手写模式下这一段不能省略否则生产环境的多副本部署一定会给你上一课。我后来把这份骨架复用在了不少场景里每次改动其实只集中两处一是换 Informer 监听的目标资源二是重写 Reconcile 里的期望状态计算。脚手架能帮你省掉大量重复组装工作但手写一遍帮你建立的那套“调谐直觉”才是排障时真正值钱的东西。如果你正在写自己的第一个控制器我的建议是从一个最小调谐场景开始跑通之后再加 Status 回写再加选主每一步都验证完再往生产推这个顺序能让你少掉很多头发。
阅读完成 · 觉得有帮助?
咨询建站