外观
发布脚本:软链切换与失败自动回滚
不依赖任何平台,一台机器也能做到秒级回滚:每次发布一个新目录,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.targetsystemd 在启动时才解析软链,所以改软链后必须 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.jartext
[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/currentmv -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 |