更名说明 :本项目原名 KubeSentry ,自 2026-11-03 起更名 KubeStasis (避免与既有项目重名);CRD group 也于 11-04 由
apps.sentry.io改为kubestasis.io。前几篇文章里的旧名指的都是同一个项目,代码仓库现为 github.com/weirong-ui/...。
面试里问"Informer 原理",几乎所有人都能背出这句话:ListWatch 拉数据 → DeltaFIFO 排队 → Store 缓存 → 回调业务逻辑。 但往下追三层就开始含糊了:DeltaFIFO 为什么要"压缩"?Watch 断了为什么不续传而是重新 List?WorkQueue 明明也存 key,为什么不能替代 DeltaFIFO?我在 KubeStasis 里写了约 480 行、零 client-go 依赖的 mini-Informer(代码,配套 11 个用例,包含断线重列),这篇文章记的是写完才想明白的部分。
一、先说不写就答不上来的三个问题
Q1:Informer 到底帮你省了什么? 省的是 apiserver 的 LIST 压力。控制器若每次 Reconcile 都 client.List,几百个控制器 × 每秒数十次 = apiserver 被打爆。Informer 把"全量"做一次,之后只吃增量事件,并把结果放内存(Store)供任意次读取。Informer 本质是一个"带版本号的本地副本 + 变更通知器"。
Q2:DeltaFIFO 和 WorkQueue 都存 key,为什么要有两个队列? 因为它们的语义完全不同:
| DeltaFIFO | WorkQueue | |
|---|---|---|
| 存什么 | key → 对象快照(变更类型 + 对象) | 只有 key |
| 谁写 | Reflector(watch 事件) | 业务代码(EventHandler / Reconcile 结束) |
| 谁读 | Informer 自己的 Pop 循环 | 控制器的 worker |
| 目的 | 把事件压缩后同步进 Store | 把"待处理任务"去重后交给 worker |
一句话:DeltaFIFO 面向"数据同步",WorkQueue 面向"任务调度"。Informer 的 Store 是只读缓存,控制器拿到的只是 key,要自己回 Store 取对象------这个设计让控制器天然幂等:同一 key 处理两次,结果一样。
Q3:Watch 断线后为什么重新 List,而不是接着 watch? 因为断线期间的事件已经丢了。client-go 有两条路:靠 resourceVersion + bookmark 续传(省流量,但要求 apiserver 还留着那段历史),或者重新 List(重、但绝对正确)。mini 版选了后者------先正确,再优化,这个取舍在文末会讲清楚。
二、四层数据流,逐层写
2.1 ListWatch:数据源,泛型化
go
type ListWatcher[T any] interface {
List(ctx context.Context) ([]T, string, error) // 返回对象 + 当前 resourceVersion
Watch(ctx context.Context, rv string) (<-chan Event[T], error)
}
type Event[T any] struct {
Type EventType // Added / Modified / Deleted
Object T
}
真实实现里 Watch 返回的是 watch.Interface,事件里带 resourceVersion;这里简化成 channel,因为 mini 版不需要断点续传(断了就重列)。
2.2 DeltaFIFO:压缩才是灵魂
最直观的实现是"来一个事件就塞一个"。问题是:一次发布可能让同一个 Pod 产生 5 个事件,消费者真的需要看中间 4 个状态吗?不需要。消费者永远只关心"最新状态"。
所以 DeltaFIFO 的规则是按键压缩:
sql
Add + Update → Add(最新对象) // 他还没见过它,"新增的是什么"就是最新值
Add/Update + Delete → Deleted // 最终消失了,中间态没有意义
Delete + Delete → 一个 Deleted // 删两遍没有意义
对应到仓库里的 enqueue(简化掉锁与 cond 通知后):
go
func (f *DeltaFIFO[T]) enqueue(t DeltaType, obj T) {
key := f.keyFunc(obj)
old, exists := f.deltas[key]
switch {
case exists && old.Type == DeltaAdded && t == DeltaUpdated:
f.deltas[key] = Delta[T]{Type: DeltaAdded, Key: key, Object: obj}
case exists && (old.Type == DeltaAdded || old.Type == DeltaUpdated) && t == DeltaDeleted:
f.deltas[key] = Delta[T]{Type: DeltaDeleted, Key: key, Object: obj}
case exists && old.Type == DeltaDeleted && t == DeltaDeleted:
// 连续删除去重,什么都不做
default:
if !exists {
f.queue = append(f.queue, key) // 新 key 才入队
}
f.deltas[key] = Delta[T]{Type: t, Key: key, Object: obj}
}
}
注意 f.queue 只存 key,同一个 key 在队列里最多出现一次 ------这是"压缩"的另一半:Update 不产生新队列项,只覆盖快照。Pop 时按 key 取出 delta 并从 map 里删掉,队列和快照始终一一对应。
与 client-go 的差异(面试可能追问):client-go 的
DeltaFIFO每个 key 保留的是Deltas []Delta(一串变更),在入队时 (queueActionLocked→dedupDeltas)只对连续的两个Deleted去重;mini 版更激进地压成单个 delta。为什么可以?因为我们的 Handler 是"按最终状态回调",不重放历史。如果你的业务需要"变更审计",就不能这么压。
2.3 Store:读路径的本地副本
go
type Store[T any] struct {
mu sync.RWMutex
items map[string]T
}
Store 对外只有两个读操作:Get(key) 和 List()(返回快照);写入一律由消费协程按 delta 驱动(set / del),不给业务代码留写口子 。它和 DeltaFIFO 的分工是写路径 vs 读路径:事件先进 FIFO,Pop 出来时同时"写 Store + 调 Handler"。
全量 List 的处理也走同一条路径:先把每个对象发成 DeltaSync 塞进 FIFO,消费时逐个 set + 调 SyncFunc,而不是另外写一个 Replace 方法。好处是初次同步和增量变更共用一套消费逻辑,不会出现"两条写路径把缓存写劈叉"。
这里有个容易忽略的细节:Update 回调的 old 对象来自 Store,不是来自事件 。因为事件里只有新对象;想要 old,只能在 Pop 之后 store.Get(key) 取旧值。这就是 Handler 签名里 SyncFunc 要单独存在的原因------List 回放时没有"变更前"的概念,只能给你"当前快照"。
2.4 Handler:四种回调,而不是三种
go
type Handler[T any] struct {
AddFunc func(T)
UpdateFunc func(oldObj, newObj T) // old 来自 Store
DeleteFunc func(T)
SyncFunc func(T) // 首次 List 回放:只告知存在,不是变更
}
SyncFunc 是 mini 版为了"启动时对齐状态"加的。client-go 用 OnAdd 统一处理初次同步(通过 isInInitialList 标识),语义上等价,但显式分开更好懂。
三、WorkQueue:幂等 + 不漏
WorkQueue 只有一个不变量:同一 key 在队列里最多出现一次,但绝不会因为"正在处理"而丢掉一次变更通知。
scss
Add(key) 未处理:入队;处理中:只标 dirty
Get() 出队,清 dirty,标 working,返回历史失败次数
Done(key) 清 working;若期间 dirty 过 → 重新入队(不漏)
Fail(key) attempts+1,重新入队(调用方按 attempts 退避)
Forget(key) 成功后清失败计数
三个字段就能实现:queue []string、dirty map[string]bool、working map[string]bool。
我在这里踩了一个坑 :第一版 Get() 出队后忘了清 dirty,导致 Done() 看到 dirty=true 又把 key 塞回队列------控制器陷入"处理完立刻重做"的死循环,测试里表现为计数器疯涨。修完才理解 client-go 里那几行 delete(q.dirty, key) 为什么必须精确到位置。
go
func (q *WorkQueue) Get() (key string, attempts int, err error) {
for len(q.queue) == 0 && !q.closed {
q.cond.Wait() // 阻塞等任务,Close 时唤醒
}
if len(q.queue) == 0 {
return "", 0, ErrClosed
}
key = q.queue[0]
q.queue = q.queue[1:]
delete(q.dirty, key) // ← 关键:出队即消费掉这轮 dirty
q.working[key] = true
return key, q.attempts[key], nil
}
(省略了 mu.Lock() / defer mu.Unlock();cond.Wait 会在等待时释放锁。)
四、断线重列与 HasSynced
一轮 listAndWatch 只有五步(外层 Run 就是 for { listAndWatch(ctx) }):
go
items, rv, err := lw.List(ctx) // 1. 全量,拿到当前 resourceVersion
for _, obj := range items {
i.fifo.Sync(obj) // 2. 全量回放成 Sync delta(不直接写 Store)
}
done := make(chan struct{})
go i.consume(ctx, done) // 3. 消费协程:Pop → 写 Store → 调 Handler
stream, err := lw.Watch(ctx, rv) // 4. 从 rv 开始增量
for ev := range stream { // 5. 通道关闭 = 断线,return 后外层重新 List
switch ev.Type {
case Added: i.fifo.Add(ev.Object)
case Modified: i.fifo.Update(ev.Object)
case Deleted: i.fifo.Delete(ev.Object)
}
}
断线重列时有个容易漏的并发细节:旧一轮的 consume 协程必须随本轮结束退出(done 通道),否则它会继续 Pop 新一轮的队列,把事件抢走。这是我在写断线重列测试时才补上的。
HasSynced 的语义是"第一轮 List 已经回放完,Store 可以读了"。控制器不等它就开始工作,会读到不完整的缓存 ------这是"控制器启动时误删资源"这类事故的经典成因。这里我得诚实说明一个取舍:client-go 要等整批 Sync 消费完才置 true,而 mini 版因为消费是 FIFO 顺序、Sync 全在最前面,简化成了"消费到第一条 Sync 就置 true"。够用,但不严格;包注释里标了这一点。面试被问到"你的实现和 client-go 差在哪",这就是其中一个具体答案。
测试里我用一个"Watch 一开就断"的 fake,断言至少发生两轮 List + 回放,否则重列逻辑相当于没测。
五、什么场景才值得自己写
写完之后我的结论可能和你想的相反:生产环境几乎永远不该自己写 Informer。 但有三类场景值得:
- 学习/面试:只有自己处理过"断线、压缩、去重"这三个麻烦,才敢说懂 client-go 的设计动机。
- 极简依赖场景:比如一个只 watch 单一资源的 sidecar,不想拖进整个 client-go 依赖树(虽然 client-go 的 informer 包可以单独引入,但 API 面很大)。
- 需要非 Kubernetes 数据源的"类 Informer"语义:比如 watch 一个配置中心、一个数据库变更流,复用同一套"List+Watch→压缩→缓存→回调"的骨架非常合适。这时 client-go 帮不上你,mini 版就是模板。
六、为什么生产用 SharedInformer 而不是自己写
| 维度 | mini-Informer | SharedInformer |
|---|---|---|
| 连接复用 | 每个 Informer 一条 watch | 同一类型共享一条 watch(几百个 controller 也只打一份) |
| 索引 | 只有 key→对象 | Indexer 支持任意索引(按 namespace、按 label) |
| 断点续传 | 断了重列 | resourceVersion + bookmark,减少全量 |
| 限流与背压 | 自己实现 | 内置 rate limiter、事件队列 |
| 内存 | 每个 Informer 各存一份全量对象 | 同类型共享一份缓存(Indexer),apiserver 侧另有 watch cache 分层 |
所以正确的表述是:"我手写过,所以知道 SharedInformer 每个设计决策在解决什么问题",而不是"我不用 client-go"。
七、把它接进真实控制器(可选)
mini 版的 Run 可以直接驱动一个"取 key → 从 Store 读对象 → 调 Reconcile"的循环。真实项目里我没有这么做------KubeStasis 用的是 controller-runtime 的 Manager(它内部就是 SharedInformer + WorkQueue 的组合)。pkg/miniinformer 的定位是可执行的原理笔记:它能跑、有测试、有断线重列,但不会被塞进生产路径。
八、跑起来
bash
git clone https://github.com/weirong-ui/kubestasis
cd kubestasis && go test ./pkg/miniinformer/ -v -count=1
11 个用例覆盖:压缩规则(Add+Update / Add+Delete / 重复 Delete / 多 key 顺序)、WorkQueue 的 dirty-while-working 与失败计数、List→Watch、断线重列、Store 快照。
系列其他文章:
- HPA 只会伸缩不会自愈?我设计了一个 K8s 应用自愈 Operator
- 从零写一个 K8s Operator:kubebuilder 初始化到第一个 Reconcile
- 为什么 HPA 会震荡?手写一个带冷却窗口的伸缩决策引擎
- 给 K8s 加一个"自动值班员":分级故障自愈引擎
代码与完整能力清单:github.com/weirong-ui/... · docs/STATUS.md。觉得有用点个赞,仓库点个 star 追更不迷路;对 DeltaFIFO 压缩策略或者 HasSynced 时机有不同意见,欢迎评论区拍砖。