diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a061eef..b4980cc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -65,6 +65,11 @@ server.js(入口,10 行) - CPU/RSS 每 2s 从 `/proc//stat|status` 采样,两档保留:秒级 150 点(≈5 分钟,实时曲线)+ 分钟级 1440 点(24 小时)。分钟档同时存均值**和峰值** —— 只存均值会把瞬时尖峰抹平, 而排查 OOM 时要看的恰恰是尖峰。都在内存,面板重启即丢 + - **口径**:采到的是 `VmRSS`,而 `metrics.ramMax` 是 `-Xmx`(堆)。两者不同口径, + `ram > ramMax` 是**常态** —— 差额是 Metaspace / Code Cache / 线程栈 / GC 结构 / + Netty direct buffer。仪表盘的内存条原先 `Math.min(100, …)` 一夹就永久钉在 100%, + 等于把"健康的堆外开销"和"真要 OOM 了"渲染成同一个样子;现在超出部分换色并标注增量。 + 配额侧同理走 `utils.memFootprintMB()` 折算,见下面「内存配额的口径」 - **隧道进程**(独立于服务端,重启实例不断线):ngrok / frpc / playit / bore / Pinggy / Serveo 六种驱动,统一输出解析(`\r`/`\n` 双分隔 + ANSI 清洗), 公网地址就绪后 4s 自动做一次 mcPing 连通性验证,失败原因写入 `tunnelError` @@ -112,6 +117,28 @@ Fabric/Forge 的模组配置数量不定,额外扫一层 `config/`。 协作者能操作实例,但**不能删实例、不能改协作者名单**(`isOwnerOrAdmin`)。 删除用户时会把他从所有实例的名单里摘掉,否则残留一个已不存在的用户名。 +## 内存配额的口径(src/utils.js) + +`utils.memFootprintMB(xmx)` = `xmx + max(memOverheadMinMB, xmx × memOverheadPct%)`, +是内存配额**唯一**的折算入口(`quotaError`、`usageOf`、`/api/host` 的 `committedMem` 都走它)。 + +为什么不能直接用 `-Xmx` 累加:`-Xmx` 只管堆,JVM 还要 Metaspace、Code Cache、线程栈、 +GC 自身结构和 Netty 的 direct buffer —— MC 服务端网络层就是 Netty,这块不小。 +实测 `-Xmx4G` 的 Paper 稳定运行 RSS 常在 4.5G 以上,重模组服更多。按 Σ-Xmx 把宿主机排满, +实际 RSS 之和必超,**而超出的部分不会报错**:是 OOM killer 半夜随机挑一个服务端杀掉。 +沉默的超卖比当场拒绝难查得多。 + +余量取百分比与固定下限的**较大值**:小堆按比例算不够(1G 的 13% 只有 133M,盖不住 +Metaspace + CodeCache + 线程栈),大堆按固定值算又不够(模组服 class 多、direct buffer 大)。 +两个参数在 `settings.thresholds` 里,**同时设 0 即退回纯 Σ-Xmx**—— +这是逃生阀,给"我自己知道我在超卖"的部署留的。 + +`settings` 用惰性 `require`:`settings.js` 自己 require 了 `utils.js`,顶层 require 会成环。 + +配额只在**内存往上加**时校验(`PATCH` 里 `if (mb > inst.xmx)`)。持平和缩小一律放行 —— +前端保存实例设置时总会带上 `xmx`,不这么写的话,管理员一旦调低某人配额、或者口径变严, +已超额的用户会连改个实例名都 403,而"把内存调小自救"这条唯一出路恰好被同一条拦住。 + ## 磁盘用量(src/disk.js) 分区容量走 `fs.statfs`(用 `bavail` 而非 `bfree` —— 后者含 root 保留块,普通用户拿不到)。 diff --git a/README.md b/README.md index e3e44ca..78f9844 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ from that process. No Java pre-install, no public IP required. | 🔍 **恢复前预览**:恢复是覆盖式且不可撤销,而备份名只有个时间戳 —— 点「恢复」先列出包内文件数、世界、插件目录,以及**哪些现有内容会被覆盖**;顺带验证归档完整性,损坏的包在覆盖任何东西之前就被拦下 | 🔍 **Pre-restore preview**: restores overwrite irreversibly and backup names are just timestamps — so the confirm dialog lists the file count, worlds, plugin dirs and **exactly which existing paths will be overwritten**. It also verifies archive integrity, so a corrupt archive is caught before a single byte is overwritten | | 🕵 **操作审计**:谁在什么时候动了什么,**含失败尝试**(403/404/登录失败);口令类字段自动脱敏,管理员在系统设置页可筛选查看 | 🕵 **Audit log**: who did what and when, **including failed attempts** (403/404/bad logins); credential fields auto-redacted, filterable by admins | | 🤝 **实例共享 + 三档权限**:把实例分享给其他面板用户,每人独立设 **只读**(看状态·日志·玩家)/ **运维**(+ 启停·命令·建备份·踢人封禁)/ **管理**(除下面两条外同主人)。任何一档都**不能删实例、不能改名单**;配额始终算在主人头上 | 🤝 **Share an instance** with a **per-collaborator permission tier**: **viewer** (status, logs, players), **operator** (+ start/stop, console commands, backups, kick/ban) or **manager** (everything but the two below). No tier can **delete the instance or edit the collaborator list**; quota always counts against the owner | -| ◉ 多租户:普通用户实例**隔离**,配额真实生效——实例数 / 内存(-Xmx 之和)/ CPU 核(taskset 绑核)/ **磁盘**(实例目录 + 备份,上传·解压·打包·备份五处校验) | Multi-tenant: isolated user instances with enforced quotas — instance count / memory (Σ-Xmx) / CPU cores (taskset pinning) / **disk** (instance dir + backups, enforced on upload, extract, pack and backup) | +| ◉ 多租户:普通用户实例**隔离**,配额真实生效——实例数 / 内存(**-Xmx 之和 + 每实例一份堆外余量**)/ CPU 核(taskset 绑核)/ **磁盘**(实例目录 + 备份,上传·解压·打包·备份五处校验) | Multi-tenant: isolated user instances with enforced quotas — instance count / memory (**Σ-Xmx plus a per-instance off-heap reserve**) / CPU cores (taskset pinning) / **disk** (instance dir + backups, enforced on upload, extract, pack and backup) | +| 🧮 **内存配额算的是 RSS 不是堆**:`-Xmx` 只管堆,而 Metaspace、Code Cache、线程栈、GC 自身结构和 Netty 的 direct buffer 都在堆外 —— MC 服务端网络层就是 Netty,实测 `-Xmx4G` 的 Paper 稳定运行 RSS 常在 4.5G 以上,重模组服更多。**按 Σ-Xmx 排满宿主机,实际内存一定会超**,而超出的部分不会报错,是 OOM killer 半夜随机挑一个服务端杀掉。所以配额按堆 + 堆外计,余量 `max(下限 MB, -Xmx×%)` 可调,**两项设 0 退回旧口径**。总览页把「实际用量」和「已承诺」并排显示,因为配额还有余、机器已经满了这种局面得看得出来 | 🧮 **Memory quota counts RSS, not heap**: `-Xmx` bounds only the heap — Metaspace, code cache, thread stacks, GC structures and Netty's direct buffers all live outside it, and a Minecraft server's network layer *is* Netty. A `-Xmx4G` Paper server steadily sits above 4.5G RSS; heavy modpacks more. **Packing a host by Σ-Xmx therefore always overcommits**, and the overshoot doesn't raise an error — it's the OOM killer picking a server at 3am. Quota counts heap + off-heap with a tunable `max(floor MB, -Xmx×%)` reserve; **set both to 0 for the old behaviour**. The overview shows *actual usage* and *committed* side by side, because "quota says room, machine says full" has to be visible | | 🎚 **阈值可配置**:磁盘告警线、崩溃计数窗口 / 次数 / 重启延迟,原先是源码常量,改一次要重启面板。重度模组服启动慢又崩得勤,固定「10 分钟 3 次」会被误判成重启风暴而停手 —— 这本来就该按部署调。存完下一次判断即生效 | 🎚 **Tunable thresholds**: disk-warning level and the crash window / count / restart delay used to be source constants. A heavy modded server is slow to boot and crashes more often, so a fixed "3 in 10 min" gets misread as a restart storm — this belongs in config. Changes apply on the next check, no restart | | 💾 **面板配置导出 / 导入**:打包 `settings` / `users` / `instances` / `tasks` / `oauth` 五个 JSON,换机器或误删时用。导入前强制干跑校验(格式、版本、**包里至少有一个管理员**),写入前把现有文件另存为 `.bak-<时间戳>`。不含会话与审计日志 —— 那是「这台机器发生过什么」,搬家没有意义 | 💾 **Panel config export / import**: bundles `settings` / `users` / `instances` / `tasks` / `oauth` for migration or recovery. Import is dry-run validated first (format, version, **at least one admin in the bundle**) and backs up existing files as `.bak-` before writing. Sessions and the audit log are excluded — those describe *this* machine and don't travel | | ⬆ 侧栏显示**面板版本**并可点击**检查更新**(比对 GitHub 最新 tag);查不到时如实显示「更新状态未知」而不是假装最新 | ⬆ The sidebar shows the **panel version** and checks for updates against the latest GitHub tag — and says "unknown" when it can't reach it rather than pretending you're current | diff --git a/public/app.js b/public/app.js index 30d595b..cf9a384 100644 --- a/public/app.js +++ b/public/app.js @@ -289,8 +289,19 @@ function applyDashboardStats(inst) { $('#stat-players').innerHTML = `${inst.playersOnline} / ${inst.maxPlayers}`; $('#stat-cpu').innerHTML = `${inst.metrics.cpu}%`; $('#bar-cpu').style.width = Math.min(100, inst.metrics.cpu) + '%'; - $('#stat-ram').innerHTML = `${inst.metrics.ram} / ${inst.metrics.ramMax} MB`; - $('#bar-ram').style.width = Math.min(100, (inst.metrics.ram / inst.metrics.ramMax) * 100) + '%'; + /* RSS 超过 -Xmx 是**常态**,不是异常:-Xmx 只管堆,RSS 还含 Metaspace、Code Cache、 + 线程栈、GC 自身结构和 Netty 的 direct buffer。原先这里 Math.min(100, …) 一夹,条子 + 就永久钉在 100% —— "健康的堆外开销"和"真的要 OOM 了"长得一模一样,等于把唯一的 + 证据藏起来了。照样满条,但换色并把超出量写出来,让人看得见超了多少。 */ + const overMB = inst.metrics.ram - inst.metrics.ramMax; + const ramPct = inst.metrics.ramMax ? (inst.metrics.ram / inst.metrics.ramMax) * 100 : 0; + $('#stat-ram').innerHTML = `${inst.metrics.ram} / ${inst.metrics.ramMax} MB 堆` + + (overMB > 0 ? ` · 堆外 +${overMB}` : '') + ''; + $('#bar-ram').style.width = Math.min(100, ramPct) + '%'; + $('#bar-ram').classList.toggle('over', overMB > 0); + $('#bar-ram').title = overMB > 0 + ? `RSS ${inst.metrics.ram} MB = 堆上限 ${inst.metrics.ramMax} MB + 堆外 ${overMB} MB(Metaspace / Code Cache / 线程栈 / Netty direct buffer)` + : `RSS ${inst.metrics.ram} MB / 堆上限 ${inst.metrics.ramMax} MB`; renderTps(inst); } @@ -377,11 +388,20 @@ async function loadOverview() { function renderHostCard(host) { $('#host-name').textContent = `${host.hostname} · ${host.platform}`; - const memPct = Math.round(((host.totalMem - host.freeMem) / host.totalMem) * 100); + /* 用 availMem(后端显式读 MemAvailable)而不是 freeMem。os.freemem() 的口径随 libuv + 版本变过 —— 老版本是 MemFree,会把可回收的 page cache 算作已用,而 MC 刷世界文件 + 时 page cache 很大,这张卡会常年虚高。老面板的响应里没有 availMem,退回 freeMem。 */ + const avail = host.availMem !== undefined ? host.availMem : host.freeMem; + const memPct = Math.round(((host.totalMem - avail) / host.totalMem) * 100); + /* 已承诺 = Σ(堆 + 堆外余量),也就是配额拦人用的那个数。跟真实用量并排放,是因为 + 两者不是一回事:配额还有余、机器已经满了,这种局面得在这里看得出来 */ + const commit = host.committedMem || 0; + const commitPct = Math.round((commit / host.totalMem) * 100); $('#host-grid').innerHTML = [ ['CPU', `${escapeHtml(host.cpuModel.split(' ').slice(0, 3).join(' '))} × ${host.cores}`], ['负载', `${host.loadavg.join(' / ')}`], - ['内存', `${memPct}% ${Math.round((host.totalMem - host.freeMem) / 1024)} / ${Math.round(host.totalMem / 1024)} GB`], + ['内存', `${memPct}% ${Math.round((host.totalMem - avail) / 1024)} / ${Math.round(host.totalMem / 1024)} GB 实际用量`], + ['已承诺', `${commitPct}% ${(commit / 1024).toFixed(1)} GB · 全部实例 -Xmx + 堆外余量`], ['磁盘', host.disk ? `${host.disk.usedPct}% ${(host.disk.usedMB / 1024).toFixed(1)} / ${(host.disk.totalMB / 1024).toFixed(1)} GB` : '不可用'], @@ -2719,7 +2739,7 @@ async function loadUsers() { ${u.defaultPassword ? '默认密码未修改' : ''}
${u.limits - ? `实例 ${u.usage.instances}/${u.limits.maxInstances} · 内存 ${u.usage.memMB}/${u.limits.maxMemMB} MB · CPU ${u.limits.maxCpuCores} 核 · 磁盘 ${u.usage.diskMB}/${u.limits.maxDiskMB || '∞'} MB · ` + ? `实例 ${u.usage.instances}/${u.limits.maxInstances} · 内存 ${u.usage.memReservedMB || u.usage.memMB}/${u.limits.maxMemMB} MB · CPU ${u.limits.maxCpuCores} 核 · 磁盘 ${u.usage.diskMB}/${u.limits.maxDiskMB || '∞'} MB · ` : `实例 ${u.usage.instances} · 不受配额限制 · `}创建于 ${fmtAgo(u.createdAt)}
@@ -2904,6 +2924,8 @@ async function loadSystem() { $('#sys-crash-win').value = t.crashWindowMin ?? 10; $('#sys-crash-max').value = t.crashMaxRestarts ?? 3; $('#sys-crash-delay').value = t.crashRestartDelaySec ?? 5; + $('#sys-mem-pct').value = t.memOverheadPct ?? 13; + $('#sys-mem-min').value = t.memOverheadMinMB ?? 512; renderRemoteBackup(s.backupRemote); renderNotify(s.notify); loadAudit(); @@ -3007,6 +3029,8 @@ $('#sys-save').addEventListener('click', async () => { crashWindowMin: parseInt($('#sys-crash-win').value, 10), crashMaxRestarts: parseInt($('#sys-crash-max').value, 10), crashRestartDelaySec: parseInt($('#sys-crash-delay').value, 10), + memOverheadPct: parseInt($('#sys-mem-pct').value, 10), + memOverheadMinMB: parseInt($('#sys-mem-min').value, 10), }, }, }); diff --git a/public/index.html b/public/index.html index 1dd2f8a..cd4ff11 100644 --- a/public/index.html +++ b/public/index.html @@ -745,7 +745,11 @@

