lenec ru

← все посты

Node.js memory leaks: находим и устраняем утечки памяти в продакшене

19K

Сервис работает неделю, потом начинает тормозить и падает с 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, подключаемся к процессу. Алгоритм поиска утечки:

  1. Снимаем heap snapshot (baseline).
  2. Выполняем действие, которое предположительно течёт (100 запросов).
  3. Принудительно вызываем GC (кнопка в DevTools).
  4. Снимаем второй snapshot.
  5. Сравниваем: 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 и безразмерные кэши. Исправляются за минуты, если знаете где искать.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.