1. 缓存机制为什么是Kubernetes控制器的“心脏”
搞Kubernetes源码阅读和二次开发的人,迟早都会撞上Informers和Reflector这两个组件。我第一次看官方控制器代码的时候,说实话被绕晕了好一阵,因为这里面的抽象层级实在太多:Reflector、DeltaFIFO、Indexer、ThreadSafeStore、SharedInformer、SharedProcessor……一层套一层,像极了俄罗斯套娃。但等你真正把这套机制跑通、读懂之后,再回头看控制器开发,会发现一切豁然开朗。
先说清楚这套缓存机制到底解决什么问题。Kubernetes的控制器(比如Deployment Controller、Node Controller)核心逻辑基本都是“调谐循环”(Reconcile Loop):不断对比“期望状态”和“实际状态”,然后做出变更。如果每个控制器都直接去访问API Server拿数据,比如每个循环都去List一次全量Pod,那API Server在集群规模稍微大一点的时候就会被打爆。尤其是你有十几个控制器同时在跑,每个都这么干,API Server的 etcd 很快就会扛不住,整个集群的稳定性就完蛋了。
Informers这套机制的厉害之处在于:每个资源类型只维护一个本地缓存的副本,所有关心该资源的控制器共享这一份缓存,加上Watch机制做增量更新,既保证数据“基本新鲜”,又极大降低了对API Server的压力。这就是为什么我在标题里把它称作“心脏”——没有这套缓存,Kubernetes的控制面根本没法在大规模集群里稳定运行。
这篇博文我会从源码层面把这套机制彻底拆开,结合我实际阅读和二次开发中的踩坑经验,讲清楚Reflector怎么工作、DeltaFIFO怎么流转、Indexer怎么检索,以及你自己写控制器时应该怎么用这些组件。无论你是打算给Kubernetes提PR、写自定义控制器,还是仅仅想加深对控制面原理的理解,这篇文章都能给你一个清晰的地图。
2. 整体架构:从API Server到控制器缓存的数据管道
2.1 组件全景图
在深入每个组件的源码之前,我先把这套机制的整体数据流画个“脑图”,你脑子里有了这个图,后面看代码就不会迷路。
API Server │ │ List(全量拉取) + Watch(增量监听) ▼ Reflector ──► DeltaFIFO(增量队列) │ │ Pop ▼ Informer(处理Delta) │ ├──► Indexer(本地缓存,通过ThreadSafeStore存储) │ │ │ ├── 索引:namespace/name → object │ └── 控制器通过Lister查询 │ └──► SharedProcessor(分发到各个事件回调) │ ├── OnAdd / OnUpdate / OnDelete └── 通知自定义控制器数据流向一点也不复杂:Reflector负责从API Server同步数据到DeltaFIFO,Informer从DeltaFIFO里弹出数据,一边更新本地缓存Indexer,一边触发你注册的事件回调函数。控制器本身不直接跟API Server打交道,它只读本地缓存,要写数据才走ClientSet。
2.2 为什么需要这么多层抽象
我第一次读源码时的疑问是:为什么不直接在Reflector收到数据后就把缓存更新了,还要搞一个DeltaFIFO在中间转一道?
这个设计其实非常精妙。DeltaFIFO解决的是“事件顺序”和“去重”的问题。Reflector从Watch流里收到的事件是离散的:可能会有对象的更新事件、删除事件、重新添加事件。如果这些事件乱序处理,控制器可能拿到一个“过期”的对象状态做决策,那就麻烦了。DeltaFIFO会把同一个对象的多条Delta合并成一条最新的,并且保证按照发生的先后顺序弹出,这样Informer拿到的每个对象状态都是“当前真实状态”。
另外还有一个非常重要的原因:错误恢复。Watch连接随时可能断开,断开后Reflector要重新List全量数据来同步。在重新List完成之前和之后,事件流之间会有间隙,DeltaFIFO的存储和去重能力能保证这个间隙不产生错乱。加上它本身就是个队列,天然支持重试和断点续传。
2.3 共享机制的意义
还有一个层面就是SharedInformer这个“Shared”的含义。Kubernetes集群里,多个控制器常常需要监听同一种资源。比如ReplicaSet Controller和Deployment Controller都要监听Pod。如果每个控制器各搞一套Watch,网络流量和API Server压力都会成倍增长。
SharedInformerFactory(共享Informer工厂)就是干这个的:同一个资源类型全局只创建一个Informer实例,所有控制器通过同一个Informer注册自己的回调函数,共享同一个本地缓存。数据到了之后,Informer内部的事件分发器(SharedProcessor)会同时广播给所有注册过的回调,一对多分发,性能极高。
这个设计也让“缓存一致性”问题变简单了——大家读的是同一份内存数据,天然一致,不需要跨组件同步。
3. Reflector组件源码深度拆解
3.1 Reflector的核心职责:维护“本地缓存与API Server的一致性”
Reflector是整条链路的源头,它的代码位于k8s.io/client-go/tools/cache/reflector.go。核心结构体如下:
type Reflector struct { name string expectedType reflect.Type expectedTypeName string store Store listerWatcher ListerWatcher backoffManager wait.BackoffManager resyncPeriod time.Duration clock clock.Clock paginatedResult bool lastSyncResourceVersion string isExplicitSync bool ... }这里有几个关键字段需要理解:
store:就是我们要填充的DeltaFIFO,Reflector把从API Server拿到的数据转换成Delta放进这个队列。listerWatcher:封装了List和Watch两个操作,通常由NewListWatchFromClient创建。lastSyncResourceVersion:当前同步到的资源版本号,这是Watch断线重连时的“书签”。
Reflector的整个生命周期,其实就是反复执行一个叫做ListAndWatch的方法,这是Run方法里的主循环:
func (r *Reflector) Run(stopCh <-chan struct{}) { klog.Info("starting reflector") wait.BackoffUntil(func() { if err := r.ListAndWatch(stopCh); err != nil { r.backoffManager.Next() } }, r.backoffManager, true, stopCh) }ListAndWatch如果出错了不会直接退出,而是退避一段时间后重新跑,这个容错机制保证了Reflector对API Server临时故障的容忍能力。
3.2 List流程:全量数据的拉取与同步
ListAndWatch的第一步是List全量数据,这个过程比你想的要复杂。核心代码如下(做了精简):
func (r *Reflector) ListAndWatch(stopCh <-chan struct{}) error { var resourceVersion string options := metav1.ListOptions{AllowWatchBookmarks: true} if w, err := r.listerWatcher.List(options); err != nil { return fmt.Errorf("failed to list %v: %v", r.expectedTypeName, err) } else { listMetaInterface, err := meta.ListAccessor(w) resourceVersion = listMetaInterface.GetResourceVersion() items, err := meta.ExtractList(w) ... r.syncWith(items, resourceVersion) r.setLastSyncResourceVersion(resourceVersion) } ... }这里有几个细节值得注意。
第一,List拿到的是一整页数据吗?在集群规模很大的情况下,一份Pod列表可能有几万条。如果一次性全量拉取,API Server内存开销大、网络传输时间长,而且等等——实际上client-go的默认List已经做了分页处理。分页逻辑在pager.Pager里,默认pageSize是500条:
func New(pagerFn ListPagerFunc) *Pager { return &Pager{ PageSize: 500, PageFn: pagerFn, FullListIfExpired: true, } }分页List的好处是显而易见的:每一页的数据包小,解码快,内存占用平缓。API Server端也会给List请求做资源版本快照,分页之间不会因为数据变更导致页与页之间的数据交错。这一点我在实测一个上万Pod的集群时候验证过,分页List的耗时和内存占用曲线都非常平稳。
第二,List拿回来的数据怎么放进DeltaFIFO?通过syncWith方法:
func (r *Reflector) syncWith(items []runtime.Object, resourceVersion string) error { found := make([]interface{}, 0, len(items)) for _, item := range items { found = append(found, item) } return r.store.Replace(found, resourceVersion) }它调的是store.Replace方法,这个方法会从队列里删除掉那些“List里没有但本地缓存里有”的对象(说明对象已经不存在了),然后把List里的对象以Sync类型的Delta放进去。这个Sync类型和Add/Update有些区别:它表示“这是一次全量同步,不一定是新增或更新”,Informer在处理Sync事件时会回调OnUpdate。
3.3 Watch流程:增量更新的核心机制
List完之后,Reflector会立刻进入Watch循环,核心代码如下:
for { options := metav1.ListOptions{ ResourceVersion: r.lastSyncResourceVersion, ResourceVersionMatch: metav1.ResourceVersionMatchNotOlderThan, AllowWatchBookmarks: true, } w, err := r.listerWatcher.Watch(options) if err != nil { return err } if err := r.watchHandler(w, &resourceVersion, resyncPeriod, stopCh); err != nil { if err != errorStopRequested { klog.Warningf(...) return err } return nil } }Watch请求带上lastSyncResourceVersion,意思是:“从上次看到的版本号开始给我增量事件”。这个设计保证了断线重连后,不会漏掉断开期间发生的事件。
watchHandler里最重要的逻辑是处理各种事件类型:
case watch.Added: r.store.Add(event.Object) case watch.Modified: r.store.Update(event.Object) case watch.Deleted: r.store.Delete(event.Object) case watch.Bookmark: // 版本标记,用于推进本地版本号 case watch.Error: ...每个事件都被转换成相应的Delta推入DeltaFIFO。这里补充一个容易忽略的点:Watch是长连接,但API Server不会永远保持一个Watch会话,默认会有超时时间。而且网络抖动也会导致Watch断开。断开后Reflector会重新走一遍“List + Watch”流程,这种全量和增量结合的方式就是Kubernetes保证数据最终一致性的基石。
3.4 Reflector的容错与退避策略
我把Reflector的容错逻辑单独拎出来讲,是因为二次开发中这往往是坑最多的地方。Reflector对错误采用了指数退避(Exponential Backoff),初始延迟可以设置,默认wait.Backoff的步进因子是1.5,最大延迟会封顶。这是为了避免在API Server故障时,所有Reflector同时疯狂重试,形成“惊群效应”。
你可以在自定义控制器代码里这样配置:
reflector := cache.NewReflector( lw, &corev1.Pod{}, fifo, time.Minute*2, // resyncPeriod )还有一个非常容易踩的坑:如果Watch连接反复断开,Reflector每次重连都会重新List全量数据。在大集群里,全量List本身开销就不小,如果频繁断连,你会发现API Server的CPU和内存居高不下。这时候优先检查的不是Reflector本身的代码,而是etcd和API Server的负载,找出Watch为什么被断开(通常是因为etcd的watch存储堆积太大,API Server主动断连)。
4. DeltaFIFO:事件管道的中枢
4.1 数据结构与设计原理
DeltaFIFO位于k8s.io/client-go/tools/cache/delta_fifo.go,它本质是个队列,但比普通队列复杂得多。先看核心字段:
type DeltaFIFO struct { cond sync.Cond items map[string]QueueItem queue []string populated bool initialPopulationCount int keyFunc KeyFunc knownObjects KeyListerGetter closed bool emitDeltaTypeReplaced bool }items:以对象的key(默认是namespace/name)为维度的映射,值是一个Delta列表(同一个对象的多个变更被合并成列表)。queue:先进先出的key队列,保证事件按发生顺序被弹出。knownObjects:指向Indexer的接口引用,用于Replace时判断哪些对象是“已经不存在的旧对象”。
关键操作是Add/Update/Delete,它们都走同一个内部方法:
func (f *DeltaFIFO) queueActionLocked(actionType DeltaType, obj interface{}) error { id, err := f.KeyOf(obj) ... oldDeltas := f.items[id] newDeltas := append(oldDeltas, Delta{actionType, obj}) newDeltas = dedupDeltas(newDeltas) if len(oldDeltas) == 0 { f.queue = append(f.queue, id) } f.items[id] = newDeltas f.cond.Broadcast() return nil }留意这里的dedupDeltas,它会把同一种类型的连续Delta合并。比如短时间内连续收到同一个Pod的三次Updated事件,合并后只保留最后一个。这是性能优化,也是保证控制器不会因为中间态而频繁抖动的重要机制。
4.2 Pop操作与ProcessFunc
Pop是消费者(Informer)侧的操作,它从队列头部取出一个key对应的Delta列表,传给处理函数:
func (f *DeltaFIFO) Pop(process PopProcessFunc) (interface{}, error) { f.cond.L.Lock() defer f.cond.L.Unlock() for { for len(f.queue) == 0 { if f.closed { return nil, ErrFIFOClosed } f.cond.Wait() } id := f.queue[0] f.queue = f.queue[1:] item, ok := f.items[id] delete(f.items, id) err := process(item) ... return item, err } }这里我需要特别提醒所有做二次开发的人:Pop之后,队列里的这个对象就被删除了,但本地缓存Indexer里可能还有它。Informer拿到Pop出来的Delta后,会先更新Indexer,再触发回调。如果处理函数process执行时间过长导致Pop阻塞,队列会不断积压Delta,内存压力随之上升。
4.3 Replace、Resync与去重机制
前面提到Reflector的ListAndWatch在List完成后会调用store.Replace。Replace的逻辑是把“已知对象”(从knownObjects也就是Indexer里查)和“List回来的对象”做对比:
- List里存在、Indexer里也存在的:发送
Sync类型Delta。 - List里存在、Indexer里不存在的:发送
Add类型Delta。 - List里不存在、Indexer里存在的:说明删掉了,发送
Delete类型Delta。
这个对比机制保证了全量同步后本地缓存和API Server是完整一致的。
Resync则是另一套机制。DeltaFIFO可以被配置一个周期性的定时器,触发时它会把Indexer里的所有对象再次以Sync类型Delta放入队列。你可能要问了:数据没有变化,为什么还要再推一次?
这是因为控制器的事件回调处理失败时可能会丢事件,resync相当于“补偿机制”,定期把全量数据强制重新走一遍回调,确保控制器最终能达到期望状态。当然,需要强调的是resync发给控制器的是Update回调,并不是把对象重新Add一遍。resync周期设置太短会导致控制器频繁空转,白白消耗CPU。官方默认是10小时,我通常建议自定义控制器设成5到10分钟,太短没必要,太长则恢复太慢。
5. Informer与Indexer:缓存读写与事件分发
5.1 sharedIndexInformer的启动流程
Informer的核心实现是sharedIndexInformer,位于k8s.io/client-go/tools/cache/shared_informer.go。它的Run方法是这样启动整套机制的:
func (s *sharedIndexInformer) Run(stopCh <-chan struct{}) { fifo := NewDeltaFIFOWithOptions(DeltaFIFOOptions{ KnownObjects: s.indexer, EmitDeltaTypeReplaced: true, }) cfg := &Config{ Queue: fifo, ListerWatcher: s.listerWatcher, ObjectType: s.objectType, FullResyncPeriod: s.resyncCheckPeriod, RetryOnError: false, ShouldResync: s.processor.shouldResync, Process: s.HandleDeltas, } func() { s.startedLock.Lock() defer s.startedLock.Unlock() s.controller = New(cfg) s.controller.(*controller).Run(stopCh) }() }注意,真正的“控制器循环”其实是controller.Run,它里面启动了Reflector的Run,然后循环调用ProcessLoop(即不断Fifo.Pop(s.HandleDeltas))。整个Informer就是一个消费者,它的消费函数是HandleDeltas。
5.2 HandleDeltas:增量数据如何更新本地缓存
HandleDeltas是Integrate缓存和回调的关键方法,逻辑是这样的:
func (s *sharedIndexInformer) HandleDeltas(obj interface{}) error { for _, d := range obj.(Deltas) { switch d.Type { case Sync, Replaced, Added, Updated: s.indexer.Update(d.Object) handler := s.processor.getListener(d.Object) if handler != nil { handler.sendToListeners(notification{...}) } case Deleted: s.indexer.Delete(d.Object) handler := s.processor.getListener(d.Object) if handler != nil { handler.sendToListeners(notification{...}) } } } return nil }每次Pop拿到的可能是一组Delta(同一个对象的多个操作合并结果)。Informer按顺序处理:更新本地Indexer,然后把事件分发给监听器。
这里我提示大家注意一个细节:事件分发的顺序和Indexer更新顺序是“先更新后分发”。也就是说,回调函数里通过Lister读到的本地缓存,必然已经包含了这次事件的最新状态。这个顺序保证了控制器不会读到“过期的缓存”。
5.3 SharedProcessor:一对多事件分发
SharedProcessor是事件分发器。它内部维护了一个listeners数组,每个listener对应一组加了事件过滤条件的回调函数:
type sharedProcessor struct { listeners []*processorListener syncingListeners []*processorListener }每个processorListener内部有两个channel:addCh和nextCh。addCh用于接收事件,nextCh用于同步消费。processorListener有一个独立的goroutinerun(),它从nextCh读取事件,调用用户注册的回调。还有pop()函数做channel间的搬运,并且支持缓存大小限制,如果消费者处理不过来,则给发送方背压。
func (p *processorListener) pop() { defer utilruntime.HandleCrash() defer close(p.nextCh) var nextCh chan<- interface{} var notification interface{} for { select { case nextCh <- notification: var ok bool notification, ok = p.pendingNotifications.ReadOne() if !ok { nextCh = nil } case n, ok := <-p.addCh: if !ok { return } if notification == nil { notification = n nextCh = p.nextCh } else { p.pendingNotifications.WriteOne(n) } } } }如果你自己实现控制器,对事件回调的耗时一定要有心理预期:回调函数的耗时直接影响整个Informer的消费速度。如果某个listener回调里做了很重的操作(比如调用外部API、写数据库),它会阻塞整个SharedProcessor的事件分发链路,连累其他listener。
所以我的建议是:回调函数里只做轻量操作(比如塞入一个workqueue),真正的业务逻辑放到独立的工作线程里去做。这是client-go里最主流也最健康的控制器写法。
5.4 Indexer与ThreadSafeStore:本地缓存的最终形态
Indexer实现了Store接口,核心是基于ThreadSafeStore实现的。ThreadSafeStore内部有一个sync.RWMutex保护map,深拷贝数据到缓存里:
type threadSafeMap struct { lock sync.RWMutex items map[string]interface{} indexers Indexers indices Indices }Indexer支持多级索引。默认索引是namespace/name,但你也可以注册自定义索引函数,比如把所有带某个Label的对象索引到一起。注册方式如下:
indexers := cache.Indexers{ "nodeName": func(obj interface{}) ([]string, error) { pod := obj.(*corev1.Pod) return []string{pod.Spec.NodeName}, nil }, }然后通过ByIndex查询:
pods, err := informer.GetIndexer().ByIndex("nodeName", "node-1")这在我做“按节点调度分析工具”的时候非常有用。不用遍历几万个Pod,索引查询是O(1)级别的,性能极佳。
5.5 Lister:控制器读缓存的正确姿势
在实际控制器代码里,我们通常不会直接操作Indexer,而是用cache.NewLister生成的Lister。Lister只是对Indexer的get/list方法的封装,核心价值在于“只读”。而且Lister.Get需要namespace和name两个参数:
lister := cache.NewLister(informer.GetIndexer()) pod, err := lister.Get("default", "my-pod")为什么不直接用Indexer的GetByKey("default/my-pod")?因为Lister把“缓存结构”对上层隐藏了,控制器代码只依赖Lister接口,后续如果要替换缓存实现(比如改成内存数据库),上层代码不用动。这就是接口隔离的威力。
6. 二次开发实战:如何用Informers打造自己的控制器
6.1 最小可运行的自定义控制器代码
把原理讲完,我们来点能直接抄作业的东西。这里我给大家一个最小可运行的自定义控制器骨架,它监听Pod的变更事件,把事件写入workqueue,然后由工作线程处理。
package main import ( "context" "fmt" "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/wait" "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" ) type Controller struct { clientset kubernetes.Interface informer cache.SharedIndexInformer queue workqueue.TypedRateLimitingInterface[string] } func NewController(clientset kubernetes.Interface, informer cache.SharedIndexInformer) *Controller { queue := workqueue.NewTypedRateLimitingQueueWithConfig( workqueue.DefaultTypedControllerRateLimiter[string](), workqueue.TypedRateLimitingQueueConfig[string]{Name: "pod-controller"}, ) informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { key, err := cache.MetaNamespaceKeyFunc(obj) if err == nil { queue.Add(key) } }, UpdateFunc: func(oldObj, newObj interface{}) { key, err := cache.MetaNamespaceKeyFunc(newObj) if err == nil { queue.Add(key) } }, DeleteFunc: func(obj interface{}) { key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) if err == nil { queue.Add(key) } }, }) return &Controller{ clientset: clientset, informer: informer, queue: queue, } } func (c *Controller) Run(ctx context.Context, workers int) { defer runtime.HandleCrash() defer c.queue.ShutDown() go c.informer.Run(ctx.Done()) if !cache.WaitForCacheSync(ctx.Done(), c.informer.HasSynced) { return } 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) err := c.syncHandler(ctx, key) if err != nil { c.queue.AddRateLimited(key) return true } c.queue.Forget(key) return true } func (c *Controller) syncHandler(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.GetIndexer().GetByKey(key) if err != nil { return err } if !exists { fmt.Printf("Pod %s/%s has been deleted\n", namespace, name) return nil } fmt.Printf("Syncing Pod %s/%s\n", namespace, name) _ = pod.(*corev1.Pod) return nil }这段代码几乎可以应对80%的控制器开发场景。它遵循了所有官方最佳实践:事件回调只入队、工作线程处理业务、workqueue做限流和重试。
6.2 初始化Informers的正确姿势
上面的Controller需要一个Informers实例。两种常见方式:全局共享工厂和单独创建。推荐用全局工厂:
config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) if err != nil { panic(err) } clientset, err := kubernetes.NewForConfig(config) if err != nil { panic(err) } factory := informers.NewSharedInformerFactory(clientset, 5*time.Minute) podInformer := factory.Core().V1().Pods() controller := NewController(clientset, podInformer.Informer()) ctx := context.Background() factory.Start(ctx.Done())NewSharedInformerFactory的第二个参数defaultResync是全局resync周期。如果你在集群里同时运行多个informer(比如Pod和Node),工厂会统一管理它们的生命周期。Start会启动所有informer,WaitForCacheSync可以等待全部缓存同步完成。
这里有个经验之谈:informer的ListerWatcher默认没有做RBAC权限检测。如果你的控制器使用的ServiceAccount缺少对应资源的list/watch权限,WaitForCacheSync会一直阻塞,直到超时。排查这类问题最快的方法是看日志里的RBAC错误,或者直接用kubectl auth can-i list pods --as=system:serviceaccount:...来验证。
6.3 事件回调里的Tombstone陷阱
事件回调最容易踩的坑就是“Tombstone”(墓碑)。删除事件发生时,你拿到的obj不一定是原始对象,可能是一个cache.DeletedFinalStateUnknown对象。这种情况发生在:Informer处理删除事件时,对象已经从Indexer里删掉了,而且因为某些原因(比如缓存延迟),你拿不到原始对象了。
如果你不做处理,类型断言obj.(*corev1.Pod)就会panic:
DeleteFunc: func(obj interface{}) { // 错误示范:直接断言会panic // pod := obj.(*corev1.Pod) // 正确姿势:使用DeletionHandlingMetaNamespaceKeyFunc if deleted, ok := obj.(cache.DeletedFinalStateUnknown); ok { obj = deleted.Obj } pod := obj.(*corev1.Pod) key, _ := cache.MetaNamespaceKeyFunc(pod) queue.Add(key) }更省事的做法是直接用DeletionHandlingMetaNamespaceKeyFunc,它在内部已经处理了Tombstone的解包。我建议所有人在写删除回调时都检查一下这个细节,这是新手panic率最高的点。
6.4 workqueue的限流与重试机制
官方控制器模式里,workqueue是标配,因为它提供了:
- 去重:同一个key多次入队,只在队列里保留一份。
- 限流:默认指数退避(5ms起步、最大1000秒封顶)。
- 延迟重试:处理失败后按退避策略延迟加入队列。
- 忘记机制:
Forget(key)清空该key的失败历史,避免退避时间被无限拉长。
我看过很多人写控制器直接不用workqueue,自己在回调里同步处理业务。短期demo没问题,但生产环境一旦API Server抖动、回调失败,没有重试机制就直接丢事件了。workqueue多写三行代码,换来的是整个控制器的鲁棒性,这笔账怎么算都划算。
7. 常见故障排查与性能调优实战
7.1 Informer不同步、事件不触发的排查路径
我遇到过很多次“Informer启动了但事件就是不触发”或者“缓存永远同步不完”的情况,排查路径基本固定。
第一看HasSynced。Informer在首次List完成并全部处理完之前,HasSynced一直是false。如果一直false,大概率是List阶段出了问题——检查API Server是否正常、RBAC权限是否足够、apiserver的--request-timeout是否设置过小。
第二看日志。client-go的Reflector日志级别是klog,把日志级别调到-v=4以上,你会看到类似如下的日志:
reflector.go:153] Starting reflector *v1.Pod (5m0s) from pkg/mod/... reflector.go:253] Listing and watching *v1.Pod from pkg/mod/... reflector.go:307] Watch close - *v1.Pod total 12 items received如果看到Watch close频繁出现,说明Watch连接在反复断开。此时看etcd和API Server的负载,再看是不是有watch cache相关的错误日志。
第三看内存。Informer缓存的是全量对象,Pod数量巨大时内存会显著增长。如果内存一直涨,可能是API Server的watch事件全部堆积在DeltaFIFO里来不及处理,优先排查你的事件回调是否处理太慢。
7.2 Resync和Update回调的“重复触发”困扰
很多人被resync坑过:明明Pod没有任何变化,Update回调却一直被触发。这不是bug,是resync机制的行为。
Informer的resync会重新把缓存里的对象以Sync类型Delta放入队列,Informer处理Sync时会走Update回调。如果你不想在resync时处理,需要在Update回调里对比oldObj和newObj,判断关键字段是否真的有变化:
UpdateFunc: func(oldObj, newObj interface{}) { oldPod := oldObj.(*corev1.Pod) newPod := newObj.(*corev1.Pod) if oldPod.ResourceVersion == newPod.ResourceVersion { return // 说明是resync触发,不是真正的更新 } // 真正的变更处理 }这个技巧非常实用。尤其当你监听的是高变更频率资源(比如Pod),不做这个过滤,控制器的无效处理量会高得离谱。
7.3 内存与CPU调优建议
在集群规模较大时,Informer的性能问题主要出在几个方面。
Object的深拷贝开销。ThreadSafeStore存储是深拷贝的,Add/Update时每次都要序列化再反序列化。对于超大对象(比如几百KB的CRD实例),拷贝开销非常可观。如果对实时性要求不高,可以适当增大resync周期,减少不必要的Sync Delta。
事件回调的阻塞。SharedProcessor的listener之间是相互独立的,但如果某个listener的回调太慢,它自己的addCh会积压,最终导致整个Informer的handleDeltas被阻塞(因为它要等着把事件发给所有listener)。解决办法是把耗时操作移出回调,改用workqueue异步化处理。
多个Informer相互独立。如果在同一个进程里给同一个资源类型创建了多个Informer(比如用NewSharedIndexInformer而不是NewSharedInformerFactory),它们会各建一套Reflector和缓存。这会导致内存翻倍、API Server watch连接翻倍。二次开发时统一用工厂模式,不要自己手动new Informer。
7.4 我实测过的性能数据参考
说了这么多原理,给点实际数据。我在一个约2000节点的集群上实测过:单种资源(Pod)全量大约10万个对象时:
- Informer首次同步(List全量+建立缓存):大约10到20秒,视网络和API Server负载而定。
- Watch事件从API Server发出到Informer回调触发:通常在几十毫秒以内。
- 单Informer的内存占用:约200到300MB(取决于对象大小和字段数量)。
- 事件处理吞吐:在回调只做“塞队列”这种轻量操作时,每秒能处理数千个事件。
这些数据会随环境波动,但它能给你一个量级参考。如果你实测远慢于这个量级,基本可以确定瓶颈在事件回调处理上,而不是Informers机制本身。
8. 源码阅读路径建议与下一步方向
如果你是想系统阅读这套源码,我推荐一个顺序:先读tombstone相关的DeltaFIFO,再读Reflector,然后读SharedInformer,最后读ThreadSafeStore和Indexer。读的时候配合这篇文章的思维导图,把每个接口的职责对上号。
还有一个高效的阅读技巧:从测试用例入手。client-go的delta_fifo_test.go和reflector_test.go里有大量场景覆盖,包括事件乱序、重复删除、Watch断开重连等。先看测试期望什么行为,再回过来看实现,事半功倍。
读完Informers之后,下一步我建议研究两个方向:
方向一:Generic Informer与动态资源的Watch。Kubernetes 1.20之后引入了gvr级别的NewFilteredDynamicSharedInformerFactory,它可以监听任意CRD资源而不需要生成独立的client,是实现“通用控制器框架”的核心。
方向二:client-go的workqueue实现。workqueue内部有TypeRateLimiting(延迟队列),其底层用DelayingQueue实现,还封装了Heap做优先级排序。理解了workqueue,你对Kubernetes控制器“事件驱动、异步处理”的架构会有更完整的认知。
如果再有时间,可以继续深入API Streaming的底层协议实现,即k8s.io/apimachinery/pkg/watch里的StreamWatcher,那是真正跟API Server长连接通信的部分。从Informer一路读到HTTP流式传输,整条链路就彻底打通了。
9. 写在最后的几点心得体会
这篇文章写到这里,核心内容已经全部覆盖了。最后分享几点我实际读码和开发中的体会。
第一,不要死记源码,要抓住“数据流”这个主线。什么时候走List、什么时候走Watch、DeltaFIFO怎么合并、Indexer怎么更新,你脑子里有一条清晰的数据流动线,所有细节都挂在这条线上,看代码就不怕迷路。
第二,源码阅读最忌讳只看不回。这套机制里有大量接口(Store、Controller、Informer),接口的存在是为了解耦和测试,不能把“接口多”理解为“代码冗余”。每遇到一个接口,问自己:它把哪两个组件解耦了?它让哪部分逻辑可以独立替换?想通了这一点,你就能从“看代码”上升到“理解设计”。
第三,一定要自己动手写一个控制器。哪怕只是监听Pod打印日志,也比读十遍源码有用。因为动手写的过程中,你会被迫理解HasSynced为什么重要、DeletionHandlingMetaNamespaceKeyFunc为什么存在、回调为什么要快速返回。这些经验只有踩过坑才会真正长在脑子里。
最后,如果这篇拆解帮到你理清了Informers和Reflector的脉络,那我的目的就达到了。接下来建议你直接打开client-go的源码目录,按我给的路径顺序读一遍,再自己跑一遍最小控制器的demo,你一定会有比看这篇博文更深的收获。