
本文将继续深挖 client-go 中的核心组件重点不是组件之间是如何工作而是为什么这么设计这么设计的优缺点。笔者只是翻了源码和部分 issue并没有看过多的设计文档等内容理解相当主观如有不足希望能及时补充并指出。架构client-go 的架构如下image笔者自己画了一个形象生动版的如下image后面的内容都将围绕着这两张图展开本文基于版本k8s.io/client-go v0.32.1sigs.k8s.io/controller-runtime v0.20.2informerreflectorlistwatchclient-go 会起一个 reflector 协程该协程负责全量 List 资源增量 Watch 资源指数退避保证 http 连接断了可恢复处理 ListWatch 等异常情况作为生产者将资源事件类型资源存入 DeltaFIFO/RealFIFO 中在 ListWatch 这里有 WatchList 和 fallbackList 两种模式可以通过特性门控制WatchList 是 client-go 客户端和 apiserver 建立一条 Watch 连接全量资源通过该连接传给 client-go由于 watch 和 list 共用一条连接apiserver 在传完全量资源后会发一个 Bookmark 事件表明全量资源传完了后面的都是 watch 资源的事件fallbackList 是传统的 list 一条短连接全量资源通过该短连接传给 client-go后面的 watch 事件走 Watch 连接在 listwatch 这里有一些面试官特别喜欢问的问题如果连接长时间断开会发生什么watch 的 resourceversion 会溢出吗等等…我们先看第一个问题如果连接长时间断开。client-go 会退回到指数退避重试最大重试时间 1000s(默认)直到建立连接。此时根据旧的 resourceversion watch 资源如果 resourceversion 在 apiserver 已被清理则收到 apiserver 410 Gone 错误client-go 则退回到全量 list 资源阶段保证资源和 Kubernetes 集群同步。在重连期间缓存中缓存的是旧数据。已入 DeltaFIFO/RealFIFO 的资源事件会继续处理。重连期间的事件不会被收到也不会补偿——事件丢失了但资源的状态不会丢失因为后续的全量 List 会补齐。比如重联期间发生了删除了资源该资源的删除事件并不会被 client-go 接收后续也不会补偿这个事件丢了。但是在重联后全量 list 资源发现缓存中的资源在 list 列表已不存在client-go 会自己发一个 Delete 事件至 FIFO 用于删除缓存中的资源。所以重联期间会丢事件但是资源的状态并不会丢失还是会保持一致性的。这种一致性也是最终一致性什么时候是最终呢可能是时间的尽头吧。第二个问题一定要用数据说话。笔者曾被面试官问过这个问题当时回答得并不理想。Kubernetes 的 resourceversion 对应的是 etcd 中的 mod revision。它是集群集单调递增整数类型为 int64。int64 约等于 9.22 * 10 的 18 次方如果一个 10w pod 的大型集群每秒写入约 10000/s QPS写完 int64 需要约 92 万年:集群规模写入 QPS跑满 int64 需要中型1k Pod ~100/s ~2.9 × 10¹⁶ 秒 ≈ 920 万年大型10w Pod高频控制器 ~10,000/s ~2.9 × 10¹³ 秒 ≈ 92 万年etcd 官方压测极限 ~100,000/s ~2.9 × 10¹² 秒 ≈ 9.2 万年FIFO这里没用 DeltaFIFO 而是用的 FIFO 是因为 client-go 新增了 RealFIFO 队列在介绍 RealFIFO 之前先简要介绍下 DeltaFIFO 事件队列。client-go 中的队列根据不同事件类型做不同处理如 Delete 事件需要删除缓存中的资源回调 Delete 事件给 eventhandler 进行业务逻辑处理。传统的 DeltaFIFO 将资源的名字作为 key资源的不同事件可以合并。如资源 A Add, UpdatedUpdated 可以合并为 Add 和 Updated 两个事件。这么做的好处是可以合并同一类资源事件减少调谐的频率。image缺点也很明显主要有两条无法表示真实的资源顺序关系如果有两个资源 A 和 BA:t1: Addt2: Updatet4: UpdateB:t3: UpdateB 在 t3 时刻更新但是在 queue 队列中 B 只能位于 A 之前或之后消费者在处理时并不会先处理 A 的 Add 和 Update 事件在去处理 B 的 Update 事件即便这是真实的事件顺序。用 key 入队列只是全量 List 一个 Add 事件就会创建大量资源 key 入队列对性能影响较大。client-go 自 v1.33 后开始引入 RealFIFO 事件队列顾名思义 RealFIFO 能保持真实的事件顺序。它以事件类型为 Key优点是能保证真实的事件顺序缺点是不会做事件的合并很难说这是不是缺点。这里用队列的好处我想主要有两个一个是削峰一个是解耦。那么为什么不用通道而是用队列呢通道天然也可以表示生产/消费这种模式啊。通道过于单一对于 DeltaFIFO通道无法做到合并同类型事件对于 RealFIFO通道无法做到合并同资源等等。在队列的另一端processor 负责消费队列中的事件并根据事件类型对缓存做对应的操作。processorprocessor 主要做了三类事消费 FIFO 中的事件并缓存触发 processorListener 回调事件管理 processorListener 生命周期client-go 的命名还是非常地道的。这里不叫 distributer不叫 listener不叫 manager而是叫 processor 有点意思。FIFO 的消费者会阻塞等待 FIFO 有事件资源如果没有则陷入阻塞休眠不会忙等待占用 CPU 资源。FIFO 生产者发送完数据之后会唤醒消费者让它起来干活。这里也有一个问题消费者可以多个并发消费吗目前的 client-go 都是单消费者。如果多消费者并发消费一个非常重要的问题是消费顺序问题。举例如下FIFO: [Delete A, Update B, Add A]consumer1:Delete Aconsumer2:Add Aconsumer3:Update B由于并发消费无法保证顺序性。可能会造成sequence1: Delete A, Add A, Update Bsequence2: Delete A, Update B Add Asequence3: Add A, Update B, Delete Asequence4: Add A, Delete A, Update Bsequence5: Update B, Delete A, Add Asequence6: Update B, Add A, Delete A等六种情况更别提可能涉及到共享资源的竞态问题了。对于不同资源事件可能还好对于同资源事件的顺序不一致就是错误了。我们可以对资源加 hash 映射到指定的消费者来保证顺序性但是对于不同资源事件依然保证不了真实顺序。这也是为什么用单消费者反而简单的原因另一方面性能瓶颈往往在业务逻辑这里的生产/消费多数情况是可以做到平衡的。FIFO 中的资源事件被 processor 拿到processor 会通知它管理的 processorListener 做相应处理。processorListener我们先抛开 client-go 的设计如果让我们自己去设计实现一个 listener 该如何设计呢我想我可能会用三个通道来接收不同事件onAdd/onUpdate/onDelete类型或者一个通道反射然后根据该事件类型回调相应的事件处理函数。这种方式可以工作但是有问题。问题在于可能会阻塞 processorprocessor 作为消息的生产者并且是 listener 的管理者必须做到无阻塞否则对性能影响很大。而且 listener 离业务侧代码很近很容易由于消费慢导致 processor 阻塞。即便用有缓冲通道也是治标不治本。client-go 的实现非常精妙它引入一个动态环形队列作为缓冲区解耦 processor 和 listener。这个动态环形队列是动态增长可复用的这里的动态增长很重要。但是这个空间并不会动态缩小的意味着可能会造成环形队列占用的内存越来越大。如果占用很大呢那就不是环形队列的问题了而是要解决为什么消费端消费不及时为什么生产端生产过猛的问题了一般不会是生产端的问题。对于有些简单的业务场景到这里就够了可以在 eventhandler 里处理业务逻辑了。对于需要调谐资源Operator 模式的场景那么还需要 controller-runtime 的助力。controller-runtimecontroller-runtime 解决的是资源调谐的问题。我们继续看它是如何做到调谐资源的。首先调谐资源主要负责重复调谐业务侧可能需要持续操作资源直到满足条件外部限流防止过多的请求打爆 apiserver内部限流对失败资源进行限流防止阻塞其它正常资源调谐等指标监控暴露调谐指标用于分析调谐性能等等等。我们接着架构图继续往下看。Delaying Queue延迟队列首先是一个队列这一正确的废话表明资源的顺序性是首要需要保证的事情。举例来说调谐者并发调谐从 Delaying Queue 中获取资源如果同一资源记在调谐者 A 出现又在调谐者 B 出现那么将无法保证调谐的顺序性。由于调谐者是业务逻辑并发是必须要的对提升性能很有帮助。解决这样的问题就得从源头入手限制队列中只会有一个同名的资源在处理解决方式是引入 dirty/processing 和 queue 结构示意图如下imagecontroller-runtime 框架如何保证并发性 这篇文章介绍的很好看的很舒服笔者不想重复造轮子有需要进一步了接的可以看看。注意这里解决的问题是同名资源的顺序性问题不同资源由于并发导致调谐顺序不一致也没有影响。队列我们说了延迟又在哪呢或者说为什么需要延迟呢主要有几点调谐失败的资源需要进入队列重新调谐如果不延迟调谐会造成队列拥堵拖慢调谐正常资源调谐失败资源频繁调谐可能会打爆 apiserver正常资源如果不加延迟限制可能会打爆 apiserver消耗 CPU/内存 等client-go 默认实现复合限流器func NewTypedRateLimitingQueueWithConfig[T comparable](rateLimiter TypedRateLimiter[T], config TypedRateLimitingQueueConfig[T]) TypedRateLimitingInterface[T] {…if config.DelayingQueue nil {config.DelayingQueue NewTypedDelayingQueueWithConfig(TypedDelayingQueueConfig[T]{Name: config.Name,MetricsProvider: config.MetricsProvider,Clock: config.Clock,})}// 复合限流器用于限流延迟 return rateLimitingType[T]{ TypedDelayingInterface: config.DelayingQueue, rateLimiter: rateLimiter, }}限流器限制资源多久才能重入队列。需要重入的资源通过 waitingForAdd 通道被 waitingLoop 消费waitingLoop 根据资源的延迟时间将资源插入/更新到小堆中。接着从小堆取出延迟时间到的资源重新添加到延迟队列中处理。这里很直觉的思考是起两个协程一个用来插入/更新资源到堆一个用来从堆中取出到期资源两个协程通过锁来防止竞态。client-go 给出了一种优雅的方案通过单协程事件循环避免锁竞争且心跳定时器保证了即使没有新事件堆顶到期元素也能被及时处理。代码在 这里读者可细细品读体会一下。informer这里的 informer 并不是介绍 informer 的组件而是想讲一个问题controller-runtime 的 informer 是在哪里创建的。这也是开篇笔者被质疑的点。同事说自定义资源在注册时 controller-runtime 就会在背后创建 informerfunc init() {utilruntime.Must(clientgoscheme.AddToScheme(scheme))// 注册资源 utilruntime.Must(webappv1.AddToScheme(scheme)) utilruntime.Must(webappv1.TestAddToScheme(scheme)) // kubebuilder:scaffold:scheme}笔者当时不知道直到深挖了源码才知道这里的注册只是告诉 client-go 资源的 gvk 和对象的转换关系等解决的是资源的转换/序列化/反序列化问题。其并没有创建 informer。实际上 informer 是基于懒加载模式创建的有几种方式会创建 informermgr.GetCache().GetInformer