140 lines
5.9 KiB
JavaScript
140 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
||||
|
|
/**
|
|||
|
|
* probe-instance-mem.cjs —— 实例内存分解探针(只读)
|
|||
|
|
*
|
|||
|
|
* 回答"一个用户实例凭什么占这么多内存":把 RSS 拆成
|
|||
|
|
* ① 共享只读代码页(node 二进制 + 原生模块)—— **多实例物理上只占一份**
|
|||
|
|
* ② 私有匿名页(V8 堆 / 线程栈 / Buffer)—— 每实例真正独占,也是 cgroup 计数的口径
|
|||
|
|
* 并列出最大的内存段,便于识别是不是某个大依赖(sharp / koffi / node-pty 等)。
|
|||
|
|
*
|
|||
|
|
* 用法(root 在宿主执行):
|
|||
|
|
* node probe-instance-mem.cjs # 自动找实例进程 + 打印空 node 基线
|
|||
|
|
* node probe-instance-mem.cjs <pid> [pid...] # 指定进程
|
|||
|
|
*/
|
|||
|
|
const fs = require('node:fs')
|
|||
|
|
const { execFileSync } = require('node:child_process')
|
|||
|
|
|
|||
|
|
const MiB = (kb) => Math.round(kb / 1024)
|
|||
|
|
|
|||
|
|
function rollup(pid) {
|
|||
|
|
const s = fs.readFileSync(`/proc/${pid}/smaps_rollup`, 'utf8')
|
|||
|
|
const num = (k) => Number((s.match(new RegExp(`^${k}:\\s+(\\d+)`, 'm')) || [])[1] || 0)
|
|||
|
|
return {
|
|||
|
|
rss: num('Rss'),
|
|||
|
|
pss: num('Pss'),
|
|||
|
|
shared: num('Shared_Clean'),
|
|||
|
|
privateDirty: num('Private_Dirty'),
|
|||
|
|
privateClean: num('Private_Clean'),
|
|||
|
|
anon: num('Anonymous'),
|
|||
|
|
swap: num('Swap'),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function biggestSegments(pid, top = 12) {
|
|||
|
|
const smaps = fs.readFileSync(`/proc/${pid}/smaps`, 'utf8')
|
|||
|
|
const blocks = smaps.split(/(?=^[0-9a-f]+-[0-9a-f]+ )/m).filter((b) => /^[0-9a-f]+-[0-9a-f]+ /.test(b))
|
|||
|
|
const rows = blocks.map((b) => {
|
|||
|
|
const nameM = b.match(/^[0-9a-f]+-[0-9a-f]+ \S+ \S+ \S+ \S+ +(.*)$/m)
|
|||
|
|
return {
|
|||
|
|
rss: Number((b.match(/^Rss:\s+(\d+)/m) || [])[1] || 0),
|
|||
|
|
anon: Number((b.match(/^Anonymous:\s+(\d+)/m) || [])[1] || 0),
|
|||
|
|
name: nameM ? nameM[1].trim() : '[anon]',
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
rows.sort((a, b) => b.rss - a.rss)
|
|||
|
|
return rows.slice(0, top)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 按"归属"聚合:每个段拆 Shared_Clean/Dirty 与 Private_Clean/Dirty,按私有量排序。
|
|||
|
|
* 这能直接回答"私有内存到底是什么" —— 是 node 二进制被写时复制,还是 JS 堆。 */
|
|||
|
|
function breakdownByOwner(pid, top = 10) {
|
|||
|
|
const smaps = fs.readFileSync(`/proc/${pid}/smaps`, 'utf8')
|
|||
|
|
const blocks = smaps.split(/(?=^[0-9a-f]+-[0-9a-f]+ )/m).filter((b) => /^[0-9a-f]+-[0-9a-f]+ /.test(b))
|
|||
|
|
const agg = new Map()
|
|||
|
|
for (const b of blocks) {
|
|||
|
|
const nameM = b.match(/^[0-9a-f]+-[0-9a-f]+ \S+ \S+ \S+ \S+ +(.*)$/m)
|
|||
|
|
const name = (nameM ? nameM[1].trim() : '') || '[anon]'
|
|||
|
|
const g = (k) => Number((b.match(new RegExp(`^${k}:\\s+(\\d+)`, 'm')) || [])[1] || 0)
|
|||
|
|
const cur = agg.get(name) || { rss: 0, shared: 0, priv: 0 }
|
|||
|
|
cur.rss += g('Rss')
|
|||
|
|
cur.shared += g('Shared_Clean') + g('Shared_Dirty')
|
|||
|
|
cur.priv += g('Private_Clean') + g('Private_Dirty')
|
|||
|
|
agg.set(name, cur)
|
|||
|
|
}
|
|||
|
|
return [...agg.entries()]
|
|||
|
|
.map(([name, v]) => ({ name, ...v }))
|
|||
|
|
.sort((a, b) => b.priv - a.priv)
|
|||
|
|
.slice(0, top)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function findInstances() {
|
|||
|
|
try {
|
|||
|
|
const out = execFileSync('ps', ['-eo', 'pid,user,args', '--no-headers'], { encoding: 'utf8' })
|
|||
|
|
return out
|
|||
|
|
.split('\n')
|
|||
|
|
.filter((l) => {
|
|||
|
|
// 只认真正的实例进程;跳过 bwrap 包装进程(它的参数里同样含 "dsh --profile",
|
|||
|
|
// 且只是 ~1 MiB 的转发壳,混进来会污染"最大内存段"清单)。
|
|||
|
|
const m = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(l)
|
|||
|
|
if (!m) return false
|
|||
|
|
return /(^|\s)node\s+\S*dsh\s+--profile/.test(m[3]) && !/bwrap/.test(m[3])
|
|||
|
|
})
|
|||
|
|
.map((l) => {
|
|||
|
|
const parts = l.trim().split(/\s+/)
|
|||
|
|
return { pid: parts[0], user: parts[1] }
|
|||
|
|
})
|
|||
|
|
} catch {
|
|||
|
|
return []
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 空 node 基线:用来回答"dsh 自身让一个 node 进程多花多少"。 */
|
|||
|
|
function baseline() {
|
|||
|
|
const code = `
|
|||
|
|
const fs=require('node:fs');
|
|||
|
|
const s=fs.readFileSync('/proc/self/smaps_rollup','utf8');
|
|||
|
|
const n=k=>Number((s.match(new RegExp('^'+k+':\\\\s+(\\\\d+)','m'))||[])[1]||0);
|
|||
|
|
process.stdout.write(JSON.stringify({rss:n('Rss'),pss:n('Pss'),shared:n('Shared_Clean'),priv:n('Private_Dirty')}));`
|
|||
|
|
const out = execFileSync(process.execPath, ['-e', code], { encoding: 'utf8' })
|
|||
|
|
return JSON.parse(out)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const args = process.argv.slice(2)
|
|||
|
|
const targets = args.length > 0 ? args.map((p) => ({ pid: p, user: '?' })) : findInstances()
|
|||
|
|
|
|||
|
|
console.log('=== 环境 ===')
|
|||
|
|
console.log('node', process.version, '| enableCompileCache:', typeof require('node:module').enableCompileCache)
|
|||
|
|
console.log('NODE_OPTIONS =', process.env.NODE_OPTIONS ?? '(未设置)')
|
|||
|
|
|
|||
|
|
const b = baseline()
|
|||
|
|
console.log(
|
|||
|
|
`空 node 基线:Rss ${MiB(b.rss)} MiB | 共享 ${MiB(b.shared)} | 私有 ${MiB(b.priv)} | 虚拟已用 ${MiB(b.pss)} MiB`,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
for (const t of targets) {
|
|||
|
|
let r
|
|||
|
|
try {
|
|||
|
|
r = rollup(t.pid)
|
|||
|
|
} catch (e) {
|
|||
|
|
console.log(`\n=== pid ${t.pid}:读不到(${String(e.message).slice(0, 60)})`)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
const cmd = fs.readFileSync(`/proc/${t.pid}/cmdline`, 'utf8').split('\0').filter(Boolean).join(' ')
|
|||
|
|
console.log(`\n=== pid ${t.pid}(user ${t.user})===`)
|
|||
|
|
console.log(cmd.slice(0, 150))
|
|||
|
|
console.log(
|
|||
|
|
`RSS ${MiB(r.rss)} MiB = 共享代码 ${MiB(r.shared)} + 私有 ${MiB(r.privateDirty)}\n` +
|
|||
|
|
` PSS ${MiB(r.pss)} MiB(按共享比例摊分后的"真实归属")| 匿名 ${MiB(r.anon)} | swap ${MiB(r.swap)}`,
|
|||
|
|
)
|
|||
|
|
console.log(` dsh 自身开销(相对空 node):私有 +${MiB(r.privateDirty - b.priv)} MiB | RSS +${MiB(r.rss - b.rss)} MiB`)
|
|||
|
|
console.log(' 最大的内存段:')
|
|||
|
|
for (const s of biggestSegments(t.pid)) {
|
|||
|
|
console.log(` ${String(MiB(s.rss)).padStart(5)} MiB (anon ${String(MiB(s.anon)).padStart(4)}) ${s.name.slice(0, 64)}`)
|
|||
|
|
}
|
|||
|
|
console.log(' 按归属拆「共享 / 私有」(私有才是每实例独占,按私有量排序):')
|
|||
|
|
for (const o of breakdownByOwner(t.pid)) {
|
|||
|
|
console.log(
|
|||
|
|
` 共享 ${String(MiB(o.shared)).padStart(4)} MiB | 私有 ${String(MiB(o.priv)).padStart(4)} MiB | 合计 ${String(MiB(o.rss)).padStart(4)} MiB ${o.name.slice(0, 56)}`,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|