发布脚本突然退出时,不要直接执行 docker compose down。恢复动作取决于三个事实:候选版本是否已经接管公开流量、旧入口是否还能恢复、业务验证是否完成。旧版本仍在服务时,只需清理候选;入口可能切到一半时,要恢复旧上游;新版本仍在观察时,也应切回旧版;只有发布已经进入提交阶段,恢复程序才继续补齐提交记录。

候选准备、入口切换、业务观察和发布提交四种状态对应不同恢复动作
发布进程每完成一个会改变恢复选择的动作,就把状态写入磁盘;中断后从最后一条可靠状态继续。

旧版本保留到观察结束

候选容器可以启动失败,入口配置也可能校验失败,但这些错误不应影响正在工作的旧版本。发布开始后,旧容器、旧镜像和旧上游都先保留;候选版本使用另一个 Compose 项目名和回环端口启动。

旧版本  127.0.0.1:18080  ← Caddy 当前上游
候选版  127.0.0.1:18081  ← 只做回环检查

候选版本返回正确的 readiness 和 release ID 后,才有资格接管入口。公开验证、业务冒烟和观察窗口结束之前,不删除旧容器,也不清理旧镜像。

发布状态放在持久目录,例如 /var/lib/webapp-deploy/active.json

{
  "token": "deploy-20260903-201500-a1b2c3d",
  "phase": "prepared",
  "previous_release": "20260902-184000-91e0f2a",
  "candidate_release": "20260903-201500-a1b2c3d",
  "previous_upstream": "127.0.0.1:18080",
  "public_verified": false,
  "business_verified": false,
  "observation_complete": false
}

这个文件不保存密码。它只记录恢复选择所需的事实,并限制为部署账户可读写。更新时先在同一目录写临时文件,完成 fsync 后再用 rename 替换正式文件。Linux rename(2)(在新标签页打开) 在同一文件系统中替换目标时,不会暴露一个正式文件暂时消失的窗口。

主机锁和事务 token 处理不同问题。flock 阻止两个发布进程同时修改入口,进程退出后锁就会释放;写入 active.json 的 token 会继续存在,用来拒绝迟到的 CI 重试或旧 SSH 会话。停止候选、恢复入口和写入 receipt 时都核对 token,避免上一笔请求碰到下一笔发布。

候选没有接管流量时只清理候选

候选版本先在备用端口启动:

docker compose --env-file candidate.env config --quiet
docker compose --env-file candidate.env pull
docker compose --env-file candidate.env up -d --wait --wait-timeout 120

curl -fsS http://127.0.0.1:18081/readyz
curl -fsS http://127.0.0.1:18081/version

/version 返回值应与 candidate_release 相同。只检查 HTTP 200 不够:备用端口上若残留了旧进程,它也可能通过一个宽松的健康检查。

镜像拉取失败、容器启动超时或回环检查不通过时,Caddy 仍指向 18080。恢复程序只停止当前 token 对应的候选容器,然后从公开域名确认旧 release ID 仍在服务。它不改入口,也不停止整个 Compose 项目。

候选容器需要带上事务 token 或 release ID 标签。这样恢复程序可以准确找到本次发布创建的实例,不会误删同一主机上的其他服务。

入口切换前保存旧上游

切换 Caddy 之前,先保存旧上游文件及其摘要,再把 phase 写成 switching

state_dir=/var/lib/webapp-deploy
upstream=/etc/caddy/sites/webapp-upstream.caddy

cp -- "$upstream" "$state_dir/previous-upstream.caddy"
sha256sum "$state_dir/previous-upstream.caddy" \
  > "$state_dir/previous-upstream.sha256"

write_phase switching

switching 写在替换入口之前。主机恰好在两条命令之间断电时,恢复程序会保守地恢复旧上游;如果先改入口、后记状态,磁盘仍显示“候选未接管”,恢复程序可能把正在承接流量的候选直接停掉。

候选上游同样先写到目标目录中的临时文件。配置通过校验后原子替换正式文件,再平滑加载 Caddy:

sudo caddy validate \
  --config /etc/caddy/Caddyfile \
  --adapter caddyfile
sudo systemctl reload caddy

curl -fsS https://example.com/readyz
curl -fsS https://example.com/version

Caddy 的 reload 命令(在新标签页打开)会在新配置成功加载后替换运行配置。即使 reload 失败后旧配置仍在运行,磁盘上的上游文件也要恢复,否则下一次服务重启可能读到错误配置。

公开检查失败时,恢复程序复制回已校验的旧上游,重新执行 validate 和 reload,直到公开域名再次返回 previous_release。没有看到旧 release ID,就不能把事务记为已回滚。

新版本公开后继续观察业务结果

公开域名返回候选 release ID,只能说明 DNS、TLS、Caddy 和 Host 路由已经到达新版本。登录、写入、队列消费或第三方回调仍可能出错。

