发布脚本
本文范围
这篇讲的是单机或少量服务器场景下,如何把手工发布动作沉淀成可执行脚本。它覆盖制品校验、目录约定、锁、备份、软链接切换、systemd 停启、健康检查、失败回滚、远程执行、Docker 发布、前端静态资源和 Nginx reload。
本文会覆盖:
- 从手工发布抽象出脚本主线和失败主线。
- 发布目录、制品包、checksum、环境变量、锁文件和状态文件。
- Spring Boot jar、前端静态资源、Docker Compose 和远程执行的脚本模板。
- 内网和离线环境下的制品、镜像、工具包、校验和回滚闭环。
- 常见失败现象、排查命令、生产发布前检查清单。
本文不展开 Jenkins、GitLab CI、Argo CD、K8S 灰度、服务网格和复杂发布平台。脚本是发布平台的稳定执行单元,不应该把审批、权限、流量治理和复杂编排都塞进一份 Shell 里。
前置条件
你需要一个可测试的服务包或静态资源包,一台 Linux 目标服务器,以及受控的部署用户。示例默认服务可以被 systemd 管理,并且有一个 HTTP 健康检查接口。生产环境执行前,必须确认旧版本可备份、新版本可校验、回滚路径可执行。
先确认:
command -v bash rsync ssh scp flock sha256sum curl systemctl readlink find tar
systemctl status demo-api --no-pager 2>/dev/null || true
df -h /opt /var 2>/dev/null || df -h如果目标环境是企业内网或完全离线,要提前准备制品包、镜像 tar、系统工具包、校验文件、Nginx 配置、systemd unit、回滚包和操作窗口。示例里的路径、端口、服务名、镜像名、域名都是占位,不要直接暴露真实生产信息。
先把问题说清楚
很多团队一开始发布服务,都是这样干的:本地打一个包,scp 到服务器,登录服务器,备份一下旧文件,替换新文件,重启服务,然后开一个窗口看日志。
这种方式不是不行,小团队、测试环境、第一次上线都可能这么做。但只要进入生产,你很快就会遇到几个问题:
- 包传错了,没人第一时间发现。
- 旧版本没有备份,想回滚只能靠记忆。
- 服务启动了,但接口其实不通。
- Nginx reload 失败,页面还是旧的。
- 两个人同时发布,把彼此的文件覆盖了。
- 脚本执行一半断线,再跑一次不知道会发生什么。
发布脚本要解决的就是这些现场问题。它不是为了显得自动化,而是为了让每一次发布都有固定动作:先检查、再备份、再替换、再启动、再验证,失败就回滚,整个过程有日志可查。
下面我们不从复杂平台讲起,先从一台机器、一个服务开始,把发布脚本一步步写出来。等这个脚本稳定了,再让 Jenkins、GitLab CI、Ansible 或其他发布平台去调用它。
先跑一遍手工发布
假设现在有一个 Spring Boot 服务,名字叫 demo-api,端口是 8080,健康检查地址是:
http://127.0.0.1:8080/actuator/health手工发布一般会这么做:
scp target/demo-api.jar deploy@app-01:/opt/packages/demo-api-1.2.3.jar
ssh deploy@app-01
sudo systemctl stop demo-api
cp /opt/apps/demo-api/current/app.jar /opt/apps/demo-api/backups/app.jar.bak
cp /opt/packages/demo-api-1.2.3.jar /opt/apps/demo-api/current/app.jar
sudo systemctl start demo-api
curl http://127.0.0.1:8080/actuator/health这里每条命令都有作用:
scp是把新包传到服务器。systemctl stop是停止旧服务。cp app.jar app.jar.bak是备份。- 第二个
cp是替换新包。 systemctl start是启动服务。curl是验证服务能不能访问。
问题也很明显:这些动作靠人记,顺序靠人保证,失败了也靠人判断。发布脚本就是把这几个动作固定下来,并且让每一步失败时都能给出明确结果。
先记住一个发布主线:
检查参数 -> 加锁 -> 校验制品 -> 备份当前版本 -> 替换文件 -> 启动服务 -> 健康检查 -> 成功记录失败主线也要提前想好:
任何关键步骤失败 -> 保留日志 -> 回滚上一版 -> 再次健康检查 -> 返回非 0这里要注意,返回非 0 很重要。脚本如果失败了还返回 0,流水线就会以为发布成功,这比脚本直接报错更危险。
先把目录建好
脚本能不能写得清楚,第一步看目录。不要把包、运行目录、日志、备份都混在一个地方。
一般建议这样放:
/opt/apps/demo-api/
bin/
deploy-jar.sh
rollback.sh
healthcheck.sh
packages/
demo-api-1.2.3.jar
releases/
1.2.2/
app.jar
1.2.3/
app.jar
current -> /opt/apps/demo-api/releases/1.2.3
previous -> /opt/apps/demo-api/releases/1.2.2
shared/
config/
demo-api.env
logs/
backups/
state/
current-version
previous-target
locks/
deploy.lock这套目录真正解决的是“当前版本是谁、上一版在哪里、失败后切回哪里”。用软链接看会更直观:
发布后可以用几条命令确认目录模型没有走偏:
readlink -f /opt/apps/demo-api/current
cat /opt/apps/demo-api/state/current-version
cat /opt/apps/demo-api/state/previous-target 2>/dev/null || true
find /opt/apps/demo-api/releases -maxdepth 2 -type f -name 'app.jar.sha256' -print常见坑是把 current 做成真实目录,发布时直接覆盖文件;或者把日志、上传文件放进 releases/版本号,清理旧版本时一起删掉。生产约束是:版本目录只放不可变制品,运行期数据放 shared,切换版本只改软链,回滚也只改软链,删除旧版本必须经过保留策略和路径保护。
如果目录还没有,就先创建:
sudo mkdir -p /opt/apps/demo-api/{bin,packages,releases,shared/config,shared/logs,backups,state,locks}
sudo mkdir -p /var/log/deploy/demo-api
sudo chown -R deploy:deploy /opt/apps/demo-api /var/log/deploy/demo-api执行完以后看一下:
ls -lah /opt/apps/demo-api正常应该能看到 bin、packages、releases、shared、backups、state、locks 这些目录。
这里要注意两点:
current不要是真实目录,最好是软链接,指向当前版本。- 日志、配置、上传文件这类运行期数据不要放进
releases/版本号目录,否则回滚或清理旧版本时容易误删。
第一次没有 current 没关系,第一次发布成功后再创建。
准备脚本入口
先写一个最小的脚本骨架,文件放到:
/opt/apps/demo-api/bin/deploy-jar.sh内容如下:
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
APP_NAME="${APP_NAME:-demo-api}"
SERVICE_NAME="${SERVICE_NAME:-demo-api}"
BASE_DIR="${BASE_DIR:-/opt/apps/$APP_NAME}"
LOG_DIR="${LOG_DIR:-/var/log/deploy/$APP_NAME}"
LOCK_DIR="$BASE_DIR/locks"
STATE_DIR="$BASE_DIR/state"
PACKAGE_FILE=""
VERSION=""
DRY_RUN=0
usage() {
cat <<EOF
Usage:
$0 --package <jar-file> --version <version> [--dry-run]
Example:
$0 --package /opt/packages/demo-api-1.2.3.jar --version 1.2.3
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--package)
PACKAGE_FILE="${2:-}"
shift 2
;;
--version)
VERSION="${2:-}"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage
exit 2
;;
esac
done
}
prepare_runtime() {
install -d "$LOG_DIR" "$LOCK_DIR" "$STATE_DIR"
LOG_FILE="$LOG_DIR/deploy-$(date +%Y%m%d_%H%M%S).log"
touch "$LOG_FILE"
}
log() {
local level="$1"
shift
printf '%s [%s] %s\n' "$(date '+%F %T')" "$level" "$*" | tee -a "$LOG_FILE" >&2
}
die() {
log ERROR "$*"
exit 1
}
run() {
log INFO "RUN $*"
if [[ "$DRY_RUN" == "0" ]]; then
"$@"
fi
}
main() {
parse_args "$@"
prepare_runtime
log INFO "deploy start: app=$APP_NAME version=$VERSION package=$PACKAGE_FILE"
}
main "$@"给脚本加执行权限:
chmod +x /opt/apps/demo-api/bin/deploy-jar.sh先执行帮助:
/opt/apps/demo-api/bin/deploy-jar.sh --help如果能看到 Usage 和示例命令,说明脚本入口已经正常。
这里要注意,脚本开头用了:
set -Eeuo pipefail它能让很多错误提前暴露,比如变量未定义、管道失败、命令失败。它不是万能的,健康检查、回滚、远程执行这些关键步骤,后面还是要显式判断。
参数和环境变量怎么放
发布脚本里一般有三类信息。
第一类是每次发布都会变的,比如包路径和版本号,放命令行参数里:
--package /opt/packages/demo-api-1.2.3.jar
--version 1.2.3第二类是不同环境不同,但不想每次都敲的,比如服务名、端口、健康检查地址,可以放环境变量:
export APP_NAME=demo-api
export SERVICE_NAME=demo-api
export HEALTH_URL=http://127.0.0.1:8080/actuator/health第三类是长期配置,比如目录、保留几个版本,可以放配置文件:
# /opt/apps/demo-api/shared/config/deploy.env
APP_NAME=demo-api
SERVICE_NAME=demo-api
BASE_DIR=/opt/apps/demo-api
HEALTH_URL=http://127.0.0.1:8080/actuator/health
RETENTION_RELEASES=5
RETENTION_BACKUPS=10脚本里这样加载:
CONFIG_FILE="${CONFIG_FILE:-/opt/apps/demo-api/shared/config/deploy.env}"
if [[ -f "$CONFIG_FILE" ]]; then
# shellcheck disable=SC1090
source "$CONFIG_FILE"
fi这里要注意,密码、token、私钥不要写进脚本和配置文件。生产环境一般放在受控凭据系统、发布平台变量或专门的密钥管理系统里。脚本日志也不要随手打印 env,否则很容易把敏感信息带到日志里。
加锁,防止两个人同时发
发布脚本最容易被忽略的一步是加锁。你可以想象一下,两个人同时发同一个服务:一个人刚备份完,另一个人开始替换文件,最后谁成功谁失败都说不清。
在脚本里加一个锁:
acquire_lock() {
local lock_file="$LOCK_DIR/deploy.lock"
exec 9>"$lock_file"
if ! flock -n 9; then
die "another deploy is running: $lock_file"
fi
log INFO "lock acquired: $lock_file"
}在 main 里调用:
main() {
parse_args "$@"
prepare_runtime
acquire_lock
log INFO "deploy start: app=$APP_NAME version=$VERSION package=$PACKAGE_FILE"
}验证一下:
/opt/apps/demo-api/bin/deploy-jar.sh --package /opt/packages/demo-api-1.2.3.jar --version 1.2.3如果另一个窗口同时执行,应该看到类似:
another deploy is running: /opt/apps/demo-api/locks/deploy.lock这里要注意,flock 锁文件尽量放本机文件系统,不要放 NFS、CIFS 这类网络盘。多台机器之间的发布锁,要放到发布平台、数据库、Redis 或其他统一控制面里,单机 flock 管不了跨机器并发。
发布前先检查
不要等文件替换完了才发现包不存在、磁盘满、systemctl 没权限。发布脚本一开始就要检查。
加几个检查函数:
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing command: $1"
}
verify_jar() {
local file="$1"
if command -v jar >/dev/null 2>&1; then
jar tf "$file" >/dev/null || die "invalid jar package: $file"
elif command -v unzip >/dev/null 2>&1; then
unzip -t "$file" >/dev/null || die "invalid jar package: $file"
else
die "jar or unzip command is required to verify jar package"
fi
}
check_disk_free_mb() {
local path="$1"
local required_mb="$2"
local free_mb
free_mb="$(df -Pm "$path" | awk 'NR==2 {print $4}')"
[[ "$free_mb" -ge "$required_mb" ]] || die "not enough disk: path=$path free=${free_mb}MB required=${required_mb}MB"
}
precheck() {
require_cmd bash
require_cmd curl
require_cmd flock
require_cmd systemctl
require_cmd sha256sum
[[ -n "$VERSION" ]] || die "--version is required"
[[ "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]] || die "invalid version: $VERSION"
[[ -s "$PACKAGE_FILE" ]] || die "package not found or empty: $PACKAGE_FILE"
[[ -d "$BASE_DIR" ]] || die "base dir not found: $BASE_DIR"
[[ -w "$BASE_DIR" ]] || die "base dir not writable: $BASE_DIR"
verify_jar "$PACKAGE_FILE"
check_disk_free_mb "$BASE_DIR" 2048
log INFO "precheck passed"
}执行脚本时,如果包不存在,会看到:
package not found or empty: /opt/packages/demo-api-1.2.3.jar这时候先看文件:
ls -lh /opt/packages/如果包存在但校验失败,先不要发布。常见原因是包没传完、传错版本、下载过程中断。
如果旁边有校验文件,可以这样校验:
sha256sum -c /opt/packages/demo-api-1.2.3.jar.sha256正常应该看到:
demo-api-1.2.3.jar: OK生产建议:制品包应该从构建系统或制品库产生,发布机器只负责下载、校验和部署,不要在生产机上临时 mvn package 或 npm install。
备份当前版本
备份不是出了问题才想起来做,而是替换前必须做。
先写备份函数:
backup_current() {
local current_link="$BASE_DIR/current"
local backup_dir="$BASE_DIR/backups"
local current_target
local backup_file
install -d "$backup_dir"
if [[ ! -L "$current_link" ]]; then
log WARN "current link not found, maybe first deploy, skip backup"
return 0
fi
current_target="$(readlink -f "$current_link")"
[[ -d "$current_target" ]] || die "current target not found: $current_target"
echo "$current_target" > "$STATE_DIR/previous-target"
backup_file="$backup_dir/$(date +%Y%m%d_%H%M%S)_$(basename "$current_target").tar.gz"
log INFO "backup current release: $current_target -> $backup_file"
tar -czf "$backup_file" -C "$current_target" .
sha256sum "$backup_file" > "$backup_file.sha256"
}发布前如果已经有 current,执行完应该能看到备份文件:
ls -lh /opt/apps/demo-api/backups一般应该看到类似:
20260709_213000_1.2.2.tar.gz
20260709_213000_1.2.2.tar.gz.sha256这里要注意,备份目录不要放到即将被删除或替换的目录下面。比如你要替换 /opt/apps/demo-api/current,就不要把备份放到 current/backups 里。
还要注意磁盘空间。备份本身可能把磁盘写满,所以前面才需要 df 检查。生产上一般会保留最近 5 到 10 个版本,老备份定期清理,但不要发布刚成功就立刻删上一版。
安装新版本并切换 current
不要直接覆盖运行目录。更稳的方式是:每个版本一个目录,解压或复制到 releases/版本号,确认没问题后再切换 current 软链接。
jar 包安装函数:
install_release() {
local target_dir="$BASE_DIR/releases/$VERSION"
[[ ! -e "$target_dir" ]] || die "release already exists: $target_dir"
install -d "$target_dir"
cp "$PACKAGE_FILE" "$target_dir/app.jar"
sha256sum "$target_dir/app.jar" > "$target_dir/app.jar.sha256"
log INFO "release installed: $target_dir"
}
switch_current() {
local target_dir="$BASE_DIR/releases/$VERSION"
local current_link="$BASE_DIR/current"
[[ -d "$target_dir" ]] || die "target release not found: $target_dir"
if [[ -L "$current_link" ]]; then
readlink -f "$current_link" > "$STATE_DIR/previous-target"
fi
ln -sfn "$target_dir" "$current_link.new"
mv -Tf "$current_link.new" "$current_link"
echo "$VERSION" > "$STATE_DIR/current-version"
log INFO "current switched to: $target_dir"
}执行后看软链接:
readlink -f /opt/apps/demo-api/current应该输出:
/opt/apps/demo-api/releases/1.2.3这里要注意,不建议这样写:
rm -rf "$BASE_DIR/current"如果变量为空、路径拼错,风险很大。必须删除目录时,至少做路径保护:
safe_rm_dir() {
local dir="$1"
local base="$2"
local real_dir
local real_base
real_dir="$(readlink -f "$dir")"
real_base="$(readlink -f "$base")"
[[ -n "$real_dir" ]] || die "empty dir"
[[ "$real_dir" == "$real_base"/* ]] || die "refuse to remove outside base: $real_dir"
[[ "$real_dir" != "/" ]] || die "refuse to remove /"
rm -rf "$real_dir"
}生产脚本里看到 rm -rf "$变量",都要停下来多看两眼。
用 systemd 管 Spring Boot
Spring Boot jar 不建议长期用 nohup java -jar 手工后台跑。生产上更推荐交给 systemd 管,服务状态、日志、开机启动、失败重启都更清楚。
新建 unit:
# /etc/systemd/system/demo-api.service
[Unit]
Description=Demo API
After=network-online.target
Wants=network-online.target
[Service]
User=deploy
Group=deploy
WorkingDirectory=/opt/apps/demo-api/current
EnvironmentFile=-/opt/apps/demo-api/shared/config/demo-api.env
ExecStart=/usr/bin/java $JAVA_OPTS -jar /opt/apps/demo-api/current/app.jar
Restart=on-failure
RestartSec=5
SuccessExitStatus=143
[Install]
WantedBy=multi-user.target加载并启动:
sudo systemctl daemon-reload
sudo systemctl enable demo-api
sudo systemctl start demo-api看状态:
systemctl status demo-api --no-pager正常应该能看到 active (running)。
发布脚本里的重启函数这样写:
restart_service() {
log INFO "restart service: $SERVICE_NAME"
sudo -n systemctl restart "$SERVICE_NAME"
sleep 2
if ! sudo -n systemctl is-active "$SERVICE_NAME" >/dev/null; then
sudo -n systemctl status "$SERVICE_NAME" --no-pager || true
return 1
fi
log INFO "service is active: $SERVICE_NAME"
}这里的 sudo -n 表示不交互输入密码。如果 sudo 权限没配好,脚本会直接失败,而不是卡在那里等你输入密码。
给发布用户配置最小权限:
# /etc/sudoers.d/demo-api-deploy
deploy ALL=(root) NOPASSWD: /bin/systemctl restart demo-api
deploy ALL=(root) NOPASSWD: /bin/systemctl start demo-api
deploy ALL=(root) NOPASSWD: /bin/systemctl stop demo-api
deploy ALL=(root) NOPASSWD: /bin/systemctl status demo-api校验 sudoers:
sudo visudo -cf /etc/sudoers.d/demo-api-deploy如果有语法错误,先修 sudoers,不要绕过去用 root 发布。
健康检查不要只看进程
服务进程在,不代表业务可用。端口监听,也不代表依赖正常。发布脚本一定要有 HTTP 健康检查。
Spring Boot 常见地址:
curl -fsS http://127.0.0.1:8080/actuator/health如果正常,一般会看到类似:
{"status":"UP"}脚本里写成函数:
healthcheck() {
local url="${HEALTH_URL:-http://127.0.0.1:8080/actuator/health}"
for i in {1..30}; do
if curl -fsS --max-time 3 "$url" >/tmp/demo-api-health.out 2>/tmp/demo-api-health.err; then
log INFO "healthcheck passed: $url"
return 0
fi
log WARN "healthcheck failed, retry=$i url=$url"
sleep 2
done
log ERROR "healthcheck failed after retries"
cat /tmp/demo-api-health.out 2>/dev/null | tee -a "$LOG_FILE" >&2 || true
cat /tmp/demo-api-health.err 2>/dev/null | tee -a "$LOG_FILE" >&2 || true
return 1
}这里要注意,curl 建议加 -f 或 --fail。否则 HTTP 返回 404、500 时,curl 可能因为“网络传输成功”而返回 0,脚本就会误判健康。
如果健康检查失败,按这个顺序查:
systemctl status demo-api --no-pager
journalctl -u demo-api -n 200 --no-pager
ss -lntp | grep ':8080'
curl -v http://127.0.0.1:8080/actuator/health
tail -n 200 /opt/apps/demo-api/shared/logs/app.log一般常见原因是:配置文件没加载、数据库连不上、端口被占用、Java 版本不对、健康检查地址写错。
失败时自动回滚
回滚不能等事故发生后再临时想。前面备份和记录 previous-target,就是为了这里能用。
脚本的状态机可以这样理解:成功路径很短,失败路径必须更清楚。只要新版本启动或健康检查失败,就立刻走回滚分支;如果回滚健康检查也失败,脚本必须返回非 0,并把日志留给人工接管。
验证这张状态机,不要只跑成功发布。至少要模拟一次健康检查失败:
HEALTH_URL=http://127.0.0.1:8080/wrong \
/opt/apps/demo-api/bin/deploy-jar.sh \
--package /opt/packages/demo-api-1.2.4.jar \
--version 1.2.4
echo "$?"
readlink -f /opt/apps/demo-api/current
tail -n 100 /var/log/deploy/demo-api/deploy-*.log如果脚本失败后返回 0,或者 current 没切回上一版,就说明状态机没有闭合。生产上还要约束两点:数据库变更、缓存污染、消息副作用不能由发布脚本自动假装回滚;自动回滚只能处理制品、配置、镜像和入口流量这类可逆动作。
回滚函数:
rollback() {
local previous_target
previous_target="$(cat "$STATE_DIR/previous-target" 2>/dev/null || true)"
[[ -n "$previous_target" ]] || die "previous target not found"
[[ -d "$previous_target" ]] || die "previous target dir not found: $previous_target"
log WARN "rollback current to: $previous_target"
ln -sfn "$previous_target" "$BASE_DIR/current.new"
mv -Tf "$BASE_DIR/current.new" "$BASE_DIR/current"
sudo -n systemctl restart "$SERVICE_NAME" || true
if ! healthcheck; then
die "rollback healthcheck failed"
fi
log WARN "rollback success: $previous_target"
}在主流程里这样用:
main() {
parse_args "$@"
prepare_runtime
acquire_lock
precheck
backup_current
install_release
switch_current
if ! restart_service || ! healthcheck; then
log ERROR "deploy failed, rollback start"
rollback
exit 1
fi
log INFO "deploy success: version=$VERSION"
}验证回滚是否可靠,不能只看脚本能执行,要真的模拟一次失败。比如临时把健康检查地址改错:
HEALTH_URL=http://127.0.0.1:8080/wrong \
/opt/apps/demo-api/bin/deploy-jar.sh \
--package /opt/packages/demo-api-1.2.4.jar \
--version 1.2.4脚本应该发布失败,然后切回上一版。执行后看:
readlink -f /opt/apps/demo-api/current
systemctl is-active demo-api
curl -fsS http://127.0.0.1:8080/actuator/health这里要注意,代码包回滚不等于数据库回滚。只要涉及表结构、数据修复、状态迁移,就要提前评审 SQL 的兼容性。发布脚本可以帮你切版本,但不能替你把错误数据变回去。
完整 jar 发布脚本
把前面的函数合到一起,脚本大概长这样:
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
APP_NAME="${APP_NAME:-demo-api}"
SERVICE_NAME="${SERVICE_NAME:-demo-api}"
BASE_DIR="${BASE_DIR:-/opt/apps/$APP_NAME}"
LOG_DIR="${LOG_DIR:-/var/log/deploy/$APP_NAME}"
LOCK_DIR="$BASE_DIR/locks"
STATE_DIR="$BASE_DIR/state"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8080/actuator/health}"
PACKAGE_FILE=""
VERSION=""
usage() {
echo "Usage: $0 --package <jar-file> --version <version>"
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--package) PACKAGE_FILE="${2:-}"; shift 2 ;;
--version) VERSION="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage; exit 2 ;;
esac
done
}
prepare_runtime() {
install -d "$BASE_DIR/releases" "$BASE_DIR/backups" "$STATE_DIR" "$LOCK_DIR" "$LOG_DIR"
LOG_FILE="$LOG_DIR/deploy-$(date +%Y%m%d_%H%M%S).log"
touch "$LOG_FILE"
}
log() {
local level="$1"
shift
printf '%s [%s] %s\n' "$(date '+%F %T')" "$level" "$*" | tee -a "$LOG_FILE" >&2
}
die() {
log ERROR "$*"
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing command: $1"
}
acquire_lock() {
exec 9>"$LOCK_DIR/deploy.lock"
flock -n 9 || { log ERROR "another deploy is running"; exit 100; }
}
verify_jar() {
if command -v jar >/dev/null 2>&1; then
jar tf "$PACKAGE_FILE" >/dev/null || die "invalid jar: $PACKAGE_FILE"
elif command -v unzip >/dev/null 2>&1; then
unzip -t "$PACKAGE_FILE" >/dev/null || die "invalid jar: $PACKAGE_FILE"
else
die "jar or unzip command is required"
fi
}
precheck() {
require_cmd curl
require_cmd flock
require_cmd systemctl
require_cmd sha256sum
[[ -n "$VERSION" ]] || die "--version is required"
[[ "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]] || die "invalid version: $VERSION"
[[ -s "$PACKAGE_FILE" ]] || die "package not found or empty: $PACKAGE_FILE"
verify_jar
log INFO "precheck passed"
}
backup_current() {
if [[ -L "$BASE_DIR/current" ]]; then
local current_target
local backup_file
current_target="$(readlink -f "$BASE_DIR/current")"
echo "$current_target" > "$STATE_DIR/previous-target"
backup_file="$BASE_DIR/backups/$(date +%Y%m%d_%H%M%S)_$(basename "$current_target").tar.gz"
tar -czf "$backup_file" -C "$current_target" .
sha256sum "$backup_file" > "$backup_file.sha256"
log INFO "backup created: $backup_file"
else
log WARN "current link not found, skip backup"
fi
}
install_release() {
local target_dir="$BASE_DIR/releases/$VERSION"
[[ ! -e "$target_dir" ]] || die "release already exists: $target_dir"
install -d "$target_dir"
cp "$PACKAGE_FILE" "$target_dir/app.jar"
sha256sum "$target_dir/app.jar" > "$target_dir/app.jar.sha256"
}
switch_current() {
local target_dir="$BASE_DIR/releases/$VERSION"
if [[ -L "$BASE_DIR/current" ]]; then
readlink -f "$BASE_DIR/current" > "$STATE_DIR/previous-target"
fi
ln -sfn "$target_dir" "$BASE_DIR/current.new"
mv -Tf "$BASE_DIR/current.new" "$BASE_DIR/current"
echo "$VERSION" > "$STATE_DIR/current-version"
}
restart_service() {
sudo -n systemctl restart "$SERVICE_NAME"
sleep 2
sudo -n systemctl is-active "$SERVICE_NAME" >/dev/null
}
healthcheck() {
for i in {1..30}; do
if curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null; then
log INFO "healthcheck passed"
return 0
fi
log WARN "healthcheck retry: $i"
sleep 2
done
return 1
}
rollback() {
local previous_target
previous_target="$(cat "$STATE_DIR/previous-target" 2>/dev/null || true)"
[[ -n "$previous_target" && -d "$previous_target" ]] || die "no previous release"
ln -sfn "$previous_target" "$BASE_DIR/current.new"
mv -Tf "$BASE_DIR/current.new" "$BASE_DIR/current"
sudo -n systemctl restart "$SERVICE_NAME" || true
healthcheck || die "rollback healthcheck failed"
log WARN "rollback success: $previous_target"
}
main() {
parse_args "$@"
prepare_runtime
acquire_lock
log INFO "deploy start: app=$APP_NAME version=$VERSION package=$PACKAGE_FILE"
precheck
backup_current
install_release
switch_current
if ! restart_service || ! healthcheck; then
log ERROR "deploy failed, rollback start"
rollback
exit 1
fi
log INFO "deploy success: version=$VERSION"
}
main "$@"发布命令:
/opt/apps/demo-api/bin/deploy-jar.sh \
--package /opt/packages/demo-api-1.2.3.jar \
--version 1.2.3执行成功后,至少看三样:
readlink -f /opt/apps/demo-api/current
systemctl is-active demo-api
curl -fsS http://127.0.0.1:8080/actuator/health都正常以后,再去看入口域名、业务接口、监控指标。
前端静态资源发布
前端发布和 jar 发布很像,只是制品一般是 dist.tar.gz,服务通常由 Nginx 托管。
先看制品结构:
tar -tzf /opt/packages/demo-web-1.2.3.tar.gz | head正常最好能看到:
index.html
assets/app.xxxxx.js
assets/app.xxxxx.css如果压缩包里多套了一层 dist/,脚本也能处理,但团队最好统一包结构,别让每次发布都靠猜。
Nginx 配置一般指向 current:
server {
listen 80;
server_name demo.example.com;
root /opt/apps/demo-web/current;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
try_files $uri =404;
}
}这里要注意,带 hash 的 JS/CSS 可以长缓存,index.html 不建议长缓存。因为它引用的是最新资源入口,如果用户一直拿旧 index.html,就可能打开旧版本或白屏。
前端发布脚本核心步骤:
PACKAGE_FILE="/opt/packages/demo-web-1.2.3.tar.gz"
VERSION="1.2.3"
BASE_DIR="/opt/apps/demo-web"
TARGET_DIR="$BASE_DIR/releases/$VERSION"
mkdir -p "$BASE_DIR/releases" "$BASE_DIR/state" "$BASE_DIR/backups"
if [ -L "$BASE_DIR/current" ]; then
readlink -f "$BASE_DIR/current" > "$BASE_DIR/state/previous-target"
fi
mkdir "$TARGET_DIR"
tar -zxf "$PACKAGE_FILE" -C "$TARGET_DIR"
test -f "$TARGET_DIR/index.html"
ln -sfn "$TARGET_DIR" "$BASE_DIR/current.new"
mv -Tf "$BASE_DIR/current.new" "$BASE_DIR/current"
sudo nginx -t
sudo systemctl reload nginx
curl -fsS -I http://127.0.0.1/ >/dev/null
curl -fsS -I http://127.0.0.1/index.html >/dev/null执行完以后访问页面,应该能看到新版本。命令行也要看:
curl -I http://127.0.0.1/index.html重点看 HTTP/1.1 200,以及缓存头是否符合预期。
如果 Nginx reload 失败,先不要慌。执行:
sudo nginx -t
sudo systemctl status nginx --no-pager
journalctl -u nginx -n 100 --no-pager常见原因是配置语法错、证书路径错、Nginx worker 用户没有权限访问真实目录。目录权限可以这样查:
namei -l /opt/apps/demo-web/current从 / 到最终目录,每一级都要保证 Nginx worker 用户有进入权限。
Docker 发布脚本
Docker 发布看起来只是 docker run 或 docker compose up -d,但生产上要多想几件事:镜像从哪里来、tag 是否固定、数据卷有没有误删、容器日志怎么保留、健康检查怎么做。
生产不要依赖 latest。示例镜像使用内网仓库:
registry.example.com/team/demo-api:1.2.3如果服务器能访问内网仓库,先拉镜像:
docker pull registry.example.com/team/demo-api:1.2.3
docker image inspect registry.example.com/team/demo-api:1.2.3如果是离线环境,就用镜像 tar:
sha256sum -c demo-api-1.2.3-image.tar.sha256
docker load -i demo-api-1.2.3-image.tar
docker image ls | grep demo-apiCompose 文件示例:
services:
demo-api:
image: ${IMAGE}
container_name: demo-api
restart: unless-stopped
env_file:
- .env
ports:
- "8080:8080"
volumes:
- ./logs:/app/logs
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/actuator/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 30s发布命令:
cd /opt/apps/demo-api
export IMAGE=registry.example.com/team/demo-api:1.2.3
echo "$IMAGE" > state/current-image
docker compose -f compose.yml up -d看容器:
docker compose -f compose.yml ps
docker logs --tail 100 demo-api
curl -fsS http://127.0.0.1:8080/actuator/health正常应该看到容器是 running,健康检查返回 200。
这里顺便讲一下 Overlay2 和 CoW。这个不是为了背概念,而是为了知道为什么不要把重要数据写在容器可写层里。Docker 镜像层是只读的,容器启动后会加一层可写层。应用往容器里写日志、上传文件、临时文件,很多时候就写到这一层。容器删了,这层也可能跟着没了;写得太多,还会把宿主机 Docker 数据目录撑爆。
看 Docker 磁盘占用:
docker system df
docker system df -v看容器挂载:
docker inspect demo-api --format '{{json .Mounts}}'生产建议:
- 日志挂载到宿主机目录,或者接入日志采集。
- 上传文件、数据库数据、对象存储数据不要放容器可写层。
- 不要在发布脚本里写
docker system prune -a --volumes。 - 回滚时切回上一个镜像 tag,不要临时拉
latest。
Docker 回滚可以记录上一版镜像:
docker inspect --format '{{.Config.Image}}' demo-api > state/previous-image 2>/dev/null || true回滚时:
export IMAGE="$(cat state/previous-image)"
docker compose -f compose.yml up -d
curl -fsS http://127.0.0.1:8080/actuator/health回滚后也要看日志:
docker logs --tail 200 demo-api远程执行发布
一台机器跑通以后,再考虑多台。这里建议一个原则:本地脚本负责分发包和触发远程脚本,真正的发布逻辑放在目标机器上。
先测试 SSH:
ssh -o BatchMode=yes deploy@app-01 "hostname && whoami"如果免密没配好,BatchMode=yes 会直接失败,不会卡住等密码。
分发包:
rsync -avzP demo-api-1.2.3.jar deploy@app-01:/opt/packages/调用远程发布脚本:
ssh -o BatchMode=yes deploy@app-01 \
"bash /opt/apps/demo-api/bin/deploy-jar.sh --package /opt/packages/demo-api-1.2.3.jar --version 1.2.3"多台机器顺序发布:
#!/usr/bin/env bash
set -Eeuo pipefail
HOSTS=("app-01" "app-02")
PACKAGE="demo-api-1.2.3.jar"
VERSION="1.2.3"
for host in "${HOSTS[@]}"; do
echo "===== deploy $host ====="
rsync -avzP "$PACKAGE" "deploy@$host:/opt/packages/"
ssh -o BatchMode=yes "deploy@$host" \
"bash /opt/apps/demo-api/bin/deploy-jar.sh --package /opt/packages/$PACKAGE --version $VERSION"
done这里要注意,多机发布第一次不要并发。先发一台,看健康检查、日志、监控都正常,再发下一台。如果入口层支持摘流,流程更稳:
摘除 app-01 -> 发布 app-01 -> 本机健康检查 -> 加回 app-01 -> 入口验证 -> 发布 app-02前端静态资源如果用 rsync --delete,必须先 dry-run:
rsync -avzn --delete ./dist/ deploy@web-01:/opt/apps/demo-web/current/确认输出没问题,再去掉 -n:
rsync -avz --delete ./dist/ deploy@web-01:/opt/apps/demo-web/current/dist 后面的斜杠要看清楚:
# 同步 dist 目录本身
rsync -avz dist deploy@web-01:/opt/apps/demo-web/
# 同步 dist 里面的内容
rsync -avz dist/ deploy@web-01:/opt/apps/demo-web/current/很多误删都是从这里开始的。
内网和离线环境怎么做
内网和离线环境最大的区别是:发布现场不能临时下载依赖。
Maven、Gradle、npm 这类依赖,应该在构建阶段解决。生产发布脚本只消费构建好的 jar、tar.gz、镜像 tar。
常见制品清单:
delivery-demo-api-1.2.3/
packages/
demo-api-1.2.3.jar
demo-web-1.2.3.tar.gz
demo-api-1.2.3-image.tar
scripts/
deploy-jar.sh
deploy-web.sh
deploy-docker.sh
rollback.sh
healthcheck.sh
config/
demo-api.env.example
nginx-demo-web.conf
checksums/
SHA256SUMS到目标机器先校验:
cd /opt/packages/delivery-demo-api-1.2.3
sha256sum -c checksums/SHA256SUMS正常应该看到每个文件后面都是 OK。
如果缺系统命令,比如 rsync、flock、curl,不要让脚本现场去公网安装。内网环境用企业 yum/dnf/apt 源;离线环境提前把 RPM、DEB、ISO 或工具包准备好。
Docker 镜像建议走两条路:
- 企业内网有 Harbor 或其他私有仓库,生产机器从内网仓库拉固定 tag 或 digest。
- 完全离线,就用
docker save和docker load,并带上 checksum。
示例:
docker save -o demo-api-1.2.3-image.tar registry.example.com/team/demo-api:1.2.3
sha256sum demo-api-1.2.3-image.tar > demo-api-1.2.3-image.tar.sha256目标机器:
sha256sum -c demo-api-1.2.3-image.tar.sha256
docker load -i demo-api-1.2.3-image.tar这里不建议把某个公共镜像加速地址写死在脚本里。公共镜像源的可用性会变,生产发布不要押注在临时网络运气上。
常见失败怎么查
包不存在或校验失败
现象:
package not found or empty
sha256 check failed
invalid jar
invalid tar.gz先看文件:
ls -lh /opt/packages/
sha256sum -c demo-api-1.2.3.jar.sha256
jar tf demo-api-1.2.3.jar | head
unzip -t demo-api-1.2.3.jar
tar -tzf demo-web-1.2.3.tar.gz | head一般是包没传完、包名写错、版本号不一致。不要为了赶时间跳过校验。
权限不足
现象:
Permission denied
sudo: a password is required这样查:
whoami
id
ls -ld /opt/apps/demo-api
sudo -n systemctl restart demo-api
sudo -l一般需要修目录 owner 或 sudo 白名单。不要直接改成 root 发布,因为这会把权限问题藏起来。
端口被占用
现象:
Port 8080 was already in use这样查:
ss -lntp | grep ':8080'
sudo lsof -iTCP:8080 -sTCP:LISTEN
systemctl status demo-api --no-pager先确认是不是旧进程没退出,或者容器端口映射冲突。不要看到端口占用就直接 kill -9,先确认进程是谁的。
服务启动失败
现象:
systemctl restart failed
healthcheck timeout这样查:
systemctl status demo-api --no-pager
journalctl -u demo-api -n 200 --no-pager
tail -n 200 /opt/apps/demo-api/shared/logs/app.log常见原因:Java 版本不匹配、配置文件缺失、数据库连不上、Redis 连不上、端口被占用、环境变量没有被 systemd 加载。
处理建议:先回滚恢复服务,再拿失败版本和日志去测试环境复现,不要在生产机器上边猜边改。
健康检查失败
现象:
curl: (22) The requested URL returned error: 503
curl: (7) Failed to connect这样查:
curl -v http://127.0.0.1:8080/actuator/health
ss -lntp | grep ':8080'
journalctl -u demo-api -n 200 --no-pager如果进程在、端口在,但 health 是 DOWN,一般是依赖服务不通。继续查数据库、Redis、MQ、配置中心。
Nginx reload 失败
现象:
nginx: configuration file /etc/nginx/nginx.conf test failed这样查:
sudo nginx -t
sudo systemctl status nginx --no-pager
journalctl -u nginx -n 100 --no-pagerNginx 配置变更必须先 nginx -t,通过后再 reload。配置校验失败,不要继续发布。
Docker 发布失败
现象:
manifest unknown
pull access denied
i/o timeout
container unhealthy这样查:
docker login registry.example.com
docker pull registry.example.com/team/demo-api:1.2.3
docker image inspect registry.example.com/team/demo-api:1.2.3
docker ps -a | grep demo-api
docker logs --tail 200 demo-api
docker inspect demo-api --format '{{.State.Health.Status}}'常见原因:tag 不存在、仓库权限不足、生产网络不能访问仓库、镜像架构不匹配、容器内健康检查命令缺失。
锁文件残留
现象:
another deploy is running先查是不是真的有发布在跑:
ps -ef | grep deploy
lsof /opt/apps/demo-api/locks/deploy.lock
tail -n 100 /var/log/deploy/demo-api/deploy-*.log确认没有进程后,再人工处理锁。处理前先看当前版本:
readlink -f /opt/apps/demo-api/current
systemctl is-active demo-api
curl -fsS http://127.0.0.1:8080/actuator/health不要一看到锁就删。锁文件可能正在保护一次真实发布。
落地工程深水区
发布脚本最危险的地方,是它能把“错误动作”执行得非常快。脚本上线前,要把下面这些深水问题提前压住。
第一是状态模型。脚本必须知道当前版本、上一版本、目标版本、发布中的临时状态和失败后的回滚目标。只靠一个 app.jar.bak 不够,至少要有 current、previous、state/current-version、制品 checksum 和发布日志。否则失败后无法判断应该退到哪一版。
第二是并发控制。发布不能两个人同时执行,也不能 CI 重试和人工补救同时执行。flock 要覆盖整个关键区间,锁日志要写清楚持有者、开始时间和命令参数。锁残留时先查进程和当前版本,不要直接删除。
第三是数据和配置风险。脚本能回滚 jar、静态资源、镜像和 Nginx 配置,不代表能自动回滚数据库。只要包含 DDL、数据修复、缓存刷新、消息订阅变化,就要在发布单里写清楚补偿方案和人工确认点。脚本不要假装能解决所有一致性问题。
第四是健康检查质量。进程 active 不等于服务可用,端口监听不等于业务正常。健康检查至少分本机接口、入口域名、核心业务冒烟和日志错误四层。回滚后也要再次健康检查,不能“回滚命令执行成功”就收工。
第五是权限和安全。部署用户应该只能写应用目录、读必要日志、重启指定服务。脚本日志不能打印环境变量里的密码、token、私钥;远程执行用 BatchMode=yes,避免脚本卡在交互密码输入。
第六是内网离线。发布脚本不能现场构建、现场下载依赖、现场拉不确定 tag。内网使用私有制品库和镜像仓库;离线使用 tar 包、镜像包、校验和、安装顺序和回滚包。没有依赖闭环时,脚本越自动化,失败越快。
生产发布前检查清单
最后把清单收一下。脚本上线前,一般要检查这些项:
做到这里,发布脚本就不是几条命令的拼接了,而是一套能落地的发布流程。它不一定很花哨,但每一步都能解释、能验证、能排查、能回滚,这才是生产真正需要的东西。
官方文档入口
- GNU Bash Manual
- flock manual page
- rsync manual page
- curl manual page
- systemd.service manual
- systemctl manual page
- Nginx control
- Docker image save
- Docker image load
- Docker container logs
- Docker Compose up
- Docker Compose services reference
- Spring Boot Actuator health API
- Spring Boot Actuator monitoring
- Maven settings
- Maven mirror settings guide
- Harbor proxy cache
- OpenSSH manual pages
