KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

06 · 升级与回滚:要么升上去,要么原样退回来 — keel 龙骨

这一章要回答的是:怎么让「升级」这个动作只有两种结局。 中间态——升了一半、 结构和代码对不上、数据被新版本写脏——是私有化交付里最贵的一种状态,因为在客户现场没人能收场。 这一章的所有设计都在消除中间态,包括一个真实存在过的缺陷和一次踩进去的坑。

这一章要回答的是:怎么让「升级」这个动作只有两种结局。 中间态——升了一半、
结构和代码对不上、数据被新版本写脏——是私有化交付里最贵的一种状态,因为在客户现场没人能收场。
这一章的所有设计都在消除中间态,包括一个真实存在过的缺陷和一次踩进去的坑。


一、先看一个真实的缺陷

初版 1.0.0 里有一个缺陷,它的形态是这样的:worker 在被 SIGKILL 时,正在跑的任务会永久停在 running,重启也不回收。

要复现它,需要一套「任务够长」的现场。第二套实例的 BOARD_JOB_SECONDS 就是为这件事改成 20 的(05 章那三行差异的最后一行)。

正面:把停机窗口设得比任务耗时长。

==> docker compose stop worker
 Container board2-worker-1 Stopping
 Container board2-worker-1 Stopped
docker compose stop 耗时 16 秒

worker 的日志是完整的四行:

{"event": "job.started",       "id": 1, "title": "停机窗口实验"}         09:06:52
{"event": "shutdown.requested","signal": "SIGTERM", "in_flight": 1}      09:07:01
{"event": "job.done",          "id": 1, "seconds": 20.03}                09:07:12
{"event": "worker.stopped",    "in_flight": null}                        09:07:12

逐行读一遍,时间戳是关键:

  1. 09:06:52 任务开始,预计跑 20 秒;
  2. 09:07:01 收到 SIGTERM,日志里带着 in_flight: 1——手上有 1 个任务;
  3. 它没有立刻退出。它把这 20 秒跑完了;
  4. 09:07:12 任务 done,然后才是 worker.stopped。

任务状态:done,结果是「处理完成,用时 20.0s」。这是一次干净的停机。

反面:把停机窗口改小。

同一个实验,只把 BOARD_WORKER_STOP_GRACE 从 60s 改成 5s:

docker compose stop 耗时 9 秒

注意这个数字——9 秒,而不是 16 秒。 它说明 docker stop 没有等到任务做完,而是等到窗口用完就 SIGKILL 了。worker 日志到这一行就断了:

{"event": "shutdown.requested", "signal": "SIGTERM", "in_flight": 2}

没有 job.done,没有 worker.stopped。 任务状态:永远停在 running——而且重启 worker 也不会收拾它,因为新 worker 只领 pending 的任务。

二、那个参数到底在管什么

把上面两组读数放一起,机制就清楚了:

正面(窗口 60s > 任务 20s) 反面(窗口 5s < 任务 20s)
docker compose stop 耗时 16 秒 9 秒
worker 日志的最后一行 worker.stopped shutdown.requested
任务最终状态 done 永远 running

stop_grace_period 的语义只有一句:SIGTERM 之后等多久才 SIGKILL。 所以它必须与任务最长耗时对齐:

后半句才是重点。SIGKILL 不给进程任何机会——它不能写日志、不能改数据库、不能告诉任何人「我手上这个任务没做完」。所以「停不干净」的表现不是一条报错,而是一行永远停在那里的数据。

这套编排给三个服务配了三个不同的窗口,理由也在这里:

db:      stop_grace_period: 30s
web:     stop_grace_period: 15s
worker:  stop_grace_period: ${BOARD_WORKER_STOP_GRACE:-60s}

「停得干净」对三个服务是三件事:数据库要时间落盘并关闭连接;web 手上只有短请求,15 秒够,设太长只会拖慢客户的重启;worker 手上可能是几十秒的长任务,它的窗口必须由任务耗时决定,而不是由某个经验数字决定。这也是为什么它是环境变量——它是现场参数,不是制品参数。

三、修法:把孤儿捡回来

窗口调对能防住大部分情况,但防不住全部:容器被 OOM 杀、机器断电、docker kill——这些都不看窗口。所以 1.1.0 加了一道兜底。

思路是把「状态是 running 但已经没有进程在跑它」这一类任务识别出来,放回队列:

UPDATE tasks SET status = 'pending', updated_at = now()
 WHERE status = 'running'
   AND (claimed_at IS NULL OR claimed_at < now() - make_interval(secs => %s))
 RETURNING id

修好之后,同一个「被砍在中间的任务」重启后的读数是:

{"event": "worker.started", ...}
{"event": "orphans.reaped", "ids": [2], "after_seconds": 120}
{"event": "job.started",  "id": 2, "title": "被砍在中间的任务"}
{"event": "job.done",     "id": 2, "seconds": 20.03}

