maxSurge 与 maxUnavailable 两个窗口,给一次发布过程中最多能多出或缺少的 Pod 数定了上限,让升级始终在容量可控的前提下进行。

一次只换一小批

滚动更新不会把整份负载一次换掉,而是一批一批地先起新、再撤旧,并且整条链路都尊重 readiness。控制器会等新的 Pod 变就绪,才认为这次发布往前挪了一步,于是那些始终就绪不了的 Pod 会卡住这次发布,而不是偷偷用坏版本扛流量。这正是旧行为:任何时刻都不会从旧版一步跨到新版、中间一刻也没有空窗。因为切换被 readiness 拦住,你会在第一批新 Pod 就发现问题、中断发布,而不是等全量上线才知道砸了。

用 surge 与 drain 两扇窗

maxSurge 与 maxUnavailable 两扇窗,规定了整个发布的形状。maxSurge 允许多少个高于期望的额外 Pod,用于边排旧边补新;maxUnavailable 允许替换期间低于期望多少个。它们一组合,既定速度也定余量:surge 给得多就跑得快,代价是要有抵抗它的容量;而 maxUnavailable:1 + maxSurge:0 则偏向容量不动,却慢,并且发布窗口可以拉得很长。挑这两扇窗,看的应该是「如果这次发坏了我能容忍多坏」,而不是挑一组看起来左右均衡的数字,因为它们字面上就限定了这次发布能触碰多少 Pod。

回滚只是重新部署

因为一次发布本身只是另一张 spec,回滚不过是把镜像指回上一次跑的那份,然后同样受 readiness 约束的批次扰动,倒着再来一遍。把上一个版本保留着、随时能起,并优先用一个你读过、测过的旧镜像,而不是现场打的补丁——这样回滚路径本身就是一条被测试过的路径。审计一致、Pod 更替受同样的窗口约束,而团队也确切知道「回滚」是什么意思,它就是一次对最后已知好版本的重新部署,没有任何别的。

先选形状,再调节奏

strategy.type 只有两个值,而默认值把这个选择藏了起来。RollingUpdate 让新旧 Pod 在 surge 与 unavailable 两扇窗里重叠;Recreate 先把旧 Pod 删光,再建新 Pod,最小可用数按构造就是零,两扇窗直接被忽略。当两个版本绝不能并存,就选 Recreate:旧代码读不回去的单写迁移、排他文件锁或单例 worker、新旧互不兼容的有线协议。姊妹篇调的是波次节奏,这篇定的是发布形状。无状态层默认 RollingUpdate;一旦选了 Recreate,就要把停机排进计划,而不是等它找上门:测一次冷启动、预拉镜像、提前公告窗口。选之前先答三问:两个版本能同时 serving 吗,旧版本占着新版本要独占的资源吗,停机窗口能量化吗。第一问答否,直接 Recreate;第二问答是也倾向 Recreate,比如旧 Pod 占着的 hostPort 或 ReadWriteOnce 卷,新 Pod 根本起不来;第三问答否,说明还没测冷启动,先测再选。

坏掉的样子

经典死法一:有状态负载配了 RollingUpdate,直接脑裂。新旧 Pod 在一波里重叠,两个 leader 短暂并存,各接各的任务、各按各的 schema 写,事后合并只能人工。版本不能并存的地方,重叠本身就是 bug,解法是 Recreate 或真正的协调。它最难查:服务一直有响应,错误藏在重复任务与主键冲突里,等对账才爆发。有 leader 选举或单写假设的负载,别用重叠发布。

镜像死法二:Recreate 的停机超出窗口。团队只预算了三十秒,可新镜像拉得慢、readiness 要几分钟,而最小可用数是零,多出来的每一分钟都是全停。更糟的是回滚也要再停一次,窗口直接翻倍。选它之前,先计时一次冷启动。

死法三:把 RollingUpdate 配成了全停。四个副本配 maxUnavailable: 50%,一次撤掉两个,又有一个新 Pod CrashLoop,剩下孤零零一个扛全流量,直接被压垮。小舰队把 maxUnavailable 留在零或一。

快速验证

把下面存成 strategy_window.py,跑 python3 strategy_window.py。只用标准库,不碰进程之外的任何东西,用副本数与窗口算术推每个策略的可用下限。

"""Compare the availability floor of Recreate vs RollingUpdate (stdlib only)."""

def rolling_window(replicas, max_surge, max_unavailable):
    if replicas <= 0:
        raise ValueError("replicas must be positive")
    if max_unavailable >= replicas:
        raise ValueError("maxUnavailable covers the whole fleet: configured full stop")
    floor = replicas - max_unavailable
    ceiling = replicas + max_surge
    return floor, ceiling


def recreate_window():
    return 0, 0


def main():
    replicas, surge, unavailable = 6, 1, 1
    rolling_floor, rolling_ceiling = rolling_window(replicas, surge, unavailable)
    stopped, _ = recreate_window()
    assert rolling_floor == 5, rolling_floor
    assert rolling_ceiling == 7, rolling_ceiling
    assert rolling_floor > 0, "rolling update must keep serving"
    assert stopped == 0, "recreate stops everything first"
    try:
        rolling_window(4, 1, 4)
    except ValueError as error:
        print("caught expected misconfiguration:", error)
    else:
        raise AssertionError("maxUnavailable >= replicas should fail")
    print(f"replicas={replicas} surge={surge} unavailable={unavailable}")
    print(f"rolling: min available={rolling_floor}, max total={rolling_ceiling}")
    print("recreate: min available=0 (full stop, then rebuild)")
    print("PASS: rolling keeps serving; recreate stops everything first")


if __name__ == "__main__":
    main()

六副本、一 surge、一 unavailable,能保持五个 serving、最多七个总量;Recreate 按构造下限就是零。断言把这条契约写死,而 maxUnavailable 盖住全部四个副本的误配必须抛错,而不是算出一个下限。把六副本换成你的真实副本数再跑一遍,结论不变,数字跟着变。

断言在 macOS 的 Python 3.14.4 下通过。模型只算窗口算术,不覆盖拉镜像、探针与调度。发布日之前,先确认线上真实的 strategy.type,再去 staging 计时一次冷启动,计时要计全程:拉镜像、探针变绿、旧连接排空,三段加起来才是真实窗口。

延伸阅读

来源