Commit 289a7182 by luoqi

merge: 09-04 事故的两道兜底(mem_limit + 外部探针)→ main

parents 3e62c5f0 6cb7b2f6
Pipeline #3677 failed in 0 seconds
......@@ -24,6 +24,22 @@ log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
warn() { printf '\033[1;33mWARN: %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; }
# 探活。⚠️ 两个坑,都踩过:
# ① **路由**:真实健康路由是 `/health` —— 它在 GLOBAL_PREFIX_EXCLUDE 里,**不带** /pac/v1 前缀。
# ② **判据**:全局响应拦截器把 404 也包成 HTTP 200 ——
# /pac/v1/health → 200 {"code":10004,"msg":"Cannot GET /pac/v1/health"}
# 于是"只看状态码"的探活**恒真**。本脚本此前探的正是这个不存在的路由(2026-09-05 发现),
# 三项硬验证里的 health 那项一直只证明了"Nest 的 HTTP 层还在应答",没证明应用健康。
# ⛔ 以后改这里,判据必须落在 body 上,别退回只看 %{http_code}。
probe_health() { # $1=base(如 http://127.0.0.1:3101) → stdout "<code> <body截断>";健康时返回 0
local out code body
out=$(curl -s -m 5 -w '\n%{http_code}' "$1/health" 2>/dev/null) || out=$'\n000'
code=${out##*$'\n'}
body=${out%$'\n'*}
printf '%s %s' "$code" "${body:0:120}"
[[ "$code" == "200" && "$body" == *'"status":"ok"'* ]]
}
main() {
cd "$(dirname "$0")/.."
......@@ -135,13 +151,12 @@ main() {
echo " migrate OK"
log "验证 3/3:health + web"
local i code webcode
local i hres webcode ok=0
for i in $(seq 1 30); do
code=$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:3101/pac/v1/health || true)
[[ "$code" == "200" ]] && break
if hres=$(probe_health http://127.0.0.1:3101); then ok=1; break; fi
sleep 2
done
[[ "${code:-}" == "200" ]] || die "service health=$code"
[[ "$ok" == 1 ]] || die "service /health 不健康(重试 30 次):${hres:-无响应}"
# / 现为 307→/plans(next.config redirects),直接验 /plans 的 200
webcode=$(curl -s -m 30 -o /dev/null -w '%{http_code}' http://127.0.0.1:3100/plans || true)
[[ "$webcode" == "200" ]] || die "web http=$webcode"
......@@ -155,8 +170,7 @@ main() {
local priv phealth pweb
priv=$(ip route get 1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}')
[[ -n "$priv" ]] || die "拿不到私网 IP(ip route get 1 解析失败)"
phealth=$(curl -s -m 5 -o /dev/null -w '%{http_code}' "http://$priv:3101/pac/v1/health" || true)
[[ "$phealth" == "200" ]] || die "私网 $priv:3101 health=$phealth — 应用可能绑成 loopback,网关够不到(会 502)。托管环境需 web/service 绑 0.0.0.0(见 docker-compose.managed.yml)"
phealth=$(probe_health "http://$priv:3101") || die "私网 $priv:3101 /health 不健康($phealth)— 应用可能绑成 loopback,网关够不到(会 502)。托管环境需 web/service 绑 0.0.0.0(见 docker-compose.managed.yml)"
pweb=$(curl -s -m 30 -o /dev/null -w '%{http_code}' "http://$priv:3100/plans" || true)
[[ "$pweb" == "200" ]] || die "私网 $priv:3100 web=$pweb — 同上(loopback 绑定 → 网关 502)"
echo " 私网 $priv health 200 / web 200(网关路径 OK)"
......
#!/usr/bin/env bash
# PAC 外部存活探针 —— 由 cron 在**被监控机器之外**的主机上跑。
#
# 【为什么必须在外部】2026-09-04 生产宕 11.8 小时,全程零告警。
# PAC 现有 8 类告警**全部由 pac-service 进程内推送**(AlertService → 企微机器人),
# 进程一死/一卡,告警通道跟着一起死 —— 「谁来告诉你告警器坏了」这个经典缺口。
# 当天连云监控 agent 自己都在 20:35 被饿死。于是没有任何人知道,直到第二天早上。
# ⛔ 所以本脚本**绝不能**装在它监控的那台机器上,否则等于没装。
# 当前部署:测试机(47.251.104.47)cron → 探生产 pac.friday.tech。
#
# 【判据为什么看 body 不看状态码】真实健康路由是 `/health`(在 GLOBAL_PREFIX_EXCLUDE 里,
# **不带** /pac/v1 前缀)。而全局响应拦截器把 404 也包成 HTTP 200:
# /pac/v1/health → 200 {"code":10004,"msg":"Cannot GET /pac/v1/health"}
# ⛔ 只看 %{http_code} 的探活**恒真**。deploy-prod.sh 的 health 验证就这么空转着,
# 2026-09-05 发现并一起修。本脚本判据必须落在 body 的 "status":"ok" 上。
#
# 用法:
# bash deploy/health-watch.sh # 探一次(读环境变量配置)
# bash deploy/health-watch.sh --dry-run # 不推送,只打印会推什么
# bash deploy/health-watch.sh --status # 打印当前状态文件,不探测
#
# 环境变量:
# WATCH_URL 被监控服务的 base(默认 https://pac.friday.tech),脚本自己拼 /health
# WATCH_NAME 告警里显示的名字 + 状态文件名(默认 pac-prod)
# ALERT_WEBHOOK_URL 企微群机器人 webhook。⛔ 不设 = 只写日志不推送(等于没装)
# WATCH_FAIL_THRESHOLD 连续失败几次才告警(默认 3)。× cron 间隔 = 真实容忍时长
# WATCH_REPEAT_EVERY 告警后每累计多少次失败重复提醒一次(默认 12)
# WATCH_TIMEOUT 单次 curl 超时秒(默认 10)
# WATCH_CONTROL_URL 可选对照探针。设了则**只有**在对照可达时才判定目标故障 ——
# 防「探针机自己断网 → 误报生产挂了」。默认空=不启用
# (连续 3 次 × 5 分钟 = 15 分钟已能滤掉绝大多数网络抖动)
# WATCH_STATE_DIR 状态文件目录(默认 /var/lib/pac-health-watch)
#
# crontab 示例(测试机 root,每 5 分钟):
# */5 * * * * ALERT_WEBHOOK_URL='https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx' \
# /bin/bash /root/pac/deploy/health-watch.sh >> /var/log/pac-health-watch.log 2>&1
#
# 依赖:只要 bash + curl。⛔ 别引入 jq/python —— 探针机的环境不归我们管,少一个依赖少一种
# "探针自己挂了没人知道"。JSON 转义用纯 bash 参数展开(json_escape)。
#
# ⚠️ 本脚本**永远 exit 0**(除 --status / 参数错)—— cron 不该因为"目标挂了"就给 root 发一堆
# 邮件,真正的通知走 webhook。要在别处判断结果,读状态文件。
set -uo pipefail # ⛔ 不加 -e:探测失败是正常业务路径,不是脚本错误
WATCH_URL="${WATCH_URL:-https://pac.friday.tech}"
WATCH_NAME="${WATCH_NAME:-pac-prod}"
WATCH_FAIL_THRESHOLD="${WATCH_FAIL_THRESHOLD:-3}"
WATCH_REPEAT_EVERY="${WATCH_REPEAT_EVERY:-12}"
WATCH_TIMEOUT="${WATCH_TIMEOUT:-10}"
WATCH_CONTROL_URL="${WATCH_CONTROL_URL:-}"
WATCH_STATE_DIR="${WATCH_STATE_DIR:-/var/lib/pac-health-watch}"
WATCH_DRY_RUN="${WATCH_DRY_RUN:-0}"
case "${1:-}" in
--dry-run) WATCH_DRY_RUN=1 ;;
--status) cat "$WATCH_STATE_DIR/$WATCH_NAME.state" 2>/dev/null || echo "(无状态文件)"; exit 0 ;;
"") ;;
*) echo "未知参数:$1(支持 --dry-run / --status)" >&2; exit 2 ;;
esac
ts() { date '+%Y-%m-%d %H:%M:%S%z'; }
say() { printf '%s [%s] %s\n' "$(ts)" "$WATCH_NAME" "$*"; }
mkdir -p "$WATCH_STATE_DIR" 2>/dev/null || { say "无法创建状态目录 $WATCH_STATE_DIR"; exit 0; }
STATE="$WATCH_STATE_DIR/$WATCH_NAME.state"
# 防重叠:目标网络卡死时一次探测可能拖到超过 cron 间隔,不加锁会叠出一堆进程、把计数器写乱。
# ⚠️ 用 `flock -n <file> <cmd>` 直接形态,**不用 exec** —— exec 会把 flock 的退出码(拿不到锁=1)
# 变成本脚本的退出码,破坏"永远 exit 0"的约定,cron 会开始发邮件。
if command -v flock >/dev/null 2>&1 && [[ "${_WATCH_LOCKED:-}" != "1" ]]; then
export _WATCH_LOCKED=1
flock -n "$WATCH_STATE_DIR/$WATCH_NAME.lock" bash "$0" "$@"
exit 0
fi
# ── 探测 ───────────────────────────────────────────────────────────────
# 返回 0 = 健康。stdout 是给人看的诊断串(状态码 + body 截断)。
probe() {
local base="$1" out code body
out=$(curl -sS -m "$WATCH_TIMEOUT" -w $'\n%{http_code}' "$base/health" 2>&1) || out=$'\n000'
code=${out##*$'\n'}
body=${out%$'\n'*}
body=${body//$'\n'/ }
printf 'http=%s body=%s' "$code" "${body:0:200}"
[[ "$code" == "200" && "$body" == *'"status":"ok"'* ]]
}
# ── 状态读写 ───────────────────────────────────────────────────────────
fails=0; alerted=0; since=""
# shellcheck disable=SC1090
[[ -f "$STATE" ]] && . "$STATE"
[[ "$fails" =~ ^[0-9]+$ ]] || fails=0
[[ "$alerted" =~ ^[01]$ ]] || alerted=0
save_state() {
printf "fails=%s\nalerted=%s\nsince='%s'\nlast='%s'\n" \
"$fails" "$alerted" "$since" "$(ts)" > "$STATE"
}
# ── 推送 ───────────────────────────────────────────────────────────────
# 纯 bash 的 JSON 字符串转义(避开 jq/python 依赖)。顺序要紧:反斜杠必须最先换。
json_escape() {
local s=$1
s=${s//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\r'/}
s=${s//$'\n'/\\n}
s=${s//$'\t'/\\t}
printf '"%s"' "$s"
}
push() { # $1=icon $2=color $3=标题 $4=正文
local content resp payload
content=$(printf '%s **<font color="%s">[PAC 外部探针] %s</font>**\n%s\n> 目标: %s/health\n> 探针机: %s\n> time: %s' \
"$1" "$2" "$3" "$4" "$WATCH_URL" "$(hostname)" "$(ts)")
if [[ "$WATCH_DRY_RUN" == "1" ]]; then
printf '%s [%s] [DRY-RUN 不推送] ↓↓↓\n%s\n' "$(ts)" "$WATCH_NAME" "$content"
return 0
fi
if [[ -z "${ALERT_WEBHOOK_URL:-}" ]]; then
say "⚠️ 未设 ALERT_WEBHOOK_URL,只写日志不推送:$3"
return 0
fi
payload="{\"msgtype\":\"markdown\",\"markdown\":{\"content\":$(json_escape "$content")}}"
# ⚠️ 企微格式错也回 HTTP 200,错误在 body 的 errcode —— 必须看 body(与 AlertService 同坑)
resp=$(curl -sS -m 10 -H 'Content-Type: application/json' --data-binary "$payload" \
"$ALERT_WEBHOOK_URL" 2>&1) || { say "❌ webhook 请求失败:${resp:0:200}"; return 0; }
if [[ "$resp" == *'"errcode":0'* ]]; then say "✅ 已推送:$3"
else say "❌ webhook 被拒:${resp:0:200}"; fi
}
# ── 主流程 ─────────────────────────────────────────────────────────────
if diag=$(probe "$WATCH_URL"); then
if [[ "$alerted" == "1" ]]; then
push "🟢" "info" "$WATCH_NAME 已恢复" \
"$(printf '连续失败 %s 次后恢复。\n首次失败: %s\n当前: %s' "$fails" "${since:-?}" "$diag")"
fi
[[ "$fails" -gt 0 ]] && say "恢复正常(此前连续失败 $fails 次)"
fails=0; alerted=0; since=""
save_state
say "OK $diag"
exit 0
fi
# —— 目标探测失败 ——
# 对照探针:探针机自己断网时不该去指控生产。设了 WATCH_CONTROL_URL 才启用。
if [[ -n "$WATCH_CONTROL_URL" ]]; then
if ! curl -sS -m "$WATCH_TIMEOUT" -o /dev/null "$WATCH_CONTROL_URL" 2>/dev/null; then
say "⚠️ 目标失败但对照 $WATCH_CONTROL_URL 也不通 → 判定为**探针机自身网络问题**,本次不计数"
exit 0
fi
fi
fails=$((fails + 1))
[[ -z "$since" ]] && since="$(ts)"
say "失败 #$fails $diag"
DIAG_HINT='**先看这几样:**
> docker ps -a --filter name=pac-
> 容器 exit=137 → 撞了 compose 的 mem_limit(8g);restart:always 会自己拉起,回头查那轮 plan 的 heapUsed= 日志
> free -g / dmesg -T | tail -30 → 整机内存(生产无 swap,活锁不会留 OOM 日志)
> docker logs --tail=200 pac-pac-service-1'
if [[ "$alerted" == "0" && "$fails" -ge "$WATCH_FAIL_THRESHOLD" ]]; then
alerted=1
push "🔴" "warning" "$WATCH_NAME 探测不通(连续 $fails 次)" \
"$(printf '%s\n首次失败: %s\n\n%s' "$diag" "$since" "$DIAG_HINT")"
elif [[ "$alerted" == "1" && $((fails % WATCH_REPEAT_EVERY)) -eq 0 ]]; then
push "🔴" "warning" "$WATCH_NAME 仍未恢复(连续 $fails 次)" \
"$(printf '%s\n首次失败: %s' "$diag" "$since")"
fi
save_state
exit 0
......@@ -91,6 +91,43 @@ services:
dockerfile: apps/pac-service/Dockerfile
target: prod
restart: always
# 🔴 **容器内存上限** —— ⛔ 别删。它不省内存,它决定「出事时炸多大」。
#
# 【为什么加】2026-09-04 生产宕 11.8 小时。同一台机、同样吃到 ~6GB,对照 08-29:
# 08-29 内核 OOM kill → 进程死 → restart:always 拉起 → 分钟级自愈
# 09-04 三道兜底全被绕过 → **整机活锁** → 挂 11.8h,要人硬重启
# 三道兜底怎么被绕过的:
# ① V8 abort —— 天花板 08-26 抬到 8G,堆没顶到就先把机器吃穷了
# ② cgroup OOM —— 容器没上限(HostConfig.Memory=0),这条压根不存在 ← 本行修的就是它
# ③ 内核 OOM killer —— 机器**没有 swap**,内核宁可反复丢/重读 page cache 也不判 OOM
# (file page 永远"可回收"),于是没有任何一次 out_of_memory() 被触发 → refault 活锁。
# 所以本行买的不是"不涨",是**把爆炸半径从整机缩回单容器**:越界 → SIGKILL(exit 137)
# → restart:always 拉起 → 回到 08-29 那种分钟级自愈。
#
# 【8g 这个数怎么来的】(生产 pac-friday 实测)
# 全机 15.36 GB
# OS + page cache 必须留 ~2.5 GB ← 无 swap,这块被挤掉就是活锁本身
# dockerd(闲时 0.4G,08-29 压力下实测 2.7G) ~2.7 GB ← ⚠️ 它在容器外,本行限不住
# pac-web + pac-docs(实测 49+64 MB) ~0.2 GB
# ────────────────────────────────────────────────
# 可给 pac-service ~10 GB → 观测峰值 6.03G,取 8G(留 33% 余量)
#
# ⚠️ **与下面 NODE_OPTIONS 的 8192 相等,是刻意的过渡态,不是笔误。**
# RSS 恒大于 V8 堆(堆外还有 Buffer/Prisma engine/源码映射),两者都是 8G 时一定是
# **先撞 cgroup 被 SIGKILL(exit 137,无任何日志)**,而不是 V8 抛 heap limit(有堆栈)。
# 理想是堆上限 < mem_limit,让 V8 先自己抛。但现在**没有生产实测峰值**可依据 ——
# 分批改造(09-05 上线)每 5 批打一行 `heapUsed=`,拿到真实峰值后再压堆上限。
# ⛔ 别凭感觉先把 8192 调小:调过头会把本来能跑完的轮次变成必崩。
#
# ⚠️ **swap 语义**:docker 不显式设 memswap_limit 时,总量 = 2×mem_limit(8G RAM + 8G swap)。
# 生产**没有 swap** → 就是硬 8G,精确。测试机有 16G swapfile → 那边实际是 8G+8G。
# ⛔ 不设 memswap_limit 是刻意的:测试机靠 swap 撑着三十来个容器,一刀切断会造出
# **生产根本不存在的**失败模式,把验证信号污染掉。
#
# ⚠️ 被 SIGKILL 会在 sync_logs 留下 status='running' 的僵尸锁 —— 靠 09-04 那条
# 「下一轮开跑前周期回收」自愈(sync-incremental.scheduler.ts reapStaleRunningLocks)。
# ⛔ 删那条回收 = 这里每杀一次就永久卡死增量。两者是配套的。
mem_limit: 8g
depends_on:
postgres:
condition: service_healthy
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment