第 7 章 控制器模式全集:每种工作负载各自背的锅
墨:老哥,前面几章我们讲了”调和循环”这个万能模板。这章要戳破一个幻想:模板万能,但”期望状态”长啥样,因负载而异。 你写 Deployment 管无状态服务,写 StatefulSet 管数据库——这俩的期望状态天差地别。今天我们就看,K8s 怎么用不同的控制器,接住不同种类的麻烦。
7.1 为什么不能一个控制器通吃?
墨:你先想,如果 K8s 只有一个”Pod 管理器”,所有负载都往里塞,会卡在哪?
你脑补一下:数据库(MySQL 主从)和 nginx 服务,对”正确运行”的要求一样吗?
- nginx:挂一个、起一个,无所谓顺序,IP 变不变都行(前面有 Service 兜着),数据不重要(无状态);
- MySQL 主:不能随便起两个主(脑裂),从库要按主库的顺序复制,重启不能丢数据盘,网络标识最好稳定(从库认主靠地址)。
把一个”无状态”的期望模型套在”有状态”负载上,会出大事故(比如两个主同时写,数据分裂)。所以 K8s 的设计是:控制器按”负载类型”分家,每类负载有专属的”期望状态”语义。这不是偷懒分成多个,是职责不同必须分开。
核心洞察:抽象的代价是”抹平差异”。当差异是本质的(无状态 vs 有状态),强行统一抽象会比分开更危险。 K8s 选择了”在 Pod 这个最小单位之上,用不同控制器表达不同语义”。
7.2 Deployment:无状态服务的全能管家
Deployment 是你最常用的。它管三件事:维持副本数、滚动更新、回滚。它的结构是个俄罗斯套娃:
Deployment
└─ 管理 → ReplicaSet(某个版本)
└─ 管理 → Pod(N 个)
7.2.1 它怎么维持副本数
Deployment 控制器读 spec.replicas(期望),看实际 Pod 数(通过它管的 RS),少了建、多了删。这我们第 3 章讲过,RS 干的。Deployment 在 RS 之上,专注”版本演进”。
7.2.2 滚动更新(Rolling Update):优雅的核心
你 kubectl set image 改了镜像,Deployment 怎么做才不让服务断?
墨:最傻的更新是”全删再全起”,为什么不行?
全删瞬间,Service 后端为 0,所有请求 503;全起又慢(拉镜像),服务长时间不可用。所以 Deployment 用滚动更新:新旧交替共存,保证”始终有够用的实例在接流量”。两个旋钮控制节奏:
maxSurge:最多比期望多几个(比如 25%,即 4 副本时最多允许 5 个存在);maxUnavailable:最多允许几个不可用(比如 25%,即更新时至少 3 个在跑)。
过程:先按 maxSurge 多起一个新 Pod → 新的 Ready 了 → 按 maxUnavailable 删一个旧的 → 循环,直到全换成新镜像。全程”新起一个、稳了、再撤一个旧的”,流量不中断。
📦 案例: 你设 maxSurge=1, maxUnavailable=0,4 副本。更新时:先起第 5 个新 Pod(此时 5 个在跑,多 1 个,符合 maxSurge),新 Pod Ready 后删 1 个旧的(此时又 4 个,0 不可用,符合 maxUnavailable=0)。如此滚动,服务零中断。代价是更新慢一点(一个个来)。如果你设 maxUnavailable=4,那就是”先全删再全起”——快但断流。这两个值就是”速度 vs 可用性”的旋钮,由你按业务容忍度调。
7.2.3 回滚(Rollback)
新版本崩了?kubectl rollout undo。Deployment 怎么回?它保留最近几次的 ReplicaSet(老的 RS 不删,只是副本数缩到 0)。回滚就是把”旧 RS 的副本数恢复、新 RS 缩到 0”。因为旧 RS 的 Pod 模板还在,瞬间就能拉起老版本——回滚快,因为”历史版本”一直在那候着。
墨:它保留几次旧 RS?无限保留不爆 etcd 吗? 默认保留 revisionHistoryLimit(通常 10)个旧 RS,多了老的 RS 对象被清理(但它的 Pod 模板信息没了就不能无限回滚)。又是”留历史有成本,所以限量”。
7.2.4 金丝雀与蓝绿(用 Deployment 组合出来)
K8s 原生 Deployment 不直接提供”金丝雀”按钮,但你能组合出来:起两个 Deployment(v1 和 v2),用 Service 的 selector 或 Ingress 权重把少量流量导到 v2。或者用一个 Deployment 临时 maxSurge=1 让 1 个新实例先跑(天然金丝雀),观察没问题再全量。这体现 K8s 哲学:提供原语,不提供全流程——金丝雀流程由你用原语拼(后来 Argo Rollouts 等把”金丝雀”做成高级原语,但底层还是这些)。
7.3 ReplicaSet:副本数维持的专精工
RS 是 Deployment 的底层。它唯一职责:保证”带某 selector 的 Pod 数 == replicas”。它不关心版本、不关心更新——那是 Deployment 的活。这种”只管一件事”的拆分,让 Deployment 专注演进、RS 专注数量,各司其职,通过状态串联(第 3.7.1 讲过 RC→RS 的演化)。
墨:既然 RS 这么专,我能不能直接写 RS 不用 Deployment? 能,但你就失去了滚动更新和回滚——每次更新得手动建新 RS、手动缩旧 RS。Deployment 就是帮你自动化这事的。所以:新人用 Deployment,底层 RS 交给它管,你别手写 RS。
7.4 StatefulSet:有状态负载的专属语义
这是本章最难、最见设计功力的部分。有状态应用(数据库、消息队列、分布式存储)要三样无状态应用不要的东西:
7.4.1 稳定的网络标识(Stable Network Identity)
无状态 Pod 的 Pod 名、DNS 名是随机的(nginx-7d9c8b5f4-x2k9p),重启可能变。但 MySQL 从库要能稳定地找到”主”的地址。StatefulSet 给每个 Pod 一个固定的、带序号的名字:mysql-0、mysql-1、mysql-2。哪怕 Pod 重建(调度到别的节点),名字还是 mysql-0,DNS(通过 headless Service)解析到的还是它。
为什么序号这么重要? 因为分布式有状态系统常是”角色固定”的:mysql-0 是主,mysql-1/2 是从。序号让”谁是谁”永久确定,集群拓扑不乱。要是名字随机变,主从关系每次重建都错乱,数据就废了。
7.4.2 稳定的存储(Stable Storage)
Pod 漂到别的节点,它的数据盘得跟着走。StatefulSet 配合 PVC 模板:每个序号 Pod 绑定一个专属 PVC(如 data-mysql-0),Pod 重建后(哪怕换节点),重新挂回同一个 PVC,数据不丢。这是”有状态”的命根——存储身份跟着序号走,不跟着 Pod 实例走(第 10 章细讲存储)。
7.4.3 有序的部署 / 扩缩 / 删除
StatefulSet 启动是顺序的:先 mysql-0 完全 Ready,再 mysql-1,再 mysql-2。删除是逆序:先删 mysql-2,再 mysql-1,最后 mysql-0。为什么?因为很多有状态系统启动有依赖(从库要等主库起来才能连),逆序删除避免”先删主、从库成孤儿的脑裂”。
墨:这跟 Deployment 的”并行起”完全相反,为什么有状态要”慢且有序”?
因为对有状态系统,“正确性”优先于”速度”。并行起可能让从库在没主库时瞎初始化,造成数据不一致。StatefulSet 用”顺序 + 等待 Ready”保证拓扑正确建立。这是又一次”一致性/正确性优先于效率”的取舍——你在 K8s 里反复看到这个取向(etcd 选 C、调度求够好、这里 StatefulSet 求有序)。
7.4.4 headless Service:不要负载均衡的”服务”
StatefulSet 通常配合 headless Service(clusterIP: None)——它不分配虚拟 IP,也不做负载均衡,而是让每个 Pod 有独立 DNS 记录(mysql-0.mysql.default.svc)。调用方能精确访问某个具体实例(如从库连主库 mysql-0)。这和普通 Service”随机挑后端”的语义相反——有状态场景你经常”就要连那个特定的”。
演化看点: StatefulSet 从 K8s 早期 beta 到 1.9 才 GA(稳定),因为”有状态语义”极难做对(存储绑定、顺序、网络稳定,任何一处错都数据事故)。它稳定后,K8s 才敢说”能跑有状态负载”。这又是一个”难的事慢慢做对再 GA”的兼容/质量纪律。
7.5 DaemonSet:每节点必有一个
有些负载你要”每台机器都跑且只跑一个”:日志采集(Fluentd/Filebeat)、监控 agent(node-exporter)、网络插件(CNI 的 daemon)。DaemonSet 保证新节点加入时自动在那台起一个,节点删除时跟着删。
墨:DaemonSet 和”副本数=N 节点数”的 Deployment 有啥区别?
区别在”亲和语义”:Deployment 的副本是”随便放 N 个”,可能两台放同节点、某节点一个没有。DaemonSet 是”每节点恰好一个”,且节点增删自动跟随。它用节点亲和(nodeAffinity)+ 容忍所有污点(tolerate 所有 taint)实现”每台都跑”。所以监控类的必须 DaemonSet,不能 Deployment。
7.6 Job:一次性任务,做完即止
有些负载不是”一直跑”,是”算完就完”:批处理、数据迁移、跑个测试。Job 管”N 个 Pod 成功完成(exit 0)就结束”,不像 Deployment 永远维持。特性:
completions:要几个成功(默认 1);parallelism:同时跑几个(批处理并行度);- 失败重试:Pod 非 0 退出,Job 再起一个,直到达到 completions;
backoffLimit:失败几次放弃(避免无限重试)。
演化看点(重要):
- Indexed Job(K8s 1.21+):给每个 Pod 一个索引号(0..N-1),适合”分片处理”——Pod i 处理第 i 片数据。这把”MapReduce 式分片”做成一等公民,不用自己写序号分配;
- TTLAfterFinished:Job 完成后自动删除(避免堆满已完成的 Job 对象占 etcd);
- Suspend:Job 可挂起再恢复。
这些演化都在回答”一次性任务”的真实麻烦:分片、清理、可控。
7.6.1 Job 和 Pod 的”完成”语义
墨:Job 怎么知道”任务成功”? 靠容器的退出码:Pod 里主进程 exit 0 = 成功,非 0 = 失败。Job 控制器数”成功退出的 Pod 数”达到 completions 就标记 Job Complete。这把”业务成功”翻译成”进程退出码”——极朴素但极可靠。设计上”用进程退出码表达业务结果”是批处理系统的通用契约,你写脚本 if [ $? -eq 0 ] 也是这逻辑。
7.7 CronJob:定时的一次性任务
CronJob = “按 cron 时间表创建 Job”。比如”每天凌晨备份数据库”。它本质是 Job 的时间触发器。坑点:如果上次 Job 还没跑完,下次到点了怎么办? 有 concurrencyPolicy:Allow(并发)/ Forbid(跳过这次)/ Replace( kill 上次的)。还有 startingDeadlineSeconds 处理”错过触发时间”的补救。
墨:分布式下”定时任务”最怕什么? 怕重复执行(多个调度实例都触发了)和漏执行(实例挂了没触发)。单实例 CronJob 简单但会单点。生产常用专门的定时框架(如 k8s 上的 cron 控制器或外部 Quartz)保证”恰好一次触发”。K8s CronJob 是”尽力触发”,不保证严格不重不漏——你做关键定时任务要知道这个边界。
7.8 垃圾回收与 ownerReferences:级联删除的学问
墨:你 kubectl delete deployment,它管的 Pod 怎么没了的? 靠 ownerReferences(属主引用)。
每个被管理的对象(如 Pod)的 metadata 里记着 ownerReferences: [{kind: ReplicaSet, name: xxx}]。当你删 Deployment,级联删除一路传导:Deployment 删 → 它的 RS 删(因为 RS 的 owner 是 Deployment)→ RS 删触发它的 Pod 删(Pod 的 owner 是 RS)。这叫级联删除(cascade deletion)。
两种模式:
- Foreground(前台):先删子再删父,保证顺序(避免父没了子变孤儿);
- Background(后台,默认):立刻删父,GC 控制器异步清理子。
坑: 如果你 kubectl delete pod 但又想”别删 Deployment 管的 Pod(会被重建)“——你删了它,RS 立刻补一个,白删。所以排障时别手动删被控制器管的 Pod,要改期望(缩 replicas 或删 Deployment)。ownerReferences 是 K8s 对象关系的血缘图谱,理解它才懂”删一个为什么引发一串”。
演化看点: 早期删除是简单递归,后来引入 Garbage Collector 控制器专门异步处理级联,避免删除大对象时 API Server 卡顿。又见”把一类职责抽成独立控制器”的模式。
7.9 一张表:五类控制器的”期望状态”差异
| 控制器 | 期望状态核心 | 生命周期 | 典型麻烦 |
|---|---|---|---|
| Deployment | replicas + 镜像版本 | 永远维持 + 可滚动 | 零中断更新、回滚 |
| ReplicaSet | replicas | 永远维持 | 只管数量,不管版本 |
| StatefulSet | 序号身份 + 稳定存储 + 有序 | 永远维持且有序 | 主从拓扑、数据不丢 |
| DaemonSet | 每节点一个 | 跟节点走 | 全节点覆盖、容忍污点 |
| Job | N 个成功完成 | 做完即止 | 分片、失败重试、清理 |
| CronJob | 按 cron 触发 Job | 周期性 | 重复/漏触发 |
墨:你看这张表,领悟到什么?
领悟是:“期望状态”的形状,完全由负载的”麻烦类型”决定。 无状态要”永远在+能换版本”,有状态要”身份永恒+有序+存储跟随”,一次性要”完成即停”。控制器不是随便分的,是按麻烦的形状切的。这就是设计者视角:先问”这负载最怕什么”,再定”它的期望长啥样”。
7.10 本章演化线小结
- Deployment 取代 ReplicationController,套娃结构(Deploy→RS→Pod)分离”演进”与”数量”;
- 滚动更新用 maxSurge/maxUnavailable 平衡速度与可用性;回滚靠保留旧 RS;
- StatefulSet 从 beta 到 GA 花了多年,因有状态语义(稳定标识/存储/有序)极难做对;
- Indexed Job / TTLAfterFinished / Suspend 是 Job 随真实批处理需求逐步补足;
- 级联删除靠 ownerReferences + GC 控制器,是对象血缘图谱;
- 五类控制器 = 五类麻烦,期望状态形状由麻烦决定。
7.11 本章思考题
🔧 思考题 1
一个 4 副本无状态服务,你设 maxSurge=1, maxUnavailable=1 滚动更新。请推演更新过程中”同时存在的 Pod 数”和”可用 Pod 数”的上下限,并和 maxSurge=1, maxUnavailable=0 对比,说明后者为什么更安全但更慢。
【参考答案】
maxSurge=1, maxUnavailable=1:期望 4。上限 Pod 数 = 4+1=5(多起 1 个新的),下限可用 = 4-1=3(最多删 1 个旧的)。过程可”起 1 新 + 删 1 旧”并行推进,速度快,但最坏有 1 个旧的可能在替换间隙短暂不可用(实际流量靠新 Ready 顶上,一般不掉)。maxSurge=1, maxUnavailable=0:上限 5,下限可用始终=4(旧的必须等新 Ready 才删)。更安全:任何时刻至少 4 个在服务,零容量损失;更慢:严格”先起后撤”,每步多等一个新 Pod Ready。对比结论:maxUnavailable=0 适合”容量敏感、不能少一个”的服务(如直面大促),代价更新慢;maxUnavailable=1 适合”多一个少一个无所谓的内部服务”,更新快。又是”可用性 vs 速度”旋钮,由业务定。
🔧 思考题 2
StatefulSet 的 mysql-0 主库 Pod 因为节点故障被调度到另一台机器。它的 DNS 名 mysql-0.mysql 还指向它吗?它的数据盘还在吗?请沿”稳定网络标识 + 稳定存储”两条线说清机制。
【参考答案】
DNS 名仍指向 mysql-0:StatefulSet 的 Pod 名带序号且稳定(重建后还是 mysql-0),headless Service 为它维护的 DNS 记录基于”Pod 名+序号”,与节点无关。所以无论 mysql-0 漂到哪台节点,DNS mysql-0.mysql.default.svc 解析到的就是当前 mysql-0 Pod 的 IP。数据盘也在:StatefulSet 的 PVC 模板为每个序号生成专属 PVC(data-mysql-0),PVC 与 Pod 实例解绑、与序号绑定。Pod 重建(换节点)后,kubelet 按 volumeClaimTemplates 把同一个 PVC(data-mysql-0)挂回——底层存储(如网络盘/PV)跨节点可见,数据不丢。所以”身份(序号)“是锚点,网络和存储都挂在身份上而非实例上,这正是有状态负载能”漂而不乱”的关键。
🔧 思考题 3
你 kubectl delete deployment/web 后,它管的 Pod 还在 etcd 里吗?如果只想删 Deployment 但保留 Pod 自己跑(变裸 Pod),怎么做?为什么一般不这么做?
【参考答案】
默认级联删除:删 Deployment → GC 沿 ownerReferences 删 RS → 删 Pod,最终 Pod 也不在 etcd。若想”删 Deploy 但留 Pod”,用 --cascade=orphan(孤儿模式):只删 Deployment 对象,不动它的子(RS 和 Pod 变孤儿,无 owner)。一般不这么做的原因:(1) 留下的 Pod 成裸 Pod,没人维持,挂了不重建;(2) RS 也孤儿,后续管理混乱;(3) 这些孤儿 Pod 不再受期望状态约束,集群状态变”不可声明管理”,违背声明式哲学。orphan 只在”紧急抢救数据/排查”时短暂用,正常应改期望(缩 replicas=0 再删)或让控制器自然回收。
🔧 思考题 4(进阶)
K8s 没有原生”金丝雀发布”控制器(只靠 Deployment + 手动组合)。后来 Argo Rollouts 等做了高级发布。从”K8s 该提供原语还是该提供全流程”的角度,评一下这个边界该怎么划?如果你设计,会把金丝雀做成内置还是插件?
【参考答案】 边界原则:内核提供通用、稳定、不可歧义的原语(Pod/Deployment/Service/探针等),上层复杂流程交给扩展(CRD/Operator/插件)。 理由:(1) 金丝雀的”流量切分”涉及 Ingress/Service Mesh,超出核心调度语义,且各家实现不同(Istio、Nginx、云 LB 各异),内置会绑架某方案;(2) 原语稳定后,上层创新(Argo Rollouts、Flagger)可独立快速迭代,不受 K8s 发布周期限制;(3) 内核一旦内置某流程,要兼容维护,包袱重(呼应第 4 章”公开 API 是负债”)。我的设计:金丝雀做成插件/CRD(如 Rollout 对象),复用 Deployment/Service 原语,不进核心。这也是 K8s 实际的演化取向——核心保持小且稳,复杂编排交给 CNCF 生态。结论是”内核管原子,生态管组合”,边界划在”是否依赖外部不确定组件/是否多家实现分歧大”。
7.12 小结
- 控制器按”负载麻烦类型”分家,不强行统一抽象;
- Deployment 套娃(→RS→Pod)分离演进与数量,滚动更新靠 maxSurge/maxUnavailable 平衡快慢与可用,回滚靠留旧 RS;
- StatefulSet 给有状态三件套:稳定网络标识(序号+headless)、稳定存储(PVC 随序号)、有序启停;正确性优先于速度;
- DaemonSet 每节点一个,靠亲和+容忍全污点;Job 做完即止,Indexed/TTL/Suspend 逐步补足;CronJob 是时间触发器,不保证严格不重不漏;
- ownerReferences + GC 实现级联删除,是对象血缘图谱;
- 五类控制器 = 五类麻烦,期望状态由麻烦形状决定。
下一章进运行时——Pod 与容器运行时(CRI)。我们先回答一个你大概率从没想过的问题:为什么 K8s 调度的不是容器,是 Pod?