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 一律写「远程服务器」。
11 KiB
74 · 实例内存治理:V8 堆限下调 + 内存阈值告警(2026-09-12)
- 日期:2026-09-12
- 状态:✅ 已落地并实测验证(配置 + 运维脚本;未 commit、未推 docs,待用户发话)
- 触发:用户报「排查 guest 最新会话,两个会话看看有什么问题」→ 排查发现两个会话在同一秒被打断,根因是实例被内核 OOM kill
- 范围(3 处,均不涉及官方 dsh 主程序):
/etc/dshs.env—— 加 1 行 env 覆盖项/opt/dshs/scripts/instance-mem-sample.cjs—— 加阈值告警/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
NODE_OPTIONS: process.env.DSH_INSTANCE_NODE_OPTIONS ?? '--max-old-space-size=256',
而 service 用 EnvironmentFile=/etc/dshs.env ⇒ 只需加一行,不必改码、不必 build:
# /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 个官方插件是运行骨架,禁任一都可能搞坏实例 |
八、遗留与后续
- 本机仓库
D:\github\dsh_shenxian的scripts/instance-mem-sample.cjs已改但未 commit;服务器/opt/dshs工作树对应文件已部署(内容与本机一致,LF 行尾已确认)。 ⚠️ 踩坑记录:本机工作树是 CRLF(core.autocrlf=true)、服务器是 LF —— 直接 scp 会把 CRLF 带进生产。部署前必须tr -d '\r'。 - 阈值告警目前只写日志(
/var/log/dsh-instance-mem.log),无外发通道;若要"崩了能主动知道",需另加通知机制。 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)真正可用,必须先给这台机器的实例更多内存预算——建议同时动两处:
DSH_INSTANCE_NODE_OPTIONS回到--max-old-space-size=256(= 平台代码默认值,本次下调前的值);- 实例 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)保留不动——它正是本次发现问题的功臣。