灰度发布回滚冷静期失效:判据只读动作名,没读 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 复现
一、问题背景
订单服务的发布流程分三步:
- 新版本
0.0.0-broken-canary先灰度,只占一小部分副本; - 观察窗口满 120 秒(
CANARY_HOLD_SEC)且指标达标,才允许全量推进(full_rollout); - 任何一次针对灰度版本的回滚之后,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里, 本次灰度就永远别想推进 —— 上线后表现为「这个服务再也不敢发版了」。
缺陷清单
| # | 位置 | 缺陷 | 后果 |
|---|---|---|---|
| D1 | app/canaryctl.py decide() | 未读 last_action_version | 过宽:别的版本的回滚也拦住本次发布 |
| D2 | app/canaryctl.py decide() | 未算 now - last_action_at vs ROLLBACK_HOLD_SEC | 过宽:冷静期永不失效 |
| D3 | app/canaryctl.py decide() | 命中冷静期返回 wait 而非 rollback | 过窄:版本边界失守,留下错误中间态 |
| D4 | app/deployer.py | ROLLBACK_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 前 | rollback | rollback(剩 1199s) |
ROLLBACK_HOLD_SEC=0 | full_rollout | full_rollout |
last_action_at == now - 1800(正好卡边界) | full_rollout | full_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(看当前态)。
八、一句话总结
状态判据的输入必须是"人、事、时"的完整组合,只取其中一个字段, 得到的不是"保守",而是"既拦不住该拦的、又拦住了不该拦的"。
配套的第二个教训:
该来自配置的版本号,被写成代码里的常量,就是静默失效 —— 它不报错、不打日志、数据结构完全合法,只是那条规则从此永远不生效。
