KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 滚动更新与回滚:两个数字的算术 — keel 龙骨
这一章回答:滚动更新到底在滚什么?maxUnavailable: 0 保证什么、不保证什么?为什么"摘流量"和"发信号"是同时发生的?
这一章回答:滚动更新到底在滚什么?maxUnavailable: 0 保证什么、不保证什么?为什么"摘流量"和"发信号"是同时发生的?
前四章铺垫的东西在这一章合流:探针(第 03 章)决定"就绪"如何被判定,资源(第 04 章)决定"新副本能不能被放下",停机窗口(《Docker 应用课》第 06 章)决定"旧副本能不能体面地退出"。滚动更新是把这三件事串起来的那个动作。
现场
一次滚动发布,用户侧出现间歇性 503,持续约 40 秒,之后自动恢复。发布日志看起来完美:
$ kubectl rollout status deploy/orders-api
deployment "orders-api" successfully rolled out
successfully rolled out——它是真的成功了。发布确实完成了,新版本也确实在服务。所以这个 503 不是"发布失败",而是"发布过程中的一段时间里,可用副本比你以为的少"。
kubectl get pods 在发布期间会看到这样的阶段(示意):
orders-api-7d4b9c-aaa 1/1 Running 0 43d ← 旧
orders-api-7d4b9c-bbb 1/1 Running 0 43d ← 旧
orders-api-5f8c2e-ccc 0/1 Running 0 8s ← 新:Running 但 0/1
orders-api-5f8c2e-ddd 0/1 Running 0 6s ← 新:Running 但 0/1
新 Pod 的状态是 Running 但 0/1:容器起来了、进程活着,但 readiness 还没通过。而此刻旧副本可能已经开始被减了。问题的核心就在这个交叉点上。
一、谁在滚:Deployment 与 ReplicaSet 的分工
滚动更新的机制比想象中简单,但有一个前提概念要清楚:
Deployment:管理"版本与副本数的期望"
├── ReplicaSet(v1):管这一版的 Pod 副本数 ← 发布后仍保留
└── ReplicaSet(v2):管新一版的 Pod 副本数 ← 发布时新建
每次 Pod 模板发生变化(换镜像、改探针、改资源),Deployment 就会新建一个 ReplicaSet,然后指挥新旧两个 ReplicaSet 一增一减。两个关键推论:
- 旧 ReplicaSet 不会立刻被删——它被保留下来(默认保留 10 个历史版本),所以"回滚"本质上是把旧 ReplicaSet 的副本数调回来。
- "版本"(revision)就是 Pod 模板的快照。所以任何模板改动都会产生新 revision——改一个探针周期也算一次新版本。这与"发布一个新镜像"是两件事。
二、两个数字的算术
滚动更新的节奏由两个参数决定:
| 参数 | 含义 | 决定 |
|---|---|---|
maxSurge |
允许超出期望副本数的上限(可写数字或百分比) | 一次能多起几个新 Pod |
maxUnavailable |
允许低于期望副本数的下限 | 一次能少几个可用 Pod |
用一组具体数字看它们的算术(4 副本):
| 配置 | 发布期间的副本区间 | 代价 |
|---|---|---|
maxSurge: 1 / maxUnavailable: 0 |
4 – 5(先增后减,可用数永不下降) | 需要额外资源;节奏最慢,但可用性不降低 |
maxSurge: 1 / maxUnavailable: 1 |
3 – 5 | 快一些;发布期间可用数会掉到 3 |
maxSurge: 25% / maxUnavailable: 25%(默认值) |
3 – 5(4 副本时各 1) | 默认就是"允许掉四分之一" |
maxSurge: 0 / maxUnavailable: 1 |
3 – 4(原地替换) | 省资源;必然有额外资源缺口的时间窗 |
maxUnavailable: 0 保证的是"可用副本数不低于期望值"——注意"可用"的定义:通过 readinessProbe 的 Pod 才算可用。
由此得到两条必须一起理解的判断:
maxUnavailable: 0的"零停机"承诺,前提是 readinessProbe 正确。 如果没配 readinessProbe,新 Pod 一被创建就算可用,于是"可用数从不低于 4"这个约束在名义上成立、实际上新 Pod 还在初始化就已被计入——现场那 40 秒的 503 就是这么来的。maxUnavailable: 0会强制"先增后减",因此必须有资源余量。而第 04 章说过调度只看requests:如果节点装不下多出来的那个 Pod,新 Pod 会 Pending,于是发布卡住——它不会报错,只是永远不前进。
死锁组合:maxSurge: 0 + maxUnavailable: 0
两个都是 0 意味着"既不能多起、也不能少留"——没有空位可以替换。本课示例里的坏清单就是这个组合:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 0
它的处理结果很有教育意义:
$ kubeconform -strict -verbose broken
broken\03-rollout-zeros.yaml - Deployment rollout-zeros is valid ← schema 校验:通过
单对象 schema 校验直接放过它——因为 0 是一个合法的整数,字段类型没问题。而静态不变量检查能抓到:
[严重] rollout-zeros: maxSurge=0 且 maxUnavailable=0 → 无空位可换,滚动更新会卡死(API 服务器会拒绝这个组合)
这是第 01 章那张"三段验证"表的一个实例:schema 通过 ≠ 语义正确。真正的拒绝发生在 API 服务器的语义校验阶段——那是本课唯一无法在离线环境验证的一层,所以静态检查在这里格外值钱。
三、一次完整的滚动时序
flowchart TD
A["① 改 Pod 模板(换镜像 tag)"] --> B["② Deployment 新建一个 ReplicaSet(v2)"]
B --> C["③ 控制器算出本轮允许的增量<br/>maxSurge / maxUnavailable"]
C --> D["④ 拉起新 Pod(先增)"]
D --> E{"⑤ 新 Pod 的 readinessProbe 通过?"}
E -->|"通过"| F["⑥ 计入可用数 → 减掉一个旧 Pod"]
E -->|"一直不通过"| STUCK1["rollout 卡住<br/>直到 progressDeadlineSeconds 超时告警"]
F --> G{"⑦ 旧副本减到 0 了吗?"}
G -->|"还没有"| D
G -->|"减完了"| OK["⑧ 发布完成<br/>旧 ReplicaSet 保留,可用于回滚"]
D -.->|"节点装不下(requests 超了)"| STUCK2["新 Pod Pending<br/>发布永远不前进"]
F -.->|"被减的旧 Pod 正在处理请求"| GRACE["进入停机流程<br/>见下一节"]
图上有两条通向"卡住"的路,它们的排查方向完全不同:
- 第 ⑤ 跳不通过(
STUCK1)→ 是探针问题(第 03 章); - 第 ④ 跳就起不来(
STUCK2)→ 是资源问题(第 04 章)。
而两者的表现都是"rollout 不前进"。这就是为什么看到 rollout 卡住时,第一件事是看新 Pod 的状态:Pending 指向资源,Running 但 NotReady 指向探针。
四、停机那一跳:摘流量与发信号是同时发生的
第 ⑥ 跳减掉旧 Pod 时发生的事情,值得单独讲——因为这是整个发布链路里最容易理解错的一环。
直觉上人们以为的顺序是:
先把流量摘掉(等摘干净) → 再通知进程退出
实际是并发的两件事同时发生:
Pod 被标记删除
├── ① 从 Service 的 endpoints 里移除 ── 需要时间传播到所有节点/代理
└── ② 向容器内的进程发 SIGTERM ── 立即到达
(preStop 钩子若有,先执行它)
① 是异步传播的,② 是即时的。 于是在这个窗口里存在一个真实的状态:已经被通知退出的进程,仍然在被转发过来的流量访问。
flowchart TD
DEL["Pod 被标记删除"] --> E1["① 从 endpoints 移除<br/>(异步:要传播到各节点的代理)"]
DEL --> S1["② 执行 preStop 钩子<br/>(若有)→ 之后发 SIGTERM"]
S1 --> DRAIN["应用开始排空"]
E1 -.->|"传播需要时间<br/>此期间流量仍可能到达"| DRAIN
DRAIN --> Q{"在 terminationGracePeriodSeconds 内<br/>应用完成排空并退出?"}
Q -->|"是"| DONE["容器以 0 退出"]
Q -->|"否"| KILL["SIGKILL<br/>在途请求被切断"]
E1 -.->|"若 Pod 已从 endpoints 摘除但进程还在收流量<br/>→ 连接被拒"| BAD["用户侧的 502 / 503"]
这解释了两件常被归因为"框架有问题"的现象:
preStop里那个sleep不是为了"等应用排空",而是为了"等摘除传播完"。 常见写法是preStop: exec: command: ["/bin/sh","-c","sleep 5"],然后才让应用收到SIGTERM并开始排空。如果 preStop 里不 sleep,应用在摘除传播完成前就开始关监听,那段窗口里的连接会被拒。terminationGracePeriodSeconds必须覆盖preStop时间 + 应用排空时间。 本课示例(构建产物实测):
✓ orders-api: grace=60s > preStop=5s(留给应用排空 55s)
这条判断在《Docker 应用课》第 06 章已经建立(那边是 stop_grace_period 与应用排空的关系),K8s 里多了一层:preStop 的时间也要算进去,而且第 ① 跳的传播延迟是 K8s 特有的。"编排层等待窗口短于应用排空时间则后者永不生效"这条判断,在 K8s 里同样成立——只是窗口的名字换了。
五、回滚:revision 不等于"上一个可用版本"
kubectl rollout undo 会切回上一个 revision。这里有两个容易误解的地方:
误解一:revision 记录的是"可用版本"。 实际上它记录的是模板快照——包括那些没跑起来、或者跑起来但立刻出问题的版本。所以:
"上一个 revision" ≠ "上一个可用版本"。 如果你的 v3 发布失败了,
rollout undo切回的是 v2——但如果你之前的 v2 和 v3 之间还夹着一个配置改动(也产生 revision),切回去的可能是那个坏配置。
正确的做法是维护一份"最后已验证可用版本"的记录,由发布后的冒烟验证来更新它,而不是依赖 revision 序号。这一点与《构建打包与部署上线》第 05 章的"回滚的四种形态"、以及《CI 流水线与质量门禁》里的"last-good-digest"是同一个思路:可用性必须被验证过才算数,序号本身不是证据。
误解二:回滚是瞬间的。 回滚也是滚动更新——它同样要经历"拉起旧模板的新 Pod、等就绪、减掉当前的 Pod"这一整套流程。所以:
- 回滚的耗时与正向发布同量级,不是"一键恢复";
- 回滚过程本身也可能卡住(如果旧模板要求的资源现在装不下,或者旧镜像被删了);
- 回滚需要用到旧镜像,所以镜像仓库的保留策略是回滚能力的一部分——删掉旧 tag 就等于删掉了回滚路径。
六、误判澄清
| 读者常见的理解 | 核对后的事实 | 为什么会被误导 |
|---|---|---|
"maxUnavailable: 0 就是零停机" |
它保证的是"通过 readiness 的副本数不低于期望"。没有 readiness,这个约束形同虚设 | 配置写了,看起来就该成立 |
"successfully rolled out 说明过程没问题" |
它只说明最终完成了;过程中的可用副本下降不会被这句话反映 | 结果与过程被混为一谈 |
| "摘流量发生在发信号之前" | 两者并发:摘除异步传播、信号立即到达,中间有真实窗口 | 顺序的直觉来自"先善后"的思维习惯 |
"preStop 的 sleep 是为了让应用处理完请求" |
排空由应用响应 SIGTERM 完成;preStop 的 sleep 是为了等摘除传播 |
两者都是为了"优雅",但负责的是不同的事 |
"rollout undo 回到上一个可用版本" |
回到上一个模板快照,它可能从未可用过 | revision 与"可用"是两套记录 |
| "回滚是一条命令、瞬间生效" | 回滚也是滚动更新,耗时同量级,也可能卡住 | undo 这个词暗示了撤销 |
| "删掉旧镜像 tag 能省仓库空间" | 同时也删掉了回滚路径 | 只算存储成本,没算回滚能力 |
| "改一个探针周期不算发布" | 任何 Pod 模板改动都产生新 revision,都触发一次滚动 | "发布"被等同于"换镜像" |
生产边界
| 教学替身 | 真实替换点 | 要注意什么 |
|---|---|---|
单集群 kubectl rollout |
GitOps 控制器驱动的发布 + 发布后冒烟验证 | 控制器只保证"对齐",不保证"可用"——可用要靠验证步骤回写 |
maxSurge: 1 / maxUnavailable: 0 |
按可用性目标与资源成本调整 | 需要资源余量(第 04 章:调度只看 requests) |
| 手写回滚流程 | 自动回滚(health check 失败则回退)+ 人工闸门 | 自动回滚也要走完整滚动流程,耗时不是零 |
revision 序号当回滚依据 |
维护"最后已验证可用版本"记录(digest + 验证结论) | 序号不是证据;验证结论才是 |
| 单环境发布 | 多环境晋级(staging 验证 → prod 发布) | 晋级的是同一份产物(digest),不是"重新构建一遍" |
动手
- 用
kubectl kustomize构建你的清单,读出每个 Deployment 的maxSurge/maxUnavailable与副本数,算出发布期间的副本区间; - 算出停机预算:
terminationGracePeriodSeconds是否大于preStop时间 + 应用排空时间; - 检查每个 Deployment 是否有
readinessProbe——没有的话,maxUnavailable: 0是不成立的; - 判断标准:能回答"我的发布过程中,最少有几个可用副本在服务",并说明这个数字是怎么算出来的;
- 完成标志:写出你的回滚路径,并回答"回滚用的是哪个镜像、那个镜像现在还在仓库里吗"。
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
maxSurge: 1 / maxUnavailable: 0,但节点没有余量(requests 装不下) |
新 Pod 状态与 rollout 进度 | 新 Pod Pending,发布永远不前进(不是报错) |
配 maxUnavailable: 0 但删掉 readinessProbe |
发布期间的错误率 | "零停机"失效:新 Pod 未就绪就被计入可用 |
maxSurge: 0 + maxUnavailable: 0 |
schema 校验与 apply 的结果 | schema 通过;静态检查抓出;API 服务器语义校验拒绝 |
让 terminationGracePeriodSeconds 小于 preStop + 排空时间 |
退出码与在途请求完成率 | 被 SIGKILL;在途请求被切断 |
把 preStop 里的 sleep 去掉 |
发布期间的用户侧错误率 | 摘除传播未完成就关监听 → 连接被拒 |
rollout undo 到一个从未成功过的 revision |
回滚后的状态 | 又回到失败状态——revision 不代表"可用" |
| 删掉仓库里的旧镜像 tag,然后尝试回滚 | 回滚是否成功 | 失败:回滚需要旧镜像,仓库保留策略是回滚能力的一部分 |
自测
maxSurge与maxUnavailable分别控制什么?4 副本配合maxSurge: 1 / maxUnavailable: 0时,发布期间的副本区间是多少?maxUnavailable: 0的"零停机"承诺依赖哪一个前置条件?缺少它会发生什么?- 为什么
maxSurge: 0+maxUnavailable: 0会导致发布卡死?为什么 schema 校验放过了它? - Pod 被删除时"摘流量"与"发 SIGTERM"是顺序的还是并发的?
preStop里的sleep起什么作用? - 为什么
rollout undo可能让你回到一个"从未可用过"的版本?正确的回滚依据应该是什么? - 为什么"回滚"不是瞬间的?它可能因为哪些原因失败?
现在能解释什么
- 每次 Pod 模板变更都会新建 ReplicaSet,旧的那个被保留——这就是回滚的物质基础。
maxSurge/maxUnavailable的算术决定了发布期间的副本区间;maxUnavailable: 0的承诺建立在readinessProbe之上。- 发布卡住有两条完全不同的路径:
Pending(资源,第 04 章)与NotReady(探针,第 03 章),表现一样、方向相反。 - 摘流量(异步传播)与发信号(立即)是并发的,这解释了两个字段:
preStop的 sleep 是等传播,terminationGracePeriodSeconds要覆盖 preStop + 排空。 revision是模板快照,不是"可用版本"的记录;回滚本身也是一次滚动更新,并且依赖旧镜像仍然存在。
下一章进入第四个判断点:配置怎么进容器,以及为什么改了 ConfigMap 之后 Pod 里可能没变。