Files
vps-manager/deploy/update.sh
T
gouki 87e144e022 feat(deploy): update.sh 健康闸门增加核心 API 探测,失败触发回滚
在 wait_health commit 追平通过后、清除 FAILED_STATE 之前,新增
check_core_apis 函数探测 4 个核心端点(/api/assets、/api/accounts、
/api/providers、/api/stats/overview),复用 deploy.sh 的端点列表与
判定标准(curl -w %{http_code},非 200 视为验收失败)。

API 探测失败时走现有完整回滚路径:git reset --hard $LOCAL +
systemctl restart + 二次 wait_health + 写 FAILED_STATE + 告警。
告警消息新增「失败原因」字段区分健康检查与 API 探测失败。
2026-10-14 08:31:10 +00:00

371 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# vps-manager 自动更新脚本(由 systemd timer 定时触发)
# 1. 自动同步 deploy/ 下的 systemd 单元文件(有变更才拷贝 + daemon-reload;
# timer 变更 enable --now,主服务 unit 变更额外 restart)
# 2. 从 Gitea 拉取最新代码,如有更新则装依赖并重启服务
# 3. 重启后健康闸门:轮询 /health 直到 commit 追平新 HEAD 且 status=ok;
# 超时或异常则回滚到 reset 前的提交、再次重启,并通过 .env 已配置渠道告警
#
# 可配置项(环境变量):
# VPS_MANAGER_DIR 项目根目录(默认 /opt/vps-manager)
# VPS_MANAGER_SVC systemd 服务名(默认 vps-manager)
# GIT_BRANCH 跟踪的远端分支(默认 main)
# DEPLOY_HEALTH_URL 健康检查地址(默认 http://127.0.0.1:8000/health)
# DEPLOY_HEALTH_TIMEOUT 追平新版本的轮询总超时秒数(默认 120)
# DEPLOY_HEALTH_INTERVAL 轮询间隔秒数(默认 15)
# DEPLOY_HEALTH_CURL_MAXTIME 单次请求超时秒数(默认 10)
# DEPLOY_ROLLBACK_TIMEOUT 回滚后确认恢复的超时秒数(默认 60)
#
# 使用示例:
# VPS_MANAGER_DIR=/srv/vps ./update.sh
# 或在 systemd unit 中设置 Environment=VPS_MANAGER_DIR=/srv/vps
set -euo pipefail
APP_DIR="${VPS_MANAGER_DIR:-/opt/vps-manager}"
SERVICE_NAME="${VPS_MANAGER_SVC:-vps-manager}"
BRANCH="${GIT_BRANCH:-main}"
cd "$APP_DIR"
# ---- 部署后健康闸门配置(均可用环境变量覆盖)----
# cc1 上服务由 systemd 拉起并监听 0.0.0.0:8000,本机直连 127.0.0.1 最稳(不依赖 Tailscale/证书)
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:8000/health}"
HEALTH_TIMEOUT="${DEPLOY_HEALTH_TIMEOUT:-120}" # 追平新版本的轮询总超时(秒),对齐 deploy-verification.md
HEALTH_INTERVAL="${DEPLOY_HEALTH_INTERVAL:-15}" # 轮询间隔(秒)
HEALTH_CURL_MAXTIME="${DEPLOY_HEALTH_CURL_MAXTIME:-10}" # 单次请求超时(秒)
ROLLBACK_TIMEOUT="${DEPLOY_ROLLBACK_TIMEOUT:-60}" # 回滚后确认服务恢复的超时(秒)
# 记录“上次因健康检查失败被回滚的远端提交”,避免每分钟 timer 反复部署同一坏版本导致
# 服务抖动 + 告警轰炸。放在 .git 目录内:不被 git 跟踪、不出现在 status、reset --hard 也不清除。
GIT_DIR_PATH="$(git rev-parse --absolute-git-dir 2>/dev/null || echo "$APP_DIR/.git")"
FAILED_STATE="$GIT_DIR_PATH/vps-manager-deploy-failed"
# JSON 解析用的 Python:优先 venv(cc1 必然存在),回退系统 python3;均不可用则跳过闸门(不误回滚)
PY=""
for _cand in ".venv/bin/python" "python3"; do
if "$_cand" -c 'import json' >/dev/null 2>&1; then PY="$_cand"; break; fi
done
# 健康取数优先用 curl(与 deploy.sh / deploy-verification.md 一致);cc1 万一没有 curl 则回退 Python urllib,
# 避免因取数工具缺失导致 /health 永远拿不到响应、误判超时回滚一个好版本。
HAS_CURL=0
if command -v curl >/dev/null 2>&1; then HAS_CURL=1; fi
# 输出当前运行版本号(语义版本 + git 短哈希),便于比对本地与线上是否一致
print_version() {
local semver commit
semver=$(cat VERSION 2>/dev/null | tr -d '[:space:]')
[ -z "$semver" ] && semver="dev"
commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
echo "[$(date '+%F %T')] 当前版本: v${semver} (${commit})"
}
# 拉取 /health 响应体(失败或超时返回空串;|| true 避免 set -e 因 curl 非零退出而中断)
fetch_health() {
if [ "$HAS_CURL" = "1" ]; then
curl -s --max-time "$HEALTH_CURL_MAXTIME" "$HEALTH_URL" 2>/dev/null || true
elif [ -n "$PY" ]; then
"$PY" - "$HEALTH_URL" "$HEALTH_CURL_MAXTIME" <<'PYEOF' 2>/dev/null || true
import sys, urllib.request
try:
with urllib.request.urlopen(sys.argv[1], timeout=float(sys.argv[2])) as r:
sys.stdout.write(r.read().decode("utf-8", "replace"))
except Exception:
pass
PYEOF
fi
}
# 从 JSON 串提取字段值(解析失败返回空串)
health_field() {
printf '%s' "$1" | "$PY" -c "import json,sys; print(json.load(sys.stdin).get(sys.argv[1],''))" "$2" 2>/dev/null || true
}
# 轮询 /health 直到 status=ok 且 commit 追平 expected,或超时。返回 0=通过,1=未通过。
# expected 必须用 `git rev-parse --short HEAD` 计算,与 app.main._read_git_commit 完全一致,
# 否则缩写长度不同(--short 自适应 vs --short=7)会导致永不匹配、误触发回滚。
wait_health() {
local expected="$1" timeout="$2" deadline resp status commit
deadline=$(( $(date +%s) + timeout ))
while [ "$(date +%s)" -lt "$deadline" ]; do
resp="$(fetch_health)"
if [ -n "$resp" ]; then
status="$(health_field "$resp" status)"
commit="$(health_field "$resp" commit)"
if [ "$status" = "ok" ] && [ "$commit" = "$expected" ]; then
echo "[$(date '+%F %T')] 健康检查通过:status=ok commit=${commit}"
return 0
fi
echo "[$(date '+%F %T')] 健康检查中:status=${status:-?} commit=${commit:-?} 目标=${expected},${HEALTH_INTERVAL}s 后重试…"
else
echo "[$(date '+%F %T')] 健康检查中:${HEALTH_URL} 暂无响应,${HEALTH_INTERVAL}s 后重试…"
fi
sleep "$HEALTH_INTERVAL"
done
echo "[$(date '+%F %T')] 健康检查超时(${timeout}s)"
return 1
}
# 通过 .env 已配置的通知渠道(Telegram/邮件)发送告警:直接调用 app 的 notification_service,
# 用 venv Python 独立进程发送,不依赖 Web 服务是否存活;渠道未配置时自动跳过、不抛错。
send_alert() {
local msg="$1" py=".venv/bin/python"
if [ ! -x "$py" ]; then
echo "[$(date '+%F %T')] 未找到 .venv/bin/python,跳过告警发送(无法导入 app 通知服务)"
return 0
fi
echo "[$(date '+%F %T')] 发送部署告警…"
"$py" - "$msg" <<'PYEOF' 2>&1 || echo "[$(date '+%F %T')] 告警发送异常(渠道未配置或调用失败)"
import sys
sys.path.insert(0, ".")
try:
from app.services import notification_service
for r in notification_service.notify(sys.argv[1], subject="vps-manager 部署告警"):
print(f" 通知[{r.get('channel')}]: {'成功' if r.get('ok') else r.get('message')}")
except Exception as e: # noqa: BLE001
print(f" 通知调用失败: {e}")
sys.exit(1)
PYEOF
}
# 探测 4 个核心 API 端点,全部返回 HTTP 200 时返回 0,否则返回 1。
# 复用 deploy.sh 的端点列表与判定标准(非 200 视为验收失败)。
check_core_apis() {
local base="${HEALTH_URL%/health}"
local path code failed=0
for path in /api/assets /api/accounts /api/providers /api/stats/overview; do
if [ "$HAS_CURL" = "1" ]; then
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$HEALTH_CURL_MAXTIME" "${base}${path}" 2>/dev/null || echo "000")
elif [ -n "$PY" ]; then
code=$("$PY" - "${base}${path}" "$HEALTH_CURL_MAXTIME" <<'PYEOF' 2>/dev/null || echo "000"
import sys, urllib.request
try:
with urllib.request.urlopen(sys.argv[1], timeout=float(sys.argv[2])) as r:
print(r.getcode())
except Exception:
pass
PYEOF
)
code="${code:-000}"
else
code="000"
fi
if [ "$code" = "200" ]; then
echo "[$(date '+%F %T')] ✓ ${path} -> ${code}"
else
echo "[$(date '+%F %T')] ❌ ${path} -> ${code}"
failed=1
fi
done
return "$failed"
}
# ---- 前端 JS 语法闸门:restart 前用 node --check 逐个校验自有 JS ----
# 只检查项目自有文件(app.js / api.js / modals.js / store.js / views/*.js),
# 排除第三方 vendored 文件(vue.global.prod.js / tailwind.js / chart.umd.js)。
# 保持零构建现状(raw JS + ?v=mtime 缓存失效),仅做最小语法校验。
check_js_syntax() {
local node_bin="" failed=0 f
# node 探测:优先 venv 内(如有),回退系统 PATH
for _cand in ".venv/bin/node" "node"; do
if command -v "$_cand" >/dev/null 2>&1; then node_bin="$_cand"; break; fi
done
if [ -z "$node_bin" ]; then
echo "[$(date '+%F %T')] ⚠️ 未找到 node,跳过 JS 语法闸门"
return 0
fi
echo "[$(date '+%F %T')] JS 语法闸门:使用 $node_bin"
# 自有 JS 列表(顶层固定文件 + views/ 下全部 .js)
local js_files=(
"static/js/app.js"
"static/js/api.js"
"static/js/modals.js"
"static/js/store.js"
)
for f in static/js/views/*.js; do
[ -f "$f" ] && js_files+=("$f")
done
for f in "${js_files[@]}"; do
[ -f "$f" ] || continue
if ! "$node_bin" --check "$f" 2>&1; then
echo "[$(date '+%F %T')] ❌ JS 语法错误: $f"
failed=1
else
echo "[$(date '+%F %T')] ✓ $f"
fi
done
return "$failed"
}
# 同步 systemd 单元文件:deploy/ 下的 .service/.timer 与 /etc/systemd/system
# 不一致时拷贝并 reload;timer 变更额外 enable --now(新调度周期立即生效且开机自启),
# 主服务 unit 变更额外 restart(否则光 daemon-reload 日志说已更新、实际没生效)。
# 每次都执行(而非仅版本更新时),保证单元变更不依赖代码变更触发。
sync_units() {
local changed=0 restart_main=0 f base
for f in "$APP_DIR"/deploy/*.service "$APP_DIR"/deploy/*.timer; do
[ -f "$f" ] || continue
base=$(basename "$f")
if ! cmp -s "$f" "/etc/systemd/system/$base"; then
cp "$f" "/etc/systemd/system/$base"
echo "[$(date '+%F %T')] 单元已更新: $base"
changed=1
case "$base" in
*.timer)
# enable --now:新增的 timer 只 restart 不会建开机自启软链,宿主重启后就丢了
systemctl enable --now "$base" 2>/dev/null \
|| systemctl restart "$base" 2>/dev/null || true
;;
"$SERVICE_NAME".service)
# 常驻服务的 unit 变更(如 ExecStart)光 daemon-reload 不生效,必须 restart;
# oneshot 服务下次触发自然读新配置,无需 restart
restart_main=1
;;
esac
fi
done
if [ "$changed" -eq 1 ]; then
systemctl daemon-reload
echo "[$(date '+%F %T')] systemd 单元同步完成"
fi
if [ "$restart_main" -eq 1 ]; then
systemctl restart "$SERVICE_NAME"
echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 使 unit 变更生效"
fi
}
sync_units
git fetch -q origin "$BRANCH"
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse "origin/$BRANCH")
if [ "$LOCAL" = "$REMOTE" ]; then
echo "[$(date '+%F %T')] 代码已是最新,无需更新"
print_version
exit 0
fi
# 若该远端提交此前已因健康检查失败被自动回滚,则不再重复部署同一坏版本:
# 否则每分钟 timer 都会 reset→重启→失败→回滚,服务反复抖动且告警轰炸。
# 等到有人推送修复提交(REMOTE 变化)后,此判断自然放行。
if [ -f "$FAILED_STATE" ] && [ "$(cat "$FAILED_STATE" 2>/dev/null || true)" = "$REMOTE" ]; then
echo "[$(date '+%F %T')] 远端 ${REMOTE:0:8} 此前健康检查失败已回滚,跳过重复部署(等待修复提交)"
print_version
exit 0
fi
echo "[$(date '+%F %T')] 检测到新版本: ${LOCAL:0:8} -> ${REMOTE:0:8}"
# 部署节点始终与远端完全一致:reset --hard 丢弃任何本地变更(含文件 mode 变化),
# 避免 cc1 上误改文件导致 pull 拒绝合并、更新链路卡死
git reset --hard -q "origin/$BRANCH"
# 依赖如有变更自动安装(无变化时 pip 会快速跳过)
if [ -x ".venv/bin/pip" ]; then
# 不再用 2>/dev/null || true 把报错全吞:装不上时只表现为服务重启后 ImportError,极难定位
if ! .venv/bin/pip install -q -r requirements.txt; then
echo "[$(date '+%F %T')] 警告:依赖安装失败,服务可能因 ImportError 起不来;请手动执行 .venv/bin/pip install -r requirements.txt 看完整报错"
fi
fi
# ---- 前端 JS 语法闸门 ----
if ! check_js_syntax; then
FAIL_REASON="前端 JS 语法检查失败(node --check 未通过,已中止上线)"
echo "[$(date '+%F %T')] ❌ ${FAIL_REASON},回滚至 ${LOCAL:0:8}"
git reset --hard -q "$LOCAL"
systemctl restart "$SERVICE_NAME" || true
echo "$REMOTE" > "$FAILED_STATE" 2>/dev/null || true
send_alert "🚨 vps-manager 部署失败:JS 语法闸门拦截
坏版本: ${REMOTE:0:8}
回滚至: ${LOCAL:0:8}
失败原因: ${FAIL_REASON}
时间: $(date '+%F %T')
主机: cc1:${APP_DIR}
排查: journalctl -u ${SERVICE_NAME} -n 50"
exit 1
fi
# ---- 测试闸门:restart 前用 pytest 拦截坏版本 ----
# 旧版本服务仍在运行(尚未 restart),测试失败时只需回退磁盘代码、不 restart,
# 避免坏版本上线。使用规则要求的 .venv/bin/python 解释器。
if [ -x ".venv/bin/python" ]; then
echo "[$(date '+%F %T')] 运行测试闸门(pytest -x -q)…"
if ! .venv/bin/python -m pytest tests/ -x -q 2>&1; then
FAIL_REASON="测试闸门失败(pytest -x -q 未通过),已阻止 restart"
echo "[$(date '+%F %T')] ❌ ${FAIL_REASON}"
# 回退磁盘代码到 reset 前的提交;旧版本服务仍在运行,无需 restart
git reset --hard -q "$LOCAL"
# sync_units 可能已 daemon-reload 过新 unit 文件,代码回退后需再 reload 一次对齐旧版本
systemctl daemon-reload 2>/dev/null || true
# 记录失败提交,阻止后续 timer 重复部署同一坏版本
echo "$REMOTE" > "$FAILED_STATE" 2>/dev/null || true
send_alert "🚨 vps-manager 测试闸门失败,已阻止部署
坏版本: ${REMOTE:0:8}
保留版本: ${LOCAL:0:8}
失败原因: ${FAIL_REASON}
时间: $(date '+%F %T')
主机: cc1:${APP_DIR}
排查: .venv/bin/python -m pytest tests/ -x -q"
exit 1
fi
echo "[$(date '+%F %T')] ✅ 测试闸门通过"
else
echo "[$(date '+%F %T')] ⚠️ 未找到 .venv/bin/python,跳过测试闸门"
fi
# 重启服务加载新代码
systemctl restart "$SERVICE_NAME"
echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 服务"
# ---- 部署后健康闸门:轮询 /health 直到 commit 追平新 HEAD 且 status=ok,否则回滚 ----
# LOCAL 是 reset 前记录的 HEAD,即回滚点;EXPECTED_SHORT 与 app 上报口径一致(--short 自适应)
if [ -z "$PY" ]; then
echo "[$(date '+%F %T')] ⚠️ 无可用 Python 解析 /health,跳过健康闸门(不回滚,请人工确认服务状态)"
print_version
exit 0
fi
EXPECTED_SHORT="$(git rev-parse --short HEAD)"
if wait_health "$EXPECTED_SHORT" "$HEALTH_TIMEOUT"; then
# /health commit 追平通过,进一步验证核心 API 端点可用性
if check_core_apis; then
rm -f "$FAILED_STATE" 2>/dev/null || true
echo "[$(date '+%F %T')] ✅ 部署健康检查通过(含核心 API 验证)"
print_version
exit 0
fi
# API 探测失败:视同健康检查未通过,进入回滚路径
FAIL_REASON="核心 API 探测失败(/health 已通过但部分端点非 200)"
echo "[$(date '+%F %T')] ❌ 核心 API 探测未通过,触发回滚"
else
FAIL_REASON="健康检查未通过(${HEALTH_TIMEOUT}s 内 commit 未追平 ${EXPECTED_SHORT} 或 status≠ok)"
fi
# 健康检查或 API 探测失败:回滚到 reset 前的提交并重启,尽量恢复到已知可用版本
echo "[$(date '+%F %T')] ❌ ${FAIL_REASON},回滚至 ${LOCAL:0:8}"
git reset --hard -q "$LOCAL"
systemctl restart "$SERVICE_NAME" || true
PREV_SHORT="$(git rev-parse --short HEAD)"
recovered="否(回滚后服务仍未通过健康检查,需人工介入)"
if wait_health "$PREV_SHORT" "$ROLLBACK_TIMEOUT"; then
recovered="是(已恢复到回滚版本)"
echo "[$(date '+%F %T')] 已回滚至 ${LOCAL:0:8},服务恢复正常"
else
echo "[$(date '+%F %T')] ⚠️ 已回滚至 ${LOCAL:0:8},但健康检查仍未通过,需人工介入"
fi
# 记录失败的远端提交,阻止后续 timer 重复部署同一坏版本(见文件头 FAILED_STATE 说明)
echo "$REMOTE" > "$FAILED_STATE" 2>/dev/null || true
# 通过 .env 已配置的通知渠道告警
send_alert "🚨 vps-manager 部署健康检查失败,已自动回滚
坏版本: ${REMOTE:0:8}
回滚至: ${LOCAL:0:8}
失败原因: ${FAIL_REASON}
服务恢复: ${recovered}
时间: $(date '+%F %T')
主机: cc1:${APP_DIR}
排查: journalctl -u ${SERVICE_NAME} -n 50"
# 以非 0 退出,让 systemd 记录本次更新失败(告警已发出)
exit 1