任务从卡死的 running 变成了 done。这条的四个设计点值得逐条看清:

  1. 它跑在 worker 启动时,不是定时任务里。 因为它要解决的问题恰恰发生在「上一个进程已经不在」之后——重启是唯一稳定的触发点。
  2. 判据是 claimed_at,不是 status。 刚被别的 worker 领走的任务也是 running,无条件扫会把正在跑的任务抢回来。所以 1.1.0 的迁移 3 加的正是 tasks.claimed_at 这一列。
  3. 它必须容忍 claimed_at IS NULL。 从 1.0.0 升上来的老数据没有这一列的值——升级路径上的历史数据必须是新代码能处理的输入。
  4. 它自己失败不能拖垮 worker。 收拾不了就记一条 orphans.reap_failed 继续干活:孤儿多等一会儿不致命,而 worker 因为这件事起不来,就是全套停摆。

四、升级的四步,顺序不能换

升级脚本的顺序是刻意排的,理由是每一步失败时,能退回的位置不同:

flowchart TD
    START["开始升级,当前 board:1.0.0(结构 v2)"] --> B["① 备份数据库 pg_dump"]
    B -->|"备份失败"| ABORT["中止:本次没有做任何改动"]
    B --> C["② 导入新镜像 board:1.1.0"]
    C -->|"导入失败"| KEEP["中止:运行中的栈没被动过"]
    C --> D["③ 切 .env 到新镜像并起栈,migrate 先跑"]
    D -->|"migrate 非 0 退出"| RB["自动回滚:切回旧镜像、下行走结构、起栈"]
    D --> E["④ 自检 selfcheck.sh"]
    E -->|"自检不过"| RB
    E -->|"全部通过"| OK["升级完成:board:1.1.0,结构 v3"]
    RB --> BACK["退回 board:1.0.0 与结构 v2,备份留在 backups/"]

成功路径的读数:

==> 升级完成:board:1.1.0
==> 回滚命令:./rollback.sh --image board --schema 2 --restore backups/pre-upgrade-20261015-171030.sql.gz

注意最后那行。 升级脚本在成功的时候,把「怎么退回去」这条命令打印出来。不是希望有人记住,而是因为退回需要两个现场才知道的信息:退到哪个结构版本(--schema 2)、用哪一份备份(--restore backups/...sql.gz)。把它们留在升级那一刻的屏幕上,比留在任何文档里都可靠。

备份用的是 pg_dump 而不是卷快照,理由也在脚本注释里:客户现场往往没有快照能力,而一个 SQL 文件可以拷到任何地方——这是最不依赖环境的退路。

五、回滚:最容易写错的那一条

回滚 1.1.0 → 1.0.0,正常路径的读数是干净的:

{"event": "migrate.step", "direction": "down", "version": 3}
{"event": "migrate.done", "from": 3, "to": 2}

结构版本回到 2,claimed_at 列随结构一起消失。迁移不是一段 SQL,而是一组 (版本号, 上行, 下行) 三元组——「升级 = 跑到目标版本」和「回滚 = 退到目标版本」是同一段代码的两个方向。 这是「可回滚」能成立的地基。

但初版回滚踩了一个坑,它的形态值得完整记住:

flowchart TD
    R0["要回滚:当前 board:1.1.0(结构 v3),目标 board:1.0.0(结构 v2)"] --> R1{"用哪一版镜像执行下行迁移"}
    R1 -->|"用旧镜像 board:1.0.0"| W["它的迁移表里没有第 3 版,--to 2 变成一次静默的空操作:退出码 0、什么都没做"]
    W --> W2["起栈后 worker 探针报:库结构版本 3,镜像要求 2"]
    W2 --> W3["docker compose up 报 container board2-worker-1 is unhealthy"]
    R1 -->|"用当前在跑的那一版 board:1.1.0"| G["它认识新结构,能执行第 3 版的下行语句"]
    G --> G2["结构退回 v2,claimed_at 列随结构一起消失"]

坑在于:用旧镜像去退结构,旧镜像的迁移表里根本没有新那一版。 于是 --to 2 变成一次静默的空操作——命令成功、退出码 0、什么都没发生。它不报错,因为从一个不懂第 3 版的镜像的角度看,库本来就在它认识的那个版本上。

然后起栈,worker 的健康检查喊出真相:

[FAIL] 库结构版本:3,镜像要求 2 —— 先跑 `board migrate`

紧接着 docker compose up --wait 报 container board2-worker-1 is unhealthy。

修法只有一条:先取「当前在跑的那一版镜像」,用它跑下行迁移,跑完再切 .env。 于是有了这条通则——

回滚时执行迁移的镜像,必须是认识新结构的那一版。

这个坑的教训比坑本身更值钱:一个退出码为 0 的动作,不等于它做了你以为的那件事。 任何时候一个「应该改了点什么」的命令返回成功、而现场状态没变,第一件要问的是**「它有没有能力做这件事」**,而不是「它为什么没做」。

六、迁移失败时,它真的「什么都没做」吗

最后一个必须澄清的误解,用一个故意做坏的版本演练。把 1.2.0 的第 4 版迁移写成一条引用不存在列名的语句,然后升级:

