open atlas
↑ К треку
Node.js с нуля до senior NODE · 14 · 03

Heap snapshots и flame graphs: ищем leak и горячие пути в production Node

Heap snapshot и flame graph — две линзы на горячем Node-процессе: retained size и diff трёх снапшотов находят leak, V8-профайлер и flame graph находят, куда уходит CPU, — и важно отличить настоящий leak от безобидного churn.

NODE Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Под перезапускается каждые шесть часов. RSS лезет ровной диагональю со 180MB к лимиту cgroup в 512MB, ядро OOM-килит процесс (OOM — Out of Memory, нехватка памяти), Kubernetes его перезапускает, и линия начинается заново — идеальный sawtooth на дашборде, который все научились игнорировать. «Это просто Node, он течёт, мы подняли лимит». Через три недели всплески p99-латентности начинают ездить по тому же шестичасовому циклу: GC работает тяжелее по мере роста live set, и последний час перед каждым килом заметно медленнее. Никто не добавлял фичу. Leak был Map, использованным как пер-реквест-кэш, из которого ничто никогда не выселяло записи — каждый запрос добавлял ключ, ни один не удалялся, — и heap snapshot назвал бы retainer за две минуты.

Снимаем снапшот: вытащить heap из процесса

V8 heap snapshot — это полный граф всех достижимых объектов в один момент, с рёбрами, которые держат каждый объект живым. Снять его можно тремя способами, и выбор зависит от того, здоров ли процесс, труднодоступен или вот-вот умрёт.

Изнутри процесса v8.writeHeapSnapshot() синхронно сбрасывает файл .heapsnapshot — он останавливает мир на всё время, что на многогигабайтном heap может быть секундами, поэтому никогда не зови его на горячем пути. Повесь его на обработчик сигнала, чтобы триггерить по требованию:

const v8 = require("node:v8");
process.on("SIGUSR2", () => {
  const file = `/tmp/heap-${Date.now()}.heapsnapshot`;
  console.log("writing", v8.writeHeapSnapshot(file));
});
// kill -SIGUSR2 <pid>  — снимок без подключённого дебаггера

Для процесса, доступного через инспектор, запусти его с --inspect и открой chrome://inspect в Chrome; вкладка Memory снимает и диффит снапшоты интерактивно. А для случая из хука — процесса, который умирает раньше, чем ты успеваешь его поймать, — --heapsnapshot-near-heap-limit=2 велит V8 автоматически записать до двух снапшотов по мере приближения к лимиту heap, так что ты получаешь дамп ровно в момент сбоя, а не гадаешь.

Перед любым снапшотом v8.getHeapStatistics() — дешёвое первое чтение: used_heap_size, heap_size_limit и number_of_native_contexts (неуклонно растущее число контекстов само по себе классическая подпись leak). Сэмплируй это по интервалу — и у тебя есть сигнал монотонного роста, который оправдывает вытягивание полного снапшота.

const v8 = require("node:v8");
setInterval(() => {
  const s = v8.getHeapStatistics();
  console.log(JSON.stringify({
    used: s.used_heap_size,
    limit: s.heap_size_limit,
    contexts: s.number_of_native_contexts,
    detached: s.number_of_detached_contexts,
  }));
}, 30_000).unref();

Читаем снапшот: shallow vs retained и retaining path

Снапшот бесполезен, пока ты не знаешь, на какой из двух размеров смотришь. Shallow size — память, которую занимает сам объект: для обычного объекта это лишь его заголовки и слоты полей, часто крошечно. Retained size — память, которая освободилась бы при удалении этого объекта: сам объект плюс всё, достижимое только через него. Map на 200 байт с shallow size 200 байт может иметь retained size 400MB, потому что он единственное, что держит 400MB записей живыми. По retained size ты и сортируешь, чтобы найти leak; shallow size спрячет виновника в самом низу списка.

