Files
dsh_shenxian/dsh-server-docs/04-调整方案/74-实例内存治理-V8堆限下调与阈值告警.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

169 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 74 · 实例内存治理:V8 堆限下调 + 内存阈值告警(2026-09-12)
- 日期:2026-09-12
- 状态:✅ **已落地并实测验证**(配置 + 运维脚本;**未 commit、未推 docs**,待用户发话)
- 触发:用户报「排查 guest 最新会话,两个会话看看有什么问题」→ 排查发现**两个会话在同一秒被打断**,根因是实例被内核 OOM kill
- 范围(3 处,均**不涉及官方 dsh 主程序**):
1. `/etc/dshs.env` —— 加 1 行 env 覆盖项
2. `/opt/dshs/scripts/instance-mem-sample.cjs` —— 加阈值告警
3. `/etc/cron.d/dsh-maintenance` —— 该采样任务频率 每小时 → 每 10 分钟
- 影响面:执行时重启 `dshs` **一次**(断在线实例约 4 秒);重启后 guest 实例被回收,由 `/api/dsh/enter` 重建(档案 72 的自动唤醒路径)
---
## 一、结论速览
**根因不是某个插件作恶,而是「参数依据被实际负载推翻」——配额 384 MiB 对当前负载零余量。**
| # | 判断 | 决定性证据 |
|---|---|---|
| 1 | **实例被内核 OOM kill**(不是插件崩、不是服务重启) | `kernel: oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/system.slice/dsh-100002-299e7f95.scope, task=node`;平台紧随 `[crash-restart] exitCode 137` |
| 2 | **内存 95% 不可回收**,OOM killer 无路可退 | `memory.stat`:`rss 363.7 MiB` vs `cache 仅 17.8 MiB` |
| 3 | **配额已精确打满过** | `memory.max_usage_in_bytes == memory.limit_in_bytes == 384 MiB` |
| 4 | **V8 堆许可严重超额**(档案 58 的假设失效) | 档案 58 自述依据「dsh 实测老生代仅 **27~30 MiB**,三倍余量」→ 实测被会话数据填到 **~250 MiB**,**低估近 9 倍** |
| 5 | **留给用户任务的空间被吃光** | 档案 58 设计「384 − 145 = 239 MiB 留给用户任务」→ 实测 dsh 自身吃 **363.7 MiB**,只剩 ~20 MiB |
| 6 | **两条死亡路径都存在,且都不留可读日志** | 见 §三 实验 |
**改动后**:实例 `rss` **363.7 → 195.5 MiB**(降 46%),总量余量 ~110 MiB。
---
## 二、用户侧现象与因果链
```
20:05 用户在会话 eda867a0 让 AI 做「抖音爆款视频榜」表格
20:08 univer 插件网关起不来(与平台 loopback 封锁冲突,另有专档)→ agent 开始现场排查
20:11:05 / 20:11:09 两个会话(8cdf723e、eda867a0)的工具调用在同一秒被打断
20:11:10 内核 OOM 杀掉实例;平台自动 crash-restart
20:12:12 用户问「是报错了吗」
```
**关键判据**:**两个独立会话在同一秒被掐断 ⇒ 只可能是实例级事件**,不可能是模型或用户操作。
> 排查方法已沉淀为技能 `dsh-instance-diagnose`(cgroup 内存三件套 / 两条死亡路径区分 / 隔离复现配方 / 6 条实测踩坑)。
---
## 三、隔离复现实验(**全部在独立 cgroup,未碰生产实例**)
`systemd-run -p MemoryMax=402653184` 起独立 scope,跑完即回收:
| 实验 | 负载 | 结果 | 说明 |
|---|---|---|---|
| 1 | 只加载 guest 插件集 | 125 MiB,**未撞墙** | 插件不是主因 |
| 2 | JS 对象堆压力 + `--max-old-space-size=256` | `FATAL ERROR: Reached heap limit`(Mark-Compact **254 MB**)→ systemd `code=dumped/status=ABRT` | **死亡路径 A**:V8 堆限自崩 |
| 3 | 纯堆外分配(`Buffer.allocUnsafe` + `fill`) | `oom-kill:...CONSTRAINT_MEMCG, task=node` → `code=killed/status=9/KILL` | **死亡路径 B**:**与 20:11 事故记录逐字同构** |
**踩坑**:实验 3 首版用 JS 对象数组,**先撞了路径 A**,永远复现不出 137 —— 必须用**纯堆外分配**才能走到 cgroup 限。
---
## 四、改动明细
### 4.1 V8 堆许可 256 → 160(零代码改动)
平台**早已预留覆盖点**:`src/supervisor/orchestrator.ts:402`
```ts
NODE_OPTIONS: process.env.DSH_INSTANCE_NODE_OPTIONS ?? '--max-old-space-size=256',
```
而 service 用 `EnvironmentFile=/etc/dshs.env` ⇒ **只需加一行,不必改码、不必 build**:
```bash
# /etc/dshs.env 追加
DSH_INSTANCE_NODE_OPTIONS=--max-old-space-size=160
systemctl restart dshs # 改 .env 只需 restart,无需 daemon-reload
```
- **取值依据**:空载实测 125 MiB(含 univer),160 给长会话留余量又不让它吃满配额
- **副作用(须知情)**:该 env 被实例内**所有 node 子进程继承**,用户自己跑 node 脚本也受 160 限制;python/ffmpeg 等非 node 任务不受影响
### 4.2 内存阈值告警(`instance-mem-sample.cjs`)
在原有每小时采样基础上新增:
- `highStreak`:同一 uid **连续 N 次**(默认 3)采样 ≥ `limit × 85%` → 日志行尾部 `⚠ 连续 N 次 ≥ 85%` + 整行 `WARN` 前缀(便于 grep)
- 未达「连续」但单次高位 → 标注 `(高位 N%,x/3)`,便于观察爬升趋势
- **`tooSoon` 保护**:距上次采样 < 60s 视为同一轮内重复触发,**只保持计数、不累计也不清零**(否则手动连跑几次就会把 highStreak 顶到阈值造成误告警 —— 实测踩过)
- 阈值可用 `DSH_MEM_ALERT_PCT` / `DSH_MEM_ALERT_STREAK` / `DSH_MEM_ALERT_MIN_GAP_MS` 覆盖
- 输出契约向后兼容:新增字段 `pct` / `highStreak`,原字段与日志前缀格式不变
### 4.3 采样频率 每小时 → 每 10 分钟
**为什么必须配套**:每小时 × 连续 3 次 = **3 小时才告警**,而实例 OOM 是**分钟级**的(实测 20:11:10 崩,上一次采样还是 19:35)—— 小时级采样等于没有预警。10 分钟 × 3 次 = **30 分钟**提前量。
---
## 五、验收(实测)
| 项 | 改前 | 改后 |
|---|---|---|
| 实例进程 `NODE_OPTIONS` | `--max-old-space-size=256` | **`--max-old-space-size=160`** ✅ |
| cgroup `rss` | 363.7 MiB | **195.5 MiB**(−46%) |
| cgroup 总量 | 382 MiB / 384 | **274 MiB / 384**(余量 ~110 MiB) |
| 告警脚本 | 无 | 正常识别新 scope,71% 时正确不告警 ✅ |
| 临时 session | — | 已自动删除(`[cleanup] 已删除 1 条`)✅ |
**验证方式**(R4 合规):用直插临时 session(不触发 `deleteUserSessions`,不踢用户浏览器)调 `POST /api/dsh/enter` 拉起实例,验证后立即删除 session。
**验收(待观察 24h)**:`crash-restart` 归零、`/var/log/dsh-instance-mem.log` 无 `WARN` 行。
---
## 六、回滚
| 改动 | 回滚步骤 | 备份 |
|---|---|---|
| env 覆盖项 | 删掉 `.env` 末两行(注释 + 变量)+ `systemctl restart dshs` | `/opt/dsh/backups/dshs.env.bak-20260912-2054` |
| 采样脚本 | 覆盖回原版 | `/opt/dsh/backups/instance-mem-sample.cjs.bak-20260912-2050` |
| cron 频率 | 改回 `35 * * * *` | `/opt/dsh/backups/dsh-maintenance.bak-20260912-2053` |
---
## 七、未做项与理由
| 项 | 状态 | 理由 |
|---|---|---|
| **配额 384 → 512** | ❌ **不做** | 要改码 + build + 重启;且宿主物理内存仅 **1.87 GB**,2 实例即 1.02 GB,会把风险从「单实例崩」升级为「宿主级 OOM」(牵连其他进程)。**收益 < 风险** |
| **卸载 `dsh-univer-office`** | ⏳ **用户自决** | 它与平台 loopback 封锁(nft `reject with tcp reset`)冲突、功能本质不可用,且省 65 MiB —— 但**平台机制是用户在实例「功能管理」里自主启停**,不代劳 |
| **升级宿主内存** | ⏳ **待用户决策** | 唯一根治路径;涉及花钱,属用户决策域 |
| 禁用部分官方插件减负 | ❌ 不做 | `cordis.patch.yml` 明确 148 个官方插件是运行骨架,禁任一都可能搞坏实例 |
---
## 八、遗留与后续
1. **本机仓库 `D:\github\dsh_shenxian` 的 `scripts/instance-mem-sample.cjs` 已改但未 commit**;服务器 `/opt/dshs` 工作树对应文件已部署(内容与本机一致,**LF 行尾已确认**)。
⚠️ **踩坑记录**:本机工作树是 **CRLF**(`core.autocrlf=true`)、服务器是 **LF** —— 直接 scp 会把 CRLF 带进生产。**部署前必须 `tr -d '\r'`**。
2. 阈值告警目前只写日志(`/var/log/dsh-instance-mem.log`),**无外发通道**;若要"崩了能主动知道",需另加通知机制。
3. `instance-mem-peak.json` 的 `peakMiB` 是**按 uid 跨 scope 保留**的历史峰值 —— 改配置后旧峰值(382)会一直显示,观察新配置峰值时需注意此干扰。
---
## 追加(2026-09-13 · **现场反证**):160 MiB 对重负载账号**太紧**,会造成 V8 abort 崩溃循环
**发现路径**:用户报「admin 页面断开无浮层」(已由档案 77 热修解决)→ 排查中在 journal 里发现 **guest(uid 100002)实例
07:43–07:45 连崩 5 次并被熔断**,退出码与栈指向 **V8 堆**,而非 cgroup。
**证据链(全部只读取证)**:
| 时间 | 事件 | 证据 |
|---|---|---|
| 07:42:52 | guest 在「功能管理」**启用 `dsh-plugin-mcn-suite`** | `POST /api/plugins/mine/apply`(任务 `c8ac2686539aeb7c`) |
| 07:43–07:45 | 实例**连崩 5 次** | `exitCode=134`(SIGABRT);`lastError` 栈 = `v8::internal::JsonParser<unsigned short>::…` + `HeapAllocator::AllocateRawWithRetryOrFailSlowPath` ⇒ **V8 堆分配失败** |
| 07:46 | 熔断(**本次改动前的旧代码**,故 `crash-loop-circuit-open` 里没有 `opens`/`cooldownUntil` 字段) | journal `[crash-restart]` |
| 07:45 / 08:31 | 平台**自动回滚** → guest 现 bundles **不含** mcn-suite | `package.json.bak-rollback-20260913T0748`;`pnpm-lock.yaml`/`cordis.patch.yml` 均 **0** 命中 |
**关键对照(说明是 V8 堆、不是 cgroup)**:
- 实例进程实测 env:`NODE_OPTIONS=--max-old-space-size=160`(平台进程 `DSH_INSTANCE_NODE_OPTIONS=--max-old-space-size=160` 生效);
- 崩溃是 **134(abort)** 而非 **137(SIGKILL/cgroup OOM)**;09-12 起 exitCode 分布:`1 ×21`(插件/配置类)、**`134 ×8`(V8 堆)**、`137 ×1`。
- **但 cgroup 也已贴顶**:`/var/log/dsh-instance-mem.log` 显示 guest **峰值 382 MiB / 上限 384 MiB**(01:30 一度 351 MiB,触发 91% 高位告警)⇒ 两个天花板**同时**逼近。
**结论**:160 MiB 是在**空闲实例**上调出来的(当时 rss 363.7→195.5 MiB),对"加载重型功能插件(41 MB univer / 7 插件整合包 / 大 JSON 榜单)」的真实负载**不够**。
⇒ **要让 T03(用户自助启用 mcn-suite)真正可用,必须先给这台机器的实例更多内存预算**——建议**同时**动两处:
1. `DSH_INSTANCE_NODE_OPTIONS` 回到 `--max-old-space-size=256`(= 平台代码默认值,本次下调前的值);
2. 实例 cgroup `MemoryMax` 由 **384 → 512 MiB**(否则改了堆限只是把 abort 134 换成 OOM 137)。
⚠️ 两者都由平台进程**启动时读取**(`orchestrator.ts` 的 `NODE_OPTIONS: process.env.DSH_INSTANCE_NODE_OPTIONS ?? '--max-old-space-size=256'` 与 scope 的 `MemoryMax`)⇒ **必须重启 `dshs` 才生效(R8,断在线用户 2–5 s)**。
**本档的阈值告警部分(85%×3 → WARN)保留不动**——它正是本次发现问题的功臣。