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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
etcd Watch stream


Reflector (ListWatch)
│ delta events

DeltaFIFO queue


Indexer (in-memory cache)
│ AddFunc/UpdateFunc/DeleteFunc

workqueue (rate-limited, deduped)


Reconcile(namespace/name)
┌───────────────────────────────┐
1. Get current state from cache│
2. Compute diff │
3. Call API Server to patch │
└───────────────────────────────┘
│ error → requeue with backoff

Done (or retry)

从 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// controller-runtime Reconciler interface
type Reconciler interface {
Reconcile(ctx context.Context, req Request) (Result, error)
}

// Request carries only the namespaced name — not the object itself
// The Reconciler reads current state from the cache, never from the event
func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var obj MyResource
if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// compute desired state, call r.Update / r.Create / r.Delete
return ctrl.Result{}, nil
}

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
2
3
4
5
6
ctrl.NewControllerManagedBy(mgr).
For(&appsv1.Deployment{}). // 监听 Deployment,触发 Reconcile
Owns(&appsv1.ReplicaSet{}). // 监听 Deployment 拥有的 RS,变更时也触发
Watches(&corev1.ConfigMap{}, // 监听 ConfigMap,自定义 key 映射
handler.EnqueueRequestsFromMapFunc(mapConfigMapToDeployment)).
Complete(r)

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
2
kind create cluster --name informer-lab
kubectl create deployment demo --image=nginx:1.25 --replicas=2

观察 controller-manager 的 Reconcile 日志(需要提升日志级别):

1
2
3
4
5
6
7
8
# 查看 kube-controller-manager 日志,过滤 deployment controller 相关行
kubectl logs -n kube-system -l component=kube-controller-manager --follow \
| grep -i "deployment\|reconcil" &

# 快速连续修改 Deployment 的 annotation(产生 5 次 MODIFIED 事件)
for i in $(seq 1 5); do
kubectl annotate deployment demo --overwrite "run=$i"
done

观察日志中 Reconcile 的触发次数是否少于 5 次。workqueue 去重会把多次快速 MODIFIED 事件折叠成少数几次 Reconcile 调用。实际触发次数取决于 Reconcile 执行时间与事件到达间隔的比值:如果每次 Reconcile 需要 50ms,而事件间隔只有 10ms,则大部分中间事件在 workqueue 中被折叠,最终只触发 1-2 次 Reconcile。

观察 resync 周期触发的 Reconcile:

1
2
3
4
# 不做任何修改,等待 10 分钟后查看日志
# deployment controller 的默认 resync 周期是 30 秒到几分钟不等
# 可以通过 controller-manager 启动参数 --min-resync-period 调整
kubectl get --raw /metrics | grep workqueue_depth

workqueue_depth 指标显示各控制器 workqueue 中当前待处理的 key 数量,workqueue_adds_total 显示总入队次数,workqueue_work_duration_seconds 显示 Reconcile 的执行耗时分布。

观察 Finalizer 机制的删除保护:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 给 Deployment 添加 finalizer
kubectl patch deployment demo -p '{"metadata":{"finalizers":["test/my-finalizer"]}}'

# 尝试删除 Deployment(会卡住,因为有 finalizer)
kubectl delete deployment demo &

# 观察 DeletionTimestamp 被设置但对象未删除
kubectl get deployment demo -o jsonpath='{.metadata.deletionTimestamp}'

# 手动移除 finalizer,对象才会真正删除
kubectl patch deployment demo \
-p '{"metadata":{"finalizers":[]}}' \
--type=merge

这个实验直接演示了 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 的签名虽然包含 oldObjnewObj,但标准实践是忽略两者,只提取 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 可以减少选举延迟,实现更快的故障切换。

系列导航

参考资料