没有 Kubernetes,我用 700 行 Bash 实现了 Docker Compose 零停机平滑发布

项目开源地址:github.com/EvanSerene/... (欢迎 Star ⭐)

一、为什么要写这个脚本?

大多数中小团队的微服务是部署在单台服务器上的,用的是 Docker Compose。发布流程通常是:

bash 复制代码
docker compose down your-service
docker compose up -d your-service

简单,但有一个绕不开的痛点:发布 = 停服。每次发版用户都会看到"服务暂时不可用",前端网关疯狂报 502。发版频率一高,运维和开发的体验都很难受。

想彻底解决,第一反应是上 Kubernetes。但单机场景上 K8s 属实有点"杀鸡用牛刀":集群组件多、维护成本高、学习曲线陡,对小团队不友好。

那有没有一条中间路线?用纯 Bash + Docker Compose 原生能力,自己实现滚动发布。于是就有了这个项目------一个零依赖、开箱即用的单机平滑发布脚本。

二、脚本能干什么?

一句话:构建镜像、平滑滚动发布、失败自动回滚、手动回滚,一体化完成。不需要 Swarm、不需要 K8s,Docker Compose 环境拿来即用。

核心能力:

  • 平滑滚动发布:逐副本替换,新副本健康检查通过才停旧容器,全程零停机
  • 应用级健康检测 :通过 /actuator/health 接口探活,未就绪不发流量
  • 失败自动回滚:发布任意环节异常,自动清理新容器并恢复旧版本
  • 手动回滚:支持回滚到上一有效版本或指定 tag
  • 发布历史:每次发布自动记录,作为回滚依据
  • 并发锁:flock 按服务粒度加锁,防止同一服务并发发布/回滚
  • 双模式发布:白名单服务走平滑滚动,其余服务走停机发布(不是所有服务都值得平滑)

三、滚动发布的核心原理

这是整个脚本最有技术含量的部分,拆开讲。

3.1 先扫盲 30 秒:compose 的 scale 是什么

可能有读者没用过副本扩缩容,这里快速过一遍:

  • scale 是什么 :compose 文件里给服务声明 scale: 3,启动时就会创建 3 个副本------同一个服务的 3 个容器,共享 compose 网络
  • 命令行覆盖--scale 参数可以在不改文件的情况下临时指定副本数,例如 docker compose up --scale your-service=3 -d
  • --no-recreate 的含义:只处理新增的容器,现有容器一律不动
  • createup 的区别up = 创建 + 启动(配置变更时还会重建容器),create 只创建不启动,行为更可控

平时 up --scale 最常见的用途是手动扩缩容(比如大促临时多开几个实例)。而本文真正的主角是更细粒度的组合:create --scale N+1 --no-recreate------精准地多创建一个容器,其他容器一动不动,这就是滚动发布的地基。

3.2 一个关键前提:容器不能绑宿主端口

平滑发布依赖 compose 的 create --scale 增量创建行为:只创建新容器,不动旧容器。这时新旧副本会短暂并存------如果服务绑定了宿主端口,扩容瞬间就会端口冲突。

所以平滑发布的服务不能绑定宿主端口,由前置网关/代理(Nginx、云 LB 等)统一收口,把流量分发到所有副本。

3.3 不修改原始 compose 文件

如果直接改 docker-compose.yml 里的镜像 tag 再 up -d,compose 会检测到配置变更,触发全量重建------所有容器一起重启,这就又变成停机发布了。

正确的做法是生成一个临时 override 文件,只覆盖目标服务的镜像 tag:

yaml 复制代码
# .deploy-override.yml(临时生成,用完即删)
services:
  your-service1:
    image: your-service1:20260818160000

然后:

bash 复制代码
docker compose -f docker-compose.yml -f .deploy-override.yml create \
  --scale your-service1=N+1 --no-recreate your-service1

--no-recreate 保证现有容器原地不动,--scale N+1 只把副本数临时扩到 N+1,compose 会只创建新增的那个容器,且它用的是 override 里的新镜像。

