Сервис месяцами работает ровно, а потом начинает деградировать. Метрика rss на графике ползёт вверх, ночной рестарт сбрасывает её в ноль, дежурные привычно перезапускают контейнер и закрывают инцидент. Однажды рестарт не успевает: процесс падает с ошибкой нехватки памяти, система управления контейнерами поднимает его заново, падение повторяется, и перезапуском такое уже не закрыть. Сборщик мусора V8 справляется с циклами, но он принципиально не умеет угадывать, какая ссылка ещё пригодится. Пока на объект смотрит хоть одна живая ссылка, объект жив. Ниже разбор реальных источников утечек в Node.js и рабочая методика поиска виновника через снимки кучи.

Чем настоящая утечка отличается от нормального поведения сборщика мусора V8

Куча Node.js растёт и без всякой аварии. Сборщик мусора V8 работает поколениями: объекты рождаются в "молодом" пространстве, выжившие переезжают в "старое", и занятый объём после старта закономерно растёт, пока приложение прогревается. Паника из-за восходящего графика rss часто оказывается преждевременной.

Надёжный признак беды другой: зубчатая линия, у которой каждый новый минимум стоит выше предыдущего. Память поднимается, сборщик её сбрасывает, но не до прежнего уровня. Если после долгой нагрузки объём не возвращается к базовой отметке, утекает что-то реальное.

Первый инструмент диагностики всегда под рукой:

setInterval(() => {
  const m = process.memoryUsage();
  console.log(
    'rss', (m.rss / 1048576).toFixed(1),
    'heapUsed', (m.heapUsed / 1048576).toFixed(1),
    'external', (m.external / 1048576).toFixed(1)
  );
}, 10000);

Поле rss показывает весь процесс, heapUsed занятую часть кучи V8, external память вне кучи, в основном объекты Buffer. Практическая тонкость: утечка в Buffer почти не отражается в heapUsed, зато честно видна в external и rss. Разработчик гоняет снапшоты, ничего в них не находит и делает вывод, что утечек нет. А они есть, просто в другом отсеке.

Лимит старого поколения на 64-битной системе по умолчанию обычно лежит в районе 4 ГБ, меняет его флаг вида node --max-old-space-size=4096. Точный лимит своей сборки можно спросить напрямую:

node -p "require('v8').getHeapStatistics().heap_size_limit / 2 ** 30 + ' GiB'"

При исчерпании процесс завершается с кодом 134 и последним словом:

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

Незакрытые таймеры и стримы как самые частые источники роста кучи

Классика жанра: интервал создаётся на каждое соединение, а очищается только при идеальном стечении обстоятельств.

function handle(socket) {
  const buffer = [];
  socket.on('data', chunk => buffer.push(chunk));
  setInterval(() => {
    if (buffer.length) send(socket, buffer.splice(0));
  }, 1000);
}

Каждый вызов handle оставляет в процессе интервал, который держит socket и buffer. Соединений миллион, интервалов миллион, и ни один не остановлен. Лечится дисциплиной: идентификатор сохраняется, clearInterval вызывается в обработчике close, причём именно close, а не end, потому что сокет способен оборваться без вежливого прощания. Одноразовые setTimeout утекают слабее: они срабатывают и отпускают замкнутые значения. Генератор цепочек, который внутри себя планирует следующий таймаут, собирает всё то же самое, только медленнее.

Со стримами история похожая. Файловый поток без обработчика ошибки, поток сокета, забытый в массиве открытых соединений, pipe без обработки обрыва на дальнем конце. Утилита stream.pipeline из стандартной библиотеки закрывает всех участников цепочки при любом исходе и заслуженно вытесняет ручной pipe:

const { pipeline } = require('node:stream/promises');

async function archive(src, dst) {
  await pipeline(
    fs.createReadStream(src),
    zlib.createGzip(),
    fs.createWriteStream(dst)
  );
}

Ошибка чтения, ошибка записи, обрыв соединения: во всех сценариях файловые дескрипторы закрываются сами, и массив "открытых" стримов перестаёт быть кладбищем.

Глобальные переменные, кэши без границ и растущие коллекции

