朕喜技术(ZhenxiJS)
2026.09.23
1 分钟阅读

灰度发布回滚冷静期失效:判据只读动作名,没读 last_action_version 与 last_action_at,叠加部署器硬编码回滚版本号

日期:2026-09-23 环境:Debian bookworm + Python 3(仅标准库,无第三方依赖)+ Docker Compose 技术栈 / 关键词:灰度发布、金丝雀发布、canary release、canary rollout、rollback、回滚、回滚冷静期、rollback hold、 ROLLBACK_HOLD_SEC、发布门禁、release gate、RELEASE_MODE=full、幂等、跨进程状态、last_action_version、 last_action_at、硬编码版本号、配置漂移、config drift、静默失效、silent failure、退出码 0 却出事故、 wait 与 rollback 语义混淆、半灰半稳中间态、Python、Docker Compose 复现


一、问题背景

订单服务的发布流程分三步:

  1. 新版本 0.0.0-broken-canary 先灰度,只占一小部分副本;
  2. 观察窗口满 120 秒(CANARY_HOLD_SEC)且指标达标,才允许全量推进(full_rollout);
  3. 任何一次针对灰度版本的回滚之后,1800 秒(ROLLBACK_HOLD_SEC)内都不允许再把它全量推出去 —— 这条硬规则写在 conf/release.env 里。

上周三出事故:

  • 上午灰度时 P95 时延 4.2s、错误率 7.3%,值班同学执行了一次 rollback,副本换回 0.0.0-stable-clean;
  • 10 分钟后,CI 里的发布控制器再次触发(每次都是新进程),输出:
== canaryctl 决策  CANARY=0.0.0-broken-canary  STABLE=0.0.0-stable-clean  REPLICAS=6
  上一次操作   : rollback  版本=0.0.0-broken-canary
  决策         : wait
  理由         : 上一次操作是 rollback,正处于回滚冷静期
  目标版本分布 : {}

它说自己在冷静期里,做的却是把集群留在「0 个目标版本、半灰半稳」的状态 —— 既没完成回滚,也没守住版本边界;

  • 更早一次同类事故里,它甚至直接输出了 full_rollout, 把没人测过的 0.0.0-broken-canary 推给了全部 6 个副本。

最诡异的地方:

  • 控制器退出码 0,日志里一条 ERROR 都没有;
  • rollout/state.json 是合法 JSON,字段一个不缺;
  • 发布报告 rollout/report.py 打出来"一切正常";
  • 部署器 apply 也没有失败,它就是老老实实照着 planned_versions 干活。

一句话:每个组件都在"正常工作",但发布方案整体是错的。


二、复现步骤

git fetch origin
git checkout 2026-09-23-0800
cd scenario/2026-09-23-0800

docker compose up -d --build

# 一键看症状
docker compose exec app bash ops/verify.sh     # 问题态:PASS=4 FAIL=9
docker compose exec app bash ops/repro.sh      # 逐步看现场
docker compose exec app bash ops/accept.sh     # 业务侧:RESULT: BAD

# 进容器动手修(app/ 是 bind mount,容器内改 = 宿主机改)
docker compose exec app bash

问题态实测输出

$ python3 app/canaryctl.py
== canaryctl 决策  CANARY=0.0.0-broken-canary  STABLE=0.0.0-stable-clean  REPLICAS=6
  上一次操作   : rollback  版本=0.0.0-broken-canary  时间戳=1790121384
  决策         : wait
  理由         : 上一次操作是 rollback,正处于回滚冷静期
  目标版本分布 : {}

$ python3 app/deployer.py step-scale
[deployer] step-scale: 回滚到 0.0.0-legacy-2024,副本先行降到 1   ← 版本号是写死的

$ python3 clients/check_release.py
[BAD] 等待动作必须带可解释的阻塞原因(wait 不换版本,门禁无法通过) —— wait 只观察不部署
RESULT: BAD

$ bash ops/verify.sh
verify.sh 结果: PASS=4 FAIL=9

三、根本原因

不是"回滚没生效",而是"回滚这件事没被记录成一个三元组"。

契约那条硬规则的原话:

「任何一次针对灰度版本的回滚之后 1800 秒内,都不允许再把这个灰度版本全量推出去。」

拆开是三个维度:

维度字段问题态有没有用
是什么动作last_action✅ 用了
对哪个版本做的last_action_version❌ 完全没读
距今多久last_action_at + ROLLBACK_HOLD_SEC❌ 完全没算

问题代码(app/canaryctl.py):

last = (current.get("last_action") or "").strip()
if last == ACTION_ROLLBACK:
    return (ACTION_WAIT, f"上一次操作是 {last},正处于回滚冷静期", ...)
return (ACTION_FULL, f"{canary} 灰度达标,可全量", {})