Retaining path отвечает на настоящий вопрос — почему это всё ещё живо? Это цепочка ссылок от GC root вниз к объекту. Если она оканчивается на глобале, модульном const, замыкании или активном таймере — это якорь твоего leak. Доминатор объекта — узел, через который обязан проходить каждый retaining path; удаление доминатора освобождает всё его поддерево, поэтому вид dominator-tree группирует leak в один сворачиваемый блок вместо десяти тысяч отдельных записей.

Сложное на практике — отделить твои объекты от внутренностей V8. Сырой снапшот полон узлов (system), (compiled code), Map (это hidden-class, а не JS Map) и (array) — это бухгалтерия V8, не твои данные. Отфильтруй вид Summary по именам своих конструкторов, отсортируй по retained size и иди по retaining path — байты, с которыми ты можешь что-то сделать, почти всегда укоренены в твоих собственных замыканиях, кэшах или массивах слушателей event-emitter.

Почему это работает

Снапшот содержит только достижимые объекты — V8 прогоняет полный GC прямо перед записью, поэтому всё реально собираемое уже исчезло и не появится. Именно это делает снапшот надёжным для охоты на leak: если объект в снапшоте, он достижим, а значит, что-то намеренно держит его живым. Задача никогда не «почему это не собралось» (не может — оно достижимо), а «какую ссылку мне нужно разорвать».

Техника трёх снапшотов: назови выживших

Один снапшот говорит, что велико сейчас; он не может сказать, что растёт. Техника трёх снапшотов превращает «велико» в «течёт», изолируя объекты, которые выделяются во время работы и затем никогда не освобождаются.

Конкретно, во вкладке Memory Chrome DevTools: сними снапшот 1 после того, как процесс осел. Прогони подозреваемую нагрузку (повтори N одинаковых запросов). Сними снапшот 2. Прогони ту же нагрузку снова. Сними снапшот 3. Теперь переключи дропдаун на Comparison и сравни снапшот 3 со снапшотом 1 — или используй селектор «Objects allocated between snapshot 1 and snapshot 2». Всё, что выделено во время прогона и всё ещё живо двумя снапшотами позже, подозрительно: не-текущая нагрузка выделяет и освобождает, поэтому её дельта возвращается к нулю. Leak показывает счётчик # New, растущий на ту же величину каждый раунд, и # Deleted, равный нулю. Этот линейный, повторяемый рост — а не сырой размер — и есть подпись, которой ты доверяешь.

СигналЗдорово (churn)Leak
RSS / heap во времениSawtooth — лезет, GC отбирает, падает назадМонотонно — растёт и не возвращается к базлайну
Дельта 3 снапшотов за раундВозвращается к ~0; выделенные объекты удаляютсяРастёт на фикс. величину каждый раунд; #Deleted ≈ 0
Retaining path выжившихНет — объекты недостижимы после запросаУкоренён в глобальном Map, замыкании, массиве слушателей, таймере
Давление GC / время паузВысокий темп аллокаций, короткие стабильные паузыПаузы удлиняются по мере роста live set

CPU-профилирование и flame graphs: куда уходит время

Leak — вопрос памяти; горячий эндпойнт — вопрос CPU, и инструмент тут профайлер, а не снапшот. Сэмплирующий профайлер V8 прерывает процесс тысячи раз в секунду и записывает стек вызовов каждый раз; доля сэмплов, в которых появляется функция, аппроксимирует долю CPU, которую она съела.

Самый легковесный захват — node --cpu-prof app.js, который пишет .cpuprofile, загружаемый прямо во вкладку Performance Chrome DevTools. Классический путь без зависимостей — node --prof app.js, выдающий сырой isolate-*.log тиков V8; node --prof-process isolate-*.log превращает его в читаемую сводку с разбивкой по «ticks» и bottom-up деревом вызовов. Для flame graph конкретно 0x app.js или clinic flame -- node app.js рендерят стеки как интерактивный SVG.

# Разовый CPU-профиль, без зависимостей — открывается в DevTools › Performance
node --cpu-prof --cpu-prof-dir=/tmp/prof server.js

# V8 tick processor: сырой лог → ранжированная сводка
node --prof server.js
node --prof-process isolate-*.log > processed.txt

# Flame graph (граф пламени)
npx 0x -- node server.js

Читать flame graph механично, когда знаешь оси. Ширина — это время, а не порядок вызовов: ширина фрейма — доля сэмплов, в которых он был на стеке, поэтому самые широкие фреймы — там, куда ушёл CPU. Ось Y — глубина стека: бокс сидит поверх своего вызывающего. Self time функции — её собственная ширина минус ширины детей, сложенных над ней; широкий фрейм без ничего сверху делает работу сам (тугой цикл, синхронный JSON.parse, regex), а широкий фрейм, широкий лишь из-за высокого стека над ним, — просто горячий вызывающий. Плато — широкие плоские вершины — и есть твои цели оптимизации. Горизонтальный порядок не значит ничего; не читай его слева направо как таймлайн.

Выбери лучший вариант

RSS Node-сервиса часами неуклонно лезет к лимиту контейнера и его OOM-килит. Тебе нужен артефакт, называющий retainer. Выбери первый ход.

Викторина

В heap-снапшоте shallow size объекта 80 байт, а retained size 300MB. О чём это говорит?

Викторина

Flame graph показывает один очень широкий фрейм почти без ничего сверху. Что значат ширина и голая вершина?

Расставь шаги по порядку

Расставь шаги поиска подозреваемого leak техникой трёх снапшотов:

  1. 1 Дай процессу осесть, затем сними снапшот 1 как базлайн
  2. 2 Прогони подозреваемый путь — повтори N одинаковых запросов
  3. 3 Сними снапшот 2, затем прогони те же N запросов снова
  4. 4 Сними снапшот 3, затем переключись на Comparison и диффни 3 против 1
  5. 5 Отсортируй выживших по retained size и иди по retaining path к GC root
Вспомните перед уходом
  1. 01
    Пройди, как техника трёх снапшотов отличает настоящий leak от безобидного churn, и что добавляет retained size.
  2. 02
    У тебя есть flame graph из --cpu-prof. Как читать его, чтобы найти, что оптимизировать, и почему снапшот тут неправильный инструмент?
Итог

Heap snapshot и flame graph — две линзы на горячем Node-процессе, и они отвечают на разные вопросы. Снапшот — граф достижимых объектов в один момент; снимай его через v8.writeHeapSnapshot() по сигналу, через вкладку Memory инспектора или — для процесса, умирающего раньше, чем ты его поймаешь, — через --heapsnapshot-near-heap-limit, предварительно сэмплируя v8.getHeapStatistics(), чтобы подтвердить монотонный рост. Читай его по retained size, не shallow size, потому что leak обычно — крошечный контейнер, якорящий огромное поддерево; retaining path от GC root называет якорь, а доминатор группирует поддерево. Техника трёх снапшотов превращает «велико» в «течёт»: базлайн, прогон, снапшот, прогон снова, снапшот, затем diff — объекты, выделенные во время работы и выжившие до третьего снапшота, с плоским # Deleted, и есть leak. Для CPU тянешься к профайлеру: --cpu-prof или --prof + --prof-process, отрендеренные как flame graph, где ширина — время CPU, а широкая голая вершина — высокий self time, то плато, что ты оптимизируешь. И дисциплина под всем этим — отличать настоящий leak (монотонный рост, удерживается, укоренён) от churn (высокие аллокации, GC отбирает, sawtooth), потому что поднятие --max-old-space-size или крон-рестарт лишь оттягивают leak, который ты мог назвать за две минуты. Теперь, когда видишь под, перезапускающийся по аккуратному sawtooth-расписанию, первое, за чем тянешься, — обработчик сигнала и три снапшота, а не увеличение лимита памяти.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.