3.4 应用级健康检测(不是 docker healthcheck)

容器 running 不代表应用就绪。脚本通过容器内网 IP 直接探测应用真实健康接口:

bash 复制代码
# 拿容器内网 IP
container_ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container_id")
# 探测 /actuator/health,期望 200
ret_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 \
  "http://${container_ip}:${port}/actuator/health")

关键点:

  • 探测的是容器内网 IP,不走宿主端口,绕开"不能绑端口"的限制
  • 健康检查没通过就不停旧容器,新版本没就绪,流量继续打到旧副本,用户无感知
  • 有超时上限(默认 120s),避免新版本起不来时无限等待

3.5 逐轮滚动:N 个副本替换 N 轮

arduino 复制代码
第 1/N 轮:create 新容器 → 启动 → 探活 → 通过 → stop+rm 一个旧容器
第 2/N 轮:create 新容器 → 启动 → 探活 → 通过 → stop+rm 一个旧容器
...
第 N/N 轮:全部替换完成 → 恢复 compose → 写发布历史

每轮只替换一个副本,新副本健康才杀旧副本,整个过程中始终有 N 个副本在服务流量------这就是零停机的来源。

有个实现细节值得说:怎么识别"本轮新增的容器"?脚本在发布前采集所有旧容器 ID 列表,每轮通过容器 ID 差集识别新容器,并且记录已处理的新容器 ID 逐轮排除。这样即使"跳过构建、新旧容器同 tag"的场景也能准确识别,不依赖镜像 tag 判断新旧。

四、失败自动回滚:发布事故的自救机制

发布最怕什么?新版本起不来。如果新容器没就绪就把旧容器全杀了,服务就彻底挂了。所以脚本用 set -o errtrace + ERR trap 接住任意环节的失败,自动走回滚流程:

bash 复制代码
trap '[[ -z "${_DIE_EXIT}" ]] && smooth_deploy_fail_recover ...' ERR

回滚流程分三步:

① 清理所有新容器:无论 running 还是 Created,健康失败 = 发布未成功,全部清理,打印诊断信息(状态 + 最近 30 行日志)方便排查。

② 恢复 compose 文件为 latest 指向。

③ 按剩余新旧容器数量分支处理------这是最妙的部分:

场景 处理方式
新容器 0 个 旧容器全在,直接完成回退,服务未中断
新容器 > 旧容器 镜像本身没问题,保留新容器继续跑,提示人工清理剩余旧容器
新容器 ≤ 旧容器且 > 0 用旧镜像创建替代容器 → 探活 → 通过后删掉全部新容器,实现零停机回退;替代容器也失败则打印诊断,提示人工介入

注意第三个场景:部分副本已经是新版本、部分还是旧版本时,直接删新容器会导致副本数不足。所以先用旧镜像补一个替代容器补齐副本数,探活通过后再清新容器------回退全程也不停机。

还有个细节:die() 函数会设置 _DIE_EXIT 标志位,主动报错退出时不会误触发 ERR trap 走进回滚流程,只有真正的异常才触发,避免"错误处理本身出错误"。

五、并发锁:flock 的正确打开方式

同一服务不允许并发发布/回滚,否则容器 ID 差集就乱了。脚本用 flock 按服务粒度加锁:

bash 复制代码
exec 9>>"/tmp/deploy-${svc}.lock"   # 追加模式打开,不截断持有者写入的信息
if ! flock -n 9; then
    echo "服务已有发布任务在运行,本次执行终止"
    exit 1
fi
echo "PID=$$ | SERVICE=${svc} | START=${START_DATETIME}" > 锁文件

几个细节值得注意:

  • 锁随进程退出自动释放(fd 关闭即释放),无需手动解锁
  • 拿锁失败时打印持有者信息(PID/服务/开始时间),方便定位是谁占着
  • 锁文件打开用追加模式而不是覆盖模式,避免清掉持有者写入的占用信息
  • 禁止删除锁文件来"解锁" :flock 锁绑定在进程的文件描述符上,rm 锁文件会导致旧进程继续持有、新进程重新建锁,形成双跑------比不加锁更危险。要强制结束请 kill -9 <PID>