切换后执行与服务相符的冒烟请求。一个典型 Web 应用会检查匿名页面、会话读取、只读 API,以及一笔可识别、可撤销的写入。业务请求通过后,再观察错误率、延迟、依赖错误和队列积压。观察时长和停止阈值应在发布前确定,不在故障发生时临时决定。

Google SRE 的 Canary 发布说明(在新标签页打开)建议用关键指标比较新旧版本的行为。单机入口无法分批放量,但同样可以在旧版本仍可恢复的时间内观察候选表现。

恢复程序按下面四种现场状态选择动作:

中断时看到的状态公开流量可能在哪里恢复动作
候选尚未 ready旧版本停止本次候选,确认旧 release ID
入口正在切换新旧版本都可能恢复旧上游,reload 后确认旧 release ID
新版本正在观察候选版本切回旧上游,保留候选日志
发布正在提交已通过全部检查的候选版本核对持久证据后补齐提交

前三种状态都以旧版本重新稳定服务为终点。候选已经公开但观察尚未结束时,不猜测业务是否正常,也不因为一次 /readyz 成功就保留新版本。

业务观察结束后写入发布结果

公开检查、业务冒烟和观察窗口分别完成后,把三个结果写回事务状态。恢复程序确认它们属于同一个 candidate_release,并再次核对公开 release ID,然后把 phase 改成 committing

提交阶段按固定顺序写入:

  1. current-release 指向候选版本;
  2. receipt 记录 token、旧版本、候选版本、完成时间和验证结果摘要;
  3. token 移入已使用集合;
  4. active 事务删除。

这些动作都应支持重复执行。SSH 在 receipt 写入后断开,CI 用相同 token 查询结果即可;它不重新切换入口,也不创建第二份发布记录。主机在 committing 中重启时,只要三个验证结果和公开 release ID 仍一致,恢复程序就补齐缺失的步骤。

如果 committing 缺少业务验证或观察结果,持久状态已经互相矛盾。此时保留现场并阻止下一次发布,比猜测应该提交还是回滚更安全。

trap 和启动恢复处理不同故障

普通命令失败可以交给 shell trap:

set -euo pipefail

recover_on_exit() {
  status=$?
  trap - EXIT
  if [ "$status" -ne 0 ]; then
    /usr/local/sbin/recover-webapp-deploy --token "$transaction_token"
  fi
}
trap recover_on_exit EXIT

断电、内核崩溃和 SIGKILL 不会执行 EXIT trap。主机启动时,在 Caddy 开始读取入口配置前运行同一恢复程序:

[Unit]
Description=Recover interrupted webapp deployment
Requires=docker.service
After=docker.service
Before=caddy.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/recover-webapp-deploy --boot

[Install]
WantedBy=multi-user.target

下一次发布开始前也运行一次恢复程序。这样即使主机没有重启,只要上一条 SSH 会话留下 active 事务,新发布也不会覆盖现场。

数据库可能让旧版本失去回滚资格

入口恢复到旧容器,不代表应用一定能继续工作。数据库字段已经删除、字段含义已经改变,或数据完成不可逆转换后,旧应用可能无法读取当前 schema。

使用 Expand、Migrate、Contract 时,入口回滚只开放给仍兼容当前 schema 的版本。Contract 删除旧结构后,回滚目标也随之更新。无法同时兼容新旧应用的迁移需要维护窗口和数据库恢复方案,不能交给入口脚本自动处理。具体顺序见数据库迁移应该在发布哪一步执行

在非生产环境主动中断发布

自动恢复不能只靠一次顺利发布证明。测试时分别在候选启动后、入口文件替换后、Caddy reload 后、业务冒烟后和 receipt 写入后终止发布进程;再重启主机,检查公开 release ID、Caddy 上游、运行容器、current-release 和 receipt 是否收敛到同一结果。

候选尚未通过观察时,最终结果应是旧版本重新公开;事务进入 committing 后,最终结果应是同一候选和同一 receipt。systemd 单元显示成功,只能说明恢复命令正常退出,不能代替这些状态检查。

常见问题

set -e 和 trap 足够自动回滚吗?

不够。它们能接住发布进程仍有机会执行的失败;断电和 SIGKILL 要由持久状态与启动恢复处理。

为什么状态要写到磁盘?

发布进程消失后,恢复程序仍要知道旧版本、候选版本和入口是否开始切换。只保存在变量里的信息无法支撑重启恢复。

公开域名已经返回新版本,为什么还会回滚?

公开入口正确不代表登录、写入、队列和关键指标正常。业务冒烟与观察完成前,中断事务仍恢复旧版本。

什么时候可以继续完成提交?

公开检查、业务冒烟和观察结果都已写入,并且 phase 已进入 committing 时,恢复程序可以幂等完成提交。

什么时候可以清理旧版本?

receipt、current-release 和已使用 token 一致后,再由独立清理任务处理更早制品。当前版本和一个仍兼容数据库的回滚版本继续保留。