Второй по частоте источник принято считать безобидным кэшем. Карта сессий, реестр подключений, словарь результатов вычислений: всё это полезные структуры, пока у них нет дна.

const sessions = new Map();

function login(user, payload) {
  sessions.set(user.id, { payload, cart: [], lastSeen: Date.now() });
}

Ключ добавляется при каждом входе, удаления нет вовсе. Map держит ключи сильно: значение не освободится, пока жив ключ. Через месяц карта на продакшене хранит сессии всех, кто когда-либо заходил, включая ботов.

Если время жизни значения обязано следовать за объектом-ключом, напрашивается WeakMap:

const counters = new WeakMap();

function touch(user) {
  const c = counters.get(user) || { calls: 0 };
  c.calls += 1;
  counters.set(user, c);
}

Сборщик забирает запись вместе с ключом, утечка невозможна по построению. Для честного кэша с лимитом подходит связка из границы размера и вытеснения по времени:

const LIMIT = 10000;
const cache = new Map();

function remember(key, value) {
  if (cache.size >= LIMIT) {
    cache.delete(cache.keys().next().value);
  }
  cache.set(key, value);
}

Простейшее вытеснение старейшего ключа в десять строк заменяет десятки библиотек, а вариант с сортировкой по времени последнего обращения даёт честный LRU. Глобальные переменные заслуживают отдельного слова. Объект global превращается в склад, куда любой модуль кладёт что угодно, а разбирает потом кто-то другой. Чаще всего там оседают настройки, тяжёлые объекты и кэши, на которые после рефакторинга не осталось ни одной живой ссылки.

Слушатели событий и замыкания, которые держат прошлое навсегда

Общий EventEmitter плюс подписка на каждый запрос равняется утечке по определению.

const bus = new EventEmitter();

app.use((req, res, next) => {
  bus.on('config-reload', () => {
    res.set('x-config-version', String(latestVersion));
  });
  next();
});

Каждый HTTP-запрос добавляет слушателя, который держит res, req и замкнутые значения. Через неделю в bus висят сотни тысяч мёртвых колбэков. Node.js печатает предупреждение MaxListenersExceededWarning при добавлении одиннадцатого слушателя на одно событие, и его регулярно глушат вызовом setMaxListeners(0). Предупреждение и правда исчезает, а вот утечка остаётся на месте: отключенная сигнализация не чинит проводку.

Лекарство парное: там, где появилась подписка, появляется и отписка, лучше всего по событию завершения:

function onReload() {
  applyConfig(latestVersion);
}

bus.on('config-reload', onReload);
res.on('close', () => bus.off('config-reload', onReload));

Именованная функция здесь не каприз стилиста: анонимный колбэк с той же логикой отписать невозможно, ссылку на него взять негде. Замыкания коварнее таймеров. Крошечная функция обратного вызова тянется к одной переменной, а держит весь лексический контекст: запрос, соединение, большой массив. Чем шире контекст, тем дороже привычка подписывать анонимные функции на долгоживущие объекты. Отдельная категория: обещания, которые не получают ни результата, ни отказа. Висящий промис держит всё, что захватил, и ничто не напоминает о его существовании.

Четыре рабочих способа снять heap snapshot с живого Node.js процесса

Снимок кучи, он же heap snapshot, фиксирует все объекты процесса и ссылки между ними. Стандартная документация Node.js называет четыре способа его получить, и у каждого своя ниша.

Первый, интерактивный: запуск с флагом node --inspect и вкладка Memory в Chrome DevTools. Годится для локальной отладки. Второй, сигнальный, доступен начиная с Node.js 12:

node --heapsnapshot-signal=SIGUSR2 server.js
kill -USR2 12345

Сигнал останавливать процесс не обязан: приложение продолжает работать, а рядом с рабочей директорией появляется файл с расширением .heapsnapshot. Способ ценен на продакшене, где нет браузера под рукой. Третий, программный, доступен с версии 11.13.0:

import { writeHeapSnapshot } from 'node:v8';

app.post('/internal/heap', adminGuard, (req, res) => {
  const file = writeHeapSnapshot();
  res.json({ file });
});

