发布脚本
这篇讲的是单机或少量服务器场景下,如何把手工发布动作沉淀成可执行脚本。它覆盖制品校验、目录约定、锁、备份、软链接切换、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一般应该看到类似:
<RUN_ID>_1.2.2.tar.gz
<RUN_ID>_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 包、镜像包、校验和、安装顺序和回滚包。没有依赖闭环时,脚本越自动化,失败越快。
最后把清单收一下。脚本上线前,一般要检查这些项:
参数错误会退出,返回非 0。有日志文件,能看到每一步执行结果。有锁文件,能防止重复执行。
制品包会校验存在、格式和 checksum。发布前会检查磁盘空间。替换前会备份当前版本。
current 使用软链接,不直接覆盖运行目录。启动后有 HTTP 健康检查。健康检查失败会回滚。
回滚后还会再次健康检查。Nginx 配置变更先 nginx -t,再 reload。远程执行使用 BatchMode=yes,失败立即退出。
rsync --delete 前先 dry-run。rm -rf 有路径保护。Docker 镜像不用 latest。
Docker 发布不误删数据卷。生产机器不临时构建、不临时下载依赖。内网和离线环境有制品、镜像、系统工具闭环。
日志里不打印密码、token、私钥。
做到这里,发布脚本就不是几条命令的拼接了,而是一套能落地的发布流程。它不一定很花哨,但每一步都能解释、能验证、能排查、能回滚,这才是生产真正需要的东西。
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
