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 一律写「远程服务器」。
This commit is contained in:
admin committed 2026-09-15 18:47:13 +08:00
1 parent c70d5d860e
commit 5ad755116e
173 files changed
+27632

No files matched your search

@@ -0,0 +1,168 @@
# 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)保留不动**——它正是本次发现问题的功臣。