单机短停机发布的可靠顺序是:启动候选版本,等待业务就绪,校验并切换 Caddy 上游,从公开域名确认结果,排空旧连接,最后停止旧版本。旧容器不能在候选版本接管流量前删除。

旧版本在 18080 提供流量,候选版本在 18081 启动并通过健康检查,Caddy 切换后验证公开域名,再排空旧连接
入口切换是唯一对外生效点;候选启动和预热都发生在切换之前。

适用条件与限制

同一台主机同时运行蓝、绿两套应用实例,适合满足以下条件的服务:

  • HTTP 应用基本无状态,上传文件放在共享对象存储或兼容的数据卷;
  • 数据库变更支持新旧版本同时运行;
  • 主机有同时容纳两个应用实例的 CPU、内存和端口;
  • Caddy、Nginx 等入口可以校验并平滑加载配置;
  • 应用提供无副作用的 readiness 端点和版本标识。

单机蓝绿发布仍受主机、磁盘、Docker daemon 和共享数据库这些单点约束。若一次发布必须重启主机或执行不兼容数据库迁移,应安排维护窗口,不要把实际存在的不可用窗口包装成“零停机”。

为每个发布创建不可变目录

把发布目录、共享数据和入口配置分开:

/opt/webapp/
├── releases/
│   ├── 20260903-201500-a1b2c3d/
│   │   ├── compose.yaml
│   │   ├── .env
│   │   └── release.json
│   └── 20260902-184000-91e0f2a/
├── shared/
│   └── data/
└── state/
    ├── active-release
    └── deployment.lock

/etc/caddy/sites/
├── webapp.caddy
└── webapp-upstream.caddy

release.json 保存源代码提交、镜像 digest、配置版本和构建时间。发布目录写入后不再修改;需要变更时生成新的发布 ID。

{
  "release_id": "20260903-201500-a1b2c3d",
  "commit": "<40-character-git-commit-id>",
  "image": "registry.example.com/webapp:1.8@sha256:<64-character-digest>"
}

部署开始时取得主机锁,避免两个流水线同时切换入口:

exec 9>/opt/webapp/state/deployment.lock
flock -n 9 || {
  printf '%s\n' '已有发布正在执行' >&2
  exit 1
}

CI 平台的并发限制仍应保留,但主机锁负责阻止手工命令、另一套流水线或重试任务同时操作同一服务。

蓝绿实例使用不同项目名和回环端口

Compose 文件把项目名、宿主端口和镜像作为部署输入:

