ontroller-runtime:从 informer 到调谐的性能之道 开发 Kubernetes CRD 资源时绕不开 client-go 的 informer 机制。使用 client-go 本身并不困难——根据 kubebuilder 官网生成调谐框架然后在 Reconcile方法中添加业务逻辑即可。然而只停留在“能用”的层面很难理解其中的玄妙出了问题心里没底。有时候代码正常工作了自己也不清楚为什么能工作笔者遇到过好几次。举个例子调用调谐器中的 client.List接口读取自定义资源。写这段代码时的理解是client.List从 apiserver 中读取资源然后缓存起来。从现象上看似乎也符合预期第一次调用花了 50ms第二次只花了 4ms 左右。但这个理解忽略了一个关键问题缓存中的数据是如何保持新鲜的 如果第二次只是从第一次的缓存里读数据那这个数据岂不是永远不会更新watch 在哪里做的如果有 watch它又是怎么和缓存联动的笔者也是因为这段代码被同事挑战了。挑战并不可怕可怕的是自己说不清原理。于是带着问题把 client-go 和 controller-runtime 的源码翻了一遍。其实三年前笔者就写过 client-go 的源码分析系列文章这次翻出来再看还是觉得有点青涩看来当时还是一知半解啊。本文将继续深挖 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 解决的是资源调谐的问题。我们继续看它是如何做到调谐资源的。首先调谐资源主要负责重复调谐业务侧可能需要持续操作资源直到满足条件