Chapter 02
控制循环:让对象“活起来”的引擎
上一章把 K8s 的对象词汇表建立起来——每个对象都是 spec(期望)+ status(实际)。这一章解释这些静态记录如何被持续驱动:控制循环。
本章你将建立的 schema
- 声明式 vs 命令式:你交的是期望状态,不是操作步骤
- level-triggered(电平触发)为什么比 edge-triggered(边沿触发)更抗故障
- reconcile 循环:observe → diff → act 的三步
- 控制平面五大组件如何协作
- 一条 kubectl apply 从命令到 Pod 运行的完整路径
第 1 章末尾埋了一个问题:把一份 replicas: 3 的 Deployment 写进集群,没有任何人继续操作,Pod 却被创建、被维持、删掉一个还会自动补回。这份静态的 spec 凭什么会“动”?答案是一组永不停止的控制循环。本章的全部内容可以压成一句话:Kubernetes 是一组 level-triggered 控制循环——你声明 spec(期望),控制器持续观测 status(实际)并把实际推向期望。自愈、被改回去的 kubectl edit、改了配置却不生效,这些现象一旦看见循环,就都不再是“魔法”。
2.1声明式 vs 命令式:交期望,不交步骤
命令式是“跑 X、再跑 Y、再启动 Z”;声明式是存一份期望 spec,让系统自己想办法达到。
命令式脚本把“当前状态”这个隐含前提钉死在每一行里:第 3 行假定第 2 行成功了。脚本在中途崩溃,集群就停在一个谁也说不清的中间态——已经建了一半的资源、改了一半的配置。要恢复,得先搞清楚“现在到哪了”,而这恰恰是最难的事。声明式把这个难题反过来:不描述路径,只描述目的地。
命令式的世界里,你对系统说的是动词序列:创建这个、更新那个、删除另一个。每条指令都依赖前一条的结果,整个序列没有幂等性——重跑一遍要么报“已存在”而失败,要么重复创建。一旦执行到第 k 步崩溃,系统落在第 1…k 步之间某个无法命名的状态,没有任何记录告诉你“目标本来是什么”。
声明式的世界里,你提交的是一个名词:期望的最终状态长什么样。kubectl apply -f deploy.yaml 不执行任何动作,它只做一件事——把这份 spec 作为“意图”记录下来。达成意图是系统自己的事,而且系统可以反复尝试:这一轮没达到,下一轮接着推,因为目标始终明确地写在那里。
# 命令式:一串动词,崩在第 2 行就停在未知状态
docker run -d --name web-1 nginx
docker run -d --name web-2 nginx
docker run -d --name web-3 nginx # 若这行失败:现在是 2 个还是 3 个?无人知晓
# 声明式:一个名词(期望),系统自己想办法达到并维持
kubectl apply -f deploy.yaml # spec: replicas: 3 —— 只记录意图,不执行步骤
apply 的“意图”不是一句空话,它被结构化地存了下来。api-server 会把你提交的字段记进对象的 metadata.annotations(kubectl.kubernetes.io/last-applied-configuration),下次 apply 时做三方合并(上次意图、本次意图、集群现状),算出该改哪些字段。所以 spec 不是一次性命令,而是一份会被反复读取的、关于“你想要什么”的持久声明。这正是下一节循环能持续工作的前提——目标永远在线可读。
2.2level-triggered vs edge-triggered:抗故障的支点
edge-triggered 对“事件”做反应;level-triggered 对“当前完整状态”做反应。后者是 Kubernetes 全部容错能力的支点。
分布式系统里,组件崩溃、消息丢失、网络抖动不是异常而是常态。如果系统的正确性依赖“每个事件都被恰好处理一次”,那么任何一次丢事件都会让实际状态与期望状态永久发散,且无法自行修复。Borg 与 Omega 的教训凝结成一条设计原则:系统必须是对“部分失败”的反应式设计——这就是选 level 而非 edge 的根本原因。
edge-triggered(边沿触发):系统监听“变化事件”——“副本数从 3 改成了 5”——并对这个 delta 做出动作(多起 2 个)。它处理的是增量。问题在于:如果接收方在事件到达时正好崩溃,或者事件在传输中丢失,那么这次“+2”就永久丢失了。没有人会再提起它,因为它只是一个转瞬即逝的边沿。系统从此停在 3 个副本,而期望是 5 个,且永远不会自己纠正。
level-triggered(电平触发):系统不关心“发生了什么变化”,每次被唤醒时都重新读取完整的实际状态(现在有几个副本)并与期望状态(应该有几个)做完整比对,差多少补多少。它处理的是当前的绝对值,不是增量。无论它是被一个 watch 事件唤醒、被定时器唤醒、还是刚从崩溃中重启——动作都一样:读全量、比对、收敛。因此丢事件、组件重启、抖动,都只意味着“这一轮没动作或晚一轮动作”,下一轮照样自愈。
| edge-triggered(边沿) | level-triggered(电平) | |
|---|---|---|
| 处理对象 | 变化事件 / 增量(delta) | 当前完整状态 / 绝对值 |
| 丢失一个事件 | 那次变更永久丢失,状态发散且不自愈 | 无所谓——下一轮重读全量,照样收敛 |
| 崩溃后重启 | 错过期间的所有事件全部丢失 | 重启后重读一次全量即追平,毫无影响 |
| 代价 | 实现简单、动作少 | 每轮都要重读并比对全量状态,开销更大 |
一个 level-triggered 控制器在监听 api-server 时漏掉了一个“某 Pod 被删除”的 watch 事件。这个删除会被永久忽略吗?
展开答案(先停 10 秒)
不会。控制器下一轮 reconcile 时重读全量:它列出该 Deployment 当前实际拥有的 Pod,发现数量比期望少一个,于是新建一个补齐。漏掉的那个事件从未被“处理”,但结果完全正确——因为控制器从不依赖事件本身,只依赖它重读到的绝对状态。watch 事件在这里只是一个“该醒醒了”的提醒,丢了顶多晚一轮,正确性不受影响。
2.3reconcile 循环:observe → diff → act
每个控制器都在跑同一个三步循环:观测实际、与期望比对、改变世界——然后回到观测,永不停止。
上一节确立了“只看绝对值”的原则,这一节是它的具体形态。把 level-triggered 落成代码,就是一个三步闭环。理解这三步,才能解释为什么控制器“看起来什么都没做”却在持续工作,以及为什么它常常永远到不了稳态——而这是正常的。
observe(观测):读取当前实际状态。注意控制器不直接打 etcd,也不每轮都去问 api-server——那样会把控制平面压垮。它通过 informer 读一份本地缓存。informer 的工作方式是 list-then-watch:启动时先 list 一次拉全量建立缓存,之后用 watch 持续接收增量更新维护这份缓存;每个对象带 resourceVersion,informer 据此去重、保证不漏不重地推进缓存版本。于是“读实际状态”几乎是本地内存操作。
diff(比对):把 spec(期望)与 status(实际)做比对,算出差异。差异的形态各异——“少 2 个 Pod”“某个字段不一致”“多了一个该删的对象”。
act(行动):针对差异,通过 api-server 改变世界——创建、更新或删除对象。注意控制器自己不直接动 etcd,它把意图写给 api-server,由 api-server 落库。动作完成后,循环立刻回到 observe。
假如每个控制器每轮都直接 list 一次 etcd,几十个控制器 × 高频循环会把 etcd 打爆。informer 把“读”这一侧彻底本地化:全集群对某类对象的 watch 由共享 informer 复用一条连接,缓存在内存里,控制器读缓存而非读后端。resourceVersion 是这套机制的游标——watch 中断后可以从上次的版本号续上,不必重新拉全量。“读很便宜”是 reconcile 能跑得这么频繁的物理基础。
2.4控制平面架构:谁碰 etcd,谁只 watch
etcd 是唯一事实来源;api-server 是唯一直接读写 etcd 的组件;其余所有组件都只 watch api-server。
前三节讲的是“一个”控制器怎么工作。真实集群里有几十个控制器、一个调度器、每个节点一个 kubelet 同时在跑。要让它们协同而不互相踩踏,靠的不是一个中央指挥官,而是一条铁律:所有状态都经由 api-server 进出 etcd,组件之间不直接 RPC 编排。理解这条边界,是看懂下一节那条完整路径的前提。
控制平面五大组件,各司其职:
- etcd——唯一事实来源。一个强一致的分布式 KV 存储,集群里所有对象的 spec 与 status 最终都落在这里。它只管存,不懂业务。
- api-server——唯一直接读写 etcd 的组件。所有其他组件、所有
kubectl,都只能通过它间接访问状态。它对外是一套 REST API,对内是 etcd 的唯一守门人。一次写请求要穿过它的处理链:认证(你是谁)→ 鉴权(你能不能做)→ mutating admission(按策略改写对象,如注入 sidecar)→ schema 校验(字段合不合法)→ validating admission(按策略放行或拒绝)→ 持久化到 etcd。 - controller-manager——把几十个 reconcile 循环(Deployment 控制器、ReplicaSet 控制器、Node 控制器……)打包跑在一个进程里,每个都 watch api-server。
- scheduler——一个特殊的控制器。它专门 watch “还没有
nodeName的 Pod”,为每个这样的 Pod 选一个合适的 Node,然后把选择结果写回去(设置nodeName,即 binding)。它只决策、不启动容器。 - kubelet——每个 Node 上各一个。它 watch “
nodeName等于自己的 Pod”,把这些 Pod 通过 CRI(容器运行时接口)真正跑起来,并把容器的实际 status 回报给 api-server。
此外每个节点还有 kube-proxy,负责把 Service 的抽象编排成节点上的网络转发规则(细节留到第 4 章)。
2.5跟踪一条 kubectl apply
没有任何组件“指挥”全局;一条 apply 的落地,是若干个各自 watch + reconcile 的循环依次被触发的结果。
把前四节拼成一条完整路径,是本章的收口。读完这一节,“声明 spec、组件各自收敛”就从抽象原则变成你能在脑中逐帧播放的具体过程——而这恰恰是排障时最值钱的能力:知道一个 Pod 卡在哪一帧,就知道该看哪个组件。
提交 kubectl apply -f deploy.yaml(其中 spec.replicas: 3),发生的事按顺序是:
- kubectl → api-server:kubectl 把 YAML 转成 JSON,向 api-server 发一个 POST。api-server 跑完处理链——认证、鉴权、mutating admission、schema 校验、validating admission——然后把这个 Deployment 对象写入 etcd。此刻集群里只多了一条记录,还没有任何容器。
- Deployment 控制器:它一直 watch Deployment。看到这个新对象后 reconcile:期望有一个对应的 ReplicaSet,实际没有,于是创建一个 ReplicaSet(同样是写给 api-server,落 etcd)。
- ReplicaSet 控制器:它 watch ReplicaSet。看到新 ReplicaSet 期望 3 个副本、实际 0 个,于是创建 3 个 Pod 对象。这些 Pod 此刻是
Pending状态,没有nodeName——还没被调度。 - scheduler:它 watch “没有
nodeName的 Pod”。看到这 3 个待调度 Pod,为每个挑一个合适的 Node,把选择结果写回 Pod 的nodeName字段(binding)。scheduler 到此收工——它从不启动容器。 - 目标节点的 kubelet:每个节点的 kubelet 都 watch “
nodeName等于自己的 Pod”。被分到 Pod 的那个节点的 kubelet 看到“这是属于自己的 Pod”,于是拉镜像 → 经 CRI 起容器,然后把容器跑起来后的实际 status 回报给 api-server。
关键在于:从头到尾没有一个组件“指挥”全局。Deployment 控制器不知道 scheduler 存在,scheduler 不知道 kubelet 存在。每个组件只盯着 api-server 里与自己相关的那部分状态,做自己的 reconcile,把结果写回去——这个写回,又成了下一个组件的输入。协同是涌现出来的,不是编排出来的。
Pending 存在(第 ③ 步,无 nodeName),之后才被 scheduler 赋予 nodeName(第 ④ 步)——“创建”和“调度”是两个独立步骤、由两个互不相识的组件完成。所有箭头都进出 api-server 那条 accent 生命线,组件之间没有任何直接连线。2.6探针:循环如何“观测健康”
探针(probe)是 kubelet 这一层 reconcile 的“观测”输入——它让循环不只看“容器在不在”,还看“容器健不健康、能不能收流量”。
到这里循环只观测到“进程是否存活”这一粗粒度信号。但进程活着不等于工作正常(死锁、还没加载完)。探针给循环更细的观测维度,让“把实际推向期望”这件事能精确到“重启它”还是“先别给它发流量”。本节只建立机制,第 3、4 章会用到。
三种探针对应三种不同的“健康”问题,触发三种不同的动作:
- liveness(存活):探测失败 → kubelet 判定容器“坏了”,重启该容器。用于检测死锁这类“活着但卡死”的状态。
- readiness(就绪):探测失败 → 把该 Pod 的 IP 从 Service 端点列表里摘掉,但不重启。用于“暂时不能收流量、但本身没坏”的状态(如正在加载缓存、依赖暂时不可用)。摘掉后流量不再进来,恢复后再加回。
- startup(启动):在容器慢启动期间先顶着——startup 探针没成功之前,liveness 和 readiness 都暂缓,避免“启动还没完就被 liveness 判死、反复重启”。一旦 startup 成功,交棒给另外两个。
把外部依赖(如数据库)的可用性塞进 liveness 探针,是一个会放大故障的陷阱。一旦数据库抖动,所有副本的 liveness 同时失败 → kubelet 同时重启整队容器 → 重启期间依赖仍未恢复 → 再次失败、再次重启 → 雪崩。容器自身明明没坏,却被一次外部抖动打趴全队。外部依赖的检查应放在 readiness:依赖不可用时把 Pod 从 Service 摘掉、停止收流量即可,容器无需重启,依赖恢复后自动加回——故障被隔离,而不是被放大。
2.7回看第 1 章:自愈不是魔法,是循环
带着 reconcile 模型,重新解释第 1 章那个现象:你手动 kubectl delete pod 删掉一个由 ReplicaSet 管理的 Pod,几秒后一个新 Pod 自己冒了出来。这不是有谁在“监视你的删除操作并报复”,而是——你的删除让 etcd 里该 ReplicaSet 实际拥有的 Pod 从 3 个变成 2 个;ReplicaSet 控制器下一轮 reconcile 时 observe 到“实际 = 2”,diff 出“实际 < 期望(3)”,于是 act:新建一个 Pod 补齐。自愈 = 一个永不停止的 level-triggered 循环,碰巧把你的删除当成了一次需要纠正的偏差。
同样地,为什么 kubectl edit 改的东西会被改回去?如果你改的是 status 或某个由控制器拥有的派生字段,控制器下一轮 reconcile 会发现它与 spec 推导出的期望不符,于是把它覆盖回去——你改的是“实际”,而循环只认 spec 这个“期望”。为什么改了配置却不滚动更新?因为很多配置(如挂载的 ConfigMap 内容)不在触发 reconcile 的 spec 字段里——spec 没变,循环就认为“实际已达期望”,不会重建 Pod,配置自然不生效。每一个“反直觉”,都在循环可见之后变成“当然如此”。
§本章 self-check
先合上教程,把答案写下来,再展开对照。
- 用一句话说出 reconcile 循环的三步,并指出哪一步会“改变世界”、它是通过谁去改的。
- 为什么 level-triggered 的控制器能容忍丢失的 watch 事件?(跨机制题:把 2.2 的原则和 2.3 的循环连起来答。)
- 控制平面里哪个组件直接读写 etcd?scheduler 在一条 apply 路径里到底做了什么、没做什么?
- (设计题)某团队把“能否连上 Redis”写进了 liveness 探针。Redis 抖动了一下,结果整个服务的所有副本同时反复重启、迟迟不恢复。解释成因,并说出正确的探针选择。
答案(先做完再展开)
- observe(经 informer 本地缓存读实际)→ diff(spec 与 status 比对)→ act(改变世界)。只有 act 改变世界,且它不直接动 etcd,而是把意图写给 api-server,由 api-server 落库。
- 因为 level-triggered 控制器从不依赖“事件”,它每轮重读完整的实际状态并与期望比对、差多少补多少。丢掉的事件只是少了一次“该醒醒了”的提醒,下一轮重读全量时仍会发现实际与期望的差距并纠正——正确性绑在“绝对值比对”上,不绑在“事件被处理”上。
- 只有 api-server 直接读写 etcd,其余组件都只 watch api-server。scheduler 只为没有
nodeName的 Pod 选一个 Node 并把nodeName写回(binding);它不拉镜像、不启动容器——起容器是目标节点 kubelet 的事。 - liveness 失败会触发 kubelet 重启容器。把外部依赖塞进 liveness,等于让一次依赖抖动同时判“死”所有副本、同时重启整队;重启期间依赖仍未恢复,于是反复重启形成雪崩,而容器自身并没坏。正确做法是放进 readiness:依赖不可用时把 Pod 从 Service 端点摘掉、停收流量即可,不重启,依赖恢复后自动加回,故障被隔离而非放大。
不看教程,在纸上把图 2.4 的时序默画出来:五条生命线、五步箭头。画完翻回去对照——你把“Pod 变成 Pending”画在了 scheduler 写 nodeName 之前还是之后?这一前一后的顺序,正是“创建”与“调度”是两件事的核心。
两次 apply 打架,最终几个副本?
你先用 kubectl edit 把某 Deployment 的 replicas 改成 5;几分钟后,同事用一份旧的 YAML(里面写着 replicas: 3)对同一个 Deployment 重新 kubectl apply。最终集群里跑着几个副本?为什么?
提示(卡住再展开)
关键问:reconcile 循环认的是“最近一次写进 spec 的值”,而不是“谁先谁后的意图”。同事的 apply 是后发生的写操作,它把 spec.replicas 覆盖成了 3(apply 的三方合并会用最新提交的字段值)。于是 ReplicaSet 控制器下一轮 reconcile observe 到“实际 = 5、期望(spec)= 3”,diff 出“多了 2 个”,act:删掉 2 个,收敛到 3。你的“5”没有任何持久痕迹能让循环记住——spec 是唯一事实,循环永远收敛到最近一次写入 spec 的那个值。这也解释了为什么“谁最后 apply,谁说了算”是 K8s 协作里一条要命的隐性规则。