项目开源地址: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的含义:只处理新增的容器,现有容器一律不动create和up的区别 :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。
七、踩坑总结与最佳实践
- 平滑服务必须配置健康检测端口,否则脚本直接拒绝执行------没有探活就没有平滑发布
- 平滑服务不能绑定宿主端口,副本扩容会端口冲突,前置网关统一收口
- 滚动期间新旧副本短暂并存:应用要兼容新旧版本共存(如数据库变更需向后兼容)
- 发布历史文件别删:删了自动回滚会降级为"无历史可回滚",需要手动指定 tag
- 健康检测依赖
/actuator/health返回 200:确认 SecurityConfig 已对 actuator 路径免认证放行 - 发布历史是单机方案;多机环境可在每台服务器各放一份脚本分别执行,或者增加一个远程执行脚本。
八、为什么不用现成方案?
| 方案 | 问题 |
|---|---|
| 直接 down/up | 停机发布,用户可感知 |
| Docker Swarm | 已边缘化,需要额外部署、改编排方式 |
| Kubernetes | 单机场景过重,学习与维护成本高 |
| 商业发布平台 | 贵,且不一定适配你的 compose 编排 |
这个脚本的价值在于:在你已有的 Docker Compose 架构上零改造,一个 bash 文件就补齐了滚动发布、健康检查、自动回滚、并发锁这些"发布系统"该有的能力。代码不过 800 行,任何一段逻辑都可以按需裁剪。
开源地址
项目已开源(Apache License 2.0),欢迎使用和 Star:
- 完整脚本:
deploy.sh(700+ 行,含详细注释) - 使用文档:
DEPLOY.md(配置、原理、回滚、并发锁、FAQ 全覆盖)
如果这个脚本帮到了你,欢迎提 Issue / PR,一起把它打磨得更好。