==> 升级 board:1.0.0(结构 v2) → board:1.2.0
==> 备份数据库
==> 导入 board:1.2.0
==> 应用新版本(migrate 会先跑,失败则整栈不会起)
[失败] 新版本没有起来,开始自动回滚。
==> 回滚:board:1.2.0 → board:1.0.0,结构 v… → v2
==> 用 board:1.2.0 执行下行迁移到 v2
{"event": "migrate.step", "direction": "down", "version": 3}
{"event": "migrate.done", "from": 3, "to": 2}
  结构版本                       2
自检结束:全部通过
[失败] 升级失败,已退回 board:1.0.0。备份在 backups/pre-upgrade-20261015-171921.sql.gz。

去掉那些 ==>,这里发生了四件事:迁移失败 → 整栈没有起来 → 自动回滚(注意它回滚时用的正是 1.2.0,因为它是当前在跑的那一版)→ 结构退回 v2、自检通过、退回旧版本。

坏迁移的原始报错是:

psycopg.errors.UndefinedColumn: column "definitely_not_a_column" does not exist
LINE 3:         SELECT definitely_not_a_column FROM tasks;

docker compose up --wait 侧的原话是:

Container board2-migrate-1 Error service "migrate" didn't complete successfully: exit 1

但这里有一个细节必须单独记住。 回头上面那两行日志:

{"event": "migrate.step", "direction": "down", "version": 3}
{"event": "migrate.done", "from": 3, "to": 2}

回滚时退掉的只有第 3 版,而升级时明明跑到过第 4 版。原因在 migrate.done 的另一个读数上——坏版本那次的结构是 3,不是 2:

迁移 3 成功并已提交,迁移 4 失败。所以升级那一次结束时,migrate.done 是 from 3 to 3。

迁移是逐条提交的,所以「迁移失败」很少是「什么都没发生」,而是「停在中间」。 这就是为什么升级脚本必须在失败路径上走一次下行迁移,而不能只是「切回旧镜像重来」——结构不会自己退回去。

动手:可观察结果

在第二套实例上制造一次「被砍在中间」,然后看它怎么被收拾:

docker compose exec db psql -U board -d board \
  -c "INSERT INTO tasks (title) VALUES ('被砍在中间的任务') RETURNING id"
docker compose stop worker          # 在任务跑完之前就让它停
docker compose ps worker            # 看它停得干不干净
docker compose start worker         # 重启,看它捡不捡孤儿

要观察三件事,而不是「看到日志有输出就算跑过」:

  1. stop 花了几秒。 与 BOARD_JOB_SECONDS 比一下——这一步就能判断窗口够不够。
  2. worker 日志的最后一行是什么。 是 worker.stopped 还是断在 shutdown.requested?
    这两种结尾对应完全不同的后果。
  3. 重启之后有没有 orphans.reaped。 有它、并且随后出现 job.done,说明孤儿被捡回来了。

完成标志:能不看笔记说出「窗口设小之后,那一行数据停在了哪个状态,以及为什么重启也救不回来」。

故障注入

把 BOARD_WORKER_STOP_GRACE 改成 5s(必须小于 BOARD_JOB_SECONDS),重启 worker,然后提交一个任务并立刻停它:

docker compose up -d --force-recreate worker
docker compose stop worker

预期结果是:stop 只用掉窗口那么久,日志断在 shutdown.requested,没有 job.done;
去数据库里查那条任务,它的 status 停在 running。

再补一步才完整:把 BOARD_ORPHAN_AFTER_SECONDS 也临时调小(比如 5),重启 worker。
预期这次会看到 orphans.reaped,任务回到 pending 并被重新处理成 done。

这两步合起来说明的是:「窗口对齐」和「孤儿回收」是同一件事的两道防线。 第一道防住常规停机,
第二道兜住异常终止——只看其中一道,就会在另一道上翻车。

生产边界

教学里这样 真实交付要换成 不换会出什么事
pg_dump 备份到本地 backups/ 目录 备份落到独立存储,并做恢复演练 备份和数据库在同一块盘上,盘一坏两样一起没
升级脚本自动回滚 保留自动回滚,但同时留下人工判据与交接记录 自动回滚把「升级失败」这件事悄悄盖过去,没有人知道发生过
结构版本只退到上一版 明确「最远能退几版」,并验证跨版回滚 客户想退两版时,中间的迁移路径根本没人跑过
单实例升级 多实例时逐个升级,并明确升级顺序 两套实例共享的备份/端口资源被同时占用
手工触发升级 升级窗口、维护通知、回退判据都提前约定 升级撞上业务高峰,出了事也没人知道该不该退

自测题

  1. stop_grace_period 与任务耗时的关系是什么?设小之后,那行数据停在哪个状态,
    为什么重启也不回收它?
  2. 为什么孤儿回收要跑在 worker 启动时而不是定时任务里?为什么判据用 claimed_at
    而不是 status?
  3. 四步升级顺序里,哪一步失败时「运行中的栈没有被碰过」?为什么这个性质重要?
  4. 回滚时为什么不能用旧镜像执行下行迁移?举出它的失败形态(包括退出码)。
  5. 坏迁移演练里,回滚只退了第 3 版,而升级跑到过第 4 版——为什么?

现在能解释什么

进入 keel 阅读