Files
vps-manager/deploy/update.sh
T
gouki e6bbfdfdc7 fix(deploy): 解除更新链路死锁——失败标记改为冷却窗口而非永久拒绝
FAILED_STATE 语义原为「永久拒绝该远端提交」,但闸门失败可能源于环境问题
而非代码问题(venv 缺 pytest、systemd PATH 缺 nvm)。此时修复手段是修环境,
远端 hash 并未变化,于是形成自锁:

  旧脚本写入标记 → 修好环境后的新脚本因 hash 未变被跳过 → 标记永不失效

cc1 实测已因此冻结:dd192bac 被拦(纯文档提交),随后 e9cc95c 修 update.sh
的提交本身也被同一个错误判定写入标记,每分钟只打印「跳过重复部署」,
只能人工 ssh 删除标记才能恢复。

改为带时间戳的冷却窗口(DEPLOY_COOLDOWN,默认 6h):
- 标记内容由裸 hash 变为 "<commit> <epoch> <单行原因>"
- 到期自动重试,兼得防抖(不反复抖动/告警轰炸)与自愈(修完环境自动恢复)
- 识别旧格式(无时间戳)即清理并放行,不让旧死锁残留
- 日志输出真实失败原因与「立即重试」命令,不再一律宣称「健康检查失败」

此前同批修复的另外两处(随 e9cc95c 一并上线):
- 测试闸门区分「pytest 未安装」与「测试失败」,前者跳过而非拦截
- JS 闸门 node 探测增加 ~/.nvm/versions/node/*/bin 回退

验证:bash -n 通过;冷却逻辑 10 项分支测试全通过;pytest 334 passed
2026-10-14 12:17:29 +00:00

419 lines
19 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)
# DEPLOY_COOLDOWN 同一提交部署失败后的冷却窗口秒数(默认 21600)
#
# 使用示例:
# 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}" # 回滚后确认服务恢复的超时(秒)
# 记录“上次部署失败”的远端提交(commit + 失败时刻 + 原因)。放在 .git 目录内:不被 git 跟踪、
# 不出现在 status、reset --hard 也不清除。
# 关键设计:必须是「冷却」而非「永久拒绝」。失败原因可能是环境问题(venv 缺 pytest、缺 node)
# 而非代码问题,此时修复手段是修环境,远端 hash 并不变化——永久拒绝会让更新链路彻底死锁:
# 旧脚本已写入标记 → 修好环境后的新脚本因 hash 未变被跳过 → 标记永不失效
# (cc1 曾因此冻结数小时,且把修脚本的提交本身也冻住,只能人工 ssh 删标记才能恢复)。
# 冷却到期自动重试:兼得防抖(不反复抖动、不告警轰炸)与自愈(修完环境自动恢复)。
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"
DEPLOY_COOLDOWN="${DEPLOY_COOLDOWN:-21600}" # 同一失败提交的冷却窗口(秒)
# 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
}
# 记录部署失败:格式 "<commit> <epoch> <单行原因>",供 skip_failed_commit 判定冷却是否到期
mark_failed() {
local commit="$1" reason="${2:-未记录}"
printf '%s %s %s\n' "$commit" "$(date +%s)" "${reason//$'\n'/ }" > "$FAILED_STATE" 2>/dev/null || true
}
# 判定远端提交是否处于部署失败冷却中:命中返回 0(应跳过),否则返回 1(可部署)。
# 放行条件:远端变了(推了新提交)、冷却已到期、或标记为旧格式。
# 旧格式只有 40 位 hash、没有时间戳,语义是「永久拒绝」——继续沿用等同于保留死锁,
# 因此识别到旧格式就清理并放行。
skip_failed_commit() {
local remote="$1" commit="" stamp="" reason="" now left
[ -f "$FAILED_STATE" ] || return 1
read -r commit stamp reason < "$FAILED_STATE" 2>/dev/null || true
[ "$commit" = "$remote" ] || return 1
now=$(date +%s)
if ! printf '%s' "${stamp:-}" | grep -qE '^[0-9]+$'; then
echo "[$(date '+%F %T')] 失败标记为旧格式(无时间戳,语义为永久拒绝),已清理并重试部署"
rm -f "$FAILED_STATE" 2>/dev/null || true
return 1
fi
left=$(( DEPLOY_COOLDOWN - (now - stamp) ))
if [ "$left" -le 0 ]; then
echo "[$(date '+%F %T')] 部署失败冷却已到期(${DEPLOY_COOLDOWN}s),重新尝试部署 ${remote:0:8}"
rm -f "$FAILED_STATE" 2>/dev/null || true
return 1
fi
echo "[$(date '+%F %T')] 远端 ${remote:0:8} 处于部署失败冷却中(剩余 ${left}s),跳过重复部署"
echo "[$(date '+%F %T')] 失败原因: ${reason:-未记录}"
echo "[$(date '+%F %T')] 到期自动重试;需立即重试请执行: rm -f ${FAILED_STATE}"
return 0
}
# 探测 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 nvm_node
# node 探测:venv 内 → 系统 PATH → nvm 安装目录。
# systemd unit 的 PATH 不含 nvm,cc1 上 node 仅存在于 ~/.nvm/versions/node/*/bin,
# 缺这层回退会让 JS 闸门每轮都被静默跳过,闸门形同虚设。
nvm_node="$(ls -d "${HOME:-/root}"/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V | tail -1 || true)"
for _cand in ".venv/bin/node" "node" "$nvm_node"; do
[ -n "$_cand" ] || continue
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
# 若该远端提交刚被任一闸门拦截(JS / 测试 / 健康),冷却窗口内不重复部署同一坏版本:
# 否则每分钟 timer 都会 reset→重启→失败→回滚,服务反复抖动且告警轰炸。
# 放行条件:远端推了新提交、冷却到期、或标记为旧格式——见 skip_failed_commit。
if skip_failed_commit "$REMOTE"; then
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
mark_failed "$REMOTE" "$FAIL_REASON"
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')] ⚠️ 未找到 .venv/bin/python,跳过测试闸门"
elif ! .venv/bin/python -c 'import pytest' >/dev/null 2>&1; then
# pytest 属 requirements-dev.txt(注明生产部署不装)。模块缺失是环境问题,不是版本有问题:
# 若按「测试失败」处理,会拦截完全正常的提交,并写 FAILED_STATE 把更新链路永久冻结
# (cc1 曾因此卡在旧版本数小时,直到人工介入)。降级为跳过,与 JS 闸门缺 node 的处理一致。
echo "[$(date '+%F %T')] ⚠️ .venv 未安装 pytest(见 requirements-dev.txt),跳过测试闸门"
else
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 重复部署同一坏版本
mark_failed "$REMOTE" "$FAIL_REASON"
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')] ✅ 测试闸门通过"
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
# 记录失败的远端提交与原因,冷却窗口内阻止重复部署(见文件头 FAILED_STATE 说明)
mark_failed "$REMOTE" "$FAIL_REASON"
# 通过 .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