Skip to content

发布脚本:软链切换与失败自动回滚 ​

不依赖任何平台,一台机器也能做到秒级回滚:每次发布一个新目录,current 软链指向它,回滚就是把软链指回去。

一、目录约定 ​

text
/opt/order-api/
├── releases/
│   ├── 20261027-1530/
│   │   └── app.jar
│   ├── 20261028-0012/
│   │   └── app.jar
│   └── 20261028-0105/
│       └── app.jar
└── current -> releases/20261028-0105

好处:

  • 每个版本完整独立,回滚不用重新构建。
  • 磁盘上保留 N 个历史版本,随时可切。
  • 软链切换是原子操作,不存在"切一半"的中间态。

二、systemd 指向软链 ​

ini
# /etc/systemd/system/order-api.service
[Unit]
Description=Order API
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=app
Group=app
WorkingDirectory=/opt/order-api/current
ExecStart=/usr/local/jdk-17/bin/java -XX:MaxRAMPercentage=70 -jar /opt/order-api/current/app.jar
SuccessExitStatus=143
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

systemd 在启动时才解析软链,所以改软链后必须 restart 才会生效。

三、发布脚本 ​

bash
#!/usr/bin/env bash
# /opt/order-api/bin/deploy.sh <jar-path>
set -euo pipefail

APP="order-api"
BASE="/opt/${APP}"
REL_DIR="${BASE}/releases"
CUR="${BASE}/current"
KEEP=5
HEALTH_URL="http://127.0.0.1:8080/actuator/health"
WAIT_SECONDS=90

JAR="${1:?usage: deploy.sh <jar-path>}"

log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; }

[[ -f "$JAR" ]] || { log "ERROR: jar not found: $JAR"; exit 1; }
command -v curl >/dev/null || { log "ERROR: curl not installed"; exit 1; }

STAMP="$(date +%Y%m%d-%H%M%S)"
NEW="${REL_DIR}/${STAMP}"

mkdir -p "$NEW"
install -m 0644 "$JAR" "${NEW}/app.jar"
log "new release: ${NEW}"

# 记录切换前的软链,回滚用
PREV="$(readlink -f "$CUR" 2>/dev/null || true)"
log "previous release: ${PREV:-none}"

switch_to() {
  local target="$1"
  ln -sfn "$target" "${CUR}.tmp"
  mv -Tf "${CUR}.tmp" "$CUR"      # 原子替换软链
}

rollback() {
  if [[ -z "$PREV" || ! -d "$PREV" ]]; then
    log "ERROR: no previous release to roll back to"
    exit 1
  fi
  log "rolling back to ${PREV}"
  switch_to "$PREV"
  systemctl restart "$APP" || true
}

switch_to "$NEW"
systemctl restart "$APP"

log "waiting for health check (max ${WAIT_SECONDS}s)"
for i in $(seq 1 "$WAIT_SECONDS"); do
  if curl -fsS --max-time 2 "$HEALTH_URL" >/dev/null 2>&1; then
    log "healthy after ${i}s"
    break
  fi
  if [[ "$i" -eq "$WAIT_SECONDS" ]]; then
    log "ERROR: health check failed"
    rollback
    exit 1
  fi
  sleep 1
done

# 只保留最近 KEEP 个版本
ls -1dt "${REL_DIR}"/*/ 2>/dev/null | tail -n +$((KEEP + 1)) | xargs -r rm -rf

log "deploy done: ${STAMP}"
bash
chmod +x /opt/order-api/bin/deploy.sh
/opt/order-api/bin/deploy.sh /tmp/build/app.jar
text
[2026-10-28 00:41:02] new release: /opt/order-api/releases/20261028-0041
[2026-10-28 00:41:02] previous release: /opt/order-api/releases/20261028-0012
[2026-10-28 00:41:07] waiting for health check (max 90s)
[2026-10-28 00:41:15] healthy after 8s
[2026-10-28 00:41:15] deploy done: 20261028-0041

四、为什么用 mv -Tf 而不是 ln -sfn ​

ln -sfn new current 在 current 已是指向目录的软链时,行为依赖实现,容易变成"在目标目录里再建一个链接"。

bash
# 危险写法
ln -sfn /opt/app/releases/20261028-0041 /opt/app/current

# 安全写法(先建临时链接,再原子替换)
ln -sfn /opt/app/releases/20261028-0041 /opt/app/current.tmp
mv -Tf /opt/app/current.tmp /opt/app/current

mv -T 把目标当作普通文件处理(不进入目录),-f 强制覆盖。同一文件系统内是原子操作,任何时刻读到的 current 要么是旧的,要么是新的。

五、回滚脚本 ​

bash
#!/usr/bin/env bash
# /opt/order-api/bin/rollback.sh
set -euo pipefail

BASE=/opt/order-api
CUR="$BASE/current"

PREV="$(ls -1dt "$BASE"/releases/*/ | sed -n '2p')"
[[ -n "$PREV" ]] || { echo "no previous release"; exit 1; }

echo "rollback: $(readlink -f "$CUR") -> $PREV"
ln -sfn "$PREV" "${CUR}.tmp"
mv -Tf "${CUR}.tmp" "$CUR"
systemctl restart order-api
systemctl is-active --quiet order-api && echo "rollback ok"

回滚是秒级的:不用构建、不用传包,只改一个软链再重启。

回滚不是万能的

以下情况回滚无效,必须往前修:

  • 数据库已做不兼容变更(删列、改类型)。见 灰度与回滚。
  • 配置中心/缓存里的数据格式变了。
  • 外部依赖(第三方 API、证书)已经变更。

所以发布前要确认:旧版本能否读新版本写的数据。

六、健康检查写什么 ​

检查适合
GET /health 返回 200应用已启动
检查数据库连接依赖就绪
检查 Redis/Kafka依赖就绪
真实业务接口端到端可用

推荐分两个端点:

text
/actuator/health/liveness   只查自身(发布脚本不用它)
/actuator/health/readiness  查依赖(发布脚本查这个)

健康检查不要做重操作(比如全表扫描)。它要能承受每秒一次的探测。

七、并发保护 ​

两个人同时发布会导致软链互相覆盖。加锁:

bash
exec 9>/var/lock/order-api-deploy.lock
flock -n 9 || { echo "another deploy in progress"; exit 1; }

放在脚本开头,进程退出锁自动释放。

八、完整流程串联 ​

text
CI 构建 jar
   ↓
scp app.jar 到目标机 /tmp
   ↓
deploy.sh /tmp/app.jar
   ├── 建新 release 目录
   ├── 原子切软链
   ├── restart
   ├── 健康检查(失败则切回旧软链 + restart)
   └── 清理旧版本

整个过程可重复、可回滚、不依赖人工记忆。

九、踩过的坑 ​

现象根因处置
回滚后还是新版本软链改了但没 restart必须重启
ln -sfn 建出嵌套链接目标目录已存在用 ln + mv -Tf
健康检查通过但业务 502只查了 /health 没查真实依赖查 readiness
发布时端口被旧进程占着旧进程还没退完等 systemctl is-active 或加 TimeoutStopSec
磁盘被历史版本占满没清理脚本里保留 N 个
两人同时发布没加锁flock
回滚时发现旧版本已被删KEEP 太小至少保留 3~5 个
jar 权限不对起不来cp 后属主变了用 install -m 0644