#!/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 } # 记录部署失败:格式 " <单行原因>",供 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" } # ---- 前端 JS 单元测试闸门:syntax 通过后验证核心状态逻辑 ---- check_js_tests() { local node_bin="" nvm_node 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 if [ ! -d "node_modules" ]; then echo "[$(date '+%F %T')] ⚠️ node_modules 不存在,跳过 JS 测试闸门(需先 npm install)" return 0 fi echo "[$(date '+%F %T')] JS 测试闸门:使用 $node_bin(npx vitest run)" # 用 node 的完整路径运行 npx,确保找到 node_modules/.bin/vitest local npx_bin npx_bin="$(dirname "$node_bin")/npx" [ -x "$npx_bin" ] || npx_bin="npx" if ! "$npx_bin" vitest run 2>&1; then echo "[$(date '+%F %T')] ❌ JS 测试失败" return 1 fi echo "[$(date '+%F %T')] ✓ JS 测试通过" return 0 } # 同步 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 测试依赖(vitest):package.json 存在时自动 npm install,无变化时 npm 快速跳过 if [ -f "package.json" ]; then local_npm="" for _cand in ".venv/bin/npm" "npm"; do if command -v "$_cand" >/dev/null 2>&1; then local_npm="$_cand"; break; fi done if [ -n "$local_npm" ]; then if ! "$local_npm" install --prefer-offline -q 2>&1; then echo "[$(date '+%F %T')] 警告:npm install 失败,JS 测试闸门将被跳过" fi else echo "[$(date '+%F %T')] ⚠️ 未找到 npm,跳过前端依赖安装" 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 # ---- 前端 JS 测试闸门 ---- if ! check_js_tests; then FAIL_REASON="前端 JS 测试失败(vitest run 未通过,已中止上线)" 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} 排查: npx vitest run" 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