控制器模式与 Informer 机制:调谐循环的工程实现
Kubernetes 的控制平面由一批控制器组成,每个控制器负责把某种资源的实际状态收敛到期望状态。Deployment 控制器确保 ReplicaSet 数量正确,ReplicaSet 控制器确保 Pod 数量正确,StatefulSet 控制器维护有序 Pod 的标识和存储绑定。这些控制器全部运行在 kube-controller-manager 进程中,共享同一个 Go runtime,但逻辑上彼此独立。
控制器的核心模式是调谐循环(Reconcile Loop):读取资源当前状态,与期望状态对比,对差异执行操作,然后等待下一次触发。这个循环不是基于轮询的——每隔几秒查一次 etcd 的方式在规模上不可持续。实际实现依赖 Informer 机制:Informer 在本地维护一份与 etcd 同步的缓存,用 Watch 接收增量变更,把变更转换成事件分发给控制器。控制器从工作队列取出事件,执行 Reconcile,写回 API Server。
这篇的核心问题是:从 etcd 的一次写入,到控制器的一次 Reconcile 调用,中间经历了哪些层次,每层解决什么问题?
Informer 的数据流
1 | |
从 etcd 到 Reconcile 有五个层次:Reflector、DeltaFIFO、Indexer、workqueue、Reconcile 函数。每层解决不同的工程问题,拆开看比整体看更清晰。
Reflector:从 Watch 到 Delta 流
Reflector 是 Informer 的最底层,封装 ListWatcher 接口,负责与 API Server 通信。
启动时,Reflector 先执行 List,获取指定资源类型的全量快照,并记录返回的 resourceVersion。然后用这个 resourceVersion 建立 Watch 连接,从该版本开始接收增量事件。List 和 Watch 之间的 resourceVersion 连接保证了不会遗漏任何事件。
Reflector 把收到的 Watch 事件(ADDED/MODIFIED/DELETED)转换成 Delta 对象,推入 DeltaFIFO 队列。Delta 包含事件类型(Added/Modified/Deleted/Replaced/Sync)和对象内容。
当 Watch 连接断开时,Reflector 尝试重新 Watch,用最后记录的 resourceVersion 续流。如果 API Server 返回 410 Gone(resourceVersion 已被 compaction 清理),Reflector 重新执行 List + Watch,刷新本地状态基线。
Reflector 还有一个 resync 机制:定期把 Indexer 中的所有对象作为 Sync 类型的 Delta 重新推入 DeltaFIFO,强制触发一次 Reconcile,即使对象实际没有变更。这是幂等性的安全网——即使因为某种原因 Reconcile 漏掉了某个事件,resync 也能保证最终收敛。resync 周期可以通过 cache.WithResyncPeriod 配置,通常设为 10 分钟到 1 小时。
DeltaFIFO:有序 Delta 的缓冲
DeltaFIFO 是一个先进先出的队列,key 是对象的 namespace/name,value 是该对象的 Delta 列表。
"FIFO"保证同一个对象的多个 Delta 按到达顺序处理,不会乱序。如果对象 A 的 ADDED 还没被消费,又来了一个 MODIFIED,两个 Delta 都保留在同一个 key 下的列表里,按顺序处理。
DeltaFIFO 与普通 channel 的区别在于它对同一 key 的 Delta 做合并。如果 Deployment A 在短时间内被修改了 5 次,这 5 个 MODIFIED Delta 都会保留,按顺序处理。但如果消费者处理速度远低于事件产生速度,workqueue 的去重机制(在 EventHandler 阶段)会合并多次触发为一次 Reconcile(见下文),而不是 DeltaFIFO 本身合并——DeltaFIFO 保留完整 Delta 链,是 Indexer 得到完整历史的前提。
DeltaFIFO 的消费者是 HandleDeltas 函数,它遍历每个 Delta,先更新 Indexer(让缓存与事件保持同步),再调用 EventHandler(触发 workqueue 入队)。这个顺序很重要:先更新缓存,保证 Reconcile 从缓存读到的是最新状态,而不是旧快照。
Indexer:内存缓存与索引
Indexer 是 Informer 的本地缓存,底层是一个线程安全的 map,key 是对象的 namespace/name,value 是对象的完整 Go 结构体(不是 JSON)。
Indexer 的"Index"功能允许按自定义字段快速检索,例如按 Pod 的 spec.nodeName 查找某个节点上的所有 Pod,而不需要遍历所有 Pod。调度器使用这个功能高效查询节点上的已调度 Pod 列表。
对控制器来说,Indexer 的价值在于:Reconcile 从 Indexer 读取当前状态,而不是直接向 API Server 发 GET 请求。这避免了大量并发控制器对 API Server 的读压力,所有读操作都在内存中完成,延迟极低。Indexer 的一致性由 Reflector 保证——只要 Watch 正常工作,Indexer 的内容就与 etcd 保持接近实时的同步。
Informer 的共享性也体现在 Indexer 上:SharedInformerFactory 确保同一个资源类型在同一个进程中只有一份 Indexer,多个控制器共享同一份缓存,不会为同一资源建立多个 Watch 连接。
EventHandler 与 workqueue
DeltaFIFO 消费者在调用 EventHandler 时,传入的是 AddFunc / UpdateFunc / DeleteFunc 三个回调。控制器在注册 Informer 时提供这三个函数。
标准实现中,这三个函数都做同一件事:提取对象的 namespace/name(key),将 key 推入 workqueue。不存储对象本身,不做任何业务判断,只是把"有事情要处理"这个信号传递给 workqueue。
这个设计使 workqueue 与对象内容解耦。workqueue 中存储的是 key(字符串),而不是对象(大结构体)。同一 key 如果多次入队(因为对象被快速连续修改),workqueue 的去重机制确保它只在队列中出现一次,处理时从 Indexer 读到的是最新状态。这就是"事件驱动但不携带事件内容"的设计哲学。
workqueue 实现了 RateLimitingQueue 接口,支持:
- 去重(deduplication):同一 key 在队列中只存在一次,防止重复处理
- 限速(rate limiting):使用 BucketRateLimiter(令牌桶)+ ItemExponentialFailureRateLimiter(指数退避),对频繁失败的 key 自动延迟重新入队
- 退避(backoff):Reconcile 返回错误时,key 以指数退避(默认初始 5ms,最大 1000s)重新入队,防止热循环压垮 API Server
AddRateLimited 是控制器重新入队失败项目的标准方法,Forget 是告诉 workqueue 某个 key 处理成功、重置退避计数的方法。
Reconcile 函数:幂等调谐的实现
Reconcile 是控制器的业务逻辑入口。它接收一个 ctrl.Request,其中只包含 NamespacedName(namespace 和 name),不包含事件类型,也不包含触发这次调用的对象内容。
1 | |
Reconcile 的实现要遵守三条规则:
第一,从缓存读当前状态,不从事件内容读。r.Get 从 Indexer 获取对象当前状态,而不是 Watch 事件中的对象。这保证 Reconcile 看到的是最新状态,即使触发这次调用的事件已经过时(因为 workqueue 去重,可能跳过了中间几次修改)。
第二,忽略 NotFound 错误。对象可能在 Reconcile 执行前已被删除(DELETED 事件入队,但 Reconcile 执行时对象已不存在)。client.IgnoreNotFound(err) 将 NotFound 视为正常情况,返回 nil,不重试。对需要处理删除逻辑的场景,使用 finalizer 机制:在 DeletionTimestamp 不为空时执行清理,再删除 finalizer。
第三,返回错误时自动重试,返回 ctrl.Result{RequeueAfter: d} 时定时重试,返回 ctrl.Result{} 时不重试(处理完成)。重试由 workqueue 的退避机制管理,Reconcile 函数本身不实现重试循环。
幂等性的实现:Reconcile 不假设这是第一次调用,也不依赖上次调用的状态。每次调用都完整读取当前状态,计算全量期望状态,执行最小必要操作(CreateOrUpdate、Patch),再次检查结果是否符合预期。即使同一对象的 Reconcile 被并发调用两次(实际上 workqueue 防止了这种情况,但设计上应支持),结果也应该相同。
controller-runtime 的使用模式
sigs.k8s.io/controller-runtime 是社区标准的控制器框架,kubebuilder 和 Operator SDK 都基于它。核心概念:
Manager 是控制器的运行时容器,管理共享的 client、cache(SharedInformerFactory)、scheme(GVK 注册表)、选举锁(leader election)。一个进程只创建一个 Manager,所有控制器注册到同一个 Manager,共享底层资源。
Builder 提供声明式的控制器注册 API:
1 | |
For 指定主资源,Owns 监听子资源(通过 ownerReference 匹配),Watches 允许监听任意资源并自定义 key 映射逻辑。三者都在底层创建 Informer + EventHandler + workqueue 入队,统一汇聚到同一个 Reconcile 函数。
推荐使用 r.Patch 而不是 r.Update,因为 Patch 只发送差异字段,减少 etcd 写入大小,也避免覆盖其他控制器修改的字段。Status 子资源必须通过 r.Status().Patch(ctx, obj, patch) 单独更新,普通 r.Update 会忽略 status 字段的变更(API Server 在 Schema 验证阶段剥离 spec 中的 status 字段)。
Leader election 通过 Manager 的 LeaderElection: true 配置开启。同一个 Operator 的多个副本中只有一个成为 leader 并运行 Reconcile,其余副本处于待机状态。leader 身份通过 Lease 对象(存储在 kube-system namespace)维护,leader 崩溃后新 leader 在 Lease TTL 过期后接管,默认约 15 秒。
实验:观察 Reconcile 触发链路
准备集群和一个 Deployment:
1 | |
观察 controller-manager 的 Reconcile 日志(需要提升日志级别):
1 | |
观察日志中 Reconcile 的触发次数是否少于 5 次。workqueue 去重会把多次快速 MODIFIED 事件折叠成少数几次 Reconcile 调用。实际触发次数取决于 Reconcile 执行时间与事件到达间隔的比值:如果每次 Reconcile 需要 50ms,而事件间隔只有 10ms,则大部分中间事件在 workqueue 中被折叠,最终只触发 1-2 次 Reconcile。
观察 resync 周期触发的 Reconcile:
1 | |
workqueue_depth 指标显示各控制器 workqueue 中当前待处理的 key 数量,workqueue_adds_total 显示总入队次数,workqueue_work_duration_seconds 显示 Reconcile 的执行耗时分布。
观察 Finalizer 机制的删除保护:
1 | |
这个实验直接演示了 Finalizer 的语义:kubectl delete 只是设置 DeletionTimestamp,真正的删除需要等所有 finalizer 被移除。控制器在 Reconcile 中检测到 DeletionTimestamp 不为空时,执行清理逻辑,然后移除自己管理的 finalizer,触发最终删除。
把实验结果映射回 K8s 对象
实验中观察到的现象对应的内部层次:
annotation 修改触发 MODIFIED 事件的路径:kubectl annotate → API Server 写入 etcd → etcd Watch 推送 PUT 事件(mod_revision != create_revision,所以是 MODIFIED 而非 ADDED)→ Reflector 收到 WatchEvent → 转换为 Modified Delta → DeltaFIFO 入队 → HandleDeltas 更新 Indexer → 调用 UpdateFunc → workqueue.Add(key) → Reconcile 被调度执行。
workqueue 折叠多次事件的时机:workqueue 的 Add(key) 在 key 已存在队列中时是幂等的(不重复入队)。只有当前 key 已被出队并正在执行 Reconcile 时,新到达的 Add 才会重新入队,等待当前 Reconcile 完成后再次处理。因此 5 次快速连续修改在 Reconcile 未启动时只入队一次;在 Reconcile 执行中到达的修改则会再入队一次,最终触发第二次 Reconcile。
Finalizer 的 DeletionTimestamp 机制:kubectl delete 发送 DELETE 请求,但 API Server 发现对象有 finalizer,改为设置 metadata.deletionTimestamp(HTTP PATCH,不是真正删除),写入 etcd 触发 MODIFIED 事件。控制器的 Reconcile 检测到 DeletionTimestamp,执行清理,移除 finalizer(再次 PATCH),etcd 写入,API Server 发现 finalizer 列表为空,执行真正的删除(DELETE from etcd),触发 DELETED 事件,Informer 从 Indexer 中移除对象。
练习
练习一:实现一个最简单的自定义控制器。
用 kubebuilder 生成 Operator 骨架(kubebuilder init + kubebuilder create api),定义一个最简单的 CRD(只有一个 spec.message 字段),在 Reconcile 中把 spec.message 写入 status.observedMessage。运行控制器,观察:创建 CR 时 Reconcile 触发几次(提示:至少两次,一次处理 spec,一次因 status 更新触发)。通过日志验证第二次 Reconcile 是否正确检查了状态已同步并提前返回。
练习二:观察 workqueue 退避行为。
在 Reconcile 中人为注入错误(返回 fmt.Errorf("injected error")),观察 workqueue 的退避日志。记录每次 Reconcile 触发的时间间隔,验证指数退避的增长规律(初始约 5ms,每次失败翻倍,上限约 1000s)。然后修复注入的错误,观察退避计数是否重置(成功后再次失败时,退避是否从初始值重新开始)。
练习三:验证 Informer 的共享性。
查看 kube-controller-manager 的源码(cmd/kube-controller-manager/app/controllermanager.go),找到 SharedInformerFactory 的初始化位置。统计有多少个控制器共享同一个 Pod Informer(即多少个控制器监听 Pod 变更)。思考:如果每个控制器独立建立 Watch 连接,1000 个节点的集群中有多少个 Watch 连接会同时建立?SharedInformer 如何把这个数量压缩到一个?用 kubectl get --raw /metrics | grep watch_cache 在运行中的集群观察 Watch 连接数量。
练习四:理解 leader election 的故障切换时间。
在 kind 集群中部署一个开启 leader election 的简单 Operator(controller-runtime 默认开启),运行两个副本。观察哪个副本持有 leader Lease(kubectl get lease -n kube-system)。强制终止 leader 副本(kubectl delete pod),记录从 leader 崩溃到新 leader 接管的时间间隔(通过新副本的日志确认 Reconcile 开始执行的时间)。调整 --leader-elect-lease-duration(默认 15s)和 --leader-elect-renew-deadline(默认 10s),观察对故障切换时间的影响,理解两个参数与 etcd Lease TTL 的关系。
模式提炼
Informer 将三个经典工程模式组合在一起,缺少任何一个都会破坏整体的可靠性:
本地缓存(Indexer):把 etcd 的数据镜像到内存,控制器所有读操作在内存中完成,避免对 API Server 的大量并发读请求。缓存一致性由 Reflector 的 List-Watch 保证,不需要手动失效或定时刷新。SharedInformerFactory 确保同一进程内同一资源类型只有一个 Informer(一个 Watch 连接、一份 Indexer),多个控制器共享,不重复建立 Watch。
事件驱动(workqueue):有变更才处理,没有变更不消耗 CPU。去重保证同一对象的多次快速变更只产生一次 Reconcile 调用,消费者看到的是最新状态。限速和退避保证失败时不会立刻重试导致热循环,对 API Server 和 etcd 的写入压力是可控的。resync 机制定期把 Indexer 中所有对象重新投入 workqueue,作为安全网弥补可能漏掉的事件。
幂等调谐(Reconcile):每次调用都基于当前完整状态计算全量操作,不依赖历史调用的结果。这使得重启恢复、重试、resync 触发的重复调用都是安全的。幂等性是控制器可以在任意时刻重启的前提,也是 Kubernetes 控制平面高可用的基础——只要 etcd 里有状态,任意控制器实例从任意时间点启动都能收敛到正确结果。
三个模式共同形成了 Kubernetes 控制器的核心约束:控制器不拥有状态(状态在 etcd),控制器不依赖事件顺序(每次 Reconcile 读全量当前状态),控制器不假设唯一实例(leader election 是可选优化,不是正确性前提)。满足这三条约束的控制器天然支持水平扩展、滚动重启和故障恢复,不需要额外的协调逻辑。这是 Kubernetes 扩展性的根本原因:任何团队都可以用 CRD + controller 模式为集群添加新能力,只要遵守这套约束,就能获得与内置控制器相同级别的可靠性保证,而不需要了解 Kubernetes 内部的实现细节。
工程迁移表
| K8s 机制 | 工程类比 | 关键相似点 |
|---|---|---|
| Reflector (List + Watch) | 数据库 CDC + snapshot | 先全量快照,再从固定位置追增量变更,断线后续流 |
| DeltaFIFO | Kafka partition(有序消息) | 同一 key 的事件严格有序处理,不乱序 |
| Indexer (in-memory cache) | Redis 缓存层 / JPA 一级缓存 | 减少底层存储的实时查询,本地读,远程写 |
| EventHandler key 入队 | MQ 消费者幂等去重(消息 ID 去重表) | 只存标识符而非消息体,去重后拉最新数据 |
| workqueue RateLimiter | Hystrix / Resilience4j 指数退避 | 失败自动延迟重试,防止雪崩;成功后重置退避计数 |
| Reconcile 幂等性 | 数据库 UPSERT / INSERT OR REPLACE | 操作可以安全重复执行,结果不变 |
| Finalizer 机制 | Saga 补偿事务 / 外键 ON DELETE | 删除前先执行清理,清理完成后才真正删除资源 |
| Status subresource 分离 | CQRS 读写分离 | spec(写端期望)和 status(读端实际)分开更新,互不干扰 |
常见误解
误解一:控制器直接 Watch etcd。
控制器(包括 kube-controller-manager 内置控制器和用户自定义 Operator)从不直接连接 etcd。所有 Watch 连接都建立在 API Server 上,经过认证和授权。Informer 的 ListWatcher 底层调用的是标准的 Kubernetes client-go API(如 clientset.CoreV1().Pods().Watch(ctx, opts)),这是一个指向 API Server 的 HTTPS 调用,API Server 再把 Watch 代理到 etcd 内部的 watch cache。etcd 端口对控制器不开放,直接访问 etcd 会绕过 RBAC 授权和 Admission 控制,是安全风险,在正式环境不允许。
误解二:Reconcile 函数接收到的是完整的新旧对象 diff。
Reconcile 只接收 ctrl.Request,其中只有 NamespacedName(namespace 和 name),没有对象内容,更没有新旧 diff。控制器必须在 Reconcile 中主动从 Indexer 读取当前状态,自己计算期望状态和实际状态的差异。这个设计是刻意的:如果依赖事件中的对象内容,resync 和重试就无法安全进行(事件中的旧对象已不准确)。UpdateFunc 的签名虽然包含 oldObj 和 newObj,但标准实践是忽略两者,只提取 key 入队,让 Reconcile 从 Indexer 读最新状态。
误解三:每次对象变更都会触发一次对 API Server 的写请求。
控制器从 Indexer(内存缓存)读取当前状态,只有在发现实际状态与期望状态不一致时才向 API Server 发写请求。对于 resync 触发的 Reconcile 调用(对象没有真实变化)以及 status 更新循环触发的重复调用,正确实现的 Reconcile 在检查状态一致后直接返回,不发任何写请求。大多数 Reconcile 调用是纯内存操作(读 Indexer、比较状态),只有少数触发实际的 etcd 写入。这是 Kubernetes 控制器在大规模集群中高效运行的关键。
误解四:控制器只能有一个实例运行,否则会产生写冲突。
多个控制器副本可以同时运行(例如 kube-controller-manager 的多副本部署),因为 etcd 的乐观锁(resourceVersion CAS)保证了并发写入的安全性:两个副本同时 Reconcile 同一对象,只有一个能成功写入,另一个收到 409 Conflict,重新 Reconcile 时读到最新状态并重新计算,最终结果仍然正确。leader election 是性能优化(避免多个副本重复 Reconcile),不是正确性必需。对于写入频率低的 CRD,禁用 leader election 可以减少选举延迟,实现更快的故障切换。
系列导航
- 00 核心概念导读
- 01 API Server 与声明式 API
- 02 etcd 与持久化
- 03 控制器模式与 Informer 机制
- 04 Pod 生命周期
- 05 调度器深入
- 06 kubelet 与容器运行时
- 07 网络模型与 CNI
- 08 Service 与 kube-proxy
- 09 Ingress 与 Gateway API
- 10 存储体系
- 11 配置与密钥管理
- 12 RBAC 与安全模型
- 13 资源管理与自动伸缩
- 14 CRD 与 Operator 模式
- 15 Helm 与应用打包
- 16 可观测性
- 17 生产集群运维
参考资料
- client-go 源码 — tools/cache — Reflector、DeltaFIFO、Indexer、SharedInformer 的完整 Go 实现
- controller-runtime 文档 — Manager、Builder、Reconciler 接口的官方 API 文档
- kubebuilder Book — controller-runtime 使用的完整教程,含 CRD 定义、Reconcile 实现、webhook 集成
- A deep dive into Kubernetes controllers — Bitnami 工程博客,Informer 内部机制的图解说明
- Writing Kubernetes Controllers — best practices — Kubernetes 社区官方控制器编写规范,幂等性、错误处理和 finalizer 的实践建议
- client-go workqueue — RateLimitingQueue、BucketRateLimiter、ItemExponentialFailureRateLimiter 的接口文档与实现