services:
  app:
    image: ${APP_IMAGE:?APP_IMAGE is required}
    restart: unless-stopped
    ports:
      - "127.0.0.1:${APP_HOST_PORT:?APP_HOST_PORT is required}:8080"
    environment:
      RELEASE_ID: ${RELEASE_ID:?RELEASE_ID is required}
    volumes:
      - /opt/webapp/shared/data:/var/lib/webapp
    healthcheck:
      test: ["CMD", "/usr/local/bin/healthcheck"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 20s
    stop_grace_period: 45s

旧版本使用 COMPOSE_PROJECT_NAME=webapp-blue 和 18080,候选版本使用 webapp-green 和 18081。两个项目名必须不同,否则 Compose 会把候选实例当成旧服务的重建。

候选环境:

COMPOSE_PROJECT_NAME=webapp-green
APP_HOST_PORT=18081
RELEASE_ID=20260903-201500-a1b2c3d
APP_IMAGE=registry.example.com/webapp:1.8@sha256:<64-character-digest>

主机资源不足以并行运行时,只能停止旧实例再原地启动新实例。该路径应记录预计停机时间,并在入口返回维护页或 503;候选版本无法提前启动和预热,因此不应继续使用蓝绿切换流程。

readiness 要证明应用已经能接流量

健康端点至少确认应用初始化完成、路由可用和关键依赖处于可接受状态,但不要在每次探测中执行昂贵查询或写入数据。

GET /readyz

HTTP/1.1 200 OK
Content-Type: application/json

{"status":"ready","release":"20260903-201500-a1b2c3d"}

Docker healthcheck 可以调用容器内脚本;宿主机还要从实际回环端口检查:

docker compose --env-file candidate.env up -d --wait --wait-timeout 120
docker compose --env-file candidate.env ps
curl -fsS http://127.0.0.1:18081/readyz
curl -fsS http://127.0.0.1:18081/version

Docker Compose up --wait 文档(在新标签页打开)说明,它等待服务达到 running 或 healthy。健康状态只反映 Compose 中定义的探测,不能替代登录、读写或关键 API 的小型冒烟测试。

候选版本应在回环地址预热缓存、模板和连接池。预热失败时直接停止候选项目,公开入口仍指向旧版本。

将 Caddy 上游拆成独立配置文件

主站配置导入独立上游文件:

example.com {
    import /etc/caddy/sites/webapp-upstream.caddy
}

当前上游文件:

reverse_proxy 127.0.0.1:18080

切换时先在同目录写候选文件,再原子替换:

upstream_file=/etc/caddy/sites/webapp-upstream.caddy
candidate_file="${upstream_file}.candidate"
backup_file="${upstream_file}.previous"

printf '%s\n' 'reverse_proxy 127.0.0.1:18081' \
  | sudo tee "$candidate_file" >/dev/null
sudo cp "$upstream_file" "$backup_file"
sudo mv "$candidate_file" "$upstream_file"

mv 只有在源文件与目标文件位于同一文件系统时才具有原子替换语义。临时文件写在 /tmp 再移动到 /etc,可能跨文件系统而失去这个保证。

先校验配置,再平滑 reload

if ! sudo caddy validate \
  --config /etc/caddy/Caddyfile \
  --adapter caddyfile; then
  sudo mv "$backup_file" "$upstream_file"
  exit 1
fi

if ! sudo systemctl reload caddy; then
  sudo mv "$backup_file" "$upstream_file"
  sudo systemctl reload caddy
  exit 1
fi

Caddy 文档(在新标签页打开)建议通过 reload 平滑替换运行配置,而不是停止后重新启动。新配置加载失败时,旧配置仍应继续服务;脚本仍要恢复磁盘上的上游文件,避免下次重启加载一个未生效的错误状态。

配置 reload 成功只证明入口接受了新配置。此时还没有证明 DNS、TLS、Host 路由和候选应用组成的公开链路可用。

从公开域名验证真实入口

切换后立即检查:

curl --fail --silent --show-error \
  --connect-timeout 5 \
  --max-time 15 \
  https://example.com/readyz

curl --fail --silent --show-error \
  --connect-timeout 5 \
  --max-time 15 \
  https://example.com/version

返回的 release 必须等于候选 RELEASE_ID。只请求 127.0.0.1:18081,无法发现证书、域名路由、Caddy 导入文件或 CDN 回源仍指向旧版本。

公开验证失败时,先把上游文件恢复为 previous,校验并 reload,再确认旧版本重新对外服务:

sudo mv "$backup_file" "$upstream_file"
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy
curl -fsS https://example.com/version

旧版本恢复后,保留候选容器及其日志用于排查。立即删除失败容器会丢掉启动日志、环境差异和健康状态。

切换成功后排空旧连接

Caddy reload 成功后,新建立并按新配置路由的请求会进入候选上游;已经建立的连接、HTTP 请求或长流可能继续由旧实例处理。公开 release ID 证明新入口已经生效,却不能证明旧实例已经没有在途工作。等待时间至少覆盖应用正常请求超时和常见长请求;WebSocket、SSE 和大文件传输需要单独策略。

应用收到终止信号后应停止接受新请求,等待在途请求结束,并在 stop_grace_period 内退出。排空阶段观察:

docker stats --no-stream
docker compose --env-file active.env logs --since 2m app
ss -ntp | grep ':18080' || true

ss 只能提供连接级信号:连接消失不等于应用任务已经完成,连接存在也不一定代表仍有业务请求。应用若提供 in-flight request、活跃任务或优雅停机指标,应以这些指标和访问日志判断排空结果,再用连接列表辅助定位长连接。

公开请求持续成功、错误率和延迟正常后,先原子更新当前版本指针,再停止旧项目:

active_file=/opt/webapp/state/active-release
active_candidate="${active_file}.candidate"

printf '%s\n' '20260903-201500-a1b2c3d' \
  | sudo tee "$active_candidate" >/dev/null
sudo mv "$active_candidate" "$active_file"
docker compose --env-file active.env stop app

当前版本指针、公开验证和旧实例停止结果应写入同一份发布记录,再按保留数量清理更早版本。当前版本和最近一个可回滚版本不能在同一次清理中删除。若发布流程还要在进程中断或主机重启后自动恢复,参见持久发布事务与自动恢复

数据库和本地状态决定能否回滚

入口配置可以切回旧上游,但应用能否回滚还取决于当前数据库 schema。候选版本上线前,schema 必须同时兼容旧版本和新版本。删除字段、改变字段含义或执行不可逆数据转换后,旧容器即使能启动也可能读错数据。

数据库迁移应按Expand、Migrate、Contract拆分。Contract 阶段完成后,要把“可回滚版本”推进到兼容新 schema 的应用,而不是继续保留一个已经失效的旧镜像。

应用写入本机文件时,蓝绿实例需要同一兼容数据层或明确的迁移过程。SQLite、内嵌索引和不可并发访问的本地目录通常不适合双实例并行启动;这类应用应采用维护窗口或服务原生的备份、升级和恢复方式。

把切换结果写进发布记录

  • 候选镜像引用、源代码提交和 release ID;
  • Compose 最终配置和候选健康结果;
  • 切换前后的 Caddy 上游和配置校验结果;
  • 回环与公开域名返回的 release ID;
  • 连接排空时间、错误率和应用日志;
  • 当前版本、上一版本及其数据库兼容范围。

下一次发布从记录中读取旧版本;主机中断后的恢复程序据此判断应该终止候选、回滚入口还是完成已经验证的发布。

常见问题

Docker Compose 单机可以做到零停机吗?

无状态服务可以把不可用时间压得很短,但主机、Docker 和共享数据库仍是单点。发布时应测量不可用时间,并确认失败后能恢复旧版本。

docker compose up --wait 足够吗?

不够。还要检查业务 readiness、release ID、关键依赖和公开域名。

为什么同时保留旧版本和候选版本?

候选可以在接流量前启动和预热;切换失败时,入口能立即回到仍在运行的旧版本。

Caddy reload 失败会中断网站吗?

正确使用 validate 和 reload 时,错误配置不会替换运行中的旧配置。磁盘配置仍要恢复,避免服务重启后加载错误文件。

旧容器什么时候可以停止?

公开验证成功并完成连接排空后再停止。旧镜像和配置保留到观察窗口结束,且数据库仍需兼容它。