第23章 client-go
client-go 是 Kubernetes 生态的基石,Informer 机制则是 client-go 的灵魂。理解 Informer 的工作原理,是读懂 controller-runtime、Operator 模式乃至整个 Kubernetes 控制平面的前提。本章自顶向下拆解 Informer 的核心组件:Informer、Reflector、DeltaFIFO、Indexer、SharedInformer,给出关键结构体与伪代码,并讨论工程实践中的常见坑。
Informer
Informer 是 client-go 中用于在本地缓存 Kubernetes 资源并监听变更的高层抽象。它把"List + Watch"封装成一个事件驱动的流水线:从 API Server 拉取全量列表建立缓存,再通过 Watch 增量更新缓存,并通过回调把事件分发给业务逻辑。
是什么
一个 Informer 至少包含以下能力:
- 通过 ListerWatcher 执行 List(一次性全量)和 Watch(持续流式)。
- 把 List/Watch 返回的事件塞进 DeltaFIFO。
- 用一个消费者循环把 Delta 取出,写入 Indexer(本地缓存)。
- 把变更以 OnAdd/OnUpdate/OnDelete 形式分发给注册的 ResourceEventHandler。
- 周期性触发 Resync,把缓存里的对象重新以 Sync 事件喂给 handler。
它与直接调用 Watch 相比最大的好处是:本地缓存可以反复读、读不走 API Server;事件顺序在内存里被串行化,业务侧不需要处理 Watch 中断和重连。
数据结构与工作流
Informer 的标准实现是 cache.SharedIndexInformer(在 k8s.io/client-go/tools/cache/shared_informer.go)。核心字段简化如下:
| |
字段含义:
| 字段 | 含义 |
|---|---|
indexer | 真正的本地缓存,所有读写最终落到这里 |
controller | 把 Reflector 与 DeltaFIFO 串起来的驱动器,跑在一个独立 goroutine |
processor | 维护所有注册的 EventHandler,负责事件广播 |
listerWatcher | 用户传入的 List/Watch 封装,决定"看哪种资源、哪个 namespace" |
objectType | 关注的 GVK,用于类型断言与日志 |
resyncCheckPeriod | 多久检查一次"是否该 resync",不等于实际 resync 周期 |
Informer 工作流可以用下面这张 ASCII 图描述:
| |
关键源码要点(shared_informer.go):
Run(stopCh):启动时构造 DeltaFIFO 与 controller,再启动 processor 与 controller,最后在Stop()里清理。HandleDeltas(obj interface{}):从 DeltaFIFO 弹出 Deltas 列表,逐个处理;对每个 Delta 调用indexer.Add/Update/Delete,再通过processor.distribute把事件广播出去。AddEventHandler:把外部消费者注册到sharedProcessor,每个 handler 会被包一层缓冲队列(processorListener)。WaitForCacheSync(stopCh):阻塞等待首次 List 完成、缓存与 etcd 一致。
伪代码简化版:
| |
工程实践与常见坑
必须 WaitForCacheSync 后再读缓存:Informer 启动是异步的,启动后立刻读 Indexer 可能读到空集合,导致误判"资源不存在"。控制器入口都要
cache.WaitForCacheSync(stop, inf.HasSynced)。事件处理要快:
OnAdd/OnUpdate/OnDelete是在sharedProcessor的 goroutine 里同步执行的。如果回调里做了重活(HTTP、DB 写入),会阻塞整个 Informer 的事件分发。标准做法是回调只把 key 入队到 workqueue,真正的处理在 Reconcile 循环里做(见 第24章 Controller)。同一个 GVR 不要建多个 Informer:每个 Informer 都会与 API Server 建立一条 Watch 长连接。同一个资源类型请用
SharedInformerFactory复用,否则 API Server 压力大、Watch 也更容易被限流。Resync 周期不是越短越好:Resync 会把缓存全量以 Sync 事件重放一遍,业务侧会收到大量"无变化"的 OnUpdate。一般 10 分钟到 1 小时之间,且要保证业务幂等。
List 期间 ResourceVersion 可能过期:如果 List 数据量很大,期间 etcd 的 compact 可能让 RV 失效,导致 Watch 报
410 Gone,Reflector 会重新 List。这是设计上正常的,但要在监控里观测 Reflector 的 List 次数。Watch 不保证不丢事件:网络抖动、API Server 重启都会导致 Watch 断开。Informer 的"自愈"靠 List 重建 + ResourceVersion 续传,但中间被 compact 掉的事件是补不回来的,只能靠 Resync 兜底。
SharedInformerFactory的Start与WaitForCacheSync要分开调用:先Start启动所有 informer 的 goroutine,再WaitForCacheSync阻塞等待;如果 Start 之前调用 Wait 会死锁。
Reflector
Reflector 是 Informer 流水线的"水源",负责把 API Server 上的对象列表和变更流式拉到本地 DeltaFIFO 里。
是什么
Reflector 位于 k8s.io/client-go/tools/cache/reflector.go,核心职责:
- 调用
ListFunc拉取资源的全量列表,把结果作为初始 Deltas 推入 DeltaFIFO(以Sync类型)。 - 用上一步拿到的
ResourceVersion启动WatchFunc,把收到的事件以Added/Updated/Deleted推入 DeltaFIFO。 - Watch 出错或断开时,根据错误类型退避重试;遇到
410 Gone等致命错误则重新 List。 - 周期性检查是否到了 resync 时间。
关键结构体
| |
字段说明:
| 字段 | 含义 |
|---|---|
expectedType | 收到的事件对象做类型断言,防止误用 |
store | 通常是 DeltaFIFO,所有事件最终被 store.Add 进去 |
listerWatcher | 由用户传入,封装了 List 和 Watch 两个 HTTP 请求 |
resyncPeriod | 多久检查一次"是否该 resync";为 0 表示不 resync |
lastSyncResourceVersion | 每次 List/Watch 后更新,作为下次 Watch 的起点 |
isLastSyncResourceVersionUnavailable | 收到 410 时置 true,强制下次 List |
WatchListPageSize | 当 List 结果很大时启用分页,避免一次性请求超时 |
nextResync | 下次触发 Resync 的绝对时间,每次 resync 后顺延 |
工作原理与源码要点
Reflector.Run(stopCh) 主体是 ListAndWatch 循环:
| |
要点:
- List 不一定一次到位:默认情况下 client-go 不分页,但对于大资源(如 Pod、EndpointSlice),可以设置
WatchListPageSize,逐页拉取并合并,最后用最后一页的 RV 作为 Watch 起点。新版 client-go 还引入了WatchList(基于 etcd 的progressNotify),可以"边 Watch 边补全",无需独立 List。 - ResourceVersion 是续传凭证:每次 Watch 都把上次收到的 RV 作为
?resourceVersion=。如果该 RV 在 etcd 已被 compact,API Server 返回 410,Reflector 重新 List。 - 错误分类:网络错误直接退避重试 Watch;410/Forbidden 这类返回到外层重新 List;context canceled 退出。
- Resync 时机:在 Watch 循环里,每次进入循环前检查
nextResync,到了就调用store.Resync(),把 store(DeltaFIFO)里所有对象以 Sync 事件入队。 - WatchHandler 的事件分发:收到
watch.Event后,按EventType调用store.Add/Update/Delete,并更新lastSyncResourceVersion。
工程实践与常见坑
ListFunc 中的 ResourceVersion 选择:从缓存读还是从 etcd 读,看场景。如果需要"最新"数据,应不带 RV(让 API Server 走 quorum read);如果只是 warm up,可用
RV=0走 kube-apiserver 缓存。但RV=0在大规模集群里可能返回旧数据。List 超时要设大:全量 List 一万个 Pod 可能要几秒到几十秒。
ListWatch的Timeout参数务必调大,否则 List 失败反复重试,Informers 永远 sync 不上。FieldSelector / LabelSelector 要慎用:在 List/Watch 上加 selector 看似省流量,但 SharedInformer 复用时不同 selector 的 Informer 不能共享。如果业务侧关心的是"我自己的 namespace",不如全量 Informer + Indexer Index,按 namespace 索引。
Watch 报错日志会刷屏:网络抖动时 Reflector 会不断重连,日志里大量 “watch for *v1.Pod ended with: too old resource version”。建议在
watchErrorHandler里加节流,并暴露 Prometheus 指标。lastSyncResourceVersionUnavailable误判:早期版本某些 410 错误处理不全,会导致 Reflector 卡住。务必用较新的 client-go(v0.24+)。大集群 List OOM:List 几十万对象一次性放进内存可能 OOM。务必启用
WatchListPageSize或新版WithWatchList模式,配合 server-side 分页。
DeltaFIFO
DeltaFIFO 是 Reflector 和 Informer 处理循环之间的"缓冲带"。它是一个先入先出队列,但同一个对象(key)的多次变更会被合并成 Deltas 列表,保证消费端能拿到完整的"事件链"。
是什么
DeltaFIFO 位于 k8s.io/client-go/tools/cache/delta_fifo.go,特性:
- FIFO 语义:按入队顺序出队。
- 同一 key 的多次变更会被合并为一个 Deltas(
[]Delta)。 - 同一 key 同时只能"在队列里一份"(通过
queue数组 +dirty集合去重)。 - 支持
Sync、Added、Updated、Deleted、Replaced几种 DeltaType。 Replace()用于处理 List 全量结果:会把"新列表里没有但缓存里有"的对象标记为 Deleted(带 tombstone)。
关键结构体
| |
字段说明:
| 字段 | 含义 |
|---|---|
items | 真正的数据存储,key 到 Deltas 列表的映射 |
queue | 维持 FIFO 顺序的 key 数组 |
keyedMutexs / keys | 同 key 去重,防止一个 key 在 queue 里出现多次 |
populated | 是否已经至少 Replace 过一次 |
initialPopulationCount | 首次 Replace 的对象数,用来判断首次同步是否结束 |
keyFunc | 通常 MetaNamespaceKeyFunc,返回 namespace/name |
knownObjects | 一般就是 Indexer,用于 Replace 时算"谁被删了" |
emitDeltaTypeReplaced | 决定 Replace 事件对上层是 Replaced 还是 Sync(影响 handler 行为) |
工作原理与源码要点
生产端(Reflector)调用的方法:
Add(obj)/Update(obj)/Delete(obj):内部计算 key,调用queueActionLocked把对应 Delta 追加到items[key],并把 key 推入queue(如果还没在里面)。Replace(list, resourceVersion):把 List 的全量结果作为 Sync/Replaced Delta 入队;同时用knownObjects比对,把缓存里有但 list 里没有的对象以 Deleted 入队(带 tombstone)。这个动作是"删除漂移对象"的关键。Resync():遍历knownObjects(Indexer),把每个对象以 Sync Delta 入队。
消费端(Informer 的 controller)调用的方法:
Pop(process PopProcessFunc):阻塞直到队列非空。取出 queue[0],把对应的 Deltas 交给 process 处理;如果 process 返回ErrRequeue,调用AddIfNotPresent把 key 重新塞回队列。
伪代码:
| |
合并 Deltas 的简化逻辑:
| |
要点:
- Deltas 合并不等于事件合并:同一个对象的
Added, Updated, Updated会被合并成 3 个 Delta,但相邻同类型的Updated, Updated会被压成 1 个。 - Deleted tombstone:当 Reflector 检测到本地缓存里有、但 API Server 已经没有了的对象,会构造一个
DeletedFinalStateUnknown作为 Object,避免业务侧误用。Handler 里要注意这种对象可能是过期的。 - Replace 不等于 Sync:Replace 是"用全量列表覆盖",会把不在列表里的对象标记 Deleted;Sync 是"把缓存里的对象重新喂一遍",不删任何东西。
- initialPopulationCount 的作用:Informer 用它判断"首次 List 是否处理完",进而决定
HasSynced()返回 true 还是 false。
工程实践与常见坑
不要直接复用 DeltaFIFO:它本质上是为 Informer 设计的内部组件,外部几乎不需要直接使用。如果要写自定义 Informer,用
cache.NewDeltaFIFO配合Indexer即可。Pop 是阻塞的:消费端必须保证一直在 Pop,否则队列会无限增长,内存爆掉。Informer 里这个 Pop 是 controller 的 worker goroutine 在循环里做的。
Replace 期间对象激增:如果集群里有几十万个资源,首次 Replace 会让 DeltaFIFO 瞬间膨胀。务必给控制器配置合理的
WatchListPageSize和 List 限流。keyFunc 必须稳定:如果 keyFunc 对同一对象在不同时间返回不同 key(比如依赖可变字段),会导致同对象被当成多个 key 处理,Replace 时还会触发幽灵 Deleted。默认用
MetaNamespaceKeyFunc(namespace/name)就稳。ErrRequeue 慎用:在 Pop 的 process 函数里返回
ErrRequeue会把 Deltas 塞回队列,但 Deltas 不会被合并,会再次全量重放,容易造成放大效应。生产中一般让 Informer 直接丢弃并信任下次 resync。Replace 与 KnownObjects:DeltaFIFO 本身不维护"全集",它依赖
knownObjects(即 Indexer)来判断哪些对象该被 Replace 删除。如果knownObjects为 nil,Replace 会把所有 list 项作为 Sync 入队,但无法算出"漂移删除"。
Indexer
Indexer 是 Informer 的本地缓存层,提供按 key 读写、按自定义索引检索的能力。它在内存里维护一份"对象的全量副本",让控制器读资源时不必每次都打 API Server。
是什么
Indexer 是一个接口(k8s.io/client-go/tools/cache/index.go),底层默认实现是 threadSafeMap。它支持:
- 基本读写:
Add/Update/Delete/Get/List。 - 按 key 查询:
GetByKey。 - 多维索引:通过
Indexers注册多个索引函数,再用Index/ByIndex按 indexName + indexValue 取对象。 - 线程安全。
接口与结构体
| |
底层 threadSafeMap:
| |
字段说明:
| 字段 | 含义 |
|---|---|
items | 真正的对象存储,key 通常为 namespace/name |
indexers | 索引定义:索引名 → 索引函数 |
indices | 索引数据:索引名 → {索引值 → 该值下的所有 key 集合} |
预置索引函数:
MetaNamespaceIndexFunc:按 namespace 建索引,索引值为obj.GetNamespace()。MetaNamespaceKeyFunc:作为 keyFunc,返回namespace/name(跨 namespace 时只有name)。
工作原理
Add/Update/Delete 时除了改 items,还要更新所有 indices:
| |
要点:
- 索引是双向的:
indices[name][value]存的是 key 集合,要拿到对象还得回到items[key]。 - 更新对象时索引也要更新:先从旧索引值集合里删除该 key,再加到新值集合里,否则索引会脏。
- AddIndexers 必须在缓存为空时调用:如果缓存已经有数据,新加的索引不会回填到老对象上,会抛错。
工程实践与常见坑
善用 Index 减少 List:比如
cache.Indexers{"node": func(obj) { return []string{pod.Spec.NodeName} }},可以 O(1) 查出某个节点上的所有 Pod,比List+ 过滤快几个数量级。不要在 IndexFunc 里抛 panic:IndexFunc 是在 Add/Update 时同步调用的,panic 会让整个 Informer 崩溃。
缓存不是数据库:Indexer 里的对象可能因为 410/Gone 暂时与 etcd 不一致,业务逻辑要做幂等,不能依赖"Indexer 一定是最新"。
大对象不要全量缓存:例如 ConfigMap、Secret 这种"巨型"对象,全量缓存会吃内存。可以用 List-Watch + selector,或干脆不用 Informer 直接 Get。
不要直接修改缓存对象:从 Indexer 拿到的对象是指针,业务侧修改它等于修改缓存。要修改请 deepcopy(
obj.DeepCopyObject()),否则下一个 OnUpdate 收到的oldObj已经被你改过了,diff 失效。AddIndexers时机:必须在sharedIndexInformer.AddIndexers且 Informer 启动前(缓存还没数据)调用。一旦factory.Start之后再加,会报错。
SharedInformer
SharedInformer 是 Informer 的"多租户"版本:多个业务方共享同一个 Informer 实例,但每个方注册自己的 EventHandler,互不干扰。
是什么
SharedInformer 接口(k8s.io/client-go/informers)由 sharedIndexInformer 实现,配合 SharedInformerFactory 使用:
- 同一个 GVR(Group/Version/Resource)只创建一个 Informer 实例。
- 多个 Controller 注册自己的 EventHandler,事件通过
sharedProcessor广播。 - 所有方共享同一份 Indexer 缓存。
关键结构体
| |
字段说明:
| 字段 | 含义 |
|---|---|
listeners | 所有已注册的 EventHandler |
syncingListeners | 还没完成首次同步的 listener 子集 |
nextCh / addCh | 用两个 channel + ring buffer 实现"非阻塞分发 + 顺序消费" |
handler | 用户注册的回调 |
requestedResyncPeriod | 每个 listener 可以单独配置 resync 周期 |
pendingNotifications | 当 nextCh 满时,事件先落到 ring buffer,避免丢 |
工作原理
事件分发链路:
| |
关键点:
- 每个 listener 一对 goroutine:
run()和pop()。pop()负责把 addCh 与 resync 事件合并到 nextCh;run()负责从 nextCh 读并调用 handler。这样 handler 慢不会阻塞 distribute(最多让 ring 增长)。 - distribute 是同步的:
sharedProcessor持锁遍历所有 listener,往各自的addCh写。如果某个 listener 的addCh满,会被阻塞,从而影响整体分发。这就是为什么 handler 一定要"轻"。 - Resync 独立:每个 listener 有自己的
nextResync,到点了由pop()注入 Sync 事件。 - syncingListeners 的作用:首次同步阶段,distribute 只把事件发给 syncingListeners 子集,已经 synced 的 listener 不再收 Sync 事件。
工程实践与常见坑
- 用 SharedInformerFactory 而不是手搓 Informer:
| |
不同 namespace 用不同 factory:
NewSharedInformerFactoryWithOptions(client, resync, informers.WithNamespace(ns))。同 namespace 内的 GVR 共享;不同 namespace 是独立的 Informer 实例。Resync 周期要协调:所有 listener 共享一个 Reflector,但 resync 是 per-listener 的。listenerA 设 30s、listenerB 设 10min 是允许的,但 Reflector 实际取最小值检查。
EventHandler 注册时机:必须在
factory.Start之前AddEventHandler,否则首次 List 的事件可能漏掉。如果必须动态注册,新版 client-go 的AddEventHandlerWithResyncPeriod可以正确处理"中途加入",但首次同步事件不保证。WaitForCacheSync要传 Informer 的 HasSynced:工厂级WaitForCacheSync是等待所有 informer,业务侧一般单独podInformer.Informer().HasSynced。不要在 handler 里直接修改缓存对象:见 Indexer 章节,要 deepcopy。
RemoveEventHandler是 v0.27+ 才稳定:动态注销 listener 在老版本里有内存泄漏问题,升级前要确认版本。
本章小结
- Informer 是 client-go 的核心抽象,把 List+Watch 封装为"本地缓存 + 事件分发"流水线。
- Reflector 负责从 API Server 拉数据,处理 List/Watch/Resync,是流水线的"水源",靠 ResourceVersion 续传、410 重 List 实现自愈。
- DeltaFIFO 是中间缓冲,按 key 合并 Deltas,保证消费端拿到完整事件链;Replace 处理全量同步,Resync 周期性重放。
- Indexer 是本地缓存(threadSafeMap),支持按 key 与自定义索引查询,是控制器读资源的"快路径"。
- SharedInformer 让多业务方共享同一份缓存和 Reflector,配合 SharedInformerFactory 实现资源复用。
- 工程实践的核心是:用工厂复用、handler 入队不干活、WaitForCacheSync 后再读、Resync 不要太频繁、对象不可信要 deepcopy。
掌握 Informer 后,下一章我们将进入 第24章 Controller,看 controller-runtime 如何在 Informer 之上构建 Reconcile 控制循环。