第 3 章 · 控制器模式与调谐循环

一句话结论:声明式 API 只是「立了目标」,真正让目标变成现实的,是 K8s 的灵魂机制——控制器模式(Controller Pattern):每个控制器都是一个永不停止的「调谐循环」(reconcile loop),反复做三件事:观察实际状态 → 对比期望状态 → 动手消除差距。K8s 的所有自愈、扩缩容、滚动更新,本质都是无数个这样的循环在跑。


1. 声明式立了目标,然后呢?

第 2 章埋了一个关键问题:Apply 只是「立目标」,那「谁去达成目标」?

答案是:控制器(Controller)。一个 Deployment 控制器,就专职负责「把集群里 Deployment 的实际状态,推向其期望状态」这一件事。

它的逻辑,用一个恒温器类比就全懂了:

恒温器不看「我上次调到了几度」,它只看**「现在几度」vs「我想几度」**,然后:冷了就加热,热了就制冷,一样就啥也不干。它反复循环,直到两者一致。即使你半夜把窗打开(状态被外部改变),它也会在下一次循环里发现偏差,自动纠正。

K8s 的控制器,就是一个个「业务领域的恒温器」。


2. 伪代码:一个最小可用的调谐循环

// Deployment 控制器的最小骨架
func (c *DeploymentController) Run() {
    for {                                      // 永不停止
        key := c.queue.Get()                   // 拿到一个「要处理的对象」(来自 watch 事件)
        c.reconcile(key)                       // 对这个对象做一次调谐
    }
}
 
func (c *DeploymentController) reconcile(key string) {
    desired := c.getDesired(key)               // ① 观察:读期望状态(用户声明 replicas: 3)
    actual  := c.getActual(key)                // ② 观察:读实际状态(现在只有 2 个 pod 在跑)
 
    if actual.Replicas < desired.Replicas {    // ③ 对比:有差距
        c.createPod(desired.Template)          // ④ 动手:补一个 pod
    } else if actual.Replicas > desired.Replicas {
        c.deletePod(actual.ExtraPods[0])       //    多了就删
    }
    // 一样就啥也不干,等下一次
}

注意几个看似简单、实则深刻的点:

  1. reconcile 是幂等的:无论它被调用 1 次还是 100 次,结果都一样——把实际推向期望。幂等,是控制器能在「分布式、会出错」的环境里存活的第一前提(因为重试、事件重复、崩溃重启,都会导致 reconcile 被重复调用)。
  2. 它不「记录历史动作」,只「对比当前状态」:控制器不关心「我上次做了什么」,只关心「现在差多少」。这就是后面要讲的 level-driven(水平驱动) 思想。
  3. reconcile 只处理「一个对象」:每次聚焦一个 Deployment,而不是扫全集群。这让每个循环都短小、可重试、可并行。

3. 思考的分叉:level-driven vs edge-driven(本教程最值得深挖的一个点)

「怎么感知状态变化、怎么触发处理」,是控制器设计的一个核心分叉:

方向 A:edge-driven(边沿驱动)——每个「事件」触发一次处理。

  • 比如「一个 Pod 被删了」这个事件,立刻触发「补一个 Pod」。
  • 问题:事件会丢、会重复、会乱序。删了 3 个 Pod 产生 3 个事件,但其中一个事件丢了——系统就永远只补了 2 个,还自以为「处理过了」。要正确,你得额外记录「处理到第几个事件」,复杂度暴涨。

方向 B:level-driven(水平驱动)——不关心「发生了什么事件」,只反复看「现在状态 vs 期望状态」的差值。

  • 即使事件丢了,下一次循环一对比「实际 0 个、期望 3 个」,照样补 3 个。因为它对的是「终态」,不是「过程」。

为什么 K8s 坚定选 level-driven:

  • 因为它把「正确性」建立在一个可反复验证的稳态上,而不是一个易丢失、易乱序的事件流上。
  • 事件(watch)只是「提醒你该看一眼了」的优化手段,不是正确性的来源。真正的正确性,来自「每次 reconcile 都重新对比完整的期望态和实际态」。

类比:edge-driven 像「每收到一条『灯灭了』的短信就去开一次灯」——短信一丢,灯就永远黑着。level-driven 像「每隔一阵子抬头看一眼『灯亮没亮』」——哪怕中间漏了十次提醒,只要最后一次抬头看到「灯没亮」,就会去开。后者不依赖「提醒的完整性」,只依赖「当前状态的正确对比」,所以天然抗丢失、抗重复。