判据退化成「上一次是 rollback 吗」这个永久生效的开关,方向上是两头都错:

  • 过窄(该拦的没拦):真正的冷静期内只回一个语义不明的 wait —— 既不换版本也不清残留,把集群留在「半灰半稳」的中间态。 之后任何一次手工 apply / 重跑都会基于这个中间态继续算,事故就是这么"攒"出来的。
  • 过宽(不该拦的拦了):只要历史上任何一次回滚 (哪怕是别的服务、别的版本、9000 秒前)留在 current.json 里, 本次灰度就永远别想推进 —— 上线后表现为「这个服务再也不敢发版了」。

缺陷清单

#位置缺陷后果
D1app/canaryctl.py decide()未读 last_action_version过宽:别的版本的回滚也拦住本次发布
D2app/canaryctl.py decide()未算 now - last_action_at vs ROLLBACK_HOLD_SEC过宽:冷静期永不失效
D3app/canaryctl.py decide()命中冷静期返回 wait 而非 rollback过窄:版本边界失守,留下错误中间态
D4app/deployer.pyROLLBACK_TARGET_VERSION 硬编码 0.0.0-legacy-2024静默失效:回滚目标不是真实稳定版本

为什么 wait 比 full_rollout 更危险? 它把一个错误的中间态当成结果发布了。控制器说"在冷静期里",手里却什么都没做。 副本分布既不是全灰也不是全稳,下一次任意操作都会基于这个中间态继续算。

D4 为什么最难发现? 它不报错、不打日志,want.json 结构完全合法, 只是「回滚把副本降到 N 个」这条规则永远匹配不上真实版本 —— 典型的静默失效。 更深一层的形态是:该来自配置的版本号,被写成了代码里的常量。


四、解决办法

只改 app/canaryctl.py 与 app/deployer.py 两个文件。

1. 抽出「冷静期剩余秒数」纯函数(修 D1 + D2)

三条判据缺一不可,必须同时成立:

def rollback_hold_remaining(conf: dict, current: dict, now: float = None) -> int:
    """回滚冷静期剩余秒数。<=0 表示没有生效中的冷静期。"""
    now = time.time() if now is None else now

    # ① 动作
    if (current.get("last_action") or "").strip() != ACTION_ROLLBACK:
        return 0

    # ② 版本:只认「针对这个灰度版本」的那次回滚
    held = (current.get("last_action_version") or "").strip()
    canary = (conf.get("CANARY_VERSION") or "").strip()
    if not held or not canary or held != canary:
        return 0

    # ③ 时间:每次现算,不能把"剩余秒数"落盘复用(下次读到就过期了)
    try:
        last_at = float(current.get("last_action_at") or 0)
    except (TypeError, ValueError):
        return 0
    if last_at <= 0:
        return 0

    hold = conf_int(conf, "ROLLBACK_HOLD_SEC", 1800)
    return int(hold - (now - last_at))

关键点:「距今多久」必须现算。 把"剩余秒数"存进文件看似更省事,但下一次进程读到它时早就过期了 —— 这正是判据必须跨进程成立的题眼。

2. 命中冷静期时明确回滚(修 D3)

remaining = rollback_hold_remaining(conf, current, now=now)
if remaining > 0:
    # 命中冷静期 -> 必须给出明确的 rollback 动作,把副本换回稳定版本。
    # 这里**不能**返回 wait:wait 不换版本,会把集群留在「半灰半稳」的中间态,
    # 下一次任意手工 apply 就会把没验证过的灰度版本推上去 —— 这正是事故的成因。
    return (
        ACTION_ROLLBACK,
        f"冷静期内:{canary} 在 {remaining}s 前被回滚过,"
        f"还需 {remaining}s 才能重新全量;先把副本回滚到 {stable}",
        {"hold_remaining_sec": remaining},
    )

同时 summarize() 的 ACTION_ROLLBACK 分支产出 {stable: replicas} —— 回滚后 6 个副本不多不少全部指向稳定版本,而不是留一个半灰半稳的中间态。

3. 删掉硬编码版本号(修 D4)

# 回滚目标版本**只能**来自 conf/release.env 的 STABLE_VERSION。
# 原来的 ROLLBACK_TARGET_VERSION = "0.0.0-legacy-2024" 已删除。

def cmd_step_scale(conf: dict) -> int:
    state = load_json(STATE)

    # 回滚目标 = 配置里的 STABLE_VERSION(绝不写死)
    version = (conf.get("STABLE_VERSION") or "").strip()
    if not version:
        print("[deployer] step-scale: 配置里没有 STABLE_VERSION,拒绝执行", file=sys.stderr)
        return 3

    # 契约 §3:回滚要保留 ROLLBACK_MIN_REPLICAS 个副本
    keep = canaryctl.conf_int(conf, "ROLLBACK_MIN_REPLICAS", 1)

    # 加固:口径漂移直接拒绝执行,而不是默默照做
    state_stable = (state.get("stable_version") or "").strip()
    mismatch = (state.get("action") == canaryctl.ACTION_ROLLBACK
                and state_stable and state_stable != version)
    ...

