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:
1 parent
e18eaa2b64
commit
53b8eff870
5 files changed
+119
-114
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user