Skip to main content

2. 理解控制循环

上一章的循环有什么问题​

上一章末尾写了个骨架:

for {
desired := getFromAPIServer()
actual := getFromCluster()
reconcile(desired, actual)
time.Sleep(time.Second)
}

这个能跑,但有两个要命的问题:

问题 1:傻轮询。没事也每秒问一次 API Server"变了吗?变了吗?变了吗?"。集群一万个资源,Controller 一秒钟打一万次请求,API Server 直接被锤爆。

问题 2:调谐可能失败。createResources() 可能因为网络抖动失败。循环到下一轮才重试——但中间隔了一秒,而且失败原因没记录,下一轮是盲目重试。

K8S 的 Controller 用三件套解决这两个问题:

API Server ──Watch──→ Informer ──→ Workqueue ──→ Reconcile
事件推送 本地缓存 串行处理

Informer:不要轮询,要推送​

Informer 干的事像订阅报纸——你不用每天跑去报亭问"今天有新闻吗",有新报纸报亭直接送上门。

// 伪代码:Informer 的核心
informer := cache.NewInformer(
&cache.ListWatch{
ListFunc: func() { /* 启动时全量拉一次 */ },
WatchFunc: func() { /* 之后监听变更事件 */ },
},
&corev1.Pod{}, // 我关心 Pod
resyncPeriod, // 定期全量同步
ResourceEventHandlerFuncs{
AddFunc: func(obj) { enqueue(obj) }, // 新 Pod
UpdateFunc: func(old, new) { enqueue(new) }, // Pod 变了
DeleteFunc: func(obj) { enqueue(obj) }, // Pod 没了
},
)

Informer 启动时干两件事:

  1. List:先调 API Server 拉一次全量,存到本地缓存(这个缓存叫 Store)
  2. Watch:之后监听 API Server 的变更事件,有变化就更新本地缓存
启动:
API Server ──List(全量)──→ Store(本地缓存)
│
运行中: │
API Server ──Watch(事件)──→ Store 更新
│
▼
Controller 读缓存,不打 API Server

为什么这样省流量:Controller 要读"现在有几个 Pod",直接查本地 Store,不碰 API Server。API Server 只在资源变化时推一个事件过来。

List 的意义:建立基线​

光 Watch 不行。你想——如果 Controller 启动晚了一秒,这一秒里 Pod 变了三次,Watch 只能看到"当前状态",中间的变化丢了。List 先拉一次全量,建立基线,之后的 Watch 事件才接得上。

打个比方:你接手一个项目的 Git 仓库。先 git clone(List)拿到当前全量代码,之后 git pull(Watch)只拉增量。不 clone 直接 pull 是不行的——你没有基线。

Resync:定期对账​

光 List + Watch 还不够。万一某个 Watch 事件因为网络问题丢了?本地缓存就跟实际不一致了。

Informer 有个 resync 机制——每隔一段时间(默认 10 小时,可配),把本地缓存全量重新过一遍,触发所有资源的 Update 事件。

这像什么?像仓库盘点。你平时靠进销存系统记流水(Watch),但每隔一段时间得去仓库实地数一遍(resync),防止账跟实物对不上。

Indexer:给缓存建索引​

Store 默认是个 map,按 namespace/name 存。但 Controller 经常需要按别的维度查——"default 命名空间下所有带 app=nginx 标签的 Pod"。

Indexer 给 Store 加索引。打个比方:图书馆的书按书号排(默认 map),但你想要"按作者查""按出版年份查",就得多建几个目录(Index)。

informer.AddIndexers(cache.Indexers{
"namespace": func(obj) { return []string{obj.Namespace} },
"labels.app": func(obj) { return []string{obj.Labels["app"]} },
})

// 之后可以 O(1) 查
informer.GetIndexer().Index("labels.app", "nginx") // 所有 app=nginx 的 Pod

Workqueue:串行、去重、重试​

Informer 收到事件后,不是直接调 Reconcile,而是塞进一个队列——Workqueue。

为什么要加一层队列?直接调不行吗?

原因 1:串行化​

Informer 的回调是并发的——多个事件可能同时触发。如果直接在回调里调 Reconcile,同一个资源可能被并发处理,导致写冲突。

Workqueue 保证同一个资源的事件串行处理。同一个 key,前面的没处理完,后面的等着。

打个比方:银行柜台。你取号排队(Workqueue),同一个账号的业务不会开两个窗口同时办,得排队一个一个来。

原因 2:去重​

一个 Pod 短时间内被改了 5 次,Informer 收到 5 个 Update 事件。但 Controller 真正处理时,只需要拿最新状态处理一次就行——中间的 4 次没必要处理。

Workqueue 是个 set 语义——同一个 key 在队列里只存一份。5 个事件压成 1 次处理。

事件流: A → A → A → A → A (5 次更新)
Workqueue: A (只排一次)
Reconcile: 处理 A 一次(拿最新状态)

