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
289a7182
Commit
289a7182
authored
Sep 05, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: 09-04 事故的两道兜底(mem_limit + 外部探针)→ main
parents
3e62c5f0
6cb7b2f6
Pipeline
#3677
failed in 0 seconds
Changes
3
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
231 additions
and
6 deletions
+231
-6
deploy/deploy-prod.sh
+20
-6
deploy/health-watch.sh
+174
-0
docker-compose.prod.yml
+37
-0
No files found.
deploy/deploy-prod.sh
View file @
289a7182
...
...
@@ -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
=
$'
\n
000'
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)"
...
...
deploy/health-watch.sh
0 → 100755
View file @
289a7182
#!/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 @
289a7182
...
...
@@ -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
...
...
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