Сервис месяцами работает ровно, а потом начинает деградировать. Метрика 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
Одиночный снапшот почти бесполезен: миллионы объектов без точки отсчёта. Работает сравнение, и методика сводится к короткому циклу:
- нагрузить процесс реалистичным трафиком и убедиться, что rss стабильно ползёт вверх;
- во вкладке Memory собрать мусор и снять первый снапшот;
- повторить несколько циклов нагрузки, чтобы разница накопилась;
- снова собрать мусор и снять второй снапшот;
- переключиться в режим Comparison и отсортировать конструкторы по приросту;
- раскрыть подозреваемые классы объектов по количеству и объёму;
- пройти по цепочке 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 в динамике, снять пару снапшотов, сравнить прирост, пройти по удерживающим ссылкам до собственного кода. Час аккуратной работы вместо недель ночных перезапусков, и график памяти снова становится скучным. Скучный график, к слову, и есть цель всей этой возни.