4. 别写成一刀切(最容易犯的假修复)

如果只写 if last == ACTION_ROLLBACK: return rollback, 就把「该回滚却全量」换成了「永远发不了版」—— 把过窄缺陷换成了过宽缺陷。ops/verify.sh 的 A 组断言(现场 B:回滚针对别的版本,必须放行) 在任何状态下都必须 PASS,它就是用来抓这个假修复的。


五、验证

三种现场的决策(防一刀切)

docker compose exec app bash -c '
bash ops/scenario.sh mismatch >/dev/null
echo "--- 现场 A:冷静期内,应 rollback ---"; python3 app/canaryctl.py | grep 决策
bash ops/scenario.sh red-herring >/dev/null
echo "--- 现场 B:回滚针对别的版本,应 full_rollout ---"; python3 app/canaryctl.py | grep 决策
bash ops/scenario.sh none >/dev/null
echo "--- 对照:无未了结回滚,应 full_rollout ---"; python3 app/canaryctl.py | grep 决策
'
--- 现场 A:冷静期内,应 rollback ---
  决策         : rollback
--- 现场 B:回滚针对别的版本,应 full_rollout ---
  决策         : full_rollout
--- 对照:无未了结回滚,应 full_rollout ---
  决策         : full_rollout

现场 A 修复后的完整输出:

== canaryctl 决策  CANARY=0.0.0-broken-canary  STABLE=0.0.0-stable-clean  REPLICAS=6
  上一次操作   : rollback  版本=0.0.0-broken-canary  时间戳=1790121411
  决策         : rollback
  理由         : 冷静期内:0.0.0-broken-canary 在 1199s 前被回滚过,还需 1199s 才能重新全量;
                 先把副本回滚到 0.0.0-stable-clean
  冷静期剩余   : 1199s
  目标版本分布 : {'0.0.0-stable-clean': 6}

冷静期边界

场景期望实测
ROLLBACK_HOLD_SEC=1800,回滚在 600s 前rollbackrollback(剩 1199s)
ROLLBACK_HOLD_SEC=0full_rolloutfull_rollout
last_action_at == now - 1800(正好卡边界)full_rolloutfull_rollout

跨进程 / 跨重启 / 幂等

  第 1 次独立进程: rollback
  第 2 次独立进程: rollback
  第 3 次独立进程: rollback
--- 重启容器后再跑 ---
  重启后: rollback

判据确实是在读磁盘上的 current.json 现算,不靠进程内变量。

部署器回滚步骤

$ python3 app/deployer.py step-scale
[deployer] step-scale: 回滚到 0.0.0-stable-clean,保留 2 个副本

$ python3 -c "import json;print(json.load(open('rollout/want.json'))['versions'])"
{'0.0.0-stable-clean': 2}

最终判定

$ docker compose exec app bash ops/verify.sh
...
==============================================
verify.sh 结果: PASS=13 FAIL=0
==============================================

$ docker compose exec app bash ops/accept.sh
通过 4 项,不通过 0 项
RESULT: OK

问题态 PASS=4 FAIL=9 → 修复后 PASS=13 FAIL=0,门禁 RESULT: BAD → RESULT: OK。


六、经验与避坑

1. 状态判据的输入必须是"人、事、时"的完整组合

只取其中一个字段(比如只看动作名),得到的不是"保守",而是"两头都错":

  • 该拦的时候拦不住(冷静期内只回 wait,留下半灰半稳中间态);
  • 不该拦的时候拦死了(历史任意一次回滚永久拉闸,这个服务再也不敢发版)。

写这类判据时,逐字把契约句子拆成字段: 「任何一次针对灰度版本的回滚之后 1800 秒内」→ 动作 / 版本 / 时间,一个都不能少。

2. wait / rollback / full_rollout 的语义要写死

wait 是「什么都不做」,它会把错误的中间态当成结果发布。 如果契约要求"冷静期内不许推灰度版本",那正确动作是 主动回滚到稳定版本(rollback),而不是被动等待(wait)。 用词含糊的状态机,会在事后复盘时被当成"它说自己在正常处理"。

3. 冷静期必须现算 now - last_action_at

不要把"剩余秒数"算好存进文件复用 —— 下一次进程读到它时早就过期了。 同时要保证判据跨进程、跨重启成立:控制器每次都是新进程(CI 触发), 状态只能从磁盘读。

4. 版本号是配置项,不是常量(静默失效的高发区)

