发布脚本突然退出时,不要直接执行 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。
提交阶段按固定顺序写入:
current-release指向候选版本;- receipt 记录 token、旧版本、候选版本、完成时间和验证结果摘要;
- token 移入已使用集合;
- 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 一致后,再由独立清理任务处理更早制品。当前版本和一个仍兼容数据库的回滚版本继续保留。