没有 K8s,Docker Compose 也能自动回滚坏版本
健康检查、--wait 参数,加上 deploy.sh 里一个 else 分支,新版本未能按时变健康时就自动换回旧镜像。
要点速览
- --wait 会阻塞命令,直到服务变为健康;--wait-timeout 让坏版本导致命令失败,而不是一直挂起。
- 健康检查返回 0 表示健康,返回 1 表示不健康;2 是保留值,不要使用。
- 要做到零停机,需要一个反向代理,并且服务中不能声明 container_name 和 ports。
很多客户并不运行 Kubernetes。他们只有一台虚拟机、一个 compose.yaml 文件,以及一位只会敲 docker compose up 的运维人员。晚上 6 点新版本出了故障,能救场的不是什么平台,而是一条懂得说“不”、能自己退回旧版本的部署命令。
本指南要做的就是这样一套东西。最终你会得到:一个带健康检查的 app 服务、一个先行运行的迁移任务,以及一个接收两个版本号(新版本和旧版本)的 deploy.sh 脚本。如果新版本在时限内没有变为健康状态,脚本会自动把旧版本重新拉起来。
你需要安装了 Compose 插件的 Docker、一个带 /health 端点的小型 Web 应用,以及该应用的两个不同镜像标签,比如 v1 和 v2。下面的每条命令都可以先在笔记本上跑通,再带到客户的机器上。
健康检查必须说 Docker 听得懂的话
Docker 读取检查命令的退出码:0 表示容器健康、可以提供服务,1 表示不健康,2 是保留值。--retries 默认是 3,也就是说,连续失败三次后容器才会被标记为不健康。
第 1 步。 在应用的 Dockerfile 中加入:
# 假设镜像中有 curl,且应用监听 8080 端口
HEALTHCHECK --retries=3 CMD curl -f http://localhost:8080/health || exit 1
|| exit 1 不是摆设。很多工具失败时会返回任意错误码;强制改成 1,可以确保永远不会意外返回 2,而 Docker 并不把 2 当作有效状态。
检查: 构建镜像、运行容器,然后查看 docker ps 的 STATUS 列。接着关掉 /health 端点并等待:三次检查失败后,容器应变为 unhealthy。
有一点常被忽略:/health 端点应当检查应用真正依赖的东西,比如数据库连接。一个永远返回 200 的 /health,会让坏版本悄无声息地通过关卡。
启动顺序也是部署的一部分
借助 depends_on 加 condition: service_healthy,Compose 可以让一个服务等到它所依赖服务的健康检查通过后再启动。对于迁移这类一次性任务,service_completed_successfully 条件要求该任务成功结束后,下一个服务才能启动。
第 2 步。 一个精简版的 compose.yaml:
services:
db:
image: postgres:16
healthcheck:
test: ["CMD", "pg_isready"] # 已简化,请按你的数据库调整
migrate:
image: myapp:${APP_VERSION}
command: ["./migrate"]
depends_on:
db:
condition: service_healthy
app:
image: myapp:${APP_VERSION}
depends_on:
migrate:
condition: service_completed_successfully
${APP_VERSION} 是一个简化假设:镜像标签通过环境变量传入。./migrate 命令同样只是示意,请换成你真实的迁移命令。
检查: 运行 APP_VERSION=v1 docker compose up 并观察日志。migrate 应在 db 变为健康之后才运行,app 应在 migrate 成功退出之后才启动。如果让 migrate 以错误退出,app 就不会被启动。
部署关卡就在两个参数里
--wait 参数让 docker compose up 阻塞,直到服务处于运行或健康状态;它隐含了后台模式,因此不必再加 -d。--wait-timeout 参数设定最长等待秒数,这样坏版本会让命令失败,而不是永远挂起。
两个参数合起来,就把一条启动命令变成了一道是非题,shell 通过退出码就能读懂答案。再加上 --no-deps,只更新 app,不动 db 等关联服务。
第 3 步。 创建 deploy.sh:
#!/usr/bin/env bash
set -u
NEW="$1" # 例如 v2
OLD="$2" # 例如 v1
if APP_VERSION="$NEW" docker compose up --wait --wait-timeout 90 --no-deps app; then
echo "已部署 $NEW"
else
echo "$NEW 不健康,正在回滚到 $OLD"
APP_VERSION="$OLD" docker compose up --wait --wait-timeout 90 --no-deps app
fi
现在回滚真的只是一条命令:./deploy.sh v2 v1。半夜不需要记住任何步骤,只要知道哪个标签之前运行良好。
检查: 构建一个 /health 永远失败的 v2-broken 标签,然后运行 ./deploy.sh v2-broken v1。第一条命令应在 90 秒内失败,随后打印回滚提示;docker ps 应显示 app 重新运行 v1 镜像,并处于健康状态。
90 秒不要随手定
算一笔账。假设应用启动约需 20 秒,检查间隔 10 秒。按默认的三次重试,坏版本大约需要 20 + 3 × 10 = 50 秒才会被标记为不健康。
如果设成 --wait-timeout 30,就连一个启动稍慢的正常版本也可能被判为失败,白白回滚。如果设成 600,每次部署失败后,客户都要等十分钟旧版本才回来。先在客户机器上测出真实的启动时间,再把超时设在这个总时长之上,并留出安全余量。
--no-deps 还藏着一个坑:它不碰关联服务,所以这个脚本里 migrate 不会运行。请把迁移作为部署前的独立步骤执行,并且写成向后兼容的形式,因为回滚镜像并不会回滚表结构。v1 必须能在 v2 迁移过的数据库上运行。
零停机需要两个版本同时运行
上面的脚本只回答“新版本是否健康”,并不保证新版本启动期间旧版本仍在提供服务。
Compose 的 deploy 规范区分了两种更新顺序:stop-first 先停掉旧版本;start-first 先拉起新版本,让两者重叠运行一段时间。
如果要让用户完全感受不到中断,就需要后者:交接期间有两个容器同时存活。
--scale 参数会覆盖 Compose 文件中的 scale 设置,让一个服务的多个实例并行运行。社区工具 docker-rollout 是一个 Docker CLI 插件,正是把这个思路自动化:先把实例数翻倍,等新实例起来,再移除旧实例。
但它有限制:服务不能在 Compose 文件中声明 container_name 或 ports,而且需要一个反向代理在新旧容器之间分配流量。
这个限制有其原因:两个容器不能占用主机上同一个固定端口,也不能共用同一个名字。所以由代理持有对外端口,背后的容器创建或移除时,用户毫无察觉。
Compose 规范也有声明式回滚
Compose 的 deploy 规范以声明式方式描述了这种行为。rollback_config 块定义更新失败时服务如何回滚。在 update_config 中,failure_action 取 continue、rollback 或 pause 三个值之一,order 则在前文提到的 stop-first 和 start-first 之间选择。
services:
app:
deploy:
update_config:
failure_action: rollback
order: start-first
这是一份精简配置。依赖它之前,先确认客户机器上运行 Compose 文件的工具是否真的遵循这些 deploy 块。如果拿不准,上面的 deploy.sh 脚本更透明,因为它的每一步你都看得见。
把这套流程带到客户的机器上
前线部署工程师(FDE)的典型场景是:一台本地部署的服务器,或客户云上的一台虚拟机,IT 团队不允许再装一套编排系统。你交付的是一个仓库,里面有 compose.yaml、deploy.sh,以及一份 README,写清楚当前运行的是哪个标签、如何部署、回滚命令是什么。客户的运维人员必须不打电话给你也能完成操作。
到了客户现场,第一件事是问清应用的 /health 检查了什么,然后在他们的机器上测出真实启动时间。这两个事实决定了坏版本是被拦下,还是溜过关卡。
如果你在求职 FDE 岗位,简历上不要写“熟悉 Docker”。写得具体些,比如:为没有 Kubernetes 的环境搭建了一套 Compose 部署流程,包含健康检查、超时和自动回滚。再附上一个带 v2-broken 场景的小仓库,让招聘方可以亲自运行,看着回滚生效。
客户很少记得那些顺利的部署。他们记得的是:某次坏版本上了生产环境,三分钟后系统自己把旧版本换了回来。
这篇文章对你有帮助吗?
感谢你的反馈!