Профилирование Node.js приложений в production: clinic.js, 0x и встроенный profiler
Когда production-приложение на Node.js начинает тормозить, догадки не помогут — нужны данные. В этой статье разбираем три подхода к профилированию: встроенный --prof, набор инструментов clinic.js и flamegraph-генератор 0x. Покажем overhead каждого метода и найдём реальный bottleneck в Express API.
Встроенный профайлер: --prof и flamegraph
Node.js поставляется с CPU-профайлером V8 из коробки. Никаких зависимостей:
node --prof app.js
Запускаем приложение, генерируем нагрузку (curl в цикле, autocannon, k6), останавливаем процесс. В директории появится файл isolate-0x123456-v8.log. Обрабатываем его:
node --prof-process isolate-0x*.log > profile.txt
Открываем profile.txt — увидим статистику вызовов функций с процентами CPU-времени. Формат текстовый, но читаемый. Топ функций покажет, где процессор проводит больше всего времени.
Для визуализации можно использовать --cpu-prof (Node.js 12+), который генерирует .cpuprofile — его открывают в Chrome DevTools → Performance → Load profile. Получаем flame chart с call stack'ами.
Программный доступ через v8-profiler-next:
const profiler = require('v8-profiler-next');
profiler.startProfiling('prod-sample', true);
setTimeout(() => {
const profile = profiler.stopProfiling('prod-sample');
profile.export((err, result) => {
fs.writeFileSync(`profile-${Date.now()}.cpuprofile`, result);
profile.delete();
});
}, 30000); // 30 секунд профилирования
Этот подход безопасен для production: overhead сэмплирующего профайлера — около 1–3% CPU. Главное — ограничить время профилирования и не писать файлы на локальный диск в контейнерах (лучше в S3/GCS).
clinic.js: doctor, flame, bubbleprof
Clinic.js — это швейцарский нож для диагностики Node.js. Три инструмента под разные задачи:
clinic doctor — диагностика типа проблемы
Doctor не строит flamegraph. Он собирает четыре метрики (event loop delay, CPU, memory, active handles) и применяет эвристики, чтобы сказать, в какой категории проблема: event loop lag, I/O, GC или CPU.
npm install -g clinic
clinic doctor --on-port 'autocannon -c 100 -d 20 localhost:$PORT' -- node server.js
После завершения нагрузки (Ctrl+C) clinic откроет HTML-отчёт с цветным баннером-вердиктом. Если баннер красный и говорит "CPU-bound" — переходим к clinic flame. Если "I/O-bound" — к clinic bubbleprof. Не стоит строить flamegraph для I/O-проблемы — увидите только стену epoll_wait.
Важно: Doctor — это инструмент для staging, не для production. Overhead значительный, отчёты большие, паттерн SIGINT несовместим с graceful shutdown в Kubernetes. Для production используйте perf_hooks.monitorEventLoopDelay (встроен в Node.js 11.10+, overhead ~0%):
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
setInterval(() => {
console.log({
min: h.min / 1e6, // в миллисекундах
max: h.max / 1e6,
mean: h.mean / 1e6,
p99: h.percentile(99) / 1e6
});
}, 10000);
Экспортируйте эти метрики в Prometheus/Datadog. Алерт на p99 > 50ms — сигнал запускать clinic doctor в staging.
clinic flame — поиск горячих функций
Flame строит интерактивный flamegraph. Ось X — CPU-время (не wall-clock), ось Y — глубина стека. Широкие блоки наверху — ваши узкие места.
clinic flame -- node server.js
Генерируем нагрузку, останавливаем (Ctrl+C), открываем .clinic/flamegraph.html. Пример из практики: flamegraph показал, что JWT-верификация занимает 40ms на запрос — оказалось, мы делали её дважды (в middleware и в route handler). Двадцать минут анализа графика сэкономили месяцы неправильных предположений.
clinic bubbleprof — async-операции
Bubbleprof визуализирует async-операции: промисы, коллбэки, таймеры. Показывает, где async-цепочки тормозят. Полезен, когда doctor говорит "I/O-bound", но непонятно, какой именно I/O.
clinic bubbleprof -- node server.js
Отчёт покажет пузыри — размер пропорционален времени ожидания. Большой пузырь на database query — N+1 проблема или медленный запрос.
0x: flamegraph для production
0x генерирует flamegraph на основе Linux perf (но работает на всех платформах, включая macOS и Windows). Включает нативный код (C++ extensions), который clinic flame может пропустить.
npm install -g 0x
0x server.js
Генерируем нагрузку, жмём Ctrl+C. Получаем интерактивный SVG-flamegraph. Правила чтения те же: широкий блок = медленно, высокий стек = глубокая вложенность.
Production-режим: 0x можно запустить с автоматической нагрузкой:
0x -P 'autocannon -c 50 -d 10 localhost:$PORT' server.js
После завершения autocannon процесс получит SIGINT, и flamegraph сгенерируется автоматически. Overhead сэмплирования — 1–3% CPU, как у встроенного профайлера.
Сравнение overhead
| Инструмент | Overhead CPU | Overhead памяти | Production-safe |
|---|---|---|---|
--prof | 1–3% | Низкий | Да (короткие сессии) |
clinic doctor | 10–20% | Высокий | Нет |
clinic flame | 5–10% | Средний | Staging |
clinic bubbleprof | 15–25% | Высокий | Нет |
0x | 1–3% | Низкий | Да (короткие сессии) |
Clinic doctor и bubbleprof — тяжёлые инструменты. Используйте их в staging под production-like нагрузкой. Для production подходят --prof, 0x и программный v8-profiler-next с ограничением по времени.
Реальный кейс: находим bottleneck в Express API
Симптом: эндпоинт GET /api/users/:id отвечает 200–300ms вместо ожидаемых 50ms. Метрики показывают p99 event loop delay = 120ms.
Шаг 1: Запускаем clinic doctor в staging:
clinic doctor --on-port 'autocannon -c 100 -d 30 localhost:$PORT/api/users/123' -- node server.js
Вердикт: "CPU-bound issue detected". Event loop delay коррелирует с CPU-спайками.
Шаг 2: Строим flamegraph через clinic flame:
clinic flame --on-port 'autocannon -c 100 -d 30 localhost:$PORT/api/users/123' -- node server.js
Открываем flamegraph. Самый широкий блок — JSON.parse внутри middleware. Оказывается, мы парсим большой JSON-конфиг (5MB) на каждом запросе вместо того, чтобы сделать это один раз при старте.
Шаг 3: Фиксим — выносим парсинг в инициализацию:
// Было:
app.use((req, res, next) => {
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
req.config = config;
next();
});
// Стало:
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
app.use((req, res, next) => {
req.config = config;
next();
});
Шаг 4: Проверяем через 0x в production (30 секунд профилирования):
0x -P 'autocannon -c 50 -d 30 localhost:$PORT/api/users/123' server.js
Flamegraph чистый, JSON.parse исчез из топа. Latency упала до 45ms, p99 event loop delay — 8ms.
Подводные камни
- Не профилируйте на пустом приложении. Запускайте реалистичную нагрузку — иначе flamegraph покажет инициализацию, а не рабочий код.
- Clinic.js не поддерживается активно с 2024 года. Инструменты работают, но могут быть неточности на новых версиях Node.js (проверено на Node 16–20).
- 0x требует Chrome для открытия flamegraph. В headless-окружениях сохраняйте SVG и открывайте локально.
- Flamegraph не покажет I/O-проблемы. Если bottleneck в базе данных — используйте EXPLAIN ANALYZE для SQL или bubbleprof для async-цепочек.
- GC может маскировать CPU-проблемы. Если в flamegraph много
Scavenge/MarkSweep— проблема в памяти, а не в коде. Проверьте heap snapshots в Chrome DevTools.
Чек-лист для production-профилирования
- Добавьте
perf_hooks.monitorEventLoopDelayв метрики. Алерт на p99 > 50ms. - При срабатывании алерта — воспроизведите нагрузку в staging и запустите
clinic doctor. - Если doctor говорит "CPU-bound" — используйте
clinic flameили0x. - Если "I/O-bound" —
clinic bubbleprof+ проверка query plans в БД. - Для production-профилирования —
0xили программный v8-profiler-next с лимитом 30–60 секунд. - Проверьте, что синхронные операции (fs.readFileSync, большие JSON.parse) не попали в hot path.
- Измерьте до и после — без baseline невозможно понять, помогла ли оптимизация.
Итого
Три инструмента — три сценария. --prof и 0x — лёгкие, подходят для production с ограничением по времени. Clinic.js — мощный набор для staging, но тяжёлый. Doctor триажирует проблему, flame находит горячие функции, bubbleprof — async-bottleneck'и. Не оптимизируйте без измерений — профилируйте сначала, гадайте никогда.