diff --git a/deploy/update.sh b/deploy/update.sh index cce2d04..3597cd4 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -3,11 +3,18 @@ # 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 @@ -21,6 +28,27 @@ 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 @@ -30,6 +58,74 @@ print_version() { 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 +} + # 同步 systemd 单元文件:deploy/ 下的 .service/.timer 与 /etc/systemd/system # 不一致时拷贝并 reload;timer 变更额外 enable --now(新调度周期立即生效且开机自启), # 主服务 unit 变更额外 restart(否则光 daemon-reload 日志说已更新、实际没生效)。 @@ -80,6 +176,15 @@ if [ "$LOCAL" = "$REMOTE" ]; then 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 拒绝合并、更新链路卡死 @@ -96,4 +201,47 @@ fi # 重启服务加载新代码 systemctl restart "$SERVICE_NAME" echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 服务" -print_version + +# ---- 部署后健康闸门:轮询 /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 + rm -f "$FAILED_STATE" 2>/dev/null || true + echo "[$(date '+%F %T')] ✅ 部署健康检查通过" + print_version + exit 0 +fi + +# 健康检查失败:回滚到 reset 前的提交并重启,尽量恢复到已知可用版本 +echo "[$(date '+%F %T')] ❌ 健康检查未通过(${HEALTH_TIMEOUT}s 内 commit 未追平 ${EXPECTED_SHORT} 或 status≠ok),回滚至 ${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} +服务恢复: ${recovered} +时间: $(date '+%F %T') +主机: cc1:${APP_DIR} +排查: journalctl -u ${SERVICE_NAME} -n 50" + +# 以非 0 退出,让 systemd 记录本次更新失败(告警已发出) +exit 1