ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

深入理解Kubernetes Informer:原理、组件与实战避坑指南

2026/9/8 9:10:57 拓冰建站 浏览量
深入理解Kubernetes Informer:原理、组件与实战避坑指南 1. 为什么每个写K8s控制器的人都绕不开Informer我先说个很实在的场景。假设你现在接到一个任务监控集群里所有Deployment的副本数变化一旦发现期望副本数和实际副本数不一致就自动扩缩容。你翻开client-go的文档第一反应是这还不简单我写个for循环每5秒List一次Deployment对比一下不就行了这种做法当然能跑但等到你的控制面组件要同时监听几十种资源、每个资源几万个对象、还要在事件发生后几百毫秒内做出响应的时候轮询方案就直接崩了——API Server被你打爆不说数据延迟也完全不可控。这时候再去看社区里那些成熟的控制器代码你会发现它们清一色在用client-go提供的informers包。Informer机制就是Kubernetes控制器模型的数据底座理解了它你看任何控制器代码都会轻松一大截。这篇文章我准备把自己在项目里用Informer的完整思路、踩过的坑、以及源码层面的关键细节一次讲清楚。适合刚接触client-go的开发者也适合已经写了几个控制器但还停留在照着抄阶段的同学。简单说Informer干的事情有三件监听资源变化、维护本地缓存、触发事件回调。这三件事听起来都不复杂但合在一起就产生了一个非常优雅的效果——你的控制器永远不用直接去查API Server它只管从本地缓存里读数据然后等着被事件通知就行了。这套机制在Kubernetes生态里几乎是所有controller、operator、scheduler的公共基石。我后面会一层一层拆开讲。2. 从ListAndWatch说起Informer之类最底层的运作逻辑2.1 为什么只Watch不够还要先List一次Informer最底层依赖的是一个叫ListAndWatch的函数字面意思就是先拉全量再持续监听增量。很多人不理解为什么非得List一次不可不能直接Watch吗这里有个分布式系统的经典问题你不能保证Watch连接建立的那一刻API Server上发生过的历史事件你都能收到。万一网络抖动、连接断开重连中间丢失的事件怎么补回来所以client-go的做法很直接——先List一次拿到当前全量状态然后在此基础上建立Watch。这样即使之前丢失过事件也以全量数据为准刷新了本地状态后续增量事件基于这个快照继续叠加数据就是完整的。List和Watch的分工是这样的// 简化版伪代码展示核心逻辑 func ListAndWatch(client kubernetes.Interface, resource string) { // 1. List全量拉取 list, _ : client.CoreV1().Pods().List(context.TODO(), metav1.ListOptions{}) // 2. 把全量数据交给后续处理DeltaFIFO for _, item : range list.Items { store.Add(item) } // 3. Watch增量监听 watch, _ : client.CoreV1().Pods().Watch(context.TODO(), metav1.ListOptions{ ResourceVersion: list.ResourceVersion, }) for event : range watch.ResultChan() { // 处理 Added / Modified / Deleted 事件 } }关键就藏在ResourceVersion这里。List返回的时候会带一个资源版本号这个版本号是API Server上的逻辑时钟。你把这个版本号传给WatchAPI Server就知道从我这个版本之后开始推送变更给我。这一下就把全量和增量衔接起来了不会漏事件也不会重复处理。2.2 断线重连时为什么不会炸网络没有百分之百可靠的Watch连接随时可能断开。Informer怎么处理重连的它有一套重新List重新Watch的兜底逻辑。具体来说Reflector我后面会详细讲内部维护了一个lastSyncResourceVersion每次收到Watch事件都会更新它。当Watch连接断开时Reflector会退避重试重试成功后不是直接从断点续传而是再走一遍List全量 - 拿最新ResourceVersion - 重新Watch的流程。我第一次看到这个设计的时候心里打了个问号断线重连就把全量数据重新拉一遍那如果集群里有十万个Pod不就白白浪费大量带宽吗后来我想明白了这其实是在简单可靠和极致的增量效率之间做了一个务实的选择。直接从断点续传Watch虽然理论上可行但这要求API Server必须把历史事件全量保留。实际上API Server的etcd里的事件数据是有保留期限的默认一小时可以通过--etcd-events-ttl调节如果断线时间太长断点早就被清理了根本续不上。所以重新List就成了最稳妥的方案——它不依赖任何历史状态天然自愈。而且这里还有个很巧的优化Informer的ListWatch只发生在连接建立初期和断线重连时正常运行期间走的是长连接推送流量开销并不大。尤其配合后面的本地缓存95%以上的读操作根本不会碰到API Server。3. 组件拆解Reflector、DeltaFIFO、Indexer各管哪一段3.1 Reflector那个默默盯着API Server的哨兵Reflector直译过来是反射器但在我的理解里它就是驻守在API Server旁边的一个哨兵。它负责执行前面说的ListAndWatch循环把API Server上的资源变化转变成一个个事件。Reflector内部维护了几个关键信息expectedType要监听的对象类型比如*v1.Podstore事件要写入的存储实际就是DeltaFIFOlastSyncResourceVersion最后一次同步的版本号断线重连时用resyncPeriod周期性重新同步的间隔这个后面单独讲它每次Watch到事件不是直接分发而是包装成Delta变更记录塞进DeltaFIFO。一个Delta包含两部分变更类型和变更后的对象。变更类型有几种type DeltaType string const ( Added DeltaType Added Updated DeltaType Updated Deleted DeltaType Deleted Sync DeltaType Sync )注意有个Sync类型这个比较特殊它不是API Server主动推送的而是Informer自己定时生成的用于周期性地把本地缓存里的对象再重新同步一遍。目的和作用我放在后面第4节专门说。3.2 DeltaFIFO事件的待办队列DeltaFIFO是client-go里非常核心的一个数据结构名字拆开看就很好理解Delta一个变更记录包含变更类型和对象FIFO先进先出队列也就是说它是变更记录的先进先出队列。Reflector把事件写进来消费者processLoop从队列头部取出去处理。之所以要用队列而不是直接处理是因为生产和消费的速度不匹配——API Server的推送是突发的可能瞬间产生几百个事件而处理程序可能还在处理上一个事件。中间加一个队列做缓冲能很好地消峰。DeltaFIFO的精妙之处在于它的去重和合并逻辑。队列里的对象是按键namespace/name去重的同一个对象的新事件不会无限堆积而是会合并。比如一个Pod在短时间内连续被更新了三次队列里不会攒三个Updated事件而是把最新的对象状态直接覆盖旧的队列里最多只保留一条有效的待处理记录。这样消费端永远处理的是最新状态而不是历史事件的堆叠。这个设计背后的哲学值得品味Kubernetes的控制器模型是最终一致的中间过程根本不重要重要的是最终状态。你去问一个控制器这个Pod有几个副本它不需要知道Pod历史上经历过几次变更只需要知道当前期望是几个。DeltaFIFO的合并逻辑天然符合这个哲学。3.3 Indexer读多写少场景下的本地缓存Indexer是Informer对外提供的只读缓存也是我之前说的控制器不需要直接访问API Server的关键支撑。Indexer的内部实现是thread-safe map 索引。默认的索引是namespace/name - 对象但你可以自定义索引。比如按Label分组、按NodeName分组查询起来就会非常快。// 自定义索引示例按Pod的NodeName索引 indexers : cache.Indexers{ nodeName: func(obj interface{}) ([]string, error) { pod : obj.(*v1.Pod) return []string{pod.Spec.NodeName}, nil }, } // 初始化Informer时传入 sharedInformerFactory : informers.NewSharedInformerFactory(client, 10*time.Minute) podInformer : sharedInformerFactory.Core().V1().Pods() podInformer.Informer().AddIndexers(indexers) // 使用索引查询 podsOnNode, _ : podInformer.Informer().GetIndexer().ByIndex(nodeName, node-1)缓存的意义在控制器场景下怎么强调都不过分。一个集群有几千个Pod每个Pod每秒上报一次状态如果控制器每次处理事件都重新去API Server查一遍Pod详情API Server的QPS会高得离谱。有了Indexer控制器直接从本地缓存读读操作开销几乎为零。这也是为什么我在写所有operator时都会要求读写分离——写操作才走client读操作一律走Informer缓存。3.4 全流程串起来看我把Informer的完整数据流整理一下你跟着走一遍就全通了Reflector启动List全量数据写入DeltaFIFO然后Watch增量事件Reflector把收到的事件包装成Delta写入DeltaFIFODeltaFIFO的processLoop持续从队列弹出变更记录调用HandleDeltasHandleDeltas做两件事先更新Indexer缓存再调用用户注册的EventHandler回调你的控制器在回调里拿到变更后的对象开始业务逻辑这个流程里有一个细节很多人会忽略先更新缓存再触发回调。这意味着你在回调里通过Indexer查到的对象必然是最新的不会出现回调里读到旧数据的尴尬。4. 那些年我踩过的Informer实战坑4.1 事件处理函数阻塞导致的连锁问题这是我踩过最深的坑没有之一。刚开始写控制器的时候我在AddFunc里直接调用了业务逻辑这个业务逻辑里有访问数据库和调用外部API的操作。单个事件处理通常要200毫秒左右。当时测试环境的事件量不大没发现问题。等上了预发环境事件量一上来我发现API Server的Watch连接频繁断开重连日志里全是reflector: watch of *v1.Deployment closed。排查下来根因是这样的processLoop是单goroutine消费DeltaFIFO的如果我在回调里做耗时操作整个队列的消费就被卡住了。事件处理不过来DeltaFIFO就会越堆越长Reflector往队列里写数据的Add操作也会阻塞DeltaFIFO是有界队列。写不进去之后Reflector认为Watch异常主动断开重连。最终导致事件积压、重复处理、缓存不一致整套机制直接崩溃。正确的做法一定是回调里只做轻量级操作耗时逻辑放到独立goroutine或workqueue里异步处理。// 错误示范直接在回调里做耗时操作 podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { doExpensiveWork(obj) // 阻塞了processLoop }, }) // 正确做法回调只负责入队 podWorkQueue : workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter()) podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { key, _ : cache.MetaNamespaceKeyFunc(obj) podWorkQueue.Add(key) // 入队立即返回 }, UpdateFunc: func(oldObj, newObj interface{}) { key, _ : cache.MetaNamespaceKeyFunc(newObj) podWorkQueue.Add(key) }, }) // 然后启动worker goroutine从队列里取key处理client-go自带的workqueue包就是专门配合Informer使用的。它的核心价值有两个一是去重同一个key不需要重复入队多次二是延迟和限速失败的任务可以按指数退避的节奏重试不会因为程序崩溃反复执行。4.2 事件回调里修改了缓存对象导致的守卫事故第二个坑更隐蔽。我在一个项目里写了这么一段代码UpdateFunc里拿到新对象后想改一下它的Annotation再存储UpdateFunc: func(oldObj, newObj interface{}) { pod : newObj.(*v1.Pod) pod.Annotations[processed] true // 直接修改了缓存里的对象 processPod(pod) }这段代码跑了一段时间出现了一个诡异的现象某些Pod明明已经被处理过了但过一会又会触发一次Update事件而且业务逻辑执行了两遍。查了很久才发现我修改的不是API Server上的对象而是Indexer缓存里保存的同一份指针引用。缓存里的对象被改了但API Server并不知道。等到下次有Pod真正被更新时Reflector收到API Server推送的旧版本对象放进DeltaFIFO再更新Indexer时才发现缓存里的对象和API Server的不一致于是又触发了一次Update回调。这种bug最难查的地方在于它的表现不是直接报错而是微妙的状态漂移。事件被重复触发业务逻辑重复执行可能带来重复计费、重复发送通知等严重后果。这个事的教训就是永远不要把缓存里的对象当作自己的私有数据。需要修改对象内容时必须用DeepCopyUpdateFunc: func(oldObj, newObj interface{}) { pod : newObj.(*v1.Pod).DeepCopy() // 深拷贝之后再改 pod.Annotations[processed] true processPod(pod) }4.3 UpdateFunc触发的乱序处理问题还有一个让我挠头的问题是关于UpdateFunc只给新对象不给旧对象使用场景的。有一次我需要统计Pod重启次数的变化趋势。一开始我在UpdateFunc里拿到新旧两个对象比较Status.ContainerStatuses里的RestartCount有变化就上报指标。看起来逻辑没问题但实际跑起来指标总是对不上偶尔还会出现负数增量。问题出在事件不是保序的。Informer不保证回调的执行顺序和API Server产生事件的顺序完全一致尤其是在watch重连之后先收到的事件可能是后发生的。所以如果你的事务逻辑依赖旧对象必然是新对象的前一个状态那就会出错。解决思路很直接不要在回调里做两个状态之间的差值计算而是把两个状态都上报由下游来做聚合计算。或者干脆只用最新状态做幂等处理不依赖历史。4.4 Resync的真正目的是什么我第一次看到NewSharedInformerFactory(client, 10*time.Minute)这个参数的时候以为是让Informer每10分钟重新拉一遍全量数据。这个理解是错的。resyncPeriod做的事情是每过这个时间间隔Informer会把Indexer缓存里的所有对象重新包装成Sync类型的Delta再一次投递给DeltaFIFO。这样做的目的不是拉新数据而是让你的EventHandler有机会周期性地看到所有对象的最新状态即使这些对象在API Server上没有任何变化。这有什么用两个典型场景漏事件兜底如果你的控制器因为某种原因丢失了某些事件比如回调panic了resync能让你周期性地补偿处理一遍全量对象。定期核对如果你需要在业务上周期性扫描所有对象的状态比如每天检查一次证书是否即将过期resync就是现成的定时器。但要注意resync只触发UpdateFunc内部会把Sync转成Updated事件不会触发AddFunc和DeleteFunc。而且resync的单位是整个Informer工厂不是单个Informer所以生产环境通常给一个适中值比如10分钟或15分钟不要设置得太短否则会产生大量的无效回调。4.5 多Informer实例的事件漂移与一致性选择当你能熟练使用单个Informer之后下一个自然的问题就是如果我的控制器需要同时监听Pod和Node两种资源怎么保证它们的数据在时间上是一致的比如我需要根据Node的状态来决策Pod的处理逻辑——Node是Ready的才处理它的Pod。你可能会分别在Pod的Informer回调和Node的Informer回调里做业务逻辑。这就出现了一个问题两个回调运行在不同的goroutine里它们的执行时机是不确定的。你无法保证我看到Node是Ready的时候Pod的最新状态也一定已经更新到了Indexer里。社区对这种问题的经典解是把事件统一入队由同一个worker goroutine串行处理。也就是说不管是Pod事件还是Node事件都解析成key然后丢进同一个workqueueworker从队列里取出key后再去Indexer里查当前最新的Pod和Node状态一起做决策。这样一来决策的时刻读到的就是两个Indexer的当前状态一致性就好很多。当然这仍然不是强一致两个Indexer的更新时机仍然有极小的时间差但对几乎所有控制器的业务场景来说已经足够了。Kubernetes本身就是最终一致的系统你不可能也不需要做到绝对的强一致。5. 读懂SharedInformerFactory为什么大家都用工厂而不是裸写Informer5.1 共享机制解决的是内存爆炸问题我一开始写Informer的时候是每个资源手动一个NewPodInformer跑起来也正常。但随着监听的资源种类增多我发现一个严重的问题如果我的控制台同时需要Pod、Deployment、Service、Node、ConfigMap五种资源每个Informer都会ListAndWatch一次。假设每种资源一万个对象那就是五万次API请求打过去内存里要归五份全量缓存。这还只是一个小项目生产环境的大型controller可能要监听几十种资源。SharedInformerFactory的核心机制就是同类型资源全局只创建一份Informer所有消费者共享同一个Reflector、同一份Indexer缓存。// 同一个factory不管调用几次返回的都是同一个PodInformer实例 factory : informers.NewSharedInformerFactory(client, 10*time.Minute) podInformer1 : factory.Core().V1().Pods() podInformer2 : factory.Core().V1().Pods() fmt.Println(podInformer1 podInformer2) // true这意味着什么假设你有12个控制器逻辑都要监听Pod如果各自New一个Informer就是12份Pod缓存12条Watch连接用SharedInformerFactory它们全部归一只有一份缓存、一条Watch连接API Server的压力直接减少到原来的1/12。多个控制器共享同一个Informer时每个控制器可以注册自己独立的EventHandler互不影响。这是一对多的广播模型——一个Informer的数据源多个业务消费者。5.2 Start老生常谈但必须理解的启动顺序SharedInformerFactory有一个很容易被忽视的细节你必须在调用factory.Start(stopCh)之后再使用Informer的Lister。如果顺序反了你会拿到一份空缓存。具体原因是Start会为每个Informer启动一个goroutine执行Run而Run里才真正开始执行ListAndWatch。在List完成之前缓存是空的。如果你在Start之前就调用了Lister().List()返回的就是空列表。更稳妥的启动方式是先factory.Start(stopCh)再调用factory.WaitForCacheSync(stopCh)。WaitForCacheSync会阻塞等待所有Informer完成首次List确保缓存可用后才放行。factory : informers.NewSharedInformerFactory(client, 10*time.Minute) informer : factory.Core().V1().Pods() // 必须先启动 factory.Start(stopCh) // 必须等缓存同步完成 if !cache.WaitForCacheSync(stopCh, informer.Informer().HasSynced) { klog.Fatal(cache sync timeout) } // 到这里才能安全使用Lister pods, _ : informer.Lister().List(labels.Everything())这个WaitForCacheSync我当年第一次写的时候就跳过了结果线上出现了一个非常尴尬的bug控制器启动后的前几秒里它认为集群里一个Pod都没有把需要保留的Pod全部当成孤儿Pod清理了。这个问题后来我反思了很多本质上是没有理解缓存填充需要时间这个基本事实。5.3 多Informer之间的数据同步等待关也是等一个更进阶的场景你的控制器在AddFunc里收到一个Pod事件需要知道这个Pod属于哪个Deployment所以去查Deployment的Indexer。但如果Deployment的Informer还没有完成首次List查到的就是空的你可能就会错误地认为这个Pod不属于任何Deployment。这种跨资源依赖的问题除了统一入队workqueue之外还需要在启动阶段做额外的同步等待if !cache.WaitForCacheSync(stopCh, podInformer.Informer().HasSynced, deployInformer.Informer().HasSynced, ) { klog.Fatal(cache sync timeout) }把多个Informer的HasSynced都等一遍确保启动阶段所有资源都缓存完整了后续的事件处理就相对安全。不过这依然有一种极端情况运行过程中Deployment的Informer因为网络问题重连导致一小段时间内Deployment缓存不完整。这正是Informer机制本身无法完全避免的只能靠resync来兜底。6. 从Informer到Workqueue手写一个极简控制器的完整骨架6.1 一个能跑的最小控制器前面讲了那么多理论我最后给你贴一个经量级的完整控制器骨架你可以直接照着搭项目。这个骨架融合了我前面提到的所有实践要点回调里只入队、worker里统一处理、跨资源同步等待、缓存读取。package main import ( context fmt time corev1 k8s.io/api/core/v1 metav1 k8s.io/apimachinery/pkg/apis/meta/v1 k8s.io/apimachinery/pkg/fields 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 k8s.io/klog/v2 ) type PodController struct { informer cache.SharedIndexInformer queue workqueue.RateLimitingInterface client kubernetes.Interface } func NewPodController(client kubernetes.Interface) *PodController { // 只关注default命名空间的Pod减少不必要的缓存 factory : informers.NewSharedInformerFactoryWithOptions( client, 10*time.Minute, informers.WithNamespace(default), ) podInformer : factory.Core().V1().Pods() queue : workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter()) controller : PodController{ informer: podInformer.Informer(), queue: queue, client: client, } podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { controller.enqueue(obj) }, UpdateFunc: func(oldObj, newObj interface{}) { controller.enqueue(newObj) }, DeleteFunc: func(obj interface{}) { controller.enqueue(obj) }, }) return controller } func (c *PodController) enqueue(obj interface{}) { key, err : cache.MetaNamespaceKeyFunc(obj) if err ! nil { klog.ErrorS(err, meta namespace key func failed) return } c.queue.Add(key) } func (c *PodController) processItem(ctx context.Context, key string) error { namespace, name, err : cache.SplitMetaNamespaceKey(key) if err ! nil { return err } // 从Informer缓存中读取最新数据而不是直接访问API Server pod, exists, err : c.informer.GetStore().GetByKey(key) if err ! nil { return err } if !exists { // 对象已删除只做清理逻辑 fmt.Printf(pod %s/%s has been deleted, cleanup\n, namespace, name) return nil } p : pod.(*corev1.Pod) fmt.Printf(processing pod %s/%s, phase%s, node%s\n, namespace, name, p.Status.Phase, p.Spec.NodeName) // 这里放你的业务逻辑 // 注意不要阻塞过长时间不要把外部API调用放在这里 return nil } func (c *PodController) Run(ctx context.Context, workers int) { defer c.queue.ShutDown() // 启动底层Informer等待缓存同步 go c.informer.Run(ctx.Done()) if !cache.WaitForCacheSync(ctx.Done(), c.informer.HasSynced) { klog.Error(cache sync timeout) return } klog.Info(cache synced, starting workers) // 启动N个worker并发消费队列 for i : 0; i workers; i { go c.runWorker(ctx) } -ctx.Done() } func (c *PodController) runWorker(ctx context.Context) { for c.processNextItem(ctx) { } } func (c *PodController) processNextItem(ctx context.Context) bool { key, shutdown : c.queue.Get() if shutdown { return false } defer c.queue.Done(key) err : c.processItem(ctx, key.(string)) if err ! nil { // 处理失败重新入队并限速重试 klog.ErrorS(err, process item failed, key, key) c.queue.AddRateLimited(key) return true } // 处理成功遗忘这个key的失败历史 c.queue.Forget(key) return true } func main() { config, err : clientcmd.BuildConfigFromFlags(, /root/.kube/config) if err ! nil { panic(err) } client, err : kubernetes.NewForConfig(config) if err ! nil { panic(err) } controller : NewPodController(client) ctx, cancel : context.WithCancel(context.Background()) defer cancel() controller.Run(ctx, 4) }这套骨架我用了很多次每次新起operator项目都是在这个基础上改。它已经把事件回调、workqueue限速、缓存读取、worker并发这些核心问题都处理好了你只需要专注在processItem里的业务逻辑上。6.2 关于worker数量和处理性能的几个参考worker数量不是越多越好。你要明白worker并发数越高对API Server的写请求并发也越高。如果你的业务逻辑里有大量的Update操作建议worker数控制在2到4个避免对API Server产生过大压力。如果你的业务是纯计算型的不需要写API Server可以适当调高到8个或16个。另外一个性能调优方向是informers.WithTweakListOptions。很多场景下你并不需要监听所有namespace的所有对象可以通过LabelSelector、FieldSelector提前在源头上过滤。比如只监听带特定Label的Podfactory : informers.NewSharedInformerFactoryWithOptions( client, 10*time.Minute, informers.WithTweakListOptions(func(options *metav1.ListOptions) { options.LabelSelector appmy-app }), )这样Reflector在List和Watch阶段就只关心符合条件的对象Indexer缓存量级可能从几万降到几百内存和CPU开销都会大幅下降。选型的时候这是第一个可以考虑的优化点。7. 线下验证Informer机制的正确姿势7.1 kube-apiserver的访问压力观察写完代码总要验证一下Informer是不是真的在按预期工作。最直接的观察点就是API Server的访问日志或者请求指标。在本地用kubectl反正随时可以观察但更细致的做法是直接看client-go暴露的指标。Informer内部自带workqueue和reflector的metrics可以通过/metrics端点暴露出来。核心指标有几个值得关注workqueue_depth队列深度如果长期不为0且持续增长说明消费速度跟不上生产速度workqueue_adds_total入队总数reflector_items_total监听到的对象数量reflector_watch_events_totalWatch事件总数如果你发现reflector_watch_events_total一直在增长而业务逻辑没有对应的处理大概率是回调里逻辑写得太重或者入队逻辑被遗漏了。7.2 用代码模拟场景验证缓存一致性我验证Informer缓存和API Server一致性的土办法是起一个控制循环每隔一段时间对比一次Indexer缓存里的对象列表和直接List API Server拿到的对象列表。go func() { ticker : time.NewTicker(30 * time.Second) for range ticker.C { cachedPods, _ : informer.Lister().List(labels.Everything()) livePods, _ : client.CoreV1().Pods().List(context.TODO(), metav1.ListOptions{}) if len(cachedPods) ! len(livePods.Items) { klog.Warningf(cache size %d ! live size %d, len(cachedPods), len(livePods.Items)) } } }()这个对比脚本看着简单但能在早期发现很多问题比如缓存恐慌、事件丢失、Indexer索引配置错误等。我在几个项目里靠这个办法抓到过两个隐蔽的缓存不一致问题——都是因为跨namespace的Informer配置参数写错了。7.3 故障注入按掉网络会发生什么我强烈建议你在测试环境做一次拔网线实验。断开测试环境网络连接30秒再恢复看看你的Informer和控制器表现如何。正常表现是这样的Watch连接断开Reflector开始退避重试默认从1秒开始指数退避最大到1000秒左右断网期间的业务事件不会被处理队列里会堆积恢复网络后Reflector重新List全量数据DeltaFIFO开始重新填充队列里的key开始被消费整个过程控制器不会崩溃也不会死锁如果这个实验里你的控制器表现异常大概率是你在回调里做了太多依赖实时网络的操作——比如同步调用外部HTTP接口、访问数据库。这些都是Informer机制的死敌。正确做法是把这些操作全部丢到workqueue后面的worker里由worker去执行而不是在事件回调中直接执行。8. 把Informer放进更复杂的系统里多资源联动与权限边界8.1 TPR监听带来的RBAC设计问题当你用Informer监听自定义资源CRD时有一个很重要但又很容易被忽略的环节RBAC权限。Informer的ListAndWatch是直接访问API Server的它需要拥有对应资源的list和watch权限。我见过很多能跑但一权限收紧就崩的控制器。开发环境用的是kubeconfig权限是admin怎么跑怎么通。一上生产集群管理员给了最小RBAC权限结果控制器启动后Informer同步失败所有缓存都是空的业务逻辑全部失效。如果你的控制器部署在集群内正确做法是在ServiceAccount上绑定Role并明确授予list和watch权限。这个在RBAC里经常被忽略因为很多人只记得get就够了——但Informer偏偏用的不是get而是list和watch。apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: namespace: my-system name: my-controller-role rules: - apiGroups: [] resources: [pods] verbs: [get, list, watch] - apiGroups: [] resources: [pods/status] verbs: [get, list, watch, update]同时要注意如果你同时监听了多个namespace的Pod比如不用informers.WithNamespace限制那你需要的就不是Role而是ClusterRole了Grant范围要匹配Informer的监听范围。8.2 多资源联动的编排能力从哪里来Informer机制本身是没有编排能力的——它只是把每个资源的变化告诉你至于Pod变了之后要做哪几件事、这几件事的顺序是什么完全由控制器自己来实现。我当前的消息通知系统中就是这么做的同时监听Deployment和Pod的Informer一旦Deployment的副本数变化就把事件入队到同一个workqueueworker从队列取出key后先查Deployment的缓存拿到期望副本数再查Pod的缓存统计实际副本数最后决定是否发出告警。这里的关键是同一个workqueue串行处理不同资源的事件避免并发处理引起的数据竞争。这个模式在Kubernetes社区中就是标准的多Informer 单Workqueue 串行处理模式。几乎所有复杂的operator比如etcd-operator、prometheus-operator都是这么组织的。理解了Informer的机制你看这些operator的源码会非常快因为它们的高层逻辑就那么多真正的复杂的是业务规则。9. 把Scheduler的informer设计拿过来用Kubernetes Scheduler是把Informer机制用得最极致的一个组件。它的调度逻辑里Pod和Node的数据都不是从API Server实时读的而是通过informer缓存维护的。调度器启动时会通过informer监听待调度的Pod和集群所有Node的实时状态资源容量、亲和性、污点等全部维护在本地缓存里。每来一个待调度Pod调度器从本地缓存里快速找出符合约束的Node绑定完成后把结果写回API Server。这个模式的精髓在于把全量计算变成增量维护。调度器不需要每次调度都去全量扫描集群只在有事件发生时更新相应的缓存条目。这样即便集群有几千个Node调度性能依然能维持在毫秒级。所以当你在设计自己的系统时如果你也需要一个大量资源的状态查询 变化感知的能力Informer几乎是标配。它替你把最繁琐的数据同步、事件分发、缓存一致性都处理好了让你能专注在自己的业务逻辑上——这才是Kubernetes生态里约定优于配置的一个绝佳例证。从个人使用体验来说Informer这套机制真正让我觉得舒服的地方在于它的天然自愈倾向。网络断了重连、事件丢失重拉、数据不一致靠resync兜底处处体现着在分布式环境下不要追求绝对精确要追求最终一致的设计哲学。理解了这套哲学你会更容易看懂Kubernetes其他组件的设计意图写出来的控制器质量也会明显上一个台阶。希望这篇文章对你有用。