Четвёртый путь через inspector protocol сгодится там, где нужен автоматический съём по расписанию, например из беспилотного нагрузочного стенда.

Размер файла соразмерен куче: у процесса с 3 ГБ занятой памяти снапшот весит гигабайты, и перед съёмкой стоит убедиться, что диск и сеть переживут перенос. Три сигнала подряд создают три файла, места на диске должно хватать с запасом.

Ограничения операции надо уважать. Съёмка полностью останавливает главный поток, на большой куче занимает больше минуты, строится в памяти и способна удвоить занятый объём, так что на переполненном процессе она честно роняет его же. Правило простое: снимать снимок с процесса, чьё падение не рушит доступность сервиса, а служебный HTTP-эндпоинт прятать за проверкой прав, чтобы случайный посетитель не заказал снапшот чужого продакшена.

Методика поиска утечки через сравнение двух снапшотов в Chrome DevTools

Одиночный снапшот почти бесполезен: миллионы объектов без точки отсчёта. Работает сравнение, и методика сводится к короткому циклу:

  1. нагрузить процесс реалистичным трафиком и убедиться, что rss стабильно ползёт вверх;
  2. во вкладке Memory собрать мусор и снять первый снапшот;
  3. повторить несколько циклов нагрузки, чтобы разница накопилась;
  4. снова собрать мусор и снять второй снапшот;
  5. переключиться в режим Comparison и отсортировать конструкторы по приросту;
  6. раскрыть подозреваемые классы объектов по количеству и объёму;
  7. пройти по цепочке Retainers до собственной функции с именем файла и номером строки.

Цепочка удерживающих ссылок отвечает на главный вопрос: кто именно мешает сборщику забрать объект. В списке конструкторов массивы значатся как Array, замыкания как closure, контексты как system, а колонка Distance подсказывает, насколько далеко объект от корня. Прирост у встроенных типов сам по себе ни в чём не виноват: растут обычно не массивы, а чей-то слой логики, который их копит. Режим Objects allocated between Snapshot 1 and Snapshot 2 отфильтровывает шум и оставляет только объекты, рождённые между двумя замерами, то есть прямых подозреваемых. Когда цепочка Retainers упирается в собственный модуль, найденный виновник обычно вызывает короткое чувство стыда и быстрый фикс.

Для медленных утечек удобнее режим Allocation instrumentation on timeline: он показывает объекты, рождённые между двумя моментами, и часть из них оказывается жива тогда, когда уже не должна.

Когда помогает лимит кучи, а когда без рефакторинга не обойтись

Флаг --max-old-space-size в истории с утечками играет роль обезболивающего. Поднять лимит с 4 до 12 ГБ на машине с запасом памяти действительно можно, сервис продержится втрое дольше. Но линейная утечка доберётся до любого потолка: этаж выше лишь оттягивает падение и делает его заметнее. Лимит меняют, когда источник найден и скорость роста оценена, а не вместо поиска.

Профилактика дешевле расследования. Кэши получают границу размера и чистку по времени. Таймеры и подписки обзаводятся парным вызовом очистки там же, где создаются. Стримы собираются через stream.pipeline. Критичные места покрываются регрессионным тестом, который гоняет десятки тысяч итераций и сравнивает heapUsed до и после с принудительной сборкой мусора через флаг --expose-gc и вызов global.gc(). Для быстрой оценки без браузера годится встроенный профайлер: запуск node --heap-prof app.js оставляет рядом с рабочей директорией файл .heapprofile, который открывается в той же вкладке Memory. Инструмент показывает не объекты, а распределение по функциям, зато снимается за секунды и не требует нагрузки по протоколу.

Сама утечка в Node.js почти всегда чья-то забытая ссылка, а не каприз сборщика. Методика универсальна: измерить rss и heapUsed в динамике, снять пару снапшотов, сравнить прирост, пройти по удерживающим ссылкам до собственного кода. Час аккуратной работы вместо недель ночных перезапусков, и график памяти снова становится скучным. Скучный график, к слову, и есть цель всей этой возни.