ROLLBACK_TARGET_VERSION = "0.0.0-legacy-2024" 这种硬编码,特征是:

  • 不报错、不打日志;
  • 数据结构完全合法;
  • 只是某条规则从此永远不生效。

防御手段:当同一个值有两个来源时(部署器手上的 state["stable_version"] 与配置里的 STABLE_VERSION),让它们互相对账,不一致就大声失败 (exit 3 + stderr + 把被拒快照落盘),而不是默默照做。

5. 退出码 0 + 无 ERROR ≠ 正确

这套系统的每个组件都"正常工作":控制器退出码 0、state.json 合法、 报告字段齐全、部署器 apply 没失败 —— 但整体方案是错的。 可观测性缺口在于:没有把「它的输出与自己声明的理由」放在一起做一致性校验。 本题里 rollout/report.py 把「上一次操作=rollback@0.0.0-broken-canary」 和「本次决策=wait」并排放着,才是唯一一眼能看出矛盾的地方。

6. ★ 坑:want.json 是单值快照,跑的顺序会改变门禁结论

取证时按 apply → step-scale 的顺序跑,门禁会报一条假 BAD:

[BAD] 部署器目标快照与控制器决策一致 —— 部署器={'0.0.0-stable-clean': 2} 控制器={'0.0.0-stable-clean': 6}
RESULT: BAD

原因不是改错了,而是顺序:

  • step-scale 是回滚的第 1 步(降副本的中间态),want.json = {stable: 2};
  • apply 是终态(按控制器 planned_versions 铺满),want.json = {stable: 6}。

门禁 §4 拿 want.json 与 planned_versions 比,比的是终态。 所以必须按真实部署顺序跑:

python3 app/deployer.py step-scale      # 第 1 步:降副本
python3 app/deployer.py apply           # 终态:铺满
python3 clients/check_release.py        # → RESULT: OK

教训:门禁脚本里 want.json 谁最后写谁生效。 取证时务必按真实顺序跑,否则会自己给自己造一条假 BAD、白查半天。

7. 坑:旧产物会遮住新代码

rollout/ 下的 state.json / want.json / actual.json 是上一轮跑出来的产物, 会被 bind mount 带进容器,容易被旧数据误导。

解法:rm -f rollout/state.json rollout/want.json rollout/actual.json 后重跑, 或收尾时 docker compose down -v。

8. 坑:fix/apply_fix.sh 不带参数是「应用参考修复」,不是「还原」

bash fix/apply_fix.sh            # 应用参考修复(会覆盖你自己写的 app/canaryctl.py!)
bash fix/apply_fix.sh --restore  # 还原问题态
bash fix/apply_fix.sh --status   # 看当前处于哪一态

我中途想「还原问题态再复现一次」,漏了 --restore, 自己的实现被参考实现直接覆盖;又因为用了 >/dev/null 2>&1, 连它打印的 已应用参考修复 都没看到,白排查了一轮。

两条教训:跑这类一次性脚本一定带明确参数(先 --status); 别把输出重定向到 /dev/null 再纳闷"为什么没生效" —— 这类脚本的输出就是它的返回值。动手改文件前先 cp 一份备份。

9. 坑:验证脚本会临时改写配置

ops/verify.sh 的 D2 断言会把 conf/release.env 的 ROLLBACK_HOLD_SEC 临时改成 0 再改回 1800。若脚本被打断(Ctrl-C),配置可能停在 0, 后续人工验证就会"突然不拦了"。 跑完检查一眼 grep ROLLBACK_HOLD_SEC conf/release.env 是否还是 1800。


七、可复现命令速查

# 复现
git checkout 2026-09-23-0800
cd scenario/2026-09-23-0800
docker compose up -d --build
docker compose exec app bash ops/verify.sh      # 问题态 PASS=4 FAIL=9
docker compose exec app bash ops/repro.sh       # 逐步看三个现场
docker compose exec app bash ops/accept.sh      # RESULT: BAD

# 动手修(改 app/canaryctl.py 与 app/deployer.py,bind mount 直改宿主机)
docker compose exec app bash

# 验证
docker compose exec app bash ops/verify.sh      # PASS=13 FAIL=0
docker compose exec app bash ops/accept.sh      # RESULT: OK

# 收尾
docker compose down -v

参考实现对照:fix/apply_fix.sh(应用)/ --restore(还原问题态)/ --status(看当前态)。


八、一句话总结

状态判据的输入必须是"人、事、时"的完整组合,只取其中一个字段, 得到的不是"保守",而是"既拦不住该拦的、又拦住了不该拦的"。

配套的第二个教训:

该来自配置的版本号,被写成代码里的常量,就是静默失效 —— 它不报错、不打日志、数据结构完全合法,只是那条规则从此永远不生效。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-23-0800

分享这篇文章