原因 3:重试和退避​

Reconcile 可能失败。失败后怎么办?

Workqueue 支持 requeue——处理失败把 key 重新塞回队列。而且带退避:第一次失败立即重试,第二次等一会儿,第三次等更久,避免疯狂重试把系统打爆。

// 伪代码:Reconcile 失败后 requeue
err := reconcile(key)
if err != nil {
queue.AddRateLimited(key) // 带退避的重试
return
}
queue.Forget(key) // 成功了,忘掉这个 key 的失败记录

Workqueue 的三个变体​

类型行为用途
BasicQueue普通队列,FIFO最简单的场景
DelayingQueue支持延迟入队定时任务
RateLimitingQueue支持速率限制 + 退避Controller 标配

写 Controller 默认用 RateLimitingQueue。

Reconcile:核心逻辑​

队列里的 key 出队后,调到 Reconcile 函数。这是 Controller 的灵魂——所有业务逻辑都在这。

func Reconcile(req Request) Result {
// 1. 从 Informer 缓存读期望状态
obj, exists, err := indexer.GetByKey(req.NamespacedName.String())
if !exists {
// 资源被删了,清理关联资源
cleanup(req.NamespacedName)
return Result{}
}

// 2. 读实际状态(可能查其他资源,也可能调外部系统)
actual := getActualState(obj)

// 3. 算差异
diff := compare(obj.Spec, actual)

// 4. 调谐
if diff > 0 {
createResources(diff)
} else if diff < 0 {
deleteResources(-diff)
}

// 5. 更新 status
obj.Status.Replicas = actual.Count
updateStatus(obj)

return Result{RequeueAfter: 30 * time.Second} // 30 秒后再来一次
}

注意最后那个 RequeueAfter——即使没事件,也定期 reconcile 一次。这是 resync 之外的另一层保险。

Reconcile 的两个关键设计​

幂等性:Reconcile 被调多少次结果都一样。不能"调一次创建一个 Pod,调两次创建两个"。

声明式,不记状态:Reconcile 不应该关心"上一次我做到哪了"。每次都从头算——期望是什么、实际是什么、差异是什么、怎么补。这跟空调一样——它不记得上次开了多久,每次都重新测温度、重新决定制冷不制冷。

完整流程​

把三件套串起来:

API Server
│
Watch 事件
▼
┌─────────────────────────────────────────┐
│ Informer │
│ ┌─────────────────────────────────┐ │
│ │ Reflector │ │
│ │ (List + Watch) │ │
│ └────────────┬────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ Indexer (本地缓存 + 索引) │ │
│ └────────────┬────────────────────┘ │
│ │ │
│ 事件回调 │
└───────────────┼──────────────────────────┘
▼
Workqueue (去重 + 退避)
│
▼
┌─────────────────────────────────────────┐
│ Reconcile │
│ 1. 读期望 (从 Indexer) │
│ 2. 读实际 (查集群/外部系统) │
│ 3. 算差异 │
│ 4. 调谐 (创建/删除/更新) │
│ 5. 写 status │
└───────────────────────────────────────────┘

前置阅读​

  • 控制器模式与CRD — Starter 第 12 章,讲过 Informer/Workqueue 的概念,本章是它的深入版
  • K8S架构 — Starter 第 2 章,API Server 是 Watch 的数据源,理解架构后看本章更顺
  • etcd与Raft共识算法 — Starter 第 3 章,Watch 机制底层依赖 etcd 的 revision 推送

思考题​

问题 1:Informer 的 resync 机制,为什么不是"定期重新 List",而是"定期把本地缓存过一遍"?

问题 2:Workqueue 去重时,5 个事件压成 1 次。但中间的 4 个事件是不是白发了?

问题 3:Reconcile 为什么要幂等?不幂等会出什么问题?


解答​

问题 1:重新 List 要打 API Server,跟全量轮询没区别,回到 Informer 要解决的问题。本地缓存过一遍纯内存操作,不打网络,成本低得多。本质上 resync 是"用本地数据触发一次 reconcile",不是"重新拿数据"。

问题 2:没白发。中间的 4 个事件更新了 Informer 的本地缓存——所以 Reconcile 处理那 1 次时,读到的就是最新状态。事件的价值在于"触发处理",不在于"携带数据"。Reconcile 每次都从头读缓存,不依赖事件内容。

问题 3:不幂等会重复创建资源。比如 Reconcile 因为网络抖动失败,requeue 后再调一次——如果不幂等,第一次创建的 Pod 还在,第二次又创建一个,副本数就翻倍了。幂等性保证调多少次结果都是"3 个 Pod",不多不少。

下一章​

下一章我们看 K8S 最常用的内置 Controller——Deployment 控制循环——从 ReplicaSet 到滚动更新,看真实世界的 Reconcile 长什么样。