发布脚本:制品校验、切换与失败恢复
一次单实例发布可以拆成三个变化:新制品进入主机,运行入口指向新版本,新进程开始提供服务。三者不在同一瞬间发生。文件已经复制完成时,旧进程可能还在运行;容器已启动时,新应用也可能尚未就绪。脚本需要分别检查这些变化,并为中间失败安排后续动作。
下面实现一个 Linux 单主机实验:发布 JAR,由 Docker Compose 重建一个应用容器,通过 HTTP 返回的包内版本确认实际运行结果。这种方式有短暂停机,适合学习单实例发布和受控维护,不承担多实例灰度与零停机流量切换。
先确定脚本能改变什么
发布输入与目录
完整源码下载:release-lab.zip。源码也可分别查阅 deploy.sh、Compose 配置与 Java 服务。
release-lab/
├─ App.java / build.sh 构建实验制品
├─ compose.yaml / deploy.sh 受控运行配置与发布程序
├─ artifacts/r1.jar 已构建输入
└─ runtime/
├─ releases/r1/ 已安装版本:app.jar、version.txt、sha256.txt
├─ releases/r2/
├─ current → releases/r2 当前希望运行的版本
├─ deploy.lock 本机独占锁
└─ status 最近一次切换或恢复结果版本目录不在原地覆盖。新版本先进入临时目录,校验完成后改名,再原子替换 current 符号链接。配置、密钥与数据库不放进 JAR 版本目录;真实 Spring Boot 应用可由外置配置或秘密挂载提供环境差异,参见 Spring Boot 部署。
脚本的三个参数是版本 ID、JAR 路径和可信 SHA-256。版本 ID 只接受字母、数字、下划线和连字符,并限制长度,避免把 ../、路径分隔符或 Shell 片段带进目录。输入摘要来自制品仓库或批准的发布记录;源码 commit 不能代替 JAR 的字节摘要。
进程管理与权限
Linux 操作者拥有实验目录和 Docker 使用权限。Docker 管理权限可以影响主机,生产应限制发布身份、允许执行的项目和命令;应用容器使用 10001:10001,根文件系统与制品挂载只读。
脚本固定 Compose project 为 ops-release-lab,固定回环端口 18088。它只能在没有同名项目的专用实验目录运行;不能复制两份脚本同时管理这一个项目。多主机发布需要由发布平台协调,单个本地锁不能阻止另一台主机下发操作。
恢复目标是发布前那个实际版本目录。首次发布没有旧版本,失败后只能停止新实例并保留材料,不能凭空构造 previous。数据库迁移、外部支付、消息和其他副作用不由文件指针恢复;部署前必须单独确认新旧应用对数据的兼容性。
构建两个版本,再执行一次发布
环境与制品
准备 Linux、Bash、GNU Coreutils、util-linux 的 flock、curl 7.76+、Docker Engine/Compose、unzip 和 JDK 25。解压 ZIP 到新目录,在该目录检查工具与 Docker 上下文:
java -version
javac -version
docker context show
docker version
docker compose version
command -v flock curl sha256sum
docker pull eclipse-temurin:25.0.4_7-jre
bash build.sh r1
bash build.sh r2
bash build.sh bad预期生成三份 JAR,构建命令输出各自 SHA-256。App.java 使用 JDK HttpServer,在 /health 返回打包进 JAR 的版本;r1/r2 返回 200,bad 故意返回 503。所有版本都从 JAR 自己读取版本文本,部署脚本不能通过传入一个期望字符串让旧进程伪装成新版本。
没有本机 JDK 时,可在同一目录用构建容器替代上面的三个 build 命令:
docker run --rm --user "$(id -u):$(id -g)" \
-v "$PWD:/work" -w /work eclipse-temurin:25.0.4_7-jdk \
bash -c 'bash build.sh r1 && bash build.sh r2 && bash build.sh bad'两条构建方式选其一;build.sh 拒绝覆盖已有同名 JAR。实验制品只帮助观察进程切换,替换为 Spring Boot 可执行 JAR 时,要同时调整应用端口、启动参数和健康响应约定。
第一次发布 r1
为了在本机实验中建立期望摘要,从刚构建的文件取值;正式环境则从可信发布清单读取,不用这段自计算代替来源校验。
SHA_R1=$(sha256sum artifacts/r1.jar | cut -d ' ' -f1)
bash deploy.sh r1 artifacts/r1.jar "$SHA_R1"
cat runtime/status
readlink -f runtime/current
curl -q --noproxy '*' --fail-with-body --connect-timeout 1 --max-time 2 \
http://127.0.0.1:18088/health预期发布命令退出 0,显示 published r1;status 标记 success,current 指向 releases/r1,健康响应为 r1。只看到容器 Up 还不足以完成这些检查。
发布 r2 使用同样的调用方式:
SHA_R2=$(sha256sum artifacts/r2.jar | cut -d ' ' -f1)
bash deploy.sh r2 artifacts/r2.jar "$SHA_R2"
curl -q --noproxy '*' --fail-with-body http://127.0.0.1:18088/health预期返回 r2,r1 目录仍保留。脚本不重用已存在的 release ID,失败重试或重新发布旧字节都需要新的受控发布安排;这种拒绝重复不会冒充“幂等成功”。
Compose 为什么要重新创建容器
Compose 的 bind source 使用实际版本目录,而非一条希望自动追踪的 current 链接。Docker 创建挂载时解析路径,之后只改宿主符号链接,不保证已经运行的容器改用新文件。脚本因此把 RELEASE_DIR 设为目标目录并执行:
RELEASE_DIR="$TARGET" docker compose -p ops-release-lab -f "$BASE/compose.yaml" \
up -d --force-recreate --no-deps --pull never app这里的 TARGET 与 BASE 是 deploy.sh 内部已经校验的变量,不是需要额外粘贴执行的一步。--force-recreate 重建应用容器,--pull never 要求运行镜像已在预检阶段准备好,避免切换后才开始不可控下载。行为见 Compose up。
生产还应把运行镜像固定到批准 digest,保存配置版本,并规定优雅停止时间。若应用退出前需要排空请求,先从负载均衡摘流再停止;单纯增加 Docker stop 超时不会自动阻止新请求进入。
切换与健康判断怎样处理失败
切换前:锁、摘要和临时目录
脚本打开 runtime/deploy.lock 并使用 flock -n 申请排他锁。另一个发布已持锁时立即退出 3,不排队执行一份可能已经过时的发布。锁依赖文件描述符,由内核在相关描述符关闭时释放;锁文件仍在磁盘上是正常情况,不应靠删除锁文件“解锁”。语义与文件系统限制见 flock。
取得锁后检查旧指针、版本目录是否存在、制品摘要、Docker 可用性、运行镜像及 Compose 配置。复制制品到暂存目录后再计算一次摘要,避免输入文件在第一次检查后变化。临时目录与正式版本目录放在同一文件系统,完成安装后再移动。
指针替换使用先创建新链接、再 rename 覆盖 current 的方式:
ln -s -- "$TARGET" "$ROOT/.current-$$"
mv -Tf -- "$ROOT/.current-$$" "$ROOT/current"读者看到的是旧链接或新链接,而非先删除 current 后出现的空档。原子性只针对目录项替换,不同时覆盖容器重建、数据库操作或断电后的全部持久性;文件系统机制见 rename。
切换后:检查响应版本,不只检查 HTTP 状态
脚本最多进行 15 次健康探测,每次限制连接和整体请求时长。只有请求成功且响应文本等于预期版本,才进入 success:
if body=$(curl -q --noproxy '*' --fail --silent --show-error \
--connect-timeout 1 --max-time 2 http://127.0.0.1:18088/health) \
&& test "$body" = "$expected_version"; then
return 0
fi循环会等待进程完成启动;等待预算应按应用启动时间与服务恢复目标设置。真实应用的 readiness 还要说明依赖要求,不能把所有下游短暂抖动都转换成不断重启,也不能只检查一个永远返回 200 的静态入口。
curl 的 -q 放在首个选项,忽略用户 curlrc;--noproxy '*' 让回环探测不继承代理路径。正式测试跨主机入口时则应保留真实网络条件,并明确是否绕过负载均衡,避免测试了错误节点。
故意发布 bad,观察恢复
当前为 r2 时执行:
SHA_BAD=$(sha256sum artifacts/bad.jar | cut -d ' ' -f1)
if bash deploy.sh bad artifacts/bad.jar "$SHA_BAD"; then
printf '错误:bad 不应发布成功\n' >&2
exit 1
else
RC=$?
printf 'release exit=%s\n' "$RC"
test "$RC" = 10
fi
cat runtime/status
readlink -f runtime/current
curl -q --noproxy '*' --fail-with-body http://127.0.0.1:18088/healthbad 的健康接口返回 503。脚本切回发布前的 r2 目录,重新创建容器,再按 r2 检查响应。预期退出 10,status 为 rolled-back,最终响应 r2;恢复成功不把本次失败发布改写成成功,流水线应据此报警和停止后续晋级。
首次发布 bad 时没有旧版本,处理方式是停止候选容器、移除自己设置的 current 链接,并保存 first-deploy-failed 状态。失败制品继续留在 releases/bad,便于检查;不会用空路径、过期 previous 文件或随意找到的旧 JAR 启动服务。
如果恢复过程中容器无法启动,或旧版本也不能达到健康条件,退出 20 并记录 recovery-failed。此时停止自动重试,检查旧制品、运行配置、端口和基础设施。自动来回切换会抹去故障顺序,也可能重复触发应用启动副作用。
Shell 错误处理的适用范围
脚本使用 set -euo pipefail,但没有把它当作事务机制。Bash 在 if、条件列表等上下文中对 errexit 有特殊规则,恢复函数因此显式检查每个关键动作,避免用 || true 隐藏启动失败。完整语义见 Bash Set Builtin。
切换前的失败直接非零退出,不干扰旧服务;切换意图写入状态后,EXIT trap 承担失败恢复。INT/TERM 转为退出并进入同一处理流程,恢复过程中不再递归调用 trap。
所有恢复动作仍可能失败,包括状态文件所在磁盘已经写满。status 是帮助接管的记录,不能替代对容器和真实响应的检查;日志无法写入时,也应把退出状态和标准错误保存在外部发布系统。
中断后接管,以及扩展到真实应用
先读三种状态再继续
SIGKILL、断电和内核故障不会执行 Shell trap。恢复操作者先确认原发布进程已经退出、没有其他操作持锁,再读取:
cat runtime/status
readlink -f runtime/current
docker ps -a --filter label=com.docker.compose.project=ops-release-lab
curl -q --noproxy '*' --connect-timeout 1 --max-time 2 \
--write-out '\nHTTP %{http_code}\n' http://127.0.0.1:18088/healthstatus 说明最后记录的步骤,current 说明希望运行的目录,容器及 HTTP 响应说明实际运行情况。若 current 是 r2、响应仍为 r1,可能停在指针切换与重建之间;若没有响应,则检查容器状态、挂载来源和日志。任何一种都不能直接补写 success。脚本发现 switching、recovering 或 recovery-failed 记录时拒绝下一次发布;接管完成后保留原记录,确认实际版本健康,再记录以 success active= 开头的接管结果,才允许继续。
继续新版本或恢复旧版本,需要先核对目标 JAR 的可信摘要与配置兼容性,在同一个独占锁保护下切换指针、重建并验证响应。接管记录注明最终采用版本及失败原因;不要为了重新运行脚本而删除 releases 目录,也不要在仍有操作持锁时另建锁文件。
换成 systemd、前端目录或多实例发布
systemd 管理 JAR 时,可以继续使用固定版本目录和指针,但重启改为受限的 systemctl restart,随后读取单元状态、日志与应用版本。只授予发布身份管理目标 unit 的权限,不授任意 sudo Shell;具体单元配置见 Spring Boot 的 systemd 部署。
前端静态资源还涉及浏览器缓存。新 HTML 可能引用旧资源,旧页面也可能继续请求旧 hash 文件;发布时保留必要历史资源,避免切换目录后立刻删除。Nginx 配置变更应先通过 nginx -t 再 reload,并实际读取入口与资源文件,参见 Nginx。
多实例滚动或灰度发布还需控制批次、流量比例、错误阈值、暂停和回退。一个实例健康不代表新版本已经适合全量流量;发布平台应观察有代表性的请求和业务结果。数据库变更则尽量分解为向后兼容的扩展、迁移和收缩,旧制品能启动只是回退条件之一。
退出状态与清理
| 返回结果 | 含义 | 调用方动作 |
|---|---|---|
| 0 | 新版本通过检查 | 保存制品摘要、配置和运行结果 |
| 2 | 已识别的输入或预检问题 | 修正输入,旧服务未切换 |
| 3 | 已有发布持锁 | 检查在途操作,不并发强行发布 |
| 10 | 发布失败,已恢复旧版或停止首次候选 | 保留失败材料,阻止晋级 |
| 20 | 自动恢复失败 | 停止自动动作,人工接管 |
| 其他非零 | 工具、文件系统或其他前置失败 | 按日志和实际状态判断,不假定全部已恢复 |
实验结束后停止这个 Compose 项目,保留源码、制品和运行记录。current 存在时,在实验目录执行:
RELEASE_DIR=$(readlink -f runtime/current) \
docker compose -p ops-release-lab down首次失败没有 current 时,把 RELEASE_DIR 明确设为那个已存在的候选版本目录再执行 down。该操作移除本实验容器与网络,不删除版本目录;不要追加 -v 或清理其他项目的资源。
权威资料与规范地址
启动与切换操作
| 查阅内容 | 官方地址 |
|---|---|
| Compose 重建与启动 | https://docs.docker.com/reference/cli/docker/compose/up/ |
| 原子重命名 | https://man7.org/linux/man-pages/man2/rename.2.html |
并发控制与错误处理
| 查阅内容 | 官方地址 |
|---|---|
| 文件描述符锁 | https://man7.org/linux/man-pages/man1/flock.1.html |
| Bash 错误处理 | https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html |
