Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
c06a854f
Commit
c06a854f
authored
Sep 05, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: main → test(反向合)—— 事故兜底两条 + deploy health 判据修正
parents
aed2da93
289a7182
Pipeline
#3678
failed in 0 seconds
Changes
6
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
280 additions
and
14 deletions
+280
-14
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
+35
-6
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
+9
-1
apps/pac-service/tests/plan-engine-batch.spec.ts
+5
-1
deploy/deploy-prod.sh
+20
-6
deploy/health-watch.sh
+174
-0
docker-compose.prod.yml
+37
-0
No files found.
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
View file @
c06a854f
...
@@ -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 步扫全池时它们全还在,
...
...
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
View file @
c06a854f
...
@@ -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%),不值得引入
...
...
apps/pac-service/tests/plan-engine-batch.spec.ts
View file @
c06a854f
...
@@ -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 结束。改造把取数分块升格成端到端分批。
...
...
deploy/deploy-prod.sh
View file @
c06a854f
...
@@ -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
=
$'
\n
000'
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)"
...
...
deploy/health-watch.sh
0 → 100755
View file @
c06a854f
#!/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
=
$'
\n
000'
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
\n
alerted=%s
\n
since='%s'
\n
last='%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
docker-compose.prod.yml
View file @
c06a854f
...
@@ -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
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment