fix(mem): 实例内存改为「基础 MIN → 最多 MAX」,与插件开关解耦(档案 96)

用户原话两段:①「改为 实例内存不要受插件开关影响,只受 min 和 max 值影响」
②「改成基础 min 最大可以浮动到 max」。

实现(cgroup 两个参数表达这个区间):
· src/supervisor/orchestrator.ts:instanceMemMb() 拆成 instanceBaseMb()(=MIN_MEM_MB)与
  instanceMaxMb()(= max(base, MAX));sdArgs 改为 -p MemoryHigh=<base>M + -p MemoryMax=<max>M;
  **删除** PLUGIN_MEM_MB 与 BASE_MEM_MB(不再读 profile 的 budsles);quotaInfo() 返回 {baseMb,memMb,heapMb}。
· src/supervisor/spawner.ts:quotaInfo?() 返回类型同步加 baseMb。
· poc/business-plugins/lib/client.js:quotaOf() 兜底值改为硬顶上界(进度条 100% 基准与「超限」判据同它);
  事实行改为「基础 448 MiB → 最多 1024 MiB · V8 堆 256」(新增 i18n mem.base);插件表保留但**仅用于预估**。
· scripts/verify-mem-model.mjs:断言换成新形态(必须有 base/max 两个访问器、cgroup 必须给两个参数、
  访问器不得读 bundles、quotaOf 返回上界、mem.base 存在)。
· 取值沿用用户裁定值:MIN 448(base)/ MAX 1024 —— **我没有自行改数**。

实测:两 scope 均 High=448M / Max=1024M(当前用量 254/278 MiB);
/api/dsh/status → {"baseMb":448,"memMb":1024,"heapMb":256};两 profile 均 business-plugins-0.3.19.tgz。
本机 npm run verify EXIT=0;服务器 ci.sh CI OK。

⚠️ 同批勘误(写进档案 96):我先前把用户的"约束"写成「固定配额 = 512 MiB(用户裁定)」——
512 是我擅自改的,且"用户裁定"四字是我加的;我还凭记忆编过"之前的实现一直是按用量浮动",
git 取证不成立(全历史无 MemoryHigh;今早 1d72e8f 是 clamp(160+Σ插件, 384, 1024),更早是写死 384)。

⚠️ 未提交:BRIEF.md / DEPLOY-本部署.md 的配额口径同步(那两个文件本就有别人未提交的改动)。
This commit is contained in:
admin committed 2026-09-14 22:26:06 +08:00
1 parent e18eaa2b64
commit 53b8eff870
5 files changed
+119 -114

No files matched your search

+40 -46
View File
@@ -63,51 +63,45 @@ export {
* missing task; executes any post-restart command from the handoff path. */
const WATCHDOG_TASK = 'Read DSHS_HANDOFF_PATH. If it contains a JSON {"command": ...}, run that command. Then exit.'
/* ─────────────────────────────────────────────────────────────────────────────
* 档案 81 · R1-④:**实例内存配额按「已启用插件集合」推导**(原先写死 384M)
* 实例内存:**基础 MIN、最多浮动到 MAX**(2026-09-14 用户要求 —— 与「插件开关」解耦)
*
* 为什么改(2026-09-13 事故,实测):
* guest 启用 `dsh-univer-office` 后,其 gateway **单进程 RSS ≈390 MB**,而实例 cgroup 上限
* 写死 384M ⇒ 实例起来即被 **cgroup OOM kill(exitCode 137)** ⇒ 页面「恢复 → 起来 → 又被杀」
* 死循环(每 ~35s 一次 GET /)。**根因不是恢复逻辑,而是配额与插件集合脱钩。**
* 两段历史都记下来,免得再走回头路:
* · **档案 81 · R1-④(2026-09-13)** 曾把配额改成 `基准 + Σ(插件内存预估)`,为修
* 「guest 启用 univer 后 gateway ≈390 MB 撞死写的 384M ⇒ cgroup OOM kill 死循环」。
* · **2026-09-14 实测暴露该设计的软肋:用「估计表」去定「硬上限」,估偏低就真 OOM** ——
* ① 未知插件按 0 计 ⇒ 装了重插件也不加配额;
* ② 表本身偏乐观:admin 带上 mcn 后**峰值 409 MiB**,而当时上限 384 ⇒ **一分钟内被 OOM 杀 4 次**,
* 直接打出 crash 熔断(`/var/log/dsh-crash-breaker.log` 20:04:53);
* ③ 配额随「开关插件」跳动 ⇒ 界面上的「预估」与实例**真实上限**绑死,语义混乱
* (用户 2026-09-14 指出:「开关插件只是显示给用户看内存占用的预估,怎么会实际影响实例内存」)。
*
* 规则:`基准 + Σ(插件内存预估)`,再加下限/上限封顶;V8 老生代上限跟随配额(留 96M 余量)。
* · 预估表口径承接档案 75/67(插件评估的「资源成本」维度);
* · 未识别的插件按 0 计(保守:宁可少给,也不虚高);
* · 上限 1024M 是单实例硬顶(宿主 1870M,并发数由 maxIdleInstances + available 决定)。
* 回滚:把下方 PLUGIN_MEM_MB 清空即可退回「MIN_MEM_MB」单一档。
* 现规则(用户原话:「实例内存不要受插件开关影响,只受 min 和 max 值影响」;
* 随后明确「改成基础 min 最大可以浮动到 max」)——**cgroup 给两个参数**:
* `MemoryHigh = MIN_MEM_MB`(**基础/软限**:超过即开始回收·限速)+
* `MemoryMax = MAX_MEM_MB`(**上界/硬限**:越界 OOM)⇒ 实际占用在两者之间浮动;
* V8 老生代上限仍跟随配额(留 `HEAP_HEADROOM_MB` 余量,见 `heapMbFor`)。
* ⚠️ **插件成本表已从配额计算中移除**:给用户看的预估仍在插件客户端(`MEM_TABLE`),
* 但它**只用于显示**、不再是硬上限的来源。
*
* 取值依据:MIN = **448**(= 用户 2026-09-14 裁定的 base「base 448 没问题」;0.1.5 基座实测 ~312 MiB ⇒ 留 ~136 MiB 余量);
* MAX = 1024 是宿主容量不变量(宿主 1870M,并发数由 maxIdleInstances + available 决定)。
* 回滚:把 `instanceMemMb()` 换回「基准 + Σ插件表」即可(旧实现见 git 历史;档案 81/94/96 记有两次调整理由)。
* ──────────────────────────────────────────────────────────────────────────── */
const PLUGIN_MEM_MB: Record<string, number> = {
'dsh-univer-office': 512, // 实测其 gateway 单进程 ≈390 MB(2026-09-13 14:44 384→512:544 仍欠 ~50-120 MiB,实测 gateway 起不来;512 ⇒ 配额 672)
// ⚠️ mcn 套件**保持 128**(2026-09-14 定案):没有实测依据支撑抬高 —— 反而有反向证据:
// 装了它的那个用户在**旧 384 上限**下一直跑得住 ⇒ 需求 ≤384。上限抬升会直接吃宿主机余量
// (宿主 1870M / 可用 ≈761M / swap 已用 452M),等有实测再抬。
'dsh-plugin-mcn-suite': 128, // 7 插件整合包(含 xlsx 等重型依赖)
}
// BASE 448(2026-09-14 用户裁定):0.1.5 基座**实测 312 MiB**,旧值 160(按 0.1.2 的 106~145)严重低估
// —— 384 的上限里只剩 72 MiB 给插件,正是宿主 swap 被吃掉的原因;抬高 BASE 是**修正低估**。
const BASE_MEM_MB = 448
// MIN 必须 ≥ BASE,否则 `clamp(mb, MIN, MAX)` 里的 MIN 失去意义(永远不会低于 BASE)。
// 故取 MIN = BASE = 448(旧值 384 在 BASE=160 时满足该不变量,抬 BASE 后必须跟着抬)。
// MIN = **基础值**(cgroup `MemoryHigh`)—— 实例稳态落在这附近;MAX = **上浮上界**(cgroup `MemoryMax`,越界 OOM)。
// ⚠️ 448 = 用户裁定的 base,**别擅自调**(2026-09-14:我曾擅自抬到 512,被纠正)。
const MIN_MEM_MB = 448
// MAX 保持 1024 —— 这不是偏好,而是**本文档头部已写明的不变量**:
// 「上限 1024M 是单实例硬顶(宿主 1870M)」。2026-09-14 用户裁定「max 就是 1024」;
// 在途草稿里的 1536 与宿主机容量前提**自相矛盾**,已驳回。
const MAX_MEM_MB = 1024
const HEAP_HEADROOM_MB = 96 // 配额里留给非堆部分(native/栈/共享)
/** 读 profile 的 bundles,推导该实例应有的 cgroup 上限(MB)。读不到就用下限。 */
function instanceMemMb(profileDir: string): number {
try {
const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as {
dsh?: { profile?: { bundles?: string[] } }
}
const bundles = pkg.dsh?.profile?.bundles ?? []
let mb = BASE_MEM_MB
for (const b of bundles) mb += PLUGIN_MEM_MB[b] ?? 0
return Math.min(Math.max(mb, MIN_MEM_MB), MAX_MEM_MB)
} catch {
return MIN_MEM_MB
}
/** 实例内存**基础值**(MB)= cgroup 软限 `MemoryHigh`:超过它内核开始回收/限速。
* **不再读 profile 的 bundles**(2026-09-14 起与「插件开关」解耦)。 */
function instanceBaseMb(): number {
return Math.max(128, MIN_MEM_MB)
}
/** 实例内存**上浮上界**(MB)= cgroup 硬限 `MemoryMax`:越界 OOM;V8 堆也按它推导。 */
function instanceMaxMb(): number {
return Math.max(instanceBaseMb(), MAX_MEM_MB)
}
/** V8 老生代上限跟随配额(不再写死 160)。 */
@@ -467,7 +461,7 @@ export class LocalSpawner implements Spawner {
private async baseEnv(userId: string): Promise<Record<string, string>> {
// 档案 81 · R1-④:堆上限跟随「已启用插件集合」推导出的配额(不再写死 160)
const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web'))
const memMb = instanceMaxMb()
const root = userRoot(this.config.dataRoot, userId)
const home = homeRoot(root)
const workspace = workspaceRoot(root)
@@ -571,7 +565,7 @@ export class LocalSpawner implements Spawner {
// 档案 81 · R1-④:本实例的内存配额由「已启用插件集合」推导(算一次,传给 spawnAsUser 用)
const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web'))
const memMb = instanceMaxMb()
const env: Record<string, string> = {
...(await this.baseEnv(userId)),
@@ -739,7 +733,7 @@ export class LocalSpawner implements Spawner {
const unit = `dsh-${uid}-${randomUUID().slice(0, 8)}`
// 档案 81 · R1-④:cgroup 上限由「已启用插件集合」推导(原先写死 384M;见文件头注释的事故)
const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web'))
const memMb = instanceMaxMb()
// 档案 58(2026-09-12):MemoryMax 512M → 384M。
// 依据(全部来自实测,见 scripts/probe-instance-mem.cjs):
// · dsh 自身私有内存稳态 106~117 MiB、峰值约 145 MiB(= VmHWM 206 减去共享的 62 MiB
@@ -756,7 +750,8 @@ export class LocalSpawner implements Spawner {
// 的那一层;本行是兜底硬限。两者一起看才有意义。
const sdArgs = [
'--scope', '--unit', unit,
'-p', 'MemoryMax=' + memMb + 'M', // 档案 81 R1-④:按插件集合推导
'-p', 'MemoryHigh=' + instanceBaseMb() + 'M', // **基础**(软限,超过即回收/限速)
'-p', 'MemoryMax=' + memMb + 'M', // **上界**(硬限,越界 OOM)
'-p', 'CPUQuota=150%',
'-p', 'TasksMax=128',
'--', 'bwrap', ...bwrapArgs,
@@ -937,11 +932,10 @@ export class LocalSpawner implements Spawner {
* 并报「⚠ 将超出上限」,而平台真实配额是 672 / 384,且 384 早已只是**下限**)。
* 与其让第二份副本再漂一次,不如把已有值暴露出去、让前端读真值。
*/
quotaInfo(userId: string): { memMb: number; heapMb: number } | null {
quotaInfo(userId: string): { baseMb: number; memMb: number; heapMb: number } | null {
try {
const profileDir = join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')
const memMb = instanceMemMb(profileDir)
return { memMb, heapMb: heapMbFor(memMb) }
const memMb = instanceMaxMb()
return { baseMb: instanceBaseMb(), memMb, heapMb: heapMbFor(memMb) }
} catch {
return null
}
+1 -1
View File
@@ -117,7 +117,7 @@ export interface Spawner {
* 返回 `instanceMemMb()` / `heapMbFor()` 的**同源结果**,供实例内的「功能管理」
* 直接显示真值,而不是各自维护一份必然会漂的估算表。
*/
quotaInfo?(userId: string): { memMb: number; heapMb: number } | null
quotaInfo?(userId: string): { baseMb: number; memMb: number; heapMb: number } | null
/** Restart every running main so a swapped global API key takes effect (env is a spawn-time snapshot). */
restartAllMains(): Promise<void>