4. 为什么「一个控制器管一件事」——单一职责的分布式版

K8s 不是「一个大控制器管所有」,而是每种资源一个控制器:

  • Deployment 控制器:管「副本数」和「滚动更新」。
  • ReplicaSet 控制器:管「维持 N 个 Pod」。
  • Node 控制器:管「节点挂了就驱逐上面的 Pod」。
  • Service 控制器:管「分配虚拟 IP 和 endpoints」。

为什么拆这么细?三个理由:

  1. 每个控制器逻辑简单、好验证:一个只干「补副本」的循环,远比一个「什么都管」的循环容易写对。
  2. 可独立演进:改调度器的逻辑,不影响网络控制器。
  3. 可独立失败:一个控制器 bug 了,只影响它那一种资源,不会拖垮全集群。

这和第 1 章「拆分的本质是让每个部分聚焦」一脉相承——只是这次拆分的是「控制逻辑」,而不是「agent 角色」(呼应一下 DeerFlow 教程里的思考:拆分永远是为「聚焦 + 可管理」服务的)。


5. 思维流重放:控制器这个「循环」是怎么被想到的

上面讲了「level-driven 为什么比 edge-driven 好」,这一节退一步——「控制器」这个『永不停止的对账循环』的形态本身,是怎么一步步被逼出来的。

第 1 步 · 直觉:声明式立了目标(replicas: 3),但目标不会自己实现。最自然的想法是写一个**「一次性执行器」**——用户 apply 时,执行器跑一遍,把实际调到期望,完事。

第 2 步 · 推演:一次性执行完之后呢?过了一会儿,一个 Pod 挂了。现在实际 2 个、期望 3 个,但没有任何东西再去看一眼了。状态会一直「漂移」下去,直到下一个人手动干预。第一个裂缝:一次性执行,扛不住状态漂移。

第 3 步 · 试修补:那就「定时跑」——cron 每隔几秒全量扫一遍,发现偏差就修。方向对了,但有个新问题:集群一大、对象一多,全量扫描又慢又浪费,大部分扫描都是「白扫」。

第 4 步 · 反转:能不能「事件触发 + 对账兜底」结合?——watch 到变化就立刻处理(快、省),但正确性不靠事件,而靠「每次处理时都重新对账」兜底(稳)。这一反转,把「快」和「稳」拆开、各取所长:事件负责「快」,对账负责「稳」。

第 5 步 · 收敛:于是控制器的最终形态是——一个永不停止的循环,事件(watch)只是「敲门提醒」,真正的活儿是「重新对账」。这也是为什么 reconcile 必须幂等:因为循环会被反复触发、会被重复执行,只有幂等,才扛得住「重复」。

一句话复盘:控制器不是「设计者一开始就想好要写个循环」,而是从「一次性执行」一路被「状态漂移」「扫描浪费」逼出来的。中间最关键的一步,是意识到「事件只能用来触发,不能用来保证正确」——触发和正确性,是两个必须分开的问题。


思考题

Q:既然控制器是「永不停止的循环」,那会不会出现「两个控制器同时处理同一个对象」导致冲突?比如 Deployment 控制器和 ReplicaSet 控制器都在管 Pod 数量,它们会不会打架?

参考答案:

这正是控制器设计里一个精妙的「分层解耦」——每个控制器只对它「拥有」的那一层资源负责,通过「所属关系(owner reference)」和「层级调用」避免直接冲突:

  • Deployment 控制器不直接管 Pod,它只负责「维持 ReplicaSet 的数量和版本」。
  • ReplicaSet 控制器才负责「维持 Pod 数量」。
  • 所以「副本数变化」这个职责,被唯一地交给了 ReplicaSet 控制器;Deployment 想扩缩容,是通过「改 ReplicaSet 的期望副本数」来间接实现,而不是自己去动 Pod。

这样每个对象(Pod)在任意时刻,只有一个控制器对它有「直接管辖权」,冲突就从根上被消解了。这背后是一个更通用的分布式系统原则:当多个自治单元需要协作时,最稳的做法不是「让他们互相沟通协商」,而是「把职责拆成不重叠的层次,让每个单元只对唯一的一层负责」。 这和第 1 章 DeerFlow 教程里「监督式 vs 协作式」的讨论,是同一套思想在不同领域的回响。