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 一增一减。两个关键推论:

  1. 旧 ReplicaSet 不会立刻被删——它被保留下来(默认保留 10 个历史版本),所以"回滚"本质上是把旧 ReplicaSet 的副本数调回来。
  2. "版本"(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 才算可用。

由此得到两条必须一起理解的判断:

  1. maxUnavailable: 0 的"零停机"承诺,前提是 readinessProbe 正确。 如果没配 readinessProbe,新 Pod 一被创建就算可用,于是"可用数从不低于 4"这个约束在名义上成立、实际上新 Pod 还在初始化就已被计入——现场那 40 秒的 503 就是这么来的。
  2. 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/>见下一节"]

图上有两条通向"卡住"的路,它们的排查方向完全不同:

而两者的表现都是"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"]

这解释了两件常被归因为"框架有问题"的现象:

  1. preStop 里那个 sleep 不是为了"等应用排空",而是为了"等摘除传播完"。 常见写法是 preStop: exec: command: ["/bin/sh","-c","sleep 5"],然后才让应用收到 SIGTERM 并开始排空。如果 preStop 里不 sleep,应用在摘除传播完成前就开始关监听,那段窗口里的连接会被拒。
  2. 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"这一整套流程。所以:

六、误判澄清

读者常见的理解 核对后的事实 为什么会被误导
"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),不是"重新构建一遍"

动手

  1. 用 kubectl kustomize 构建你的清单,读出每个 Deployment 的 maxSurge / maxUnavailable 与副本数,算出发布期间的副本区间;
  2. 算出停机预算:terminationGracePeriodSeconds 是否大于 preStop 时间 + 应用排空时间;
  3. 检查每个 Deployment 是否有 readinessProbe——没有的话,maxUnavailable: 0 是不成立的;
  4. 判断标准:能回答"我的发布过程中,最少有几个可用副本在服务",并说明这个数字是怎么算出来的;
  5. 完成标志:写出你的回滚路径,并回答"回滚用的是哪个镜像、那个镜像现在还在仓库里吗"。

故障注入

注入方式 观察什么 说明的现象
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,然后尝试回滚 回滚是否成功 失败:回滚需要旧镜像,仓库保留策略是回滚能力的一部分

自测

  1. maxSurge 与 maxUnavailable 分别控制什么?4 副本配合 maxSurge: 1 / maxUnavailable: 0 时,发布期间的副本区间是多少?
  2. maxUnavailable: 0 的"零停机"承诺依赖哪一个前置条件?缺少它会发生什么?
  3. 为什么 maxSurge: 0 + maxUnavailable: 0 会导致发布卡死?为什么 schema 校验放过了它?
  4. Pod 被删除时"摘流量"与"发 SIGTERM"是顺序的还是并发的?preStop 里的 sleep 起什么作用?
  5. 为什么 rollout undo 可能让你回到一个"从未可用过"的版本?正确的回滚依据应该是什么?
  6. 为什么"回滚"不是瞬间的?它可能因为哪些原因失败?

现在能解释什么

下一章进入第四个判断点:配置怎么进容器,以及为什么改了 ConfigMap 之后 Pod 里可能没变。

进入 keel 阅读