新建用户

-
普通用户只能看到自己的实例,管理员不受配额限制。内存按全部实例 -Xmx 之和计,CPU 在启动时绑核,磁盘含实例目录与备份。
+
普通用户只能看到自己的实例,管理员不受配额限制。CPU 在启动时绑核,磁盘含实例目录与备份。
+ 内存按全部实例 -Xmx 之和 + 每个实例一份堆外余量计,不是裸 -Xmx 之和 —— -Xmx 只管堆, + JVM 还要 Metaspace、Code Cache、线程栈和 Netty direct buffer,实测 -Xmx4G 的 Paper + RSS 常在 4.5G 以上。所以实例卡片上的内存读数高于它的 -Xmx 是正常的,不是内存泄漏。 + 余量大小在「系统设置 → 阈值」里调。

邀请链接 一次性,自带配额,不受「开放注册」影响

@@ -804,6 +808,8 @@

用户列表

+
+
@@ -821,7 +827,12 @@

用户列表 API Token 不受此开关影响,否则开启的瞬间所有脚本都会断;签发新 Token 需要网页会话, 所以没配 2FA 的人也绕不过去。
- 崩溃阈值按实例类型调:重度模组服启动慢、崩得勤,3 次/10 分钟容易误判成"重启风暴"而停手。 + 崩溃阈值按实例类型调:重度模组服启动慢、崩得勤,3 次/10 分钟容易误判成"重启风暴"而停手。
+ 内存配额堆外余量:-Xmx 只管堆,而 JVM 还要 Metaspace、Code Cache、线程栈、 + GC 自身结构和 Netty 的 direct buffer(MC 服务端的网络层就是 Netty)—— 实测 -Xmx4G + 的 Paper 稳定运行 RSS 常在 4.5G 以上,重模组服更多。配额按堆 + 堆外计,否则按 -Xmx 之和 + 把宿主机排满,实际内存一定会超,而超出的部分不会报错,是 OOM killer 半夜随机挑一个服务端杀掉。 + 余量取「百分比」与「下限 MB」的较大值。两项同时设 0 就退回纯 -Xmx 之和(旧行为)。

@@ -958,7 +969,7 @@

推送哪些事件
-
+
@@ -979,7 +990,7 @@

资源配额 - + diff --git a/public/style.css b/public/style.css index f8aab36..df1bea5 100644 --- a/public/style.css +++ b/public/style.css @@ -454,6 +454,9 @@ body { background: linear-gradient(90deg, var(--blue), var(--green)); transition: width 0.6s ease; } +/* RSS 超过 -Xmx(堆外开销,常态)。条子已经满了,再拿颜色把"满"和"超了"分开 —— + 否则两种情况在视觉上完全一样,而它们要做的处置完全不同 */ +.bar i.over { background: linear-gradient(90deg, var(--green), var(--amber)); } .pill { display: inline-flex; align-items: center; gap: 7px; @@ -1444,6 +1447,10 @@ body { background: linear-gradient(180deg, #7ce658 0 40%, #3f9e4d 40%); /* XP 条质感 */ transition: width 0.3s steps(8); } +/* RSS 超 -Xmx 的琥珀色态。必须在这儿再写一遍:`.bar i.over` 和 + `[data-style="pixel"] .bar i` 特指度都是 (0,2,1),平手时后写的赢 —— + 只加前面那条的话,像素风下这个状态完全不生效(条子照样是绿的) */ +[data-style="pixel"] .bar i.over { background: linear-gradient(180deg, #ffe066 0 40%, #c8a415 40%); } [data-style="pixel"] .stat-chip { box-shadow: inset 2px 2px 0 rgba(255,255,255,0.2), inset -2px -2px 0 rgba(0,0,0,0.3); border: 2px solid #000; } /* 渐变文字改为实色(渐变裁切在像素风里发虚) */ diff --git a/scripts/smoke.js b/scripts/smoke.js index 025352f..a55c589 100644 --- a/scripts/smoke.js +++ b/scripts/smoke.js @@ -343,6 +343,34 @@ function check(name, cond, detail = '') { else { failed++; console.error(` ✘ ${name} ${detail}`); } } +/** + * 把一个 import 空壳走完 finalize,让它变成 stopped —— 否则删不掉。 + * + * `DELETE /:iid` 要求 `state === 'stopped'`,而空壳是 `importing`,唯一的出路就是 + * finalize。用例里凡是建了空壳的,收尾都得先过这一道,不然实例留在注册表里, + * 后面「删测试用户」会因为"该用户还有实例"失败,看起来像是权限用例挂了。 + */ +async function finalizeImportShell(iid) { + const fsp = require('fs/promises'); + const path = require('path'); + const os = require('os'); + const { createArchive } = require('../src/archive'); + const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'mcsp-shell-')); + try { + const src = path.join(root, 'srv'); + await fsp.mkdir(src, { recursive: true }); + await fsp.writeFile(path.join(src, 'server.properties'), 'server-port=25598\nlevel-name=world\n'); + await fsp.writeFile(path.join(src, 'server.jar'), Buffer.alloc(1024, 7)); + const zip = path.join(root, 'srv.zip'); + await createArchive(zip, src, await fsp.readdir(src), 'zip'); + await reqRaw('POST', `/api/instances/${iid}/files/upload?path=%2F&name=srv.zip&overwrite=1`, + await fsp.readFile(zip)); + await req('POST', `/api/instances/${iid}/import/finalize`, { archive: '/srv.zip', eula: true }); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } +} + /** * 多租户 / 权限边界用例(功能 15)。 * @@ -422,6 +450,58 @@ async function multiTenantSuite() { JSON.stringify(r.json)); if (r.json && r.json.ok && r.json.id) await req('DELETE', `/api/instances/${r.json.id}`); + /* 内存配额按「堆 + 堆外」计。配额 1024 MB,默认余量 max(512 MB, 13%): + 1024 的实例实占 1536 装不下,512 的实例实占 1024 正好占满。 + 这两条一起把边界钉死 —— 只测"被拒"的话,余量算成多大都能过。 */ + r = await req('POST', '/api/instances/import', { name: 'smoke-mem-1024', xmx: 1024 }); + check('quota: 1024 实例被堆外余量拦下(配额 1024)', + r.status >= 400 && !(r.json && r.json.ok), `${r.status} ${JSON.stringify(r.json)}`); + check('quota: 拒绝文案点明堆外(否则用户会以为面板算错了)', + !!(r.json && /堆外/.test(r.json.error || '')), r.json && r.json.error); + if (r.json && r.json.ok && r.json.instance) await req('DELETE', `/api/instances/${r.json.instance.id}`); + + r = await req('POST', '/api/instances/import', { name: 'smoke-mem-512', xmx: 512 }); + const memIid = r.json && r.json.instance && r.json.instance.id; + check('quota: 512 实例恰好占满配额(512 堆 + 512 堆外 = 1024)', + r.status === 200 && !!memIid, `${r.status} ${JSON.stringify(r.json)}`); + + if (memIid) { + /* 存量超额的锁死回归。制造局面:先放宽配额把实例撑到 2048,再把配额收回 1024。 + 此时实例实占 2560 > 配额 1024,和"管理员调低了配额"或"升级后口径变严" + 是同一种状态。前端保存实例设置时**总会**带上 xmx,所以挡住持平 + = 用户连改个实例名都做不了,而"把内存调小自救"恰好被同一条拦住。 */ + const tenantCookie = cookie; + const setQuota = async (memMB) => { + cookie = adminCookie; + await req('PUT', `/api/users/${uname}/limits`, + { maxInstances: 1, maxMemMB: memMB, maxCpuCores: 1, maxDiskMB: 1024 }); + cookie = tenantCookie; + }; + + await setQuota(4096); + r = await req('PATCH', `/api/instances/${memIid}`, { xmx: 2048 }); + check('quota: 配额够时可以加内存', r.status === 200, `${r.status} ${JSON.stringify(r.json)}`); + + await setQuota(1024); // ← 实例 2048(实占 2560)现在远超配额 + r = await req('PATCH', `/api/instances/${memIid}`, { xmx: 2048, name: 'smoke-mem-renamed' }); + check('quota: 超额时持平的 PATCH 放行(否则连改名都做不了)', + r.status === 200, `${r.status} ${JSON.stringify(r.json)}`); + + r = await req('PATCH', `/api/instances/${memIid}`, { xmx: 1024 }); + check('quota: 超额时缩小放行(这是唯一的自救出路)', + r.status === 200 && r.json && r.json.instance && r.json.instance.xmx === 1024, + `${r.status} ${JSON.stringify(r.json)}`); + + r = await req('PATCH', `/api/instances/${memIid}`, { xmx: 4096 }); + check('quota: 超额时继续加内存仍被拒', r.status === 403, `${r.status} ${JSON.stringify(r.json)}`); + + // 空壳是 importing,直接 DELETE 会被 state 守卫挡掉 —— 先 finalize 再删 + await finalizeImportShell(memIid); + r = await req('DELETE', `/api/instances/${memIid}`); + check('quota: 清理测试实例', r.status === 200 && r.json && r.json.ok, + `${r.status} ${JSON.stringify(r.json)}`); + } + // 看不见别人的实例 r = await req('GET', '/api/instances'); check('tenant: 实例列表已隔离', Array.isArray(r.json) && r.json.every((i) => i.owner === uname), diff --git a/src/routes/host.js b/src/routes/host.js index 0ac460e..1d67940 100644 --- a/src/routes/host.js +++ b/src/routes/host.js @@ -1,8 +1,9 @@ /** /api/host、/api/paper、/api/java — 宿主机信息、Paper 版本列表、Java 运行时 */ const os = require('os'); +const fs = require('fs'); const express = require('express'); const { PANEL_STARTED } = require('../config'); -const { asyncHandler } = require('../utils'); +const { asyncHandler, memFootprintMB } = require('../utils'); const { requireAdmin } = require('../auth'); const { instances } = require('../registry'); const { listTypes, typeVersions, TYPES } = require('../servertypes'); @@ -15,6 +16,31 @@ const { version: PANEL_VERSION } = require('../../package.json'); let updateCache = { at: 0, latest: null }; const UPDATE_TTL_MS = 3600_000; +/** + * 宿主机可用内存 MB —— 显式读 `MemAvailable`,不依赖 `os.freemem()`。 + * + * 原因不是 os.freemem() 错,是它**不稳定**:libuv 早期在 Linux 上走 `sysinfo().freeram` + * (等于 MemFree,把可回收的 page cache 算作已用),新版改成了读 `/proc/meminfo` 的 + * MemAvailable。实测 libuv 1.51(Node 25)上 `os.freemem()` 已经等于 MemAvailable, + * 但 package.json 的 engines 是 `node >= 18` —— 也就是说同一份代码在用户机器上到底 + * 读到哪个数,取决于他装了哪个 Node。 + * + * 这个差别不小:本机 MemFree 105G 而 MemAvailable 227G,page cache 占了一百多个 G。 + * MC 服务端刷世界文件,page cache 天生就大 —— 按 MemFree 显示,这张卡会常年虚高到 + * 90%+,而实际内存压力根本没那么高。既然要的就是 MemAvailable,那就直接读它, + * 别让口径跟着运行时漂。 + * + * 读不到就退回 os.freemem():MemAvailable 要 Linux 3.14+,容器里 /proc 可能被屏蔽, + * 非 Linux 上压根没有。宁可退回一个口径不确定的数,也不能在这里抛错把总览页带崩。 + */ +function availMemMB() { + try { + const m = fs.readFileSync('/proc/meminfo', 'utf8').match(/MemAvailable:\s+(\d+) kB/); + if (m) return Math.round(parseInt(m[1], 10) / 1024); + } catch { /* 非 Linux / 容器屏蔽了 /proc */ } + return Math.round(os.freemem() / 1048576); +} + /** 语义化版本比较:a 比 b 新返回 true。非数字段按 0 处理,够用了 */ function isNewer(a, b) { const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0); @@ -55,7 +81,12 @@ router.get('/host', asyncHandler(async (req, res) => { cores: cpus.length, loadavg: os.loadavg().map((n) => +n.toFixed(2)), totalMem: Math.round(os.totalmem() / 1048576), - freeMem: Math.round(os.freemem() / 1048576), + freeMem: Math.round(os.freemem() / 1048576), // MemFree,保留是为了不破坏既有消费方 + availMem: availMemMB(), // MemAvailable —— 卡片该看的是这个 + /* 已承诺:全部实例(不只当前用户可见的)按堆 + 堆外余量折算的总预留。 + 配额是拿这个数拦人的,而 OOM 是按真实 RSS 发生的 —— 把它和宿主机总量并排放, + "配额还有余、机器却已经满了"这种局面在总览页就能看出来,不用等半夜被 kill。 */ + committedMem: [...instances.values()].reduce((s, i) => s + memFootprintMB(i.xmx), 0), nodeVersion: process.version, javaVersion: java.bestJavaVersionLine(), java: java.javaInfo(), diff --git a/src/routes/instances.js b/src/routes/instances.js index 5fce7e4..6cd6dfe 100644 --- a/src/routes/instances.js +++ b/src/routes/instances.js @@ -6,7 +6,7 @@ const crypto = require('crypto'); const { spawn } = require('child_process'); const express = require('express'); const { DATA_DIR, BACKUPS_DIR, MAX_UPLOAD_MB, MAX_EXTRACT_MB, UPLOAD_CHUNK_MB } = require('../config'); -const { asyncHandler, dirSize } = require('../utils'); +const { asyncHandler, dirSize, memFootprintMB, memOverheadMB } = require('../utils'); const uploads = require('../uploads'); const { archiveKind, extractArchive, createArchive } = require('../archive'); const disk = require('../disk'); @@ -274,15 +274,26 @@ function diskQuotaError(req, needMB) { return `磁盘配额不足:本次约需 ${needMB.toFixed(0)} MB,剩余 ${left.toFixed(0)} MB(配额 ${u.limits.maxDiskMB} MB)`; } -/** 普通用户的配额检查;extraMB 为本次新增的内存需求(排除 excludeInst 自身占用) */ +/** + * 普通用户的配额检查;extraMB 为本次新增的**堆**上限(排除 excludeInst 自身占用)。 + * + * 内存一律按 memFootprintMB 折算,也就是堆 + 堆外余量,而不是裸 -Xmx —— + * 配额要防的是宿主机内存被排满,而 JVM 向宿主机要的从来不止堆那一块。 + * 按 Σ-Xmx 排满的结果是 OOM killer 半夜随机挑一个服务端杀掉。 + */ function quotaError(req, extraMB, newInstance, excludeInst) { if (req.user.role === 'admin') return null; const u = authUsers.find((x) => x.username === req.user.username); const lim = (u && u.limits) || { maxInstances: 0, maxMemMB: 0 }; const mine = [...instances.values()].filter((i) => i.owner === req.user.username); if (newInstance && mine.length >= lim.maxInstances) return `实例数已达配额上限(${lim.maxInstances} 个)`; - const used = mine.reduce((s, i) => s + (excludeInst && i.id === excludeInst.id ? 0 : i.xmx), 0); - if (used + extraMB > lim.maxMemMB) return `内存配额不足:已用 ${used} MB + 本次 ${extraMB} MB > 配额 ${lim.maxMemMB} MB`; + const used = mine.reduce((s, i) => s + (excludeInst && i.id === excludeInst.id ? 0 : memFootprintMB(i.xmx)), 0); + const over = memOverheadMB(extraMB); + if (used + extraMB + over > lim.maxMemMB) { + /* 把堆和堆外拆开写。合成一个 "本次 4608 MB" 会让填了 4096 的人以为面板算错了, + 而这恰恰是最需要解释清楚的一次 —— 用户就是在这里第一次撞见这个口径 */ + return `内存配额不足:已用 ${used} MB + 本次 ${extraMB} MB(另需堆外 ${over} MB)> 配额 ${lim.maxMemMB} MB`; + } return null; } @@ -404,8 +415,15 @@ router.patch('/:iid', asyncHandler(async (req, res) => { const mb = parseInt(body.xmx, 10); if (!Number.isFinite(mb)) return res.status(400).json({ ok: false, error: 'xmx 无效' }); if (mb < 512 || mb > 65536) return res.status(400).json({ ok: false, error: '内存上限需在 512 ~ 65536 MB 之间' }); - const qerr = quotaError(req, mb, false, inst); - if (qerr) return res.status(403).json({ ok: false, error: qerr }); + /* 只在**往上加**的时候查配额,持平和缩小一律放行。 + 前端保存实例设置时总会带上 xmx(哪怕用户只改了个名字),所以一旦管理员调低了 + 某人的配额、或者配额口径变严,已经超额的用户会连改实例名都 403 —— 而 + "把内存调小自救"这条唯一的出路,恰好也被同一条拦住。 + 缩小是让账变好看的方向,没有理由挡它。 */ + if (mb > inst.xmx) { + const qerr = quotaError(req, mb, false, inst); + if (qerr) return res.status(403).json({ ok: false, error: qerr }); + } if (mb !== inst.xmx) { inst.xmx = mb; inst.metrics.ramMax = mb; diff --git a/src/routes/users.js b/src/routes/users.js index 957e370..a295dcc 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -5,6 +5,7 @@ const { users, saveUsers, hashPassword, requireAdmin, dropUserSessions } = requi const { instances } = require('../registry'); const disk = require('../disk'); const invites = require('../invites'); +const { memFootprintMB } = require('../utils'); const router = express.Router(); router.use(requireAdmin); @@ -26,10 +27,21 @@ function sanitizeLimits(l) { }; } -/** 某用户当前占用:实例数与内存(xmx 之和) */ +/** + * 某用户当前占用:实例数、内存、磁盘。 + * + * 内存给两个数:memMB 是堆之和(用户在实例设置里填的 -Xmx,所见即所填), + * memReservedMB 是含堆外余量的实际预留 —— **后者才是配额真正拦人的那个数**。 + * 只显示前者的话,用户会看到"2048/4096 还有一半"却被拒,然后开始排查一个不存在的 bug。 + */ function usageOf(username) { const mine = [...instances.values()].filter((i) => i.owner === username); - return { instances: mine.length, memMB: mine.reduce((s, i) => s + i.xmx, 0), diskMB: disk.userUsageMB(username) }; + return { + instances: mine.length, + memMB: mine.reduce((s, i) => s + i.xmx, 0), + memReservedMB: mine.reduce((s, i) => s + memFootprintMB(i.xmx), 0), + diskMB: disk.userUsageMB(username), + }; } router.get('/', (req, res) => { diff --git a/src/settings.js b/src/settings.js index fca1d18..f82944a 100644 --- a/src/settings.js +++ b/src/settings.js @@ -36,6 +36,12 @@ const settings = { crashWindowMin: 10, // 崩溃计数窗口(分钟) crashMaxRestarts: 3, // 窗口内最多自动拉起几次,0 = 关闭崩溃自动重启 crashRestartDelaySec: 5, // 崩溃后隔多久再拉 + /* 内存配额的堆外余量(见 utils.memOverheadMB)。`-Xmx` 只管堆,而配额要防的是 + 宿主机内存被排满 —— 两者差着 Metaspace / CodeCache / 线程栈 / Netty direct buffer。 + 模组服堆外吃得比原版多,所以这同样该是每个部署自己的事。 + **两项同时设 0 = 配额退回纯 Σ-Xmx**,也就是本功能上线前的行为。 */ + memOverheadPct: 13, // 余量占 -Xmx 的百分比 + memOverheadMinMB: 512, // 余量下限:小堆按比例算不够,拿这个兜底 }, /* 异地备份目标(见 remotebackup.js)。默认关闭 —— 没配的人不该因为 升级就开始往某个地方传东西 */ @@ -66,6 +72,7 @@ const settings = { // 老配置文件里没有 thresholds / notify 或缺字段时补齐,免得各处到处判空 settings.thresholds = { diskWarnPct: 90, crashWindowMin: 10, crashMaxRestarts: 3, crashRestartDelaySec: 5, + memOverheadPct: 13, memOverheadMinMB: 512, ...(settings.thresholds || {}), }; @@ -142,10 +149,12 @@ router.put('/', requireAdmin, (req, res) => { settings.require2FA = want; } if (b.thresholds && typeof b.thresholds === 'object') { - // 每项都有下限:磁盘告警线设成 5% 会天天响,窗口设成 0 分钟等于永不计数 + // 每项都有下限:磁盘告警线设成 5% 会天天响,窗口设成 0 分钟等于永不计数。 + // 两个 memOverhead 下限是 0 —— 那是有意留的逃生阀(退回纯 Σ-Xmx),不是笔误 const lim = { diskWarnPct: [50, 99], crashWindowMin: [1, 1440], crashMaxRestarts: [0, 100], crashRestartDelaySec: [1, 3600], + memOverheadPct: [0, 100], memOverheadMinMB: [0, 4096], }; for (const [k, [lo, hi]] of Object.entries(lim)) { if (!(k in b.thresholds)) continue; diff --git a/src/utils.js b/src/utils.js index a4a040d..60c6e86 100644 --- a/src/utils.js +++ b/src/utils.js @@ -86,4 +86,34 @@ async function githubLatestTag(repo, fallback) { /** Express 异步路由包装:未捕获的 rejection 交给错误中间件而不是打崩进程 */ const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); -module.exports = { ts, stripAnsi, readJson, writeJson, dirSize, runCmd, downloadFile, githubLatestTag, asyncHandler }; +/** + * 一个 `-Xmx=xmxMB` 的 JVM 实际会向宿主机要多少内存(RSS 口径)。 + * + * `-Xmx` 只管**堆**。Metaspace、Code Cache、线程栈、GC 自身的数据结构,以及 Netty 的 + * direct buffer 全在堆外 —— MC 服务端的网络层就是 Netty,这块吃得不少。实测 `-Xmx4G` + * 的 Paper 稳定运行时 RSS 常在 4.5G 以上,重模组服更多。 + * + * 所以按 Σ-Xmx 把宿主机排满,实际 RSS 之和一定会超,而超出来的那部分不会报错, + * 是 OOM killer 在半夜随机挑一个服务端杀掉 —— 沉默的故障比拒绝服务更难查。 + * + * 余量取「百分比」与「固定下限」的较大值:小堆按比例算不够(1G 堆的 13% 只有 133M, + * 盖不住 Metaspace + CodeCache + 线程栈),大堆按固定值算又不够(重模组服 class 多、 + * direct buffer 大)。两个参数都在系统设置里(thresholds),**同时设 0 就退回纯 Σ-Xmx**。 + * + * settings 用惰性 require:settings.js 自己 require 了本文件,顶层 require 会成环。 + * 同 disk.js 的 diskWarnPct 写法。 + */ +function memOverheadMB(xmxMB) { + const t = require('./settings').get().thresholds; + const pct = Number.isFinite(t.memOverheadPct) ? t.memOverheadPct : 13; + const min = Number.isFinite(t.memOverheadMinMB) ? t.memOverheadMinMB : 512; + return Math.max(min, Math.round((xmxMB * pct) / 100)); +} + +/** 堆 + 堆外 = 这个实例真正要占的宿主机内存,配额就按这个数算 */ +const memFootprintMB = (xmxMB) => xmxMB + memOverheadMB(xmxMB); + +module.exports = { + ts, stripAnsi, readJson, writeJson, dirSize, runCmd, downloadFile, githubLatestTag, asyncHandler, + memOverheadMB, memFootprintMB, +};