Heap snapshots и flame graphs: ищем leak и горячие пути в production Node
Heap snapshot и flame graph — две линзы на горячем Node-процессе: retained size и diff трёх снапшотов находят leak, V8-профайлер и flame graph находят, куда уходит CPU, — и важно отличить настоящий leak от безобидного churn.
Под перезапускается каждые шесть часов. 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 как базлайн
- 2 Прогони подозреваемый путь — повтори N одинаковых запросов
- 3 Сними снапшот 2, затем прогони те же N запросов снова
- 4 Сними снапшот 3, затем переключись на Comparison и диффни 3 против 1
- 5 Отсортируй выживших по retained size и иди по retaining path к GC root
- 01Пройди, как техника трёх снапшотов отличает настоящий leak от безобидного churn, и что добавляет retained size.
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.