Node.js memory leaks: находим и устраняем утечки памяти в продакшене
Сервис работает неделю, потом начинает тормозить и падает с OOM. Знакомо? Утечки памяти в Node.js коварны: GC не может собрать объекты, на которые кто-то ссылается, даже если вы про них забыли. Разберём устройство памяти V8, типичные причины утечек и инструменты для их поиска в продакшене.
Как устроена память в V8
V8 делит heap на области:
- New Space (Young Generation) — короткоживущие объекты. Scavenge GC срабатывает часто и быстро (~1-2 мс).
- Old Space — объекты, пережившие 2+ цикла GC. Mark-Sweep-Compact собирает их реже, но дольше (~50-100 мс при большом heap).
- Large Object Space — объекты >512 KB, не перемещаются при компактификации.
Утечка — это объект в Old Space, на который есть ссылка из корня (global, closure, listener), но который больше не нужен приложению. GC не может его собрать.
Типичные причины утечек
1. Замыкания, удерживающие контекст:
function createHandler() {
const hugeBuffer = Buffer.alloc(10 * 1024 * 1024); // 10 MB
return (req: Request) => {
// hugeBuffer захвачен замыканием, даже если не используется
return { status: 'ok' };
};
}
2. Event listeners без removeListener:
class Connection {
connect(emitter: EventEmitter) {
// каждый вызов connect добавляет новый listener
emitter.on('data', (chunk) => this.process(chunk));
// при реконнекте старые listeners не удаляются
}
}
3. Глобальные кэши без ограничения размера:
const cache = new Map<string, object>();
function getUser(id: string) {
if (!cache.has(id)) {
cache.set(id, fetchFromDB(id)); // растёт бесконечно
}
return cache.get(id);
}
4. Забытые таймеры и интервалы:
function startPolling(resource: HeavyResource) {
setInterval(() => {
resource.check(); // resource никогда не будет собран GC
}, 5000);
}
Диагностика: heap snapshot через DevTools
Запускаем приложение с флагом --inspect:
node --inspect dist/server.js
Открываем chrome://inspect в Chrome, подключаемся к процессу. Алгоритм поиска утечки:
- Снимаем heap snapshot (baseline).
- Выполняем действие, которое предположительно течёт (100 запросов).
- Принудительно вызываем GC (кнопка в DevTools).
- Снимаем второй snapshot.
- Сравниваем: Comparison view покажет объекты, которые появились между снимками и не были собраны.
Ищите объекты с аномально большим Retained Size — это и есть утечка.
process.memoryUsage() и мониторинг
Для продакшена нужен непрерывный мониторинг:
import { register, Gauge } from 'prom-client';
const heapUsed = new Gauge({
name: 'nodejs_heap_used_bytes',
help: 'V8 heap used',
});
const rss = new Gauge({
name: 'nodejs_rss_bytes',
help: 'Resident Set Size',
});
setInterval(() => {
const mem = process.memoryUsage();
heapUsed.set(mem.heapUsed);
rss.set(mem.rss);
}, 10_000);
Алерт: если heapUsed монотонно растёт в течение часа без выхода на плато — утечка. Нормальный график — пилообразный (рост → GC → падение).
Инструменты: clinic.js, heapdump, 0x
- clinic.js doctor — автоматически определяет тип проблемы (event loop delay, memory, I/O) и рисует графики. Первый шаг диагностики.
- clinic.js heap-profiler — показывает аллокации по функциям. Видно, какой код создаёт больше всего объектов.
- heapdump — программный снимок heap по сигналу:
kill -USR2 <pid>. Полезно в проде, когда нет доступа к DevTools. - 0x — flamegraph для CPU, но помогает найти горячие функции, которые аллоцируют много.
# clinic.js — быстрая диагностика
npx clinic doctor -- node dist/server.js
# heapdump по сигналу
import heapdump from 'heapdump';
process.on('SIGUSR2', () => {
heapdump.writeSnapshot(`/tmp/heap-${Date.now()}.heapsnapshot`);
});
Реальный кейс: утечка через EventEmitter
WebSocket-сервер: при каждом подключении клиента добавляется listener на общий EventEmitter. При отключении listener не удаляется:
const bus = new EventEmitter();
wss.on('connection', (ws) => {
const handler = (msg: string) => ws.send(msg);
bus.on('broadcast', handler);
// БАГ: при закрытии соединения handler остаётся
ws.on('close', () => {
// bus.off('broadcast', handler); — забыли!
});
});
Через 10 000 подключений — 10 000 замыканий, каждое удерживает ссылку на ws объект. RSS растёт на ~500 MB. Исправление:
ws.on('close', () => {
bus.off('broadcast', handler); // удаляем listener
});
Node.js предупреждает: MaxListenersExceededWarning при >10 listeners на одном событии. Не игнорируйте это предупреждение — оно почти всегда указывает на утечку.
Утечки памяти — не приговор. Мониторьте heapUsed, настройте алерты, используйте clinic.js для быстрой диагностики и heap snapshots для точного поиска. Большинство утечек — забытые listeners и безразмерные кэши. Исправляются за минуты, если знаете где искать.