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
This commit is contained in:
+50
-11
@@ -15,6 +15,7 @@
|
|||||||
# DEPLOY_HEALTH_INTERVAL 轮询间隔秒数(默认 15)
|
# DEPLOY_HEALTH_INTERVAL 轮询间隔秒数(默认 15)
|
||||||
# DEPLOY_HEALTH_CURL_MAXTIME 单次请求超时秒数(默认 10)
|
# DEPLOY_HEALTH_CURL_MAXTIME 单次请求超时秒数(默认 10)
|
||||||
# DEPLOY_ROLLBACK_TIMEOUT 回滚后确认恢复的超时秒数(默认 60)
|
# DEPLOY_ROLLBACK_TIMEOUT 回滚后确认恢复的超时秒数(默认 60)
|
||||||
|
# DEPLOY_COOLDOWN 同一提交部署失败后的冷却窗口秒数(默认 21600)
|
||||||
#
|
#
|
||||||
# 使用示例:
|
# 使用示例:
|
||||||
# VPS_MANAGER_DIR=/srv/vps ./update.sh
|
# VPS_MANAGER_DIR=/srv/vps ./update.sh
|
||||||
@@ -35,10 +36,16 @@ HEALTH_TIMEOUT="${DEPLOY_HEALTH_TIMEOUT:-120}" # 追平新版本的轮
|
|||||||
HEALTH_INTERVAL="${DEPLOY_HEALTH_INTERVAL:-15}" # 轮询间隔(秒)
|
HEALTH_INTERVAL="${DEPLOY_HEALTH_INTERVAL:-15}" # 轮询间隔(秒)
|
||||||
HEALTH_CURL_MAXTIME="${DEPLOY_HEALTH_CURL_MAXTIME:-10}" # 单次请求超时(秒)
|
HEALTH_CURL_MAXTIME="${DEPLOY_HEALTH_CURL_MAXTIME:-10}" # 单次请求超时(秒)
|
||||||
ROLLBACK_TIMEOUT="${DEPLOY_ROLLBACK_TIMEOUT:-60}" # 回滚后确认服务恢复的超时(秒)
|
ROLLBACK_TIMEOUT="${DEPLOY_ROLLBACK_TIMEOUT:-60}" # 回滚后确认服务恢复的超时(秒)
|
||||||
# 记录“上次因健康检查失败被回滚的远端提交”,避免每分钟 timer 反复部署同一坏版本导致
|
# 记录“上次部署失败”的远端提交(commit + 失败时刻 + 原因)。放在 .git 目录内:不被 git 跟踪、
|
||||||
# 服务抖动 + 告警轰炸。放在 .git 目录内:不被 git 跟踪、不出现在 status、reset --hard 也不清除。
|
# 不出现在 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")"
|
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"
|
FAILED_STATE="$GIT_DIR_PATH/vps-manager-deploy-failed"
|
||||||
|
DEPLOY_COOLDOWN="${DEPLOY_COOLDOWN:-21600}" # 同一失败提交的冷却窗口(秒)
|
||||||
# JSON 解析用的 Python:优先 venv(cc1 必然存在),回退系统 python3;均不可用则跳过闸门(不误回滚)
|
# JSON 解析用的 Python:优先 venv(cc1 必然存在),回退系统 python3;均不可用则跳过闸门(不误回滚)
|
||||||
PY=""
|
PY=""
|
||||||
for _cand in ".venv/bin/python" "python3"; do
|
for _cand in ".venv/bin/python" "python3"; do
|
||||||
@@ -126,6 +133,39 @@ except Exception as e: # noqa: BLE001
|
|||||||
PYEOF
|
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。
|
# 探测 4 个核心 API 端点,全部返回 HTTP 200 时返回 0,否则返回 1。
|
||||||
# 复用 deploy.sh 的端点列表与判定标准(非 200 视为验收失败)。
|
# 复用 deploy.sh 的端点列表与判定标准(非 200 视为验收失败)。
|
||||||
check_core_apis() {
|
check_core_apis() {
|
||||||
@@ -249,11 +289,10 @@ if [ "$LOCAL" = "$REMOTE" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 若该远端提交此前已因健康检查失败被自动回滚,则不再重复部署同一坏版本:
|
# 若该远端提交刚被任一闸门拦截(JS / 测试 / 健康),冷却窗口内不重复部署同一坏版本:
|
||||||
# 否则每分钟 timer 都会 reset→重启→失败→回滚,服务反复抖动且告警轰炸。
|
# 否则每分钟 timer 都会 reset→重启→失败→回滚,服务反复抖动且告警轰炸。
|
||||||
# 等到有人推送修复提交(REMOTE 变化)后,此判断自然放行。
|
# 放行条件:远端推了新提交、冷却到期、或标记为旧格式——见 skip_failed_commit。
|
||||||
if [ -f "$FAILED_STATE" ] && [ "$(cat "$FAILED_STATE" 2>/dev/null || true)" = "$REMOTE" ]; then
|
if skip_failed_commit "$REMOTE"; then
|
||||||
echo "[$(date '+%F %T')] 远端 ${REMOTE:0:8} 此前健康检查失败已回滚,跳过重复部署(等待修复提交)"
|
|
||||||
print_version
|
print_version
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -277,7 +316,7 @@ if ! check_js_syntax; then
|
|||||||
echo "[$(date '+%F %T')] ❌ ${FAIL_REASON},回滚至 ${LOCAL:0:8}"
|
echo "[$(date '+%F %T')] ❌ ${FAIL_REASON},回滚至 ${LOCAL:0:8}"
|
||||||
git reset --hard -q "$LOCAL"
|
git reset --hard -q "$LOCAL"
|
||||||
systemctl restart "$SERVICE_NAME" || true
|
systemctl restart "$SERVICE_NAME" || true
|
||||||
echo "$REMOTE" > "$FAILED_STATE" 2>/dev/null || true
|
mark_failed "$REMOTE" "$FAIL_REASON"
|
||||||
send_alert "🚨 vps-manager 部署失败:JS 语法闸门拦截
|
send_alert "🚨 vps-manager 部署失败:JS 语法闸门拦截
|
||||||
坏版本: ${REMOTE:0:8}
|
坏版本: ${REMOTE:0:8}
|
||||||
回滚至: ${LOCAL:0:8}
|
回滚至: ${LOCAL:0:8}
|
||||||
@@ -307,8 +346,8 @@ else
|
|||||||
git reset --hard -q "$LOCAL"
|
git reset --hard -q "$LOCAL"
|
||||||
# sync_units 可能已 daemon-reload 过新 unit 文件,代码回退后需再 reload 一次对齐旧版本
|
# sync_units 可能已 daemon-reload 过新 unit 文件,代码回退后需再 reload 一次对齐旧版本
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
# 记录失败提交,阻止后续 timer 重复部署同一坏版本
|
# 记录失败提交与原因,冷却窗口内阻止后续 timer 重复部署同一坏版本
|
||||||
echo "$REMOTE" > "$FAILED_STATE" 2>/dev/null || true
|
mark_failed "$REMOTE" "$FAIL_REASON"
|
||||||
send_alert "🚨 vps-manager 测试闸门失败,已阻止部署
|
send_alert "🚨 vps-manager 测试闸门失败,已阻止部署
|
||||||
坏版本: ${REMOTE:0:8}
|
坏版本: ${REMOTE:0:8}
|
||||||
保留版本: ${LOCAL:0:8}
|
保留版本: ${LOCAL:0:8}
|
||||||
@@ -362,8 +401,8 @@ else
|
|||||||
echo "[$(date '+%F %T')] ⚠️ 已回滚至 ${LOCAL:0:8},但健康检查仍未通过,需人工介入"
|
echo "[$(date '+%F %T')] ⚠️ 已回滚至 ${LOCAL:0:8},但健康检查仍未通过,需人工介入"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 记录失败的远端提交,阻止后续 timer 重复部署同一坏版本(见文件头 FAILED_STATE 说明)
|
# 记录失败的远端提交与原因,冷却窗口内阻止重复部署(见文件头 FAILED_STATE 说明)
|
||||||
echo "$REMOTE" > "$FAILED_STATE" 2>/dev/null || true
|
mark_failed "$REMOTE" "$FAIL_REASON"
|
||||||
|
|
||||||
# 通过 .env 已配置的通知渠道告警
|
# 通过 .env 已配置的通知渠道告警
|
||||||
send_alert "🚨 vps-manager 部署健康检查失败,已自动回滚
|
send_alert "🚨 vps-manager 部署健康检查失败,已自动回滚
|
||||||
|
|||||||
Reference in New Issue
Block a user