第 3 章 声明式 API 与控制循环:Kubernetes 的灵魂
墨:老哥,前面两章我们都在”外围”转。这一章进 K8s 的心脏。我先扔个暴论——K8s 里没有任何一个组件知道”全局该干什么”,但整个集群却总能自己走向正确状态。 你先品这个矛盾:没有总指挥,怎么不乱?这章解的就是这个。
3.1 一个反直觉的事实:K8s 是”哑”的
你直觉里可能以为 K8s 像个”中央指挥官”:你下令”起 3 个 nginx”,它脑子里一盘算,吩咐各节点去干活。错了。
K8s 的核心设计是:没有任何一个组件掌握全局意图。 API Server 不知道”为什么”要 3 个副本,它只知道”状态里写着 3”;调度器不知道”这 Pod 干嘛用的”,它只管”找个能放的节点”;kubelet 不知道”集群打算怎样”,它只管”我这台机器上该有的 Pod 跑起来没”。
那”3 个副本”这个目标,是谁在追?是一堆各自看状态的”小工人”,每个都只做自己那一小块,但合起来,系统就收敛到你想要的样子。
这套机制,叫声明式 API + 控制循环(control loop / reconcile loop)。它是 K8s 的脊椎。你吃透这章,后面 15 章都是它的注脚。
3.2 命令式 vs 声明式:先把这两个词钉死
墨:你写 Go,写过 if err != nil { return err } 吧?那是命令式还是声明式?
你写的业务流程代码,绝大多数是命令式的:告诉计算机”先做 A,再做 B,如果 C 就做 D”。一步步的指令。Ansible playbook、shell 脚本、SQL 里的存储过程,都是命令式。
**声明式(declarative)**相反:你只说”我要什么样”,不说”怎么到那样”。SQL 本身就是个好例子——SELECT name FROM user WHERE age > 18,你声明了”我要什么数据”,至于数据库怎么扫描索引、怎么 join,你不管。结果对了就行。
K8s 的 API 是声明式的:你 kubectl apply 一个 YAML,说的是”我要 3 个 nginx 副本、镜像这个、端口那个”。至于怎么从”现在 0 个”变成”3 个”,你一个字都没写。
3.2.1 为什么 K8s 非得是声明式?
墨:你先自己推一步——如果 K8s 是命令式(你下”起一个 Pod”的指令),会出什么你受不了的问题?
命令式系统最大的死穴:它假设”指令发出时,世界是静止的、指令能一次成功”。 但分布式系统的真相是:
- 你下”起 Pod”的指令,节点正好宕机了,指令石沉大海;
- 你下”扩容到 3 个”,执行到一半网络断了,只起了 1 个;
- 你下”删除旧版本”,结果删到一半 controller 自己重启了,它根本不记得”刚才执行到哪了”。
命令式在”会出意外”的系统里,无法自我恢复——因为它没有”目标”的概念,只有”动作”的记录。动作做一半丢了,系统就停在一个半成品状态,没人管。
声明式怎么破这个局?因为它把”目标”单独存下来了。系统随时都能问:“现状离目标差多少?差就补。“就算中途崩了一百次,每次重启它都从”目标 vs 现状”重新算,自然就接着干。 目标在,路就能重走。
第一性原理:在会故障的系统里,“目标”比”动作”更值得持久化。因为动作会中断,目标不会过期。
3.3 期望状态与实际状态:K8s 世界里唯一重要的两件事
K8s 里所有组件,脑子里只转一个公式:
desired(期望) vs actual(实际)
if desired != actual:
do something to move actual toward desired
- 期望状态(desired state):你写进 API Server / etcd 的东西。比如”Deployment 要 3 个副本”。
- 实际状态(actual state):系统此刻真实的样子。比如”节点 A 上跑了 2 个,节点 B 上跑了 0 个”。
所有控制器(controller)干的事,就是缩小这两者之间的 gap。
注意:期望状态不是”过去下的命令”,而是”当前写在存储里的真相”。你改一下 YAML,期望状态就变了,控制器下一轮循环就发现 gap 变了,开始往新目标动。这就是 kubectl apply 改个数字就能触发整个系统重新编排的原因——你改的是”目标”,不是”动作”。
3.3.1 生活化类比:GPS 导航
墨:你开车用导航吗?想想导航是怎么工作的,它和 K8s 控制循环像不像?
GPS 导航是声明式系统的完美生活类比:
- 你设目的地(期望状态);
- 导航持续用 GPS 看你在哪(观测实际状态);
- 你走偏了、走错了出口,它不骂你,直接重新算一条到目的地的路(调和 / reconcile);
- 它从不在乎”你之前走没走对”,只在乎”现在怎么到目的地”。
命令式导航会是什么样?“前方 500 米右转,然后直行 2 公里,然后……”——你一旦错过一个路口,后面全废,因为它没有”持续重算”的能力,只有”一次性指令序列”。
K8s 就是集群的 GPS。 你设终点,它持续把集群导航到终点,管你中途节点宕机还是 Pod 被 OOM。
3.3.2 另一个类比:恒温器
设 26 度,压缩机反复启停维持。你不需要告诉它”现在 28 度所以启动一下”——那是它自己观测+决策的。这跟 3.2.2 里思考题 2 一个意思,但我要你注意恒温器和 GPS 的共同点:它们都不记忆”动作历史”,只比较”目标 vs 现在”。 K8s 控制器也是无状态的(stateless w.r.t. 历史):它不依赖”上一轮干了啥”,每轮只看现状。这就是它能安全地随时重启的原因。
3.4 控制循环(Reconcile Loop)长什么样?
抛开 K8s 术语,一个控制器本质就是下面这个循环(用 Go 伪代码,你肯定秒懂):
func runController() {
for {
// 1. 拿到期望状态
desired := getDesiredState() // 从 API Server 读
// 2. 观测实际状态
actual := observeActualState() // 问 kubelet / 节点 / 自己缓存
// 3. 比较,算 gap
diff := desired.Sub(actual)
// 4. 干活缩小 gap(幂等地)
if diff != nil {
actToConverge(diff) // 创建缺失的 / 删多余的 / 改错的
}
// 5. 睡一会儿,再来一轮
time.Sleep(syncPeriod)
}
}墨:你看这个循环,第一处会卡你的地方在哪?
多数人第一反应是”步骤 3 怎么比”——实际状态从哪来?这引出一个核心设计:控制器的”眼睛”必须是可靠且低延迟的。 如果它靠 kubectl get 轮询,太慢太重;所以它靠 watch(监听):API Server 一有变化就推给它(第 4 章讲 watch 机制)。这样它几乎实时知道实际状态变了,不用傻轮询。
第二处卡点(更隐蔽):步骤 4 的 actToConverge 必须是幂等的。 为什么?因为循环会跑无数次,如果”补一个副本”不是幂等的(比如每次都新建一个,不管已有多少个),那就会建出 100 个。所以动作得是”确保有 3 个”这种收敛型表达,而不是”再加 1 个”这种增量型表达。
墨:你品品”收敛型”和”增量型”这个区别——它是不是正好对应”声明式”和”命令式”?
对。声明式天然导向收敛型动作(描述终态),命令式天然导向增量型动作(描述步骤)。这就是为什么声明式和控制循环是绑定的——控制循环要求动作可重复安全,只有”描述终态”的动作才满足。
3.5 level-triggered(电平触发)vs edge-triggered(边沿触发)
这是 K8s 控制循环里最容易被忽略、却最见功力的设计选择。老哥你写前端或硬件可能听过这俩词,我翻译到 K8s 语境。
- edge-triggered(边沿触发):只在”状态发生变化那一刻”通知你一次。比如按钮”按下”的瞬间触发一次。如果你错过了这个瞬间(当时你正好在处理别的事/崩了),这个事件就永远丢了。
- level-triggered(电平触发):只要”状态处于某电平”,就持续有效。比如”门是开着的”这个事实,只要你去看,它就在那。你不需要捕捉”开门那一瞬”,你只需要看”门现在开着吗”。
墨:如果控制器靠”事件通知”工作,事件丢了会怎样?K8s 选了哪种?
K8s 选了 level-triggered。控制器不依赖”某个变化事件到达过”,它依赖”当前实际状态是什么”。每一轮循环它都重新观测实际状态,跟期望比。所以:
- 哪怕某次 watch 事件因为网络抖动丢了,下一轮循环它照样从实际状态发现 gap,照样补;
- 哪怕控制器自己重启了,重启后第一轮就重新看现状,一切照旧收敛;
- 你永远不需要”重放事件日志”来恢复控制器的记忆。
这就是 K8s 容错能力的根源之一。它不信任”消息”,它信任”状态”。 消息可能丢,状态一直在那(存在 etcd 里,第 5 章)。
设计启示:在不可靠的系统里,用”持续观测状态”代替”依赖事件通知”,是让系统自愈的最便宜的办法。 边沿触发适合”低延迟、可丢”的场景(比如实时点击),level 触发适合”必须最终正确”的场景(比如集群状态)。K8s 要的是最终正确。
演化注脚: 早期有些系统(包括 K8s 一些周边的旧设计)尝试 edge-triggered + 事件队列,结果事件积压/丢失导致状态永远追不上。K8s 核心坚持 level-triggered,是踩过无数分布式系统的坑后的共识。后来社区的 “Event-driven” 思路(如 Argo、Knative)是在 K8s 之上加的,不是替代它的核心循环。
3.6 没有总指挥,怎么不冲突?
回到开头的暴论。现在你能猜到了:因为每个控制器只看”自己负责的那类资源”的期望 vs 实际,而且动作是收敛型、幂等的。
举例:
- ReplicaSet 控制器只关心”ReplicaSet 的副本数对不对”,它只会创建/删除 Pod;
- 调度器只关心”还没绑定节点的 Pod”,它只给 Pod 打一个
nodeName; - kubelet 只关心”本节点上该有的 Pod 跑没跑”;
- Service 控制器只关心”Service 的后端 Endpoint 对不对”。
它们就像流水线上的不同工种,各看各的工位,通过”共享一块状态板(etcd)“协作,而不是直接互相喊话。 这就避免了”总指挥”的单点,也避免了组件间复杂的直接依赖。
墨:那如果两个控制器想改同一个东西怎么办?比如两个都要改 Pod 的某字段?
好问题,这逼出了**资源版本(resourceVersion)**机制(第 4 章详讲):每个对象有版本号,修改时带版本号,版本对不上就冲突重试。相当于乐观锁。所以即使多个组件都往 etcd 写,也不会写串——谁最后基于最新版本写,谁赢,输的那个下一轮重来。又因为动作是收敛型,重试是安全的。
这又是一个”声明式 + 幂等 + 版本”三位一体的设计。你单独看哪个都不稀奇,合起来才让”无总指挥的自组织”成立。
3.7 控制器模式:K8s 把”调和”做成了一个可复制的模板
K8s 最聪明的一招:它把”控制循环”本身做成了一种模式(controller pattern),然后几乎所有功能都是这个模式的一个实例。
你几乎可以认为 K8s = “一堆控制器 + 一块共享状态(etcd)+ 一个状态入口(API Server)“。Deployment 是控制器,ReplicaSet 是控制器,Node 生命周期是控制器,Endpoint 是控制器,HPA 是控制器,甚至垃圾回收(GC)都是控制器。
墨:你觉得这种设计最大的好处是什么?
(1) 一致性:所有功能用同一套机制,新人看懂一个就看懂一片;(2) 可组合:控制器之间只通过状态耦合,可以像乐高一样叠——Deployment 管”副本数”,它创建 ReplicaSet,ReplicaSet 管”Pod 数”,Pod 被调度器绑节点,kubelet 拉起。一条链,每个环节都是独立控制器;(3) 可扩展:你也能写自己的控制器(Operator,第 14 章),跟内置的平起平坐,因为底层机制对你完全开放。
演化看点: 最早 K8s 只有几个写死的控制器(ReplicationController 等)。后来社区意识到”控制器模式”该开放给用户,于是有了 CRD(自定义资源)+ client-go 的 controller-runtime。这是 K8s 从”一个产品”变成”一个平台”的转折点——它把自家最核心的能力,交给了用户去扩展。第 14 章我们展开。
3.7.1 ReplicationController → ReplicaSet 的微型演化
顺手讲个真演化:K8s 最早的副本控制器叫 ReplicationController(RC)。后来被 ReplicaSet(RS) 取代。区别在哪?RS 支持基于集合的标签选择器(set-based selector),比如”标签 env in (prod, staging)“,而 RC 只支持”env=prod”这种等式。
墨:这算大改吗?为啥要改?
不算大改,但很能体现”设计跟着需求走”:RC 的等式选择器不够表达”我要管一批有共同特征但不止一个值的 Pod”。RS 用集合选择器解决了,而且 RS 是 Deployment 的底层(Deployment 管 RS,RS 管 Pod),把”滚动更新”和”副本维持”拆成两个关注点。这又是一个关注点分离的例子:Deployment 专注”版本演进”,RS 专注”副本数”,各司其职,通过状态串联。
3.8 声明式不是银弹:它的代价
老哥你记住,墨叔讲东西从不只讲好处。声明式 + 控制循环有代价:
- 延迟(latency):从你改期望,到系统真正收敛,要经历”写 etcd → watch 推送 → 控制器循环 → 实际动作”一串,不是即时的。对”我要立刻生效”的场景不舒服(虽然通常秒级)。
- 调试难:因为没人”下令”,出问题时你找不到”谁干的”,只能看”状态怎么一步步变的”。排障要靠事件(event)和时间线(第 17 章)。
- 最终一致,不保证立即一致:你可能
apply后马上get,发现还没变——因为它还在调和路上。这是 level-triggered 的必然。 - 收敛可能卡住:如果期望状态本身不可达(比如要 100 个副本但集群只有 10 台机器),控制器会永远尝试、永远失败,表现为”一直 Pending”。你得能读懂这种”卡住的信号”。
墨:所以声明式适合什么、不适合什么?
适合:目标稳定、过程允许异步、系统会出故障、需要自愈的场景——正好是”运行一堆服务”的全部特征。不适合:强实时、强顺序、必须立即生效的场景(那种更适合命令式 + 事务)。K8s 选声明式,是因为它的主战场就是前者。
3.9 一个贯穿全书的视角:把任何 K8s 对象都翻译成”期望状态”
以后你看到任何 K8s 概念,先问一句:它的”期望状态”字段是哪几个?谁在调和它?
- Deployment:期望
replicas、template→ Deployment 控制器调和 → 改 ReplicaSet; - Service:期望
selector、ports→ 控制器维护 Endpoint/EndpointSlice; - PVC:期望”我要一块 X 大小的盘” → 被 PV 控制器/CSI 调和成真实卷;
- HPA:期望”CPU 利用率 50%” → 调和 Deployment 的 replicas;
- 你自定义的 CR:期望写在你的 CR 里 → 你的 Operator 调和。
你一旦养成这个习惯,K8s 上千个概念就塌缩成”谁在调和谁的期望”一张网。 这就是设计者视角最爽的地方。
3.10 本章演化线小结
- 核心机制从一开始(Borg 的 Omega 论文就讨论过)就是”level-triggered 的调和循环”,K8s 把这个做成脊椎;
- 控制器模式从”内置几个”演化到”对外开放(CRD/Operator)“,这是 K8s 平台化的关键一跃;
- ReplicationController → ReplicaSet,体现”选择器表达力”随需求增强;
- 声明式并非银弹,K8s 用”最终一致 + 可观测”来消化它的代价。
3.11 本章思考题
🔧 思考题 1
请你用 Go 写一个最小化的”副本控制器”伪代码(不用真的连 K8s),体现:读期望、观测实际、比较、收敛型动作、循环。然后指出哪一步最容易在分布式环境下出 bug,你会怎么防?
【参考答案】
for {
want := rs.Spec.Replicas // 期望
have := listPods(rs.Spec.Selector) // 实际(靠 watch 缓存)
if len(have) < want {
createPod(rs.Spec.Template) // 收敛型:确保达到 want,不是"再+1"
} else if len(have) > want {
deleteExtraPods(have, want)
}
time.Sleep(2 * time.Second)
}最易出 bug 的是 listPods 和 createPod 之间的竞态:你数完有 2 个,另一个控制器(或上轮自己)也数完有 2 个,俩都去建,变成 4 个。防护:(1) 用乐观锁(resourceVersion),创建带版本,冲突重试;(2) 让 createPod 本身幂等——基于唯一标识(如 ownerReferences + 序号)而非”数量”判断,建之前再查一次;(3) 用 informer 缓存保证观测接近实时,减少基于过期数据决策。核心就一句:分布式下”读-改-写”必须原子化或带版本。
🔧 思考题 2
level-triggered(电平触发)让系统能自愈,但有没有场景它不如 edge-triggered?举一个 K8s 之外的例子,说明为什么不能一概而论。
【参考答案】 有。比如实时音视频的”丢帧”处理或用户界面的”点击按钮”:你不需要系统反复确认”按钮是不是还被按着”,只在”按下那一瞬间”响应一次就够了;而且这类事件允许丢失(丢一帧画面无伤大雅)。level-triggered 在这里反而浪费——它要求持续观测状态、持续决策,带来不必要的开销和延迟。再比如金融交易的下单:一笔订单是”事件”,不是”状态”,你不该因为”系统重启后又看到这笔订单还存在”就重复下单——这里要的是精确的 edge 处理 + 幂等键,而非持续调和。结论:要”最终正确、可恢复”用 level;要”低延迟、一次性、可丢”用 edge。K8s 选 level 是因为它的目标是集群状态最终正确,而不是处理瞬时事件流。
🔧 思考题 3
书里说”K8s = 一堆控制器 + 一块共享状态 + 一个状态入口”。如果让你设计一个”迷你 K8s”,你最少需要几个控制器?分别管什么?(提示:至少要有副本、调度、节点上的实际运行)
【参考答案】 最小三件套:(1) 副本控制器(类似 ReplicaSet):保证 Pod 数量等于期望,多了删少了建;(2) 调度控制器(scheduler):给”未分配节点”的 Pod 选节点,写入 nodeName;(3) 节点代理(kubelet 角色,可视为节点上的控制器):保证本节点该跑的 Pod 真跑起来了、挂了重启、状态回报。这三者通过共享状态板协作:副本控制器建出”无 nodeName 的 Pod”→ 调度器看到就分配 → kubelet 看到本节点的 Pod 就拉起。再省也得有这三块,否则”数量""位置""运行”三个职责至少有一个没人管。真实 K8s 在这三块外还有 Service/Endpoint、Deployment、GC、HPA 等几十个控制器,但骨架就是这三。
🔧 思考题 4(进阶)
声明式系统里,“期望状态”写错了(比如 replicas: 1000 但集群只有 10 台机器)会有什么后果?系统能”拒绝”这种错误吗?该由哪一层来防?
【参考答案】 后果:1000 个 Pod 大部分 Pending 永远调度不上,API Server/etcd 里堆满对象,调度器每轮都在算不可达的目标,集群”看起来忙死但啥也没干”,还可能挤占正常调度。系统不会自动拒绝——因为”1000 副本”本身是合法声明,K8s 无法判断你是真要还是写错(就像你真可能要 1000 个短任务)。防线应分层:(1) 准入层(Admission):可写 webhook 校验”单 Deployment 副本上限”等策略(第 14 章);(2) 配额层(ResourceQuota):限制 namespace 总副本/资源,超了直接拒绝创建;(3) LimitRange:给默认值上限;(4) HPA 上限:限制自动扩的上界。所以”防呆”不是核心循环的职责(它只负责调和),而是**策略层(准入/配额)**的职责——这又体现了关注点分离:调和管”做”,策略管”能不能做”。
3.12 小结
- K8s 没有总指挥,靠”一堆只看状态的控制器”自组织;
- 声明式 = 持久化”目标”而非”动作”,让系统能从任意中断安全恢复;
- 控制循环 = 观测实际 → 比期望 → 收敛型动作 → 再来;动作必须幂等;
- level-triggered(看状态)而非 edge-triggered(听事件),是不依赖消息可靠性的自愈根基;
- 控制器模式被做成模板,且对外开放(CRD/Operator),是 K8s 平台化的关键;
- 声明式有代价(延迟、调试难、最终一致),但它的主战场正好是”要自愈的集群”。
下一章,我们走近那个”共享状态板”的入口——API Server。所有控制器读写的”唯一真理之门”到底长啥样,为什么它必须是”门”而不是”直接连 etcd”。