六、使用方式

脚本顶部有 4 个配置区,新增/调整服务全在这里完成:

bash 复制代码
SERVICE_BUILD_DIR=( ["your-service1"]="/opt/srv/app/your-service1" )   # 服务名 → 源码目录
SERVICE_DOCKERFILE=( ["your-service1"]="Dockerfile-your-service1" )    # 服务名 → Dockerfile 文件名
HEALTH_CHECK_LIST=( "your-service1:9527" )                             # 服务名 → actuator 健康端口
ROLLBACK_SERVICE_LIST=( "your-service1" )                              # 平滑发布白名单

日常命令:

bash 复制代码
./deploy.sh 1 your-service1                  # 构建镜像 + 平滑发布
./deploy.sh 0 your-service1                  # 跳过构建,复用本地 latest 镜像发布
./deploy.sh rollback your-service1           # 自动回滚到上一有效版本
./deploy.sh rollback your-service1 20260818160000   # 回滚到指定 tag

发布成功后每次写入 release_history.log(格式:时间 | 服务 | tag),回滚就是从这个历史里找上一个有效 tag。

七、踩坑总结与最佳实践

  1. 平滑服务必须配置健康检测端口,否则脚本直接拒绝执行------没有探活就没有平滑发布
  2. 平滑服务不能绑定宿主端口,副本扩容会端口冲突,前置网关统一收口
  3. 滚动期间新旧副本短暂并存:应用要兼容新旧版本共存(如数据库变更需向后兼容)
  4. 发布历史文件别删:删了自动回滚会降级为"无历史可回滚",需要手动指定 tag
  5. 健康检测依赖 /actuator/health 返回 200:确认 SecurityConfig 已对 actuator 路径免认证放行
  6. 发布历史是单机方案;多机环境可在每台服务器各放一份脚本分别执行,或者增加一个远程执行脚本。

八、为什么不用现成方案?

方案 问题
直接 down/up 停机发布,用户可感知
Docker Swarm 已边缘化,需要额外部署、改编排方式
Kubernetes 单机场景过重,学习与维护成本高
商业发布平台 贵,且不一定适配你的 compose 编排

这个脚本的价值在于:在你已有的 Docker Compose 架构上零改造,一个 bash 文件就补齐了滚动发布、健康检查、自动回滚、并发锁这些"发布系统"该有的能力。代码不过 800 行,任何一段逻辑都可以按需裁剪。

开源地址

项目已开源(Apache License 2.0),欢迎使用和 Star:

github.com/EvanSerene/...

  • 完整脚本:deploy.sh(700+ 行,含详细注释)
  • 使用文档:DEPLOY.md(配置、原理、回滚、并发锁、FAQ 全覆盖)

如果这个脚本帮到了你,欢迎提 Issue / PR,一起把它打磨得更好。

相关推荐
云原生指北1 小时前
Docker Sandboxes 的隔离例外:共享 Skills 与宿主机 MCP
运维·docker·ai·容器·agent
用户6919026813393 小时前
Docker基本概念
后端·docker·容器
江湖有缘3 小时前
从零搭建私有云相册:使用 Docker 一键部署 Damselfly全攻略
运维·docker·容器
摇滚侠3 小时前
《Docker技术入门与实战 第4版》阅读笔记 6 使用 Dockerfile 创建镜像 2
java·笔记·docker
艾伦_耶格宇14 小时前
【DOCKER进阶】-3 cgroups
docker·容器·eureka
core51220 小时前
如何从多架构 Docker 镜像中导出指定的 ARM64 版本
华为·docker·架构·镜像·导出·多架构
雪碧聊技术20 小时前
使用Docker,将fastApi项目部署到linux服务器
服务器·docker·fastapi·docker部署fastapi
我有2只猫20 小时前
vLLM Docker 本地部署小模型
docker·容器·vllm
Oo92021 小时前
Docker 入门:把应用和运行环境一起打包
docker