← назад к разделу

Сервис работает три дня, после чего RSS достигает лимита контейнера. Или p99 растет при неизменном RPS, хотя база и внешние API отвечают быстро. Метрики показывают симптом, но не называют объект или строку кода. В Node.js следующие инструменты - CPU profile, heap snapshot и разбор памяти по областям.

Сначала разделить память и нагрузку

process.memoryUsage() возвращает не одно число, а несколько:

const memory = process.memoryUsage();
console.log({
  rss: memory.rss,
  heapTotal: memory.heapTotal,
  heapUsed: memory.heapUsed,
  external: memory.external,
  arrayBuffers: memory.arrayBuffers,
});
  • rss сравнивают с лимитом контейнера;
  • heapUsed - живые объекты V8;
  • heapTotal - зарезервированная куча;
  • external и arrayBuffers помогают увидеть Buffer и внекучевые данные.

Рост RSS не равен утечке: V8 и нативный allocator не обязаны сразу возвращать память ОС. Для утечки важна растущая нижняя точка после GC при повторяющейся одинаковой нагрузке, а не высокий график сам по себе.

v8.getHeapStatistics().heap_size_limit показывает предел кучи V8. Его сравнивают с RSS и контейнерным лимитом, чтобы отличить Reached heap limit с кодом 134 от OOMKilled с кодом 137.

Код 137 против Reached heap limit

OOMKilled и 137 означают, что ядро убило процесс за cgroup limit. Трассировки может не быть. Смотрят RSS, Buffer, workers и нативные модули.

FATAL ERROR: Reached heap limit означает предел V8. Здесь полезны --heapsnapshot-near-heap-limit=3 и heap snapshot в каталог, который переживет контейнер. Не записывайте снимки в изменяемый слой и не публикуйте их наружу: внутри могут быть персональные данные и секреты.

--max-old-space-size настраивают ниже контейнерного лимита с запасом на young generation, Buffer, код, стеки workers и нативную память. Увеличение предела кучи без анализа часто откладывает падение, а не устраняет утечку.

Heap snapshot: кто держит память

Для исследования запускают отдельную реплику с inspector только на приватном адресе:

node --inspect=127.0.0.1:9229 dist/main.js

Chrome DevTools подключается по защищенному каналу, затем Memory -> Heap snapshot. Inspector нельзя выставлять на 0.0.0.0 без защиты: это удаленное выполнение кода, а не read-only диагностика.

Полезный порядок:

  1. снять первый snapshot после стабилизации нагрузки;
  2. повторить тот же сценарий;
  3. снять второй и сравнить retained size по типам;
  4. открыть путь от GC root до подозрительных объектов;
  5. сопоставить с Map, closures, listeners, timers и caches в коде.

В отличие от простого списка выделений snapshot показывает, кто удерживает объект. Рост одного класса между двумя снимками обычно полезнее общего «сколько памяти занято».

Для production без inspector используют --heapsnapshot-near-heap-limit или библиотеку v8.writeHeapSnapshot() по явной операции с ограничением доступа. Снимок большой и сам требует памяти и диска, поэтому его делают на одной реплике и по плану.

Типовые источники утечек

Map и cache без предела

Ключ customerId:sku делает множество неограниченным. У долгоживущей коллекции должны быть максимальный размер, TTL и измерение размера. Простая очистка раз в час не спасает, если за час ключи заполняют память.

EventEmitter listeners

Обработчик, добавленный на каждый запрос, удерживает связанные объекты. MaxListenersExceededWarning - симптом, а не настройка для увеличения лимита. Используют once, снятие в finally или одну регистрацию на весь жизненный цикл.

Таймеры и фоновые задачи

setInterval без остановки, рекурсивный setTimeout, повторная подписка и забытый AbortController сохраняют callbacks. При завершении запроса и shutdown отменяют работу, очищают таймеры и ждут ограниченное число текущих задач.

Streams, файлы и HTTP

Незакрытый FileHandle, response stream, socket и долгоживущий request держат ресурсы вне кучи. pipeline закрывает цепочку при ошибке. HTTP-клиент ограничивает время и число соединений, тело ответа потребляется или уничтожается.

Buffer и нативные модули

Большой Buffer живет вне обычной кучи. Сохранение небольшого slice может удерживать исходный большой ArrayBuffer. Копирование нужного фрагмента иногда уменьшает retained memory, но слепое копирование всего подряд увеличивает нагрузку.

sharp, bcrypt, argon2 и другие нативные модули требуют отдельного разбора: V8 heap snapshot не видит всю их память. Поэтому сравнивают RSS, external, arrayBuffers и heapUsed, а не только кучу.

Event-loop lag: CPU или ожидание

monitorEventLoopDelay из perf_hooks измеряет задержку основного цикла. Он растет от синхронного JSON, большого цикла, сериализации, сортировки и частого GC. Медленная база без блокировки event loop может увеличить HTTP p99, оставив lag маленьким.

Поэтому сначала сопоставляют lag с CPU, RPS и трассой. Затем снимают CPU profile:

node --cpu-prof --cpu-prof-name=orders.cpuprofile dist/main.js

Профиль записывается при корректном завершении процесса и открывается в Chrome DevTools. Для повторяемого сценария полезно снять 20-30 секунд на одной реплике, а не включать профилирование навсегда на всех.

В flame graph смотрят:

  • широкие синхронные функции приложения;
  • JSON.stringify больших ответов;
  • регулярные выражения и сортировки;
  • долю GC и число выделений;
  • сериализацию и шаблоны логов.

CPU profile не показывает, почему запрос ждал сеть: это видно в трассе. Он отвечает, какой JavaScript выполнялся на CPU.

Что сделать до production

  • Проверять размер cache и число listeners после тысячи уникальных операций.
  • Проверять, что после app.close() нет активных timers, streams и workers.
  • Гонять повторяемую нагрузку часами и сравнивать нижнюю точку heapUsed и RSS.
  • Замерять выделения и CPU profile на типичном большом ответе.
  • Хранить nodejs_eventloop_lag_seconds, heapUsed, external, RSS и версию в метриках.

Тест на точное число байтов нестабилен. Наблюдаемое свойство надежнее: размер коллекции ограничен, listeners не растут, handles закрываются, одинаковый цикл не увеличивает retained set бесконечно.

Порядок расследования

  1. Проверить версию, одну реплику или все, изменение нагрузки и размер ответов.
  2. Сравнить RSS, heapUsed, external и event-loop lag.
  3. При росте heap снять два snapshot и найти держателя.
  4. При росте вне кучи проверить Buffer, streams, workers и нативные модули.
  5. При росте lag снять CPU profile и найти синхронную работу.
  6. Исправить причину и закрепить наблюдаемым ограничением, а не увеличить память без вывода.

Коротко

  • RSS сравнивают с контейнерным лимитом, heapUsed - с V8, external и arrayBuffers смотрят отдельно.
  • Утечка видна по растущей нижней точке после GC, а не по одному высокому значению.
  • Heap snapshot ищет держателя, CPU profile - синхронную работу, трассировка - ожидание зависимостей.
  • Долгоживущие коллекции ограничены, listeners и таймеры снимаются, streams закрываются.
  • Inspector и snapshots защищены: в них код и данные приложения.
  • Код 137 и Reached heap limit имеют разные причины и разные проверки.

Что почитать дальше