Heapdump Chrome DevTools
一、认识
对于偶然的内存泄漏,一般会与特殊的输入有关系。想稳定重现这种输入是很耗时的过程。如果不能通过代码的日志定位到这个特殊的输入,那么推荐去生产环境打印内存快照了。heapdump
模块可以在运行时生成堆快照。堆快照可导入到 Chrome DevTools
进行分析,找到泄漏原因。
本文基于 Koa
框架和 heapdump
模块的 Node.js
应用,主要功能是监控内存使用情况,自动生成 heap snapshot
,并将快照上传到远程服务器。它还包含了手动生成 heap snapshot
和触发垃圾回收(GC
)的功能,帮助开发者定位和排查内存泄漏问题。主要逻辑如下:
-
应用启动时, 生成启动的
Heapdump
快照, 并开始定时监控内存使用情况 -
针对可疑路由添加
memoryUsageMiddleware
、trackRequestCountMiddleware
中间件。memoryUsageMiddleware
:定期检查内存使用情况并触发生成heap snapshot
, 比如内存每达到100M
生成一次heap snapshot
。trackRequestCountMiddleware
:根据请求计数触发heap snapshot
, 比如每 请求100
次生成一次heap snapshot
。 -
生成的
heap snapshot
会保存在本地目录heapdump
, 并通过scp
上传到指定的远程服务器目录 -
提供了
/heap-snapshot
接口,开发者可以手动触发heap snapshot
的生成。提供了/trigger-gc
开发者可以手动触发垃圾回收。
二、安装
安装 heapdump
在某些 Node.js
版本上可能出错,建议使用 npm install heapdump -target=Node.js
版本来安装
npm install heapdump -target=Node.js
三、Heapdump 快照策略
3.1 手动触发 GC 接口
提供手动触发 GC
的接口,并带上权限验证。
手动触发 GC
: Node.js
是基于 V8
引擎运行的,V8
引擎会自动进行垃圾回收,清理不再使用的对象和内存。垃圾回收的过程通常是自动进行的,由 V8
的内存管理机制触发,并不会随时执行。通过 v8 global.gc()
允许你主动触发垃圾回收,让 V8
进行内存清理。这对于 诊断内存泄漏 和 生成更准确的堆快照 非常有用。在某些场景下,通过手动触发 GC
,可以释放不再使用的内存,确保堆快照中只保留真实的内存占用情况, 如果手动触发 GC
后,内存仍然无法释放,说明存在未被回收的对象,即可能存在内存泄漏。 默认情况下,global.gc()
方法是不可用的,因为 V8
的垃圾回收机制是自动管理的。必须使用 --expose-gc
参数启动 Node.js
服务,才能在全局上下文中注入 global.gc
。注意: 在生产环境中频繁手动触发 GC
并不推荐,因为:GC
会暂停应用程序执行,影响性能, V8
的自动垃圾回收机制已经足够智能,手动触发 GC
可能导致性能下降。
node --expose-gc app.js
if (global.gc) {
global.gc(); // 手动触发垃圾回收
console.log('Manual GC triggered');
} else {
console.log('GC is not exposed. Use node --expose-gc to enable it.');
}
const heapdump = require('heapdump');
if (global.gc) {
global.gc();
console.log('Manual GC triggered before heap snapshot');
}
heapdump.writeSnapshot((err, filename) => {
if (!err) console.log(`Heap snapshot saved to ${filename}`);
});
带上权限验证:
-
使用简单的静态
Token
: 静态Token
适合测试或内部使用,简单直接,硬编码到代码中。const AUTH_TOKEN = 'your-secure-token'; // 自定义的静态 Token
在请求时,你直接将该
Token
传入x-auth-token
头部:curl -X POST -H "x-auth-token: your-secure-token" http://localhost:3000/trigger-gc
-
使用
UUID
动态生成Token
:使用
uuid
生成唯一的Token
。npm install uuid
启动服务时生成一个
Token
, 将Token
输出到日志中,供管理员使用。在服务中进行校验。const { v4: uuidv4 } = require('uuid'); // 引入 uuid 库
const AUTH_TOKEN = uuidv4(); // 生成一个唯一的 Token
console.log(`Generated Auth Token: ${AUTH_TOKEN}`);
// 权限校验中间件
function authMiddleware(req, res, next) {
const token = req.headers['x-auth-token'];
if (token !== AUTH_TOKEN) {
return res.status(403).json({ message: 'Unauthorized access' });
}
next();
}查看控制台输出的
Token
:Generated Auth Token: 123e4567-e89b-12d3-a456-426614174000
使用
Token
访问接口:curl -X POST -H "x-auth-token: 123e4567-e89b-12d3-a456-426614174000" http://localhost:3000/trigger-gc
-
使用
JWT
动态生成Token
:安装
JWT
库npm install jsonwebtoken
通过
jsonwebtoken
库生成和验证Token
:const jwt = require('jsonwebtoken');
const SECRET_KEY = 'your-very-secure-secret-key'; // Token 签名密钥
const EXPIRES_IN = '1h'; // Token 过期时间
// 生成 Token
function generateToken() {
return jwt.sign({ role: 'admin' }, SECRET_KEY, { expiresIn: EXPIRES_IN });
}
// 权限校验中间件
function authMiddleware(req, res, next) {
const token = req.headers['x-auth-token'];
if (!token) return res.status(401).json({ message: 'Token is required' });
try {
const decoded = jwt.verify(token, SECRET_KEY);
console.log('Decoded Token:', decoded);
next();
} catch (err) {
return res.status(403).json({ message: 'Invalid or expired token' });
}
}
// 启动时生成 Token
const AUTH_TOKEN = generateToken();
console.log(`Generated JWT Token: ${AUTH_TOKEN}`);生成的
Token
输出示例:Generated JWT Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2OTk5MDAwMDAsImV4cCI6MTY5OTkwMzYwMH0.O1OqWE9nsqw1KrW4Oas38jJebPrpVfKo7xBZ47em-w0
请求接口:
curl -X POST -H "x-auth-token: <你的JWT-Token>" http://localhost:3000/trigger-gc
3.2 手动触发堆快照接口
提供堆快照的接口,并带上权限验证。
3.3 堆快照被动生成策略
堆快照被动生成策略: 自动生成快照 和 手动生成快照
自动生成快照策略: 服务首次启动自动生成快照、内存达到指定阈值自动生成快照、针对可疑请求根据请求次数自动生成快照
手动生成快照策略: 开发者通过调用接口手动生成快照
做至少 3
次 heapdump
(实际上为了拿到最详细的数据我是做了 5
次)
四、Heapdump 快照与服务监控
Node.js
服务接入 Prometheus + Grafana
。主要监控服务的以下指标:
-
QPS
(每秒请求访问量) ,请求状态,及其访问路径 -
ART
(平均接口响应时间) 及其访问数据 -
NodeJs
版本 -
Actice Handlers
(句柄) -
Event Loop Lag
(事件滞后) -
服务进程重启次数
-
CPU
使用率 -
内存使用:
rss
、heapTotal
、heapUsed
、external
、heapAvailableDetail
只有 heapdump
数据是不够的,heapdump
数据非常晦涩,就算在可视化工具的加持下也难以准确定位问题。这个时候我是结合了 grafana
的一些数据一起看。
4.1 内存泄漏与句柄分析
通过 Grafana
查看 Process Memory Usage
统计图, 查看 Heap Used
选项一直在涨,查看 Active Handlers
也是一直涨。
4.2 内训泄漏与事件滞后
五、基于 Heapdump 实现内存泄漏定位
使用 Koa
框架和 heapdump
模块的 Node.js
应用,主要功能是监控内存使用情况,自动生成 heap snapshot
,并将快照上传到远程服务器。它还包含了手动生成 heap snapshot
和触发垃圾回收(GC
)的功能,帮助开发者定位和排查内存泄漏问题。
5.1 细节
1. 内存监控与Heap Snapshot
:
-
启动时会自动生成一个启动时的
heap snapshot
-
应用监控
Node.js
应用的内存使用情况,当内存使用量达到某些阈值时(例如大于设定的memoryThreshold
或接近memoryLimit
),会触发heap snapshot
的生成。 -
每当请求达到一定次数时(如
10
,20,
30
,40
,50
),也会生成heap snapshot
。
2. 自动与手动触发 GC
(垃圾回收): 提供了一个接口,可以手动触发垃圾回收。如果 Node.js
启动时开启了 --expose-gc
选项,可以通过暴露的 global.gc()
来手动触发 GC
。
3. heap snapshot
文件上传: 生成的 heap snapshot
会被上传到指定的远程服务器目录。上传是通过 scp
(安全复制协议)来实现的。上传过程支持重试机制,最多重试 maxRetries
次。
4. 安全性: 使用 x-auth-token
来验证 API
请求的合法性,确保只有授权的请求才能触发生成 heap snapshot
或触发垃圾回收。配置项包括默认的授权 token (authToken)
,和通过环境变量来动态设置 remoteServer
(远程服务器地址)、remoteDir
(远程目录)、REMOTE_DIR
、REMOTE_SERVER
等。
5. Koa
路由与中间件:
-
GET /
:模拟一个内存泄漏,生成heap snapshot
。 -
POST /heap-snapshot
:手动触发heap snapshot
的生成。 -
POST /trigger-gc
:手动触发垃圾回收。 -
memoryUsageMiddleware
:定期检查内存使用情况并触发heap snapshot
。 -
trackRequestCountMiddleware
:根据请求计数触发heap snapshot
。 -
validateAuthTokenMiddleware
:验证请求中的x-auth-token
,防止未授权访问。
6. Heap Snapshot
保存与目录管理: 生成的 heap snapshot
会保存在本地目录 heapdump
中,目录路径由 heapSnapshotDir
配置项指定。
7. 错误处理与重试机制: uploadViaScp
函数通过 exec
执行 scp
命令上传文件,并提供最多三次重试机制。
8. 定时内存检查: 使用 setInterval
定期检查内存使用情况,定期触发 heap snapshot
。
5.2 实现
const os = require("os");
const fs = require("fs");
const Koa = require("koa");
const path = require("path");
const heapdump = require("heapdump");
const KoaRouter = require("koa-router");
const { exec } = require("child_process");
const PORT = 3000;
const app = new Koa();
const router = new KoaRouter();
const CONFIG = {
maxRetries: 3,
snapshotInterval: 60000 * 2,
memoryLimit: os.totalmem() * 0.8,
memoryThreshold: 100 * 1024 * 1024,
requestSnapshotSteps: [10, 20, 30, 40, 50],
remoteDir: process.env.REMOTE_DIR || "/root",
authToken: process.env.AUTH_TOKEN || "bolawen",
heapSnapshotDir: path.resolve(__dirname, "heapdump"),
remoteServer: process.env.REMOTE_SERVER || "root@123.249.97.173",
};
let requestCount = 0;
let heapdumpCounter = 0;
let lastMemoryUsage = process.memoryUsage().heapUsed;
if (!fs.existsSync(CONFIG.heapSnapshotDir)) {
fs.mkdirSync(CONFIG.heapSnapshotDir, { recursive: true });
}
function triggerGc(onError, onSuccess) {
if (global.gc) {
global.gc();
onSuccess?.();
} else {
onError?.();
}
}
function uploadViaScp(filePath, attempt = 1) {
const fileName = path.basename(filePath);
const remotePath = `${CONFIG.remoteServer}:${CONFIG.remoteDir}/${fileName}`;
console.log(`Uploading ${fileName} to ${remotePath} (Attempt ${attempt})...`);
exec(`scp ${filePath} ${remotePath}`, (err, stdout, stderr) => {
if (err) {
console.error(`SCP upload failed: ${stderr}`);
if (attempt < CONFIG.maxRetries) {
console.log(`Retrying upload (${attempt + 1}/${CONFIG.maxRetries})...`);
uploadViaScp(filePath, attempt + 1);
} else {
console.error("Max upload retries reached. Giving up.");
}
} else {
console.log(`Heap snapshot uploaded successfully to ${remotePath}`);
}
});
}
function generateHeapSnapshot(snapshotType, onError, onSuccess) {
triggerGc();
const filename = path.join(
CONFIG.heapSnapshotDir,
`heapdump-${snapshotType}-${Date.now()}.heapsnapshot`
);
heapdump.writeSnapshot(filename, (err, file) => {
if (err) {
console.error(`Error writing heap snapshot (${snapshotType}):`, err);
onError?.(err);
} else {
console.log(`Heap snapshot (${snapshotType}) saved to ${file}`);
uploadViaScp(file);
onSuccess?.(file);
}
});
}
function checkMemoryUsage() {
const currentMemoryUsage = process.memoryUsage().heapUsed;
console.log(`Memory usage: ${currentMemoryUsage} bytes`);
if (currentMemoryUsage - lastMemoryUsage > CONFIG.memoryThreshold) {
console.log(
"Memory usage increased significantly. Taking heap snapshot..."
);
generateHeapSnapshot("memory-increase");
lastMemoryUsage = currentMemoryUsage;
}
if (currentMemoryUsage >= CONFIG.memoryLimit) {
console.warn("Memory usage critical! Taking heap snapshot...");
generateHeapSnapshot("memory-critical");
}
}
async function validateAuthTokenMiddleware(ctx, next) {
const token = ctx.headers["x-auth-token"];
if (!token || token !== CONFIG.authToken) {
ctx.status = 403;
ctx.body = { message: "Unauthorized: Invalid token" };
return;
}
await next();
}
async function trackRequestCountMiddleware(ctx, next) {
requestCount++;
console.log(`Request count: ${requestCount}`);
if (CONFIG.requestSnapshotSteps.includes(requestCount)) {
console.log(`Taking heap snapshot after ${requestCount} requests`);
generateHeapSnapshot(`request-${requestCount}`);
}
await next();
}
async function memoryUsageMiddleware(ctx, next) {
checkMemoryUsage();
await next();
}
function generateStartupHeapSnapshot() {
generateHeapSnapshot("startup");
}
const simulatedMemoryLeak = [];
router.get("/", memoryUsageMiddleware, trackRequestCountMiddleware, (ctx) => {
simulatedMemoryLeak.push(new Array(10000).fill("嘻嘻"));
ctx.body = { code: 200, message: "成功!" };
});
router.post("/heap-snapshot", validateAuthTokenMiddleware, async (ctx) => {
generateHeapSnapshot(
`manual-${++heapdumpCounter}`,
(err) => {
ctx.status = 500;
ctx.body = {
message: "Failed to write heap snapshot",
error: err.message,
};
},
(file) => {
ctx.body = { message: "Heap snapshot generated", filename: file };
}
);
});
router.post("/trigger-gc", validateAuthTokenMiddleware, async (ctx) => {
triggerGc(
() => {
ctx.status = 500;
ctx.body = {
message: "GC is not exposed. Start the app with --expose-gc",
};
},
() => {
ctx.body = { message: "Garbage Collection triggered manually" };
console.log("Manual GC triggered");
}
);
});
function startMonitoring() {
setInterval(checkMemoryUsage, CONFIG.snapshotInterval);
}
app.use(router.routes()).use(router.allowedMethods());
app.listen(PORT, () => {
console.log(`Service is running on http://localhost:${PORT}`);
generateStartupHeapSnapshot();
startMonitoring();
});
六、Heapdump 快照生成、导入、对比、分析
6.1 Heapdump 快照生成
1. 启动 Node.js
服务: 使用 --expose-gc
参数启动 Node.js
服务,才能在全局上下文中注入 global.gc
node --expose-gc app.js
2. 访问 /
接口,自动生成 Heapdump
快照
curl -X GET http://localhost:3000
3. 访问 trigger-gc
, 手动触发 GC
: 显式调用垃圾回收
curl -X POST -H "x-auth-token: your-secure-token" http://localhost:3000/trigger-gc
4. 访问 heap-snapshot
, 手动生成 Heapdump
curl -X POST -H "x-auth-token: your-secure-token" http://localhost:3000/heap-snapshot
6.2 Heapdump 快照导入
1. 打开 Chrome
浏览器,在地址栏输入 chrome://inspect
chrome://inspect
2. 打开 DevTools
窗口: 点击 Open dedicated DevTools for Node
,打开专用 DevTools
窗口
3. 点击左侧导航栏 Profiles
, 点击 Load profile
导入生成的 Heapdump
快照文件