单机短停机发布的可靠顺序是:启动候选版本,等待业务就绪,校验并切换 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 时,错误配置不会替换运行中的旧配置。磁盘配置仍要恢复,避免服务重启后加载错误文件。
旧容器什么时候可以停止?
公开验证成功并完成连接排空后再停止。旧镜像和配置保留到观察窗口结束,且数据库仍需兼容它。