Files
dsh_shenxian/dsh-server-docs/skills/dsh-instance-diagnose/SKILL.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

225 lines
16 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.
---
name: dsh-instance-diagnose
description: DSH 多租户平台(alotbuy.com / 47.77.182.89)单个用户实例的「故障诊断」技能,重点是内存 / OOM / 实例崩溃重启。当出现「实例打不开」「会话突然报错/中断」「跑着跑着断了」「响应慢」「怀疑内存不够」时触发。核心:先分「服务级 / 实例级 / 会话级」三层定位,再用 cgroup 内存三件套定量归因,最后在隔离 cgroup 里复现——**绝不在生产实例上做压力测试**。
version: 1.0.0
updated_at: 2026-09-12
last_change: 首版。由 2026-09-12 guest 实例 OOM 排查沉淀(两个会话同一秒被打断 → 内核 OOM 杀 node → exitCode 137),含 cgroup v1 路径、两条死亡路径区分、隔离复现配方与 6 条实测踩坑。
agent_created: true
---
# dsh-instance-diagnose — DSH 实例故障诊断
## 何时用
用户报「实例打不开 / 会话突然报错 / 聊到一半断了 / 很慢 / 内存不够」,或你看到 `crash-restart` 日志。**报障第一步永远是先读 journal 拿真实失败请求**,不要先猜。
## 平台事实(硬编码,勿猜)
| 项 | 值 |
|---|---|
| 服务器 | SSH 别名 `bt-server`(端口 32022,root) |
| 用户数据根 | `/var/lib/dshs/users/<uuid>/`(**不是** `/opt/dsh/users`,那里只有 main) |
| 已知账号 | guest = `4092b965-2f68-4977-9989-68b3966f7df0`(系统 uid **100002**)|admin = `cce6d1cd-b376-4304-80f0-0e1c58c9ffde`(uid 114801) |
| 实例配额 | ⚠️ **2026-09-14 起:基础 MIN、最多浮动到 MAX**(档案 **96**,用户要求:**不受插件开关影响**):cgroup `MemoryHigh = MIN_MEM_MB = 448 MiB`(**软限/基础**,超过即回收·限速)+ `MemoryMax = MAX_MEM_MB = 1024 MiB`(**硬限/上界**,越界 OOM);**不再读 profile 的 bundles**;`heapMbFor()` = 配额 − 96,**cap 256**。其余 `CPUQuota 150%` / `TasksMax 128` 未变。实测(2026-09-14):两 scope 均 `MemoryHigh=448M` + `MemoryMax=1024M`(与各自插件集合无关)。⚠️ **`dsh-univer-office` 这类重插件(gateway ≈390MB + 基座)512 装不下 ⇒ 先抬 MIN**。⚠️ `/etc/dshs.env` 里的 `DSH_INSTANCE_NODE_OPTIONS=--max-old-space-size=160` **已不是生效值**(代码侧 `withHeap()` 会摘掉 `--max-old-space-size` 再按配额补回),查看实际值请直接读 `/proc/<pid>/environ` 与 `systemctl show <scope> -p MemoryMax` |
| 宿主 | 物理内存仅 **1.83 GB**(1915896 kB);swap 1 GB |
| 会话文件 | `<用户根>/home/sessions/--var-lib-...-ws-<工作区>--/<session-id>/session.jsonl.zstd`(**多帧 zstd**,按 magic `28 b5 2f fd` 切分逐帧解压) |
> ⚠️ 会话目录名**不是** `home/sessions/<session>`,中间还有一层带名字的 workspace 转义目录。
## 三层定位(按顺序做,每层都能单独结案)
### 第 1 层 · 服务级
```bash
systemctl status dshs --no-pager | head -20 # 重启过没
journalctl -u dshs --since today --no-pager | grep -iE "crash-restart|instance-restart|instance-stable"
```
- `instance-restart` 带 **`exitCode 137`** ⇒ **内核 SIGKILL(128+9),九成是 OOM**
- `exitCode 1` + `ModuleLoader.import` ⇒ 插件加载崩(如 `duplicate loader entry id`)
- 无 exitCode ⇒ 信号终止,看紧随其后的服务重启
### 第 2 层 · 实例级(内存三件套)
**先找 scope 名**(**每次重启 hash 会变**,别写死):
```bash
ls -d /sys/fs/cgroup/memory/system.slice/dsh-*.scope
```
> ⚠️ **本服务器是 cgroup v1**,路径是 `/sys/fs/cgroup/memory/system.slice/<scope>/`,
> 文件叫 `memory.usage_in_bytes` / `memory.max_usage_in_bytes` / `memory.limit_in_bytes`。
> cgroup v2 才是 `memory.current` / `memory.peak` —— **写 v2 路径会静默读到空**。
```bash
P=/sys/fs/cgroup/memory/system.slice/<scope>
cat $P/memory.usage_in_bytes # 当前(字节!不是 KB)
cat $P/memory.max_usage_in_bytes # 历史峰值 —— 等于 limit 就是「曾精确打满」
cat $P/memory.stat # ★ 关键:分 rss 与 cache
cat /proc/<pid>/smaps_rollup # Anonymous / Pss_Anon
```
**判据**:
- `rss` 占绝对多数、`cache` 很小 ⇒ **几乎全不可回收,内核 OOM killer 无路可退**
- `rss` 接近 `limit` 且 `max_usage == limit` ⇒ 配额**零余量**,任何波动就是终点
- `rss_huge` 大 ⇒ 透明大页放大占用
> ⚠️ **`ps` 的 RSS ≠ cgroup 计费**:实测 `ps` 报 437 MiB 而 cgroup 只用 381 MiB(共享页不计入)。
### 第 3 层 · 会话级(谁在吃内存)
```bash
find <用户根>/home/sessions -name session.jsonl.zstd -printf "%TY-%Tm-%Td %TH:%TM %10s %h\n" | sort -r | head -12
```
拉回本地用 `dsh-server-docs/scripts/sess-analyze.mjs` 解(**注意该脚本同目录的 `sess-list-presets.mjs` 首行曾缺 `/**` 起始符**,如报 `SyntaxError: Unexpected token '*'` 先补)。
**别忘了对照**:多实例时横向比 `dsh-instance-mem.log`(**时间戳是 UTC,+8 才是本地时间**)。无插件的实例峰值 vs 有插件的实例峰值 = 插件的净增量。
## 两条死亡路径(症状不同,别混)
| 路径 | 触发 | 日志指纹 | systemd 结果 |
|---|---|---|---|
| **A · V8 堆限** | 堆冲到 `--max-old-space-size` | `FATAL ERROR: Reached heap limit` / `Ineffective mark-compacts` | `code=dumped/status=ABRT` |
| **B · cgroup 限** | RSS 打满 `MemoryMax` | `kernel: oom-kill:constraint=CONSTRAINT_MEMCG, task=node` | `code=killed/status=9/KILL` → 平台 `exitCode 137` |
**两条都拿不到可读的应用层日志**——这正是用户觉得「莫名其妙就断了」的原因。
## 隔离复现(**唯一允许的压测方式**)
⛔ **压测走隔离环境**:在**生产实例**上压满配额 = 当场 SIGKILL,会连带把实例带进 crash-loop / 熔断(自找干扰)。⚠️ 注意 **2026-09-13 用户已明确「服务器是开发环境,不用担心中断用户」**(R8 已放宽)—— 但**别因此就去污染实例**:隔离复现能拿到同样的数据而**不留副作用**,仍是首选;只有在需要复现"平台侧联动"(如自动回滚、熔断计数)时才动真实例。
```bash
systemd-run --wait --pipe --collect --unit=memsim-x \
-p MemoryHigh=448M \
-p MemoryMax=1024M \
node /tmp/memsim/probe.mjs
```
- 独立 cgroup ⇒ **撞墙只杀模拟进程**,生产不受任何影响(宿主 available 需 > 400 MiB)
- 跑完 `rm -rf /tmp/memsim`,`systemctl list-units "memsim*"` 确认无残留
**复现路径 B(137)的配方**:必须用**纯堆外分配**才能走到 cgroup 限——
```js
const b = Buffer.allocUnsafe(4 * 1024 * 1024); b.fill(1); bufs.push(b)
```
⚠️ 若改用 JS 对象数组,会**先撞 V8 堆限(路径 A)**,永远复现不出 137。这是本轮踩到的坑。
**复现路径 A 的配方**:正常 push JS 对象即可,同时打印 `process.memoryUsage()` 看崩在哪个 `heapUsed`。
## 踩坑清单(全部实测)
1. **cgroup v1 vs v2 路径不同** —— 写错不报错,静默读到空值。
2. **`memory.*_bytes` 单位是字节**,不是 KB(差 1024 倍)。
3. **`dsh-instance-mem.log` 时间戳是 UTC**,+8 才对得上本地时间。
4. **scope 名带随机 hash**,每次重启都变,必须现查。
5. **`ps` RSS ≠ cgroup usage**(共享页不计入 cgroup)。
6. **会话目录多一层 workspace 转义目录**,别按 `home/sessions/<id>` 找。
7. **`systemd-run` 起服务不继承调用者 env** ⇒ 压测必须 `--setenv=NODE_OPTIONS=...`,否则堆限根本没生效。**自证手段**:脚本里打印 `require('node:v8').getHeapStatistics().heap_size_limit`。
8. **同质字符串会被引擎优化**:`'x'.repeat(N) + i` 走 cons string 惰性拼接(不复制前缀)→ 实测「投喂 23 GB 只涨 140 MB」的假数据。**内存类模拟实验极易骗人,优先用真实数据统计 + smaps 实测**。
9. **`.cjs` 不支持顶层 await** ⇒ 用 `await import()` 的探针脚本要存成 `.mjs`。
## 定量归因的经验值(2026-09-12 guest 实例实测)
| 组成 | 量 | 说明 |
|---|---|---|
| V8 老生代堆 | ≤`--max-old-space-size` | **这是「许可」不是「上限」**,V8 会主动把水位推满以减少 GC |
| RSS / heapUsed 膨胀 | **约 3.4×** | 实测 heapUsed 37 MiB 时 RSS 已 125 MiB(预留未用 + 回收未归还给 OS) |
| `dsh-univer-office` | **+65 MiB** | 磁盘 168 MB,含 29 MB + 10 MB 原生绑定 |
| 页缓存(node_modules mmap) | ~18 MiB | **唯一可回收的部分** |
| V8 不管但计入配额 | ~50 MiB | Buffer / 原生库 / 模块映射 |
### 内存去向实测(2026-09-12 · 空载实例 smaps 拆解)
**空载实例(零会话)已占 285 MiB**:`V8 堆/匿名 155.9` + `[heap] 58.1` + `文件映射 69.1`(其中 **node 本体 59.8**)+ `JIT 2.2`。
⚠️ **`[heap]`(malloc 区)不受 `--max-old-space-size` 约束** ⇒ 压堆限**不会**让总量线性下降。
### 会话数据几乎不占内存(重要反直觉)
实测:**整个会话(2546 事件 / 11 轮)的全部字符串只有 0.9 MB**(最大单串 40820 字符 ⇒ 工具有截断)。
⇒ **「减会话长度 / 少用 web_fetch」对内存几乎无用**;占用主体是**代码与插件加载**。
### 插件内存成本(逐个 `import` 实测)
| 插件 | rss 增量 | 备注 |
|---|---|---|
| `dsh-univer-office` | **+65.1 MiB** | 单文件 bundle 5400 行 / 6 MB,**顶层静态 import** 拉起重型依赖(连 `@puppeteer/browsers` 都在),全文件仅 2 处动态 import |
| `libsql` | +7.8 MiB | 原生绑定 |
| 平台自研 ×3(portal-entry / workspace-scoped-picker / business-plugins) | **0.0 MiB** | 自研插件写法是轻的 |
| `puppeteer-core` | **0.0 MiB** | 只 `import` 主入口、不启动浏览器 ⇒ **懒加载确实有效** |
⇒ **成本由「装了什么」决定,不是「装了几个」** —— 一个重型插件 ≈ 无穷多个轻量插件。
⇒ 治理优先级:**卸重型插件 > 让插件懒加载 > 折腾堆参数**。
⚠️ 插件的 `pnpm remove`(禁用)**必须重启实例**才真释放 —— Node 模块缓存不卸载。
**诊断结论的落地口径**:算「V8 堆上限 + 插件 + 堆外 + 缓存」总和是否 ≥ `MemoryMax`。若 ≥,就是**配额本身零余量**(配置问题,不是 bug)——此时可调项只有:① 压 `--max-old-space-size`(代价:更易撞路径 A)② 提高配额(受宿主物理内存限制)③ 减少单会话负载。
## 已知与本平台不兼容的插件
### `dsh-univer-office`(2026-09-12 定性:**架构级不兼容,配置不可修**)
两条**独立**的阻碍,缺一都打不开:
| 阻碍 | 机理 | 证据 |
|---|---|---|
| **Host → Gateway** | 插件启动 bundled Gateway(默认 `127.0.0.1:9080`),实例内 node 要连它做健康检查;而平台 nft `dsh_egress` 对 `skuid 100000-199999 → 127.0.0.0/8` 是 `reject with tcp reset` ⇒ 永远连不上 | `univer_new` 恒报 `bundled Gateway did not become ready within 10000ms`;实例内 `curl 127.0.0.1:908x` → `000` / Connection refused |
| **Browser → Viewer** | **源码硬编码** `gateway = http://127.0.0.1:${port}`、`viewerUrl = ${gateway}/?file=...`,client 拿它当 **iframe src** ⇒ 浏览器去**用户自己电脑**的 127.0.0.1 找 Viewer,那里没有服务 | `grep -o "viewerUrl: [^,]*" lib/index.js`;`grep -oE "http://127\.0\.0\.1:[^\`\"']*"` |
⇒ 它的 Viewer **假设「浏览器与实例同机」(本地部署场景)**,与托管多租户平台根本不兼容。**解封 loopback 也没用**——第二条拦在浏览器侧。
⇒ 只能改插件(viewerUrl 改走平台代理的相对路径)。**治理结论:候选池应下架 / 用户应禁用**(顺带省 **65 MiB**)。
**替代路径**:让 agent 用 python(openpyxl + python-docx + matplotlib)直接产出 xlsx/docx,已验证可行(2026-09-12)。
⇒ **评估标准已立档**:`04-调整方案/75`(**托管友好性 H1–H4** + **资源成本**;自研插件改造规范 **R-a~R-e**)。今后候选池导入与自研插件验收按该档的检查项走 —— 核心判据一句话:**「插件的一切对外交互,是否都能走平台已有的那一条入口」**(浏览器侧只用相对路径;实例侧不依赖 loopback 网络服务)。
## 注入层 / 浮层的**真机验证配方**(2026-09-13 实测,4 轮才摸清)
**为什么不能用 curl 验**:`src/supervisor/proxy.ts` 的注释写得很明白 —— **「curl 不带 Accept-Encoding,故此前验证是假阳性」**。注入只在「客户端接受 HTML → 代理把上游 `accept-encoding` 改写成 `identity` → 上游回未压缩 HTML」时发生;curl 一不留神就绕过这个判定,于是**"有标记"不代表脚本能在浏览器里跑**,而**"没标记"也可能是 curl 自己造成的**。⇒ **判定注入层是否活着,必须用真浏览器。**
**前置(R4 允许的临时会话,别用真实账号)**:
```bash
# 服务器上:建一个 10 分钟自过期的 guest 会话(ip=127.0.0.1 / ua=poc-curl2)
TOKEN=$(ssh bt-server 'cd /opt/dshs && /usr/local/bin/node mksess-guest.cjs')
# 用完立刻删(否则留下真实可用的会话行)
ssh bt-server "cd /opt/dshs && /usr/local/bin/node -e \"const c=require('crypto');const D=require('/opt/dshs/node_modules/better-sqlite3');const db=new D('/var/lib/dshs/dshs.db');console.log(db.prepare('DELETE FROM sessions WHERE token_hash=?').run(c.createHash('sha256').update('$TOKEN').digest('hex')).changes);db.close();\""
```
**工具**:`playwright-core` + `channel:'chrome'`(装在 `E:\ProgramData\.workbuddy\binaries\node\workspace`;须 `createRequire` 指向该目录,并在该目录内跑)。把 `sid` cookie 写进 context(`domain:'.alotbuy.com'`, `secure:true`, `sameSite:'None'`),再 `goto https://<user>.alotbuy.com/`。
**判定用的哨兵与元素(直接 `page.evaluate` 读)**:
| 判据 | 说明 |
|---|---|
| `window.__dshRecover === 1` | **recovery.js 全文执行完毕的哨兵**(脚本第 23 行设)—— **最强证据**,比找 DOM 元素可靠 |
| `window.fetch.toString()` 不含 `[native code]` | 证明监控层已装载(脚本包装了 `fetch` / `EventSource` / `WebSocket`) |
| `#__dshAssistBar` 存在 | assist.js 跑起来了(它 `mount()` 时建这个容器) |
| `#__dshRecover` + 文案 + `#__dshRetryBtn` | 浮层级 |
| `document.scripts` 里含 `__dsh` 的 `<script>` 数 = 2 | 两个注入脚本都下发了 |
**4 个坑(每个都让我误判过一次)**:
1. **`page.route` 拦不住 WebSocket** ⇒ 想用「拦 `/api/*`」或 `ctx.setOffline(true)` 造断流**逼不出浮层**(dsh 的主链路是 WS/SSE)。`setOffline` 也不会立即掐断已建立的 WS。
2. **`probe()` 有 15 秒节流**(`lastProbe`),而心跳每 25 秒跑一次 ⇒ **心跳会把节流窗口占掉**,你手动 `dispatchEvent(new Event('focus'))` 往往被**静默丢弃**。要么按心跳节奏等(`status` 恒 `running:false` → 连续两次软失败 ≈ 50 s),要么确保距上次探针 ≥15 s。
3. **`recover()` 成功后 0.7 秒内 `location.replace()` / `location.reload()`** ⇒ 浮层只亮 0.7 s。**任何 ≥2 秒的轮询都会全踩空**,然后你会得出"浮层没出现"的**错误结论**。
4. **`#__dshAssistBar` 本身是容器**,里面才是「📁 我的文件」「🧭 能力」两个 button —— **点容器无效**,要用 `page.click('#__dshAssistBar button:nth-child(1)')`。
**✅ 正解(纯客户端、零生产副作用)**:拦 `status` 仿真未运行 + **把 `/api/dsh/enter` 挂住不回**,浮层就会**停住不下跳**,从容观测:
```js
await page.route('**/api/dsh/status*', r => r.fulfill({ status:200, contentType:'application/json', body:'{"running":false}' }))
await page.route('**/api/dsh/enter*', () => { /* 故意永不回包 → 浮层停住 */ })
// 之后每 2 秒采样 #__dshRecover 的可见性与文案即可
```
2026-09-13 用这套验证拿到的实测结果:文案「工作区已休眠,正在唤醒…(**已等待 N 秒**)」**倒计时逐秒递增**、`DSH · AUTO RECOVERY`、AI 核心视觉 + 进度条;助手面板点开后正确列出工作区目录。**全程零页面错误。**
## 相关
- 排查完整案例:`04-调整方案/55`(会话档位)、`04-调整方案/16`(崩溃循环)
- 实例配额与内存优化:`04-调整方案/58`
- 会话档位是「会话创建时播种」的,老会话不跟随平台默认:`04-调整方案/33`