AI时代的Kubernetes教程36招-第4招-声明式API、Controller和Reconciliation为什么是Kubernetes的灵魂?
Kubernetes 的核心不是 YAML:一次删 Pod 实验讲透声明式 API 与控制循环
删掉的 Pod 为什么会自己回来?答案不在 YAML 里。
[读者] 会写 Deployment、会 kubectl apply 的工程师
[痛点] 只会写 YAML,说不清提交之后是谁在保证它一直成立
[现在读] 自愈、回滚、扩缩容一旦不如预期,靠 YAML 语法找不到答案
[读完] 能用人话讲清声明式 API 与控制循环,并亲手复现删 Pod 自愈、定位多写者冲突
[旧方案] 把 YAML 当启动脚本,提交完靠人工巡检和脚本补 Pod
|
v
[新需求] 节点维护、误删、驱逐随时发生,副本数要持续收敛且可解释
|
v
[冲突] 人工脚本与内置控制器同时写同一份期望状态,谁都不是权威写者
|
v
[后果] 副本数漂移、审计断链、故障时没人说得清是哪个组件改的
我是老李,做云原生平台支撑,被问得最多的一句话是「这个 YAML 谁写的」。平台上几十个业务团队提交 Deployment,出问题的第一反应往往是改 YAML、加个参数、再 apply 一次,很少有人问:这份 YAML 提交上去之后,究竟是谁在保证它一直是这样。
一次节点维护把这个问题摆到了台面上。有业务把 Kubernetes 当「更好用的 docker run」用:YAML 负责启动,另写一个每分钟巡检的脚本,发现副本少了就 scale 一次。两者同时生效的那天,副本数在 3 和 5 之间来回跳,值班同学盯着 kubectl get 刷了半小时,没人说得清是哪个组件在改。
后来我们把它当成系统问题重新拆:期望状态存在哪、谁在 Watch、谁在 Reconcile、谁有资格写。拆完就发现,YAML 只是这个系统最表面的一层皮。
如果 YAML 只是「说了一遍目标」,那目标被破坏之后,是谁把它拉回来的?
一个被删掉的 Pod 为什么能被补回来,补它的到底是哪个组件,补的过程中有没有可能重复创建?
当人、脚本和控制器都想改同一份期望状态时,系统凭什么决定听谁的?
01、故事
一次例行节点维护,运维同学在节点上执行 drain,某个业务的 Pod 卡在驱逐上不动,他抬手就是一句删 Pod 的命令。三秒后,Pod 出现在另一个更空闲的节点上。
现场一片叫好:不用管,删了会自己回来。于是这条经验被写进值班手册——副本不够就删 Pod。手册里没写谁在补,也没写补的上限在哪。
真正出事的是一个配置写错的版本。Pod 起来就 CrashLoopBackOff,巡检脚本按分钟发现「可用副本为 0」,于是又删一次 Pod;控制器发现实际数量不足,于是再建两个。一个想删,一个想建,Events 里滚了上百条成功记录,值班同学却越看越糊涂。
争执的双方其实在改同一份东西:Deployment 的期望副本数。脚本用 kubectl scale 改它,控制器读 spec.replicas 用它。我们当时的任务是:让三个副本的服务在节点维护、误删和驱逐之后仍然保持可用,并且任何一次状态变化都能被解释。
于是问题回到最基础的一层:Kubernetes 到底在执行命令,还是在维持状态?
声明非命令
状态自收敛
一次删与补
看清控制环
02、问题
旧方案是「人 + 脚本 + YAML 启动脚本」。人的反应速度以分钟计,脚本的判断依据只有副本数量,YAML 被当成一次性输入。三者谁都不知道谁在改,于是收敛变成了互相覆盖。
业务影响很直接:故障恢复时间不确定,值班靠感觉;每次发布之后都要有人守着 kubectl get 刷新,确认「数字对上了」才敢下班。
技术表现可以在集群里直接看到:Pod 反复重建但服务始终不可用;Events 里 Normal 事件刷屏却没人读;Deployment 的 managedFields 里出现多个 manager;同一份期望状态被多个写者轮流覆盖。
所以我们给这次改造定了三条可验证的完成标准:第一,删除任意一个 Pod 之后,能够看到数量回到期望值,并且新 Pod 的 ownerReferences 指向同一个 ReplicaSet;第二,能够指出是哪个组件在执行这次收敛,而不是靠猜;第三,能够明确说出这套系统里谁有权写 spec.replicas。
这三条都不涉及 YAML 语法,全部涉及「谁在维持状态」。
人工巡检慢
脚本会打架
收敛要有据
判据要可验
03、原理
先把三件事分开:期望状态存在哪、谁在推动收敛、谁来真正干活。
第一件是声明式 API。用户把对象的目标状态写成资源对象,通过 kube-apiserver 完成认证、鉴权、准入和校验,最终持久化到 etcd。API Server 是所有组件唯一的通信入口,etcd 里的对象是唯一事实来源。你写的不是步骤,是一个目标。
第二件是控制循环。控制器通过 list-watch 从 API Server 拿到对象及其变化,在本地缓存中计算当前状态,与期望状态比较,再把差异写回 API Server。官方文档把它类比成空调恒温器:不是发出一次降温命令,而是不断测量、不断调整。内置控制器大多跑在 kube-controller-manager 里。
第三件是分层执行。Deployment Controller 负责创建和更新 ReplicaSet;ReplicaSet Controller 负责让 Pod 数量符合期望;Scheduler 只处理尚未绑定节点的 Pod,为它挑选节点;kubelet 在节点上把绑定过来的 Pod 落到容器运行时,通过 CRI 调用运行时并持续上报状态。每一层只对自己那一段负责。
反直觉判断:控制器不是事件处理器。watch 事件只是触发器,真正的逻辑是 Reconcile——每次都重新读取状态、重新算一遍差值。所以事件丢一次、重一次,最终结果都一样。写控制器的人如果把业务逻辑挂在事件上,一定会在重试时踩坑。
反直觉判断:Kubernetes 不承诺不出错,它承诺的是偏差能够被重新对齐。删 Pod 之后你看到的短暂多一个 Pod、旧的 Terminating 与新的 Pending 并存,就是最终一致性的现场证据,不是故障。
两个让控制循环安全的前提必须记住:幂等——同一个 Reconcile 执行多次结果相同;乐观并发——通过 resourceVersion 或 Server-Side Apply 的字段管理避免覆盖自己没看过的版本。ReplicaSet Controller 还会用 expectations 记账,避免本地缓存尚未更新时重复创建 Pod,这一点在后面的排查里会成为一个真实的坑。
不求不出错
只求能对齐
事件是触发
全量才是底
04、架构
[输入] kubectl apply 的 Deployment / 运行中发生的一切变化(删除、驱逐、节点故障)
|
v
[模块] kube-apiserver(鉴权/准入/校验/持久化) + kube-controller-manager(Deployment / ReplicaSet 等控制器)
|
v
[数据/状态] etcd 中的资源对象、resourceVersion、ownerReferences 构成的对象树
|
v
[处理] 控制器 list-watch 建立本地缓存 → 工作队列 → Reconcile(期望与观测求差,做出最小变更)
|
v
[输出] ReplicaSet / Pod 对象 → Scheduler 为未绑定 Pod 选节点 → kubelet 调 CRI 起容器并上报状态
先划边界。API Server 只负责校验与存储,不做仲裁,谁写进来它就存谁;控制器不做业务逻辑,只做状态对齐;Scheduler 不做副本决策,它只管单个 Pod 去哪;kubelet 不关心 Deployment 是否存在,它只关心落在自己节点上的 Pod。
收益是显而易见的:交付物从「一串命令」变成「一个目标」,节点故障、Pod 被驱逐、进程崩溃都能被自动拉回,状态本身成为可观测对象。
代价同样明显:控制权从人手里被交出去;时序不再确定,你提交完不代表已经完成;调试方式从翻应用日志变成看对象、看 Events、看 controller 日志;只要出现第二个写者,收敛立刻变成拉扯。
适用条件只有三条:状态可被观测、操作可被重复执行、允许一定的收敛延迟。三条缺一条,控制循环就会退化成「看起来在自愈,实际上在打架」。
输入一处存
分发靠监听
绑定与落盘
各守一段界
05、实战一次
先跑一个最小但完整的 V1:一个 Deployment、三个副本,然后手动删掉一个 Pod,观察收敛。
环境要求:一个可用集群(kind、minikube 或托管 Kubernetes 均可),kubectl 已指向该集群。未在当前环境实测,以下为预期结果。
先准备清单文件。
apiVersion: apps/v1
kind: Deployment
metadata:
name: reconcile-demo
namespace: reconcile-lab
labels:
app: reconcile-demo
spec:
replicas: 3
selector:
matchLabels:
app: reconcile-demo
template:
metadata:
labels:
app: reconcile-demo
spec:
containers:
- name: web
image: nginx
ports:
- containerPort: 80
创建命名空间并提交。
kubectl create namespace reconcile-lab
kubectl -n reconcile-lab apply -f deploy.yaml
kubectl -n reconcile-lab rollout status deployment/reconcile-demo --timeout=120s
kubectl -n reconcile-lab get pods -o custom-columns=NAME:.metadata.name,UID:.metadata.uid,OWNER_KIND:.metadata.ownerReferences[0].kind,OWNER:.metadata.ownerReferences[0].name
示例输出:
deployment reconcile-demo successfully rolled out
NAME UID OWNER_KIND OWNER
reconcile-demo-6c9f7b8d4f-2kq9x 5f1c9a ReplicaSet reconcile-demo-6c9f7b8d4f
reconcile-demo-6c9f7b8d4f-7tb4m 9b3e2d ReplicaSet reconcile-demo-6c9f7b8d4f
reconcile-demo-6c9f7b8d4f-p8wjc 1a77c0 ReplicaSet reconcile-demo-6c9f7b8d4f
现在删掉其中一个,然后立刻观察。
kubectl -n reconcile-lab delete pod reconcile-demo-6c9f7b8d4f-2kq9x
kubectl -n reconcile-lab get pods -o custom-columns=NAME:.metadata.name,UID:.metadata.uid,PHASE:.status.phase
示例输出:
NAME UID PHASE
reconcile-demo-6c9f7b8d4f-7tb4m 9b3e2d Running
reconcile-demo-6c9f7b8d4f-p8wjc 1a77c0 Running
reconcile-demo-6c9f7b8d4f-vq6hn 6d0f13 Running
最后确认这次收敛是谁做的。
kubectl -n reconcile-lab describe deployment reconcile-demo
kubectl -n reconcile-lab get pods -o custom-columns=NAME:.metadata.name,START:.status.startTime
示例输出里 describe 的 Events 段会出现一条由 deployment-controller 记录的 ScalingReplicaSet,说明是 Deployment Controller 通过更新 ReplicaSet 触发了这次补齐。
V1 到这里就跑通了,而且留下了两个必须回答的问题:新旧 Pod 的 UID 不同,说明这不是「重启同一个 Pod」;如果把三层控制器拆开看,究竟是谁决定了「补几个」。
先跑最小版
再删看自愈
输出可复现
结论才有根
06、排查
诊断链一:补回来的 Pod 为什么不是原来那个?
现象:删除 Pod 后,数量回到了 3,但新 Pod 名字后缀变了,旧 Pod 短暂处于 Terminating,集群里一度出现 4 个 Pod 对象。
怀疑:控制器是不是在重复创建,或者删掉了不该删的东西。
检查:对比新旧 Pod 的 UID、creationTimestamp 和 ownerReferences,同时观察 Events 的先后顺序。
错误尝试: 用 kubectl describe 看容器 restartCount 有没有加一,想以此判断「容器是不是被重新拉起来了」。为什么错:Pod 是被删除后重新创建的新对象,UID 不同,restartCount 属于旧容器的历史,新 Pod 的 restartCount 从 0 开始;用 restartCount 判断删 Pod 后的行为,等于用错误的观测点去回答另一个问题。
证据:新旧 pod 的 UID 不同、旧对象仍处于 Terminating、新对象的 ownerReferences 指向同一个 ReplicaSet。
根因:ReplicaSet Controller 对齐的是「数量 + 标签选择器」,不是「某个具体对象必须复活」。它的工作方式是读取当前 Pod 集合,与期望数量求差,再创建或删除 Pod。为了在本地缓存尚未同步时不重复创建,控制器还用 expectations 记账,这解释了你看到的短暂中间态。
修复:把判断标准从「Pod 还在不在」改成「数量与 Ready 是否收敛」,用 kubectl rollout status 或 kubectl wait --for=condition=available 作为判据,而不是拿到一次 kubectl get 的快照就下结论。
诊断链二:手动 scale 之后,replicas 过一会儿自己变回去了。
现象:值班同学执行 kubectl scale --replicas=5,十几秒后 Deployment 又回到 3;反复操作,变化时间点不固定。
怀疑:API Server 拒绝写入,或者资源版本冲突导致更新被丢弃。
检查:查看 spec.replicas、status、managedFields,以及集群里是否存在 HPA、Operator 或其他持续写入的控制器。
证据:managedFields 里出现多个 manager,各自都在写 /spec/replicas 这段路径;时间线上第二个写者总在上一个之后到来。
根因:声明式 API 不做仲裁。API Server 只保证并发写入不会互相覆盖未知版本,不保证「谁的期望更正确」。同一个字段有多个写者时,最终结果由最后写入者决定,表现为来回抖动。
修复:确定唯一权威写者。副本数要么交给 HPA,要么交给发布流水线,要么交给运维脚本,三者只能存在一个;其他人只读,需要表达诉求就用 minReplicas 之类的输入项,而不是直接改同一个字段。
诊断链三:数量对了,服务还是不通。
现象:Pod 数量与期望一致,但接口大量超时。
怀疑:ReplicaSet Controller 没有正常工作。
检查:直接看 Pod 的 status.phase 与 conditions,以及 Events 里的调度失败信息。
证据:部分 Pod 处于 Pending,Events 显示资源不足导致调度失败。
根因:ReplicaSet Controller 只保证「有这么多 Pod 对象」,不保证它们被调度、能拉起来、能通过探针。数量对齐发生在控制器的职责边界内,可用性发生在调度与运行时的职责边界内。
修复:补齐 requests/limits 与节点容量,给容器加 readinessProbe,让「可服务」成为一个可观测的、有明确判据的状态。
现象先分级
证据再定性
猜错也是路
根因要落地
07、优化
V2 严格建立在三条诊断链给出的根因上,只改三件事。
第一件,针对「数量不等于可用」。根因是只对齐数量,所以修改是给容器加 readinessProbe,并用 PodDisruptionBudget 表达「任何时刻至少要剩几个可服务副本」。原因是节点维护走的是 Eviction API,它会尊重 PDB,而直接 delete Pod 不会。新行为是驱逐时系统会拒绝打穿底线的操作,而不是先把服务打挂再说。验证方式是 kubectl get pdb 确认 minAvailable 生效,并在测试集群用 drain 观察拒绝行为,不要在线上验证。
第二件,针对「多写者覆盖」。根因是多个 manager 写同一字段,所以修改是把 replicas 的写权限收敛到一个控制器,其余发布流程只提交镜像与模板部分,并统一使用 Server-Side Apply 的字段管理。新行为是冲突时能直接从 managedFields 定位是谁写的,而不是靠猜。验证方式是 kubectl get deploy --show-managed-fields 观察字段归属是否唯一。
第三件,针对「判据用错」。根因是把一次 kubectl get 当成结论,所以修改是命令化收敛判据:发布与巡检脚本统一改用 rollout status 与 kubectl wait 条件等待,超时才告警,而不是在几秒内连刷快照。新行为是脚本判断的是收敛结果,不再和控制器抢时间窗口。验证方式是人为删 Pod 后,让脚本安静等待并成功收敛。
这三处改动都不涉及新组件,只是把「谁负责什么」写清楚。优化不是把系统改复杂,而是把职责边界改成可验证的。
改在根因处
验在新行为
数据不虚构
边界要说清
08、演进
[同一输入] 一个 Pod 被删除(误操作 / 驱逐 / 节点故障)
|
+--[V1] 靠人工巡检与脚本补 Pod:恢复慢、副本数漂移、多写者互相覆盖、无法解释是谁改的
|
+--[V2] 靠声明式 API 与控制循环收敛:秒级自愈、状态可观测、写者唯一、判据可验证
|
[Trade-off]
得到:系统级自愈能力与可解释的状态变化 / 失去:对每次变更时机的直接控制权
适用边界:状态可观测、操作幂等、写者唯一,且业务允许最终一致的收敛延迟
正确性上,V1 的补 Pod 依赖巡检周期和脚本的判断逻辑,脚本本身的 bug 会直接变成生产事故;V2 把判断交给控制器,逻辑只有一个,出错的入口减少。
稳定性上,V1 在故障时又多了一个写者,容易把局部故障放大成状态抖动;V2 在同样故障下的行为是可预测的:观测值与期望值求差,然后最小变更。
复杂度上,V1 看起来简单——一个脚本几百行,但复杂度藏在「谁都没定义职责」里;V2 引入了需要理解的控制循环与字段所有权,学习成本前置。
成本上,V2 不需要额外组件,只需要把已有能力用对;真正的成本是团队要接受「不再手动改副本数」这种纪律约束。
适用范围上,这套判断适用于任何长生命周期、状态可观测的资源;如果业务本身状态不可观测,或者操作天然不幂等,硬套控制循环只会制造更大的混乱。
遗留问题也很明确:我们只解决了内置控制器覆盖的部分,业务自己的状态仍然靠人守,这正是后面要引入自定义控制器与 Operator 的原因;同时,控制循环对下游依赖(调度容量、镜像仓库、存储)没有任何保证,那些是它边界之外的事。
旧路靠人守
新路靠收敛
得到自愈力
失去强控制
09、洞见
9.1 声明式 API 交付的是目标,不是步骤
YAML 是输入,不是执行计划。真正决定行为的是控制器如何比较期望与观测,以及在哪些条件下允许变更。所以排障的第一步永远是「看对象状态」,而不是「读 YAML 找语法」。反直觉判断:写得越像脚本的 YAML,越容易在收敛过程中打架。
9.2 控制循环的可靠性来自幂等与全量对齐
事件只是触发器,Reconcile 才是逻辑。一个 Reconcile 被重复执行多次结果必须相同,否则在重试和延迟的场景下必然出现重复资源。反直觉判断:控制器写得越像事件回调,越容易在重试时出问题;写得越像一个纯函数,越容易稳定。
9.3 一个期望状态只能有一个权威写者
这是本次改造中最贵的一课。API Server 不做仲裁,多个写者只会互相覆盖,而覆盖发生时你在 Events 里看到的全是成功。判断一套系统是否健康,先看 managedFields 里 /spec 的关键字段有没有多个 manager。
9.4 控制器只保证它能观测到的东西
ReplicaSet 保证 Pod 数量,不保证 Pod 可用;kubelet 保证容器起来,不保证业务健康;任何「保证」都只在观测范围内成立。反直觉判断:把健康交给数量指标,比没有监控更危险,因为数字会给你一种已经收敛的错觉。
声明即责任
写者须唯一
观测不可少
幂等是底线
10、系统落地
原来有的是一套「人能跑起来」的交付方式:一份 YAML 加一段脚本,出问题靠值班同学的经验和手速,集群里没有任何一方对期望状态负责。
本篇新增的是三样东西。第一是认知:YAML 是期望状态的载体,收敛由控制器完成。第二是职责边界:Deployment / ReplicaSet / Scheduler / kubelet 各管一段,谁都不越界。第三是纪律:副本数只有一个权威写者,可用性用探针与 PDB 表达,收敛用条件等待判断。
现在这套系统能做的事情很具体:任意 Pod 被删除、被驱逐或在节点故障中消失之后,副本数量会被自动拉回;任何一次状态变化都能通过 ownerReferences、Events 和 managedFields 追溯到是谁改的;发布流程不再和控制器抢着写同一个字段。
还缺的东西同样清楚:业务自定义的状态仍然没有人负责收敛,比如「分片是否均衡」「证书是否临期」「索引是否落后」。内置控制器不认识这些概念,巡检脚本认识,但脚本不是控制循环。
下一步演进方向是把这些状态也变成声明式对象:先用 CRD 定义期望状态,再写一个只做全量对齐、幂等、可重入的控制器,把「人守着看」改成「系统自己拉回」。在此之前,先确保现有职责边界不会被新脚本重新打破。
旧有脚本堆
新增控制环
能自愈能审计
缺口在下游
11、小结
Q1 → 谁在保证 YAML 声明的状态一直成立?
答:控制器在控制循环中持续对比期望与观测,并把差异写回 API Server
Q2 → 删掉的 Pod 为什么会被补回来?
答:ReplicaSet Controller 发现数量低于期望后创建新 Pod,是新对象而不是原对象复活
Q3 → 多个写者改同一份期望状态会怎样?
答:互相覆盖,最终由最后写入者决定,必须收敛到唯一权威写者
状态 → 可在集群复现删 Pod 自愈,并能解释 Deploy→ReplicaSet→Pod→Scheduler→kubelet 的分工
三问有答案
一事有闭环
能力可交付
边界仍要记
12、作业
12.1 理解题:为什么「删掉 Pod 后它又回来了」不能证明控制器在重启原来的 Pod?
参考答案:控制器的动作是数量对齐,不是对象复活。被删除的 Pod 是不可变对象,控制器会创建一个拥有新 UID、新名字后缀的新 Pod,只有 ownerReferences 和标签选择器保持稳定。判断依据应该看 UID、creationTimestamp 与 ownerReferences,而不是看容器重启计数。
12.2 实战题:用一条命令列出某个 Deployment 的 Pod、UID 和它们的属主,并在删除一个 Pod 后确认收敛。
参考答案:kubectl -n
12.3 排障题:执行 kubectl scale 之后 replicas 自动变回去,如何定位并修复?
参考答案:先确认是否存在第二个写者,检查 managedFields 找出写 /spec/replicas 的 manager,检查是否部署了 HPA、Operator 或持续 apply 的流水线。根因是同一字段多写者互相覆盖,修复方式是确定唯一权威写者,其他角色改为只读或通过受控输入项表达诉求,并统一使用 Server-Side Apply 管理字段归属。
12.4 架构判断题:发布流水线每次 apply 一份包含 replicas 的完整 Deployment 清单,是否合理?
参考答案:不合理。如果把 replicas 一起纳入发布清单,就等于发布系统与 HPA 或运维成为同一字段的多个写者,任何一个自动扩缩容的结果都会被下一次发布覆盖。合理做法是让发布只负责镜像与模板等字段,副本数的权威写者单独指定,并用字段管理明确区分职责。
理解靠复述
实战靠复现
排障靠证据
判断靠取舍
13、思考
回到最初那个冲突:我们到底是把 YAML 当成一段命令,还是当成一个目标。当成命令,就会忍不住在它外面再包一层脚本、再加一个写者,系统里于是出现两个都想「保证正确」的角色;当成目标,就必须接受一件事——保证正确不是某一个组件的职责,而是控制循环持续对比、持续对齐的过程。
这套判断可以迁移到任何有期望状态的地方:只要一个系统需要长期维持某种形态,就必须先说清楚期望状态存在哪、谁观测它、谁有权修改它、偏差多大算收敛。这四件事答不上来,再多的 YAML 也只是看起来在自动化。
一念辨声明
一验见收敛
一改知取舍
一役明边界

浙公网安备 33010602011771号