Commit c06a854f by luoqi

merge: main → test(反向合)—— 事故兜底两条 + deploy health 判据修正

parents aed2da93 289a7182
Pipeline #3678 failed in 0 seconds
...@@ -29,14 +29,43 @@ import { PlanLabelService } from '../plan-label.service'; ...@@ -29,14 +29,43 @@ import { PlanLabelService } from '../plan-label.service';
/** /**
* plan 写入段的**端到端批大小**(按患者切)。 * plan 写入段的**端到端批大小**(按患者切)。
* *
* 🔴 2026-09-04 生产 OOM 宕机 2.5 小时的直接对策。内核日志: * 🔴 2026-09-04 生产挂死 ~11.8 小时(20:06 → 次日 07:52 硬重启)后加的。
* `Killed process (node) anon-rss:6,321,668kB(6.03GB)`,机器 14GB,之后整机 swap 抖死、
* sshd 与 Web 全部无响应。docker-compose.prod.yml 那段注释 2026-08-26 就预言过
* 「这是止血不是根治:池子还在涨,8G 迟早也会到顶」—— 那天到了。
* *
* 堆账(550,049 命中患者 / 约 96 万条 hit,按 V8 对象布局建模): * ⚠️ **本注释 09-05 全面更正过一次,更正掉的东西比留下的多,值得先读这段。**
* 初版写的是「内核 OOM killer 杀了 node(anon-rss 6.03GB)→ 整机 swap 抖死」。
* 事后用 `journalctl -b -1` 核查,三条全错:
* ① 当晚**内核一条 OOM 都没打**。VNC 上看到的那条 `Killed process 945388 (node)`
* 是 **2026-08-29 07:26:17** 的,pid/anon-rss/pgtables 与内核日志逐字段相同 ——
* 内核 cmdline 带 `console=tty0`,它在 framebuffer 上留了 6 天没被刷掉。
* ② **这台机没有 swap**(`Swap: 0B`、`vm.swappiness=0`),「swap 抖死」不成立。
* ③ 没有崩溃循环:内核 `eth0: renamed from` 按天计数 Sep 03=0 / Sep 04=0,
* 当晚一次容器启动都没有,dockerd 整晚无日志。
* 真正的形态是**无 swap 下的 page-cache refault 活锁**:匿名页吃满后内核只能回收
* 文件页,把程序正文/mmap 刷掉、下一条指令又缺页读回来(监控上那条 286MB/s 满速读、
* 写≈0、CPU~15%)。因为**始终有文件页可回收,`out_of_memory()` 从未被调用** ——
* 没人被杀,也就没有任何自愈动作,一路挂到人工硬重启。
* ⇒ 对照 08-29:同一台机、同一水位、同样吃到 6GB,那次走 OOM kill,机器 10 分钟量级自愈;
* 这次走活锁,挂了 11.8 小时。**失败形态比内存本身更决定后果。**
*
* 死点(用生产库写入痕迹重建,应用日志已随部署 --force-recreate 永久丢失):
* 18:50:42 开跑 → 场景+预取 4,110s → 写入段 **仅 43s**(550,011 行落库)
* → 第 3 步 stale-close 分片事务最后一次提交 **20:05:49**(全库最后一次写)
* → 收尾 backfillMissing 一行没写,`[plan] 阶段耗时` 从未打出。
* ⇒ 死在第 3 步刚提交完的那几秒。场景/预取/写入三段都跑完了。
*
* 堆账(550,011 命中患者 / 约 96 万条 hit,按 V8 对象布局建模,**未经 heap snapshot 实测**):
* latestByPatient 1.93GB + hitsByPatient 1.06GB + persona/visit 0.14GB * latestByPatient 1.93GB + hitsByPatient 1.06GB + persona/visit 0.14GB
* + activePlans 0.09GB + logRows 0.07GB ≈ **活跃保留 3.3GB**,实测 RSS 6.03GB(≈1.8×)。 * + activePlans 0.09GB + logRows 0.07GB ≈ **活跃保留 3.3GB**。
*
* ⚠️ **本改动治什么、不治什么**(别把它当这次事故的直接对策):
* 治:把上面那堆「同时在堆」的产物拆成单批驻留 → 降低峰值 → 降低再次触发活锁的概率。
* 测试机实测 45 批 heapUsed 单调下降 462→107MB,零累积。
* ⛔ 不治:① selectHits → hitsByPatient 的交接仍同时持有**两份**完整 hit 载荷(≈2.1GB,
* 见下方批循环上游 `arr.push({...h})` 那处),这是现存最大的未处理项;
* ② 第 3 步仍一次拉整池(事故当轮 549,926 行);
* ③ 真正的兜底缺口不在这个文件:容器 `MemLimit=0` + 无 swap ⇒ cgroup OOM 与内核 OOM
* 两道兜底全被绕过,失败才会呈现为「活锁」而不是「杀掉重启」。
*
* 病根不是"取数没分块"(取数早就是 2000 一块),是**产物全程不释放**: * 病根不是"取数没分块"(取数早就是 2000 一块),是**产物全程不释放**:
* runPool 的闭包(见下方批循环)把 latest/persona/visit/logRows 全部 context-allocate, * runPool 的闭包(见下方批循环)把 latest/persona/visit/logRows 全部 context-allocate,
* V8 要到 runAllForHost 整个 frame 结束才可能回收 → 第 3 步扫全池时它们全还在, * V8 要到 runAllForHost 整个 frame 结束才可能回收 → 第 3 步扫全池时它们全还在,
......
...@@ -627,7 +627,15 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -627,7 +627,15 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ⛔ 必须用事务级 SET LOCAL,**不能全局调**:work_mem 是「每个排序/哈希节点」的上限, // ⛔ 必须用事务级 SET LOCAL,**不能全局调**:work_mem 是「每个排序/哈希节点」的上限,
// 不是每连接。生产 RDS 只有约 7GB 内存(shared_buffers 1.83GB 反推),全局设大值 // 不是每连接。生产 RDS 只有约 7GB 内存(shared_buffers 1.83GB 反推),全局设大值
// 遇上几十个并发连接同时排序会把内存吃穿。SET LOCAL 出了事务自动还原, // 遇上几十个并发连接同时排序会把内存吃穿。SET LOCAL 出了事务自动还原,
// Web API 的连接完全不受影响;且场景查询是串行的,同一时刻只有一条在跑,峰值可控。 // Web API 的连接完全不受影响。
//
// 🔴 **原文这里还有一句「且场景查询是串行的,同一时刻只有一条在跑,峰值可控」——
// 那句自 2026-08-30 起就是错的**,已删。生产从那天起设了
// PAC_RECALL_SUBSCENARIO_CONCURRENCY=4(见上方 conc 处注释),于是 RDS 上
// **同时有 4 个带 SET LOCAL work_mem='256MB' 的事务**。
// ⇒ DB 侧峰值内存 = 256MB × 并发数 × 该查询的排序/哈希节点数,不再"可控"。
// 生产 RDS 约 7GB,4 路已是能承受的上限;⛔ 谁要再调大 conc 或 work_mem,
// 必须先算这笔账,别只看场景段墙钟。(2026-09-05 事故复盘时发现这处注释失真。)
// //
// ⛔ 别再去加「信号码部分索引」——(content->>'code') 上的部分索引在测试机和生产 // ⛔ 别再去加「信号码部分索引」——(content->>'code') 上的部分索引在测试机和生产
// 都实测过,生产上 −5% 落在噪声里(同配置两次测量本身差 6%),不值得引入 // 都实测过,生产上 −5% 落在噪声里(同配置两次测量本身差 6%),不值得引入
......
...@@ -1160,7 +1160,11 @@ describe('归因继承的边界 — 判据是「客服碰过没」', () => { ...@@ -1160,7 +1160,11 @@ describe('归因继承的边界 — 判据是「客服碰过没」', () => {
/** /**
* 🔴 2026-09-04 端到端分批改造的**核心判据**:结果与批大小无关。 * 🔴 2026-09-04 端到端分批改造的**核心判据**:结果与批大小无关。
* *
* 事故背景:生产 pac-service 涨到 6.03GB 常驻被内核 OOM killer 打掉,整机 swap 抖死 2.5 小时。 * 事故背景:生产 pac-service 挂死约 11.8 小时(2026-09-04 20:06 → 次日 07:52 硬重启)。
* ⚠️ 09-05 更正:当晚**没有** OOM kill(那条内核日志是 08-29 的陈旧 tty 回显),
* 机器也**没有 swap** —— 真正形态是无 swap 下的 page-cache refault 活锁,
* 因为始终有文件页可回收,out_of_memory() 从未被调用,所以没人被杀、也就没有自愈。
* 详见 plan-engine.service.ts 里 resolvePlanBatchSize 上方那段更正说明。
* 堆账里 latestByPatient(1.93GB)+ hitsByPatient(1.06GB)是大头,而它们原先**全程不释放** * 堆账里 latestByPatient(1.93GB)+ hitsByPatient(1.06GB)是大头,而它们原先**全程不释放**
* —— 取数早就是 2000 一块,但产物累积在跨全量存活的 Map 里,runPool 的闭包又把它们 * —— 取数早就是 2000 一块,但产物累积在跨全量存活的 Map 里,runPool 的闭包又把它们
* context-allocate 到整个 runAllForHost frame 结束。改造把取数分块升格成端到端分批。 * context-allocate 到整个 runAllForHost frame 结束。改造把取数分块升格成端到端分批。
......
...@@ -24,6 +24,22 @@ log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } ...@@ -24,6 +24,22 @@ log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
warn() { printf '\033[1;33mWARN: %s\033[0m\n' "$*" >&2; } warn() { printf '\033[1;33mWARN: %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; } 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() { main() {
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
...@@ -135,13 +151,12 @@ main() { ...@@ -135,13 +151,12 @@ main() {
echo " migrate OK" echo " migrate OK"
log "验证 3/3:health + web" log "验证 3/3:health + web"
local i code webcode local i hres webcode ok=0
for i in $(seq 1 30); do 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) if hres=$(probe_health http://127.0.0.1:3101); then ok=1; break; fi
[[ "$code" == "200" ]] && break
sleep 2 sleep 2
done done
[[ "${code:-}" == "200" ]] || die "service health=$code" [[ "$ok" == 1 ]] || die "service /health 不健康(重试 30 次):${hres:-无响应}"
# / 现为 307→/plans(next.config redirects),直接验 /plans 的 200 # / 现为 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=$(curl -s -m 30 -o /dev/null -w '%{http_code}' http://127.0.0.1:3100/plans || true)
[[ "$webcode" == "200" ]] || die "web http=$webcode" [[ "$webcode" == "200" ]] || die "web http=$webcode"
...@@ -155,8 +170,7 @@ main() { ...@@ -155,8 +170,7 @@ main() {
local priv phealth pweb 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}}') 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 解析失败)" [[ -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=$(probe_health "http://$priv:3101") || die "私网 $priv:3101 /health 不健康($phealth)— 应用可能绑成 loopback,网关够不到(会 502)。托管环境需 web/service 绑 0.0.0.0(见 docker-compose.managed.yml)"
[[ "$phealth" == "200" ]] || 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=$(curl -s -m 30 -o /dev/null -w '%{http_code}' "http://$priv:3100/plans" || true)
[[ "$pweb" == "200" ]] || die "私网 $priv:3100 web=$pweb — 同上(loopback 绑定 → 网关 502)" [[ "$pweb" == "200" ]] || die "私网 $priv:3100 web=$pweb — 同上(loopback 绑定 → 网关 502)"
echo " 私网 $priv health 200 / web 200(网关路径 OK)" 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: ...@@ -91,6 +91,43 @@ services:
dockerfile: apps/pac-service/Dockerfile dockerfile: apps/pac-service/Dockerfile
target: prod target: prod
restart: always 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: depends_on:
postgres: postgres:
condition: service_healthy 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