跳到主要内容

Heapdump Chrome DevTools

2024年12月20日
柏拉文
越努力,越幸运

一、认识


对于偶然的内存泄漏,一般会与特殊的输入有关系。想稳定重现这种输入是很耗时的过程。如果不能通过代码的日志定位到这个特殊的输入,那么推荐去生产环境打印内存快照了。heapdump 模块可以在运行时生成堆快照。堆快照可导入到 Chrome DevTools 进行分析,找到泄漏原因。

本文基于 Koa 框架和 heapdump 模块的 Node.js 应用,主要功能是监控内存使用情况,自动生成 heap snapshot,并将快照上传到远程服务器。它还包含了手动生成 heap snapshot 和触发垃圾回收(GC)的功能,帮助开发者定位和排查内存泄漏问题。主要逻辑如下:

  1. 应用启动时, 生成启动的 Heapdump 快照, 并开始定时监控内存使用情况

  2. 针对可疑路由添加 memoryUsageMiddlewaretrackRequestCountMiddleware 中间件。memoryUsageMiddleware:定期检查内存使用情况并触发生成 heap snapshot, 比如内存每达到 100M 生成一次 heap snapshottrackRequestCountMiddleware:根据请求计数触发 heap snapshot, 比如每 请求 100 次生成一次 heap snapshot

  3. 生成的 heap snapshot 会保存在本地目录 heapdump, 并通过 scp 上传到指定的远程服务器目录

  4. 提供了 /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}`);
});

带上权限验证:

  1. 使用简单的静态 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
  2. 使用 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
  3. 使用 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 堆快照被动生成策略

堆快照被动生成策略: 自动生成快照手动生成快照

自动生成快照策略: 服务首次启动自动生成快照内存达到指定阈值自动生成快照针对可疑请求根据请求次数自动生成快照

手动生成快照策略: 开发者通过调用接口手动生成快照

做至少 3heapdump(实际上为了拿到最详细的数据我是做了 5 次)

四、Heapdump 快照与服务监控


Node.js 服务接入 Prometheus + Grafana。主要监控服务的以下指标:

  1. QPS (每秒请求访问量) ,请求状态,及其访问路径

  2. ART (平均接口响应时间) 及其访问数据

  3. NodeJs 版本

  4. Actice Handlers(句柄)

  5. Event Loop Lag (事件滞后)

  6. 服务进程重启次数

  7. CPU 使用率

  8. 内存使用:rssheapTotalheapUsedexternalheapAvailableDetail

只有 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:

  1. 启动时会自动生成一个启动时的 heap snapshot

  2. 应用监控 Node.js 应用的内存使用情况,当内存使用量达到某些阈值时(例如大于设定的 memoryThreshold 或接近 memoryLimit),会触发 heap snapshot 的生成。

  3. 每当请求达到一定次数时(如 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_DIRREMOTE_SERVER 等。

5. Koa 路由与中间件:

  1. GET /:模拟一个内存泄漏,生成 heap snapshot

  2. POST /heap-snapshot:手动触发 heap snapshot 的生成。

  3. POST /trigger-gc:手动触发垃圾回收。

  4. memoryUsageMiddleware:定期检查内存使用情况并触发 heap snapshot

  5. trackRequestCountMiddleware:根据请求计数触发 heap snapshot

  6. 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 快照文件

6.3 Heapdump 对比分析

参考资料


NodeJs 内存泄漏排查经验总结

NodeJS有难度的面试题,你能答对几个?