# 没有 K8s，Docker Compose 也能自动回滚坏版本

> 健康检查、--wait 参数，加上 deploy.sh 里一个 else 分支，新版本未能按时变健康时就自动换回旧镜像。

原文: https://fdetimes.net/zh/guides/docker-compose-wait-rollback/

很多客户并不运行 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 中加入：

```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`：

```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`：

```bash
#!/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` 之间选择。

```yaml
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` 场景的小仓库，让招聘方可以亲自运行，看着回滚生效。

客户很少记得那些顺利的部署。他们记得的是：某次坏版本上了生产环境，三分钟后系统自己把旧版本换了回来。

**本周动手试试:**

- 给你运行的某个服务的 Dockerfile 加上 HEALTHCHECK，然后故意让 /health 端点返回错误，观察容器变为 unhealthy
- 写出本指南中的 deploy.sh 脚本，尝试部署一个故意损坏的镜像，看回滚自动执行
- 把项目中的迁移任务拆成独立服务，并为它加上 service_completed_successfully 条件

## 信源

- [Control startup and shutdown order in Compose](https://docs.docker.com/compose/how-tos/startup-order.md)

- [docker compose up](https://docs.docker.com/reference/cli/docker/compose/up/)

- [Compose Deploy Specification](https://docs.docker.com/reference/compose-file/deploy.md)

- [Dockerfile reference](https://docs.docker.com/reference/dockerfile/)

- [wowu/docker-rollout: Zero Downtime Deployment for Docker Compose](https://github.com/wowu/docker-rollout)
