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

Производительность загрузки модулей: холодный старт, кэш компиляции и снапшоты

Холодный старт определяют I/O разрешения (тысячи stat()), парсинг и компиляция V8 и eval. Урезай его: собирай в один файл, кэшируй байткод через NODE_COMPILE_CACHE, откладывай тяжёлые зависимости ленивым require, снапшоть инициализацию для CLI и serverless.

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

Твоя Lambda отваливается по таймауту. Не под нагрузкой — на холостом ходу, на самом первом вызове после деплоя, где функция почти ничего не делает: распарсить один JSON-ивент, записать одну строку, вернуть ответ. CloudWatch показывает Init Duration: 4.21s, затем Task timed out after 5.00s. Тело хендлера отрабатывает за 40 мс. Остальные 4 секунды испаряются до первой строки твоего кода, пока Node обходит node_modules, парсит и компилирует несколько тысяч исходников и прогоняет код верхнего уровня всего AWS SDK, который ты подтянул ради одного PutItem. Ты не писал медленный код. Ты заплатил за загрузку кода, которым почти не пользуешься, на каждом холодном старте, и счёт пришёл по бюджету задержки.

Куда на самом деле уходят секунды холодного старта

До первой строки твоего кода node app.js тратит время в трёх разных фазах, и ты обязан знать, какая доминирует, прежде чем оптимизировать — неверный фикс это бесплатное разочарование.

  • I/O разрешения. Каждый «голый» спецификатор (require("lodash")) запускает алгоритм разрешения, который прощупывает файловую систему: для каждого кандидата он делает stat() по node_modules/lodash, затем по package.json, затем по главному файлу, часто перебирая .js/.json//index.js и обходя родительские каталоги node_modules. Каждый модуль — это несколько системных вызовов; граф из 2000 модулей — это десятки тысяч вызовов stat()/open() до того, как что-либо исполнится. На холодной файловой системе (свежий контейнер, сетевой маунт) эти вызовы не бесплатны.
  • Парсинг и компиляция. V8 обязан распарсить и скомпилировать исходник каждого модуля, который грузит Node, на каждой загрузке. Это чистый CPU, и он масштабируется с байтами кода, а не с тем, сколько ты вызываешь. Для типичного CLI компиляция часто составляет 20–40% старта — ты платишь за превращение мегабайтов исходника зависимостей в байткод, который используешь один раз.
  • Eval. Прогон кода верхнего уровня каждого модуля — построение таблиц поиска, регистрация плагинов, инстанцирование клиентов. Библиотека, делающая реальную работу при импорте, выставляет тебе счёт за неё на каждом старте.

Эти три фазы независимы: сборка схлопывает разрешение, но не трогает парсинг+компиляцию и eval; кэш компиляции режет парсинг+компиляцию, но не делает ничего со штормом syscall и кодом инициализации; снапшот пропускает eval, но не влияет на прощупывание файлов, происходящее до него. Возьмёшь не тот рычаг — не получишь ничего. Вот почему ответ на «как ускорить холодный старт?» всегда начинается с «профилируй сначала, затем подбери инструмент к фазе».

Измерь, прежде чем резать. Профиль CPU старта показывает разбивку:

# Профилируй сам старт, затем открой .cpuprofile в Chrome DevTools / speedscope
node --cpu-prof --cpu-prof-dir=./prof app.js

# Покажи каждое разрешение, что делает загрузчик (тот самый stat-шторм наглядно)
NODE_DEBUG=module node app.js 2>&1 | head -50

# Дешёвый внутрипроцессный таймер вокруг подозрительного require
const t = performance.now();
const aws = require("aws-sdk");
console.error("aws-sdk load ms:", (performance.now() - t).toFixed(0));

Ловушка — оптимизировать фазу, которая не является узким местом: сборка убивает I/O разрешения, но ничего не делает со стоимостью eval; снапшот пропускает eval, но бесполезен, если твоё время уходит на stat() в свежем контейнере. Сначала профилируй.

Кэш компиляции V8: перестань перекомпилировать одни и те же байты

V8 умеет сериализовать байткод, полученный из исходника модуля, и переиспользовать его в следующий раз, полностью пропуская фазу парсинга и компиляции. Node 22 даёт это двумя способами. Программно — вызови module.enableCompileCache() самым первым в файле-точке входа; либо задай переменную окружения NODE_COMPILE_CACHE=dir, и Node включит его на весь процесс без единой строчки кода.

// В самом верху точки входа — до любого другого require/import.
const { enableCompileCache } = require("node:module");
enableCompileCache(); // по умолчанию временный каталог; передай путь, чтобы управлять им
# Или через переменную окружения, без изменений кода — укажи на постоянный каталог
NODE_COMPILE_CACHE=./.compile-cache node app.js

Кэш ключуется по содержимому исходника, поэтому самоинвалидируется: изменил файл — эта запись перекомпилируется; всё остальное переиспользуется. Компромиссы реальны. Первый прогон платит полную цену — это прогон, который наполняет кэш, так что выигрыша ты не видишь (иногда даже чуть медленнее из-за записи кэша). Профит только на тёплых загрузках. И кэш стоит места на диске. Но выигрыш нацелен ровно на фазу парсинга и компиляции: на тёплой загрузке эта фаза может упасть на порядок, превратив 20–40% старта почти в ноль. Подвох для serverless: у свежего контейнера пустой кэш, поэтому выигрыш от кэша компиляции требует, чтобы каталог кэша переживал холодные старты — запекай его в образ деплоя или в слой, а не в /tmp.

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

Почему сборка схлопывает stat-шторм? Стоимость разрешения определяется прощупыванием файловой системы, а не чтением байтов. Загрузка 2000 модулей означает тысячи вызовов stat(), чтобы выяснить, где лежит каждый файл — каждый это системный вызов, каждый медленный на холодной или сетевой файловой системе. Сборщик (esbuild, @vercel/ncc) встраивает весь этот граф в один файл, так что загрузчик делает один stat() и одно read() почти на всё приложение. Тысячи проверок метаданных превращаются в одно последовательное чтение непрерывного файла — вот почему serverless-платформы поставляют собранные артефакты и почему холодный старт падает, хотя выполняется тот же код. Стоил не код; стоил его поиск.

Снапшоты старта: заморозь кучу после инициализации

Разрешение и компиляция происходят до eval; снапшот атакует eval. --build-snapshot прогоняет твой код инициализации один раз на этапе сборки и захватывает получившуюся кучу V8 в блоб; более поздняя загрузка с --snapshot-blob восстанавливает эту кучу напрямую, так что вся работа инициализации — парсинг конфига, построение таблиц, импорт модулей — уже сделана в момент старта процесса.

# 1. Сборка: прогнать init.js и заморозить полученную кучу в блоб
node --snapshot-blob snapshot.blob --build-snapshot init.js

# 2. Запуск: мгновенно восстановить эту кучу, пропустив повторную инициализацию
node --snapshot-blob snapshot.blob app.js

Это самый тяжёлый молот и самый зависящий от сценария: он окупается там, где доминирует холодный старт и инициализация дорогая — CLI, вызываемые тысячи раз, serverless-функции, тарифицируемые по длительности инициализации. Компромиссы острые. Снапшоты жёсткие: на момент снапшота нельзя иметь открытых дескрипторов — ни живых сокетов, ни открытых файлов, ни таймеров — потому что куча не может сериализовать ресурс ОС, так что всё, завязанное на I/O, должно быть отложено за точку снапшота. API экспериментальный и капризный, а блоб привязан к точной версии Node, его собравшей, так что это артефакт сборки, который перегенерируешь при каждом апгрейде Node. Берись за него после более дешёвых выигрышей (ленивый require, сборка, кэш компиляции), когда сама инициализация — измеренная стоимость.

Сожми граф: ленивый require и сборка

Самая дешёвая секунда — та, которую ты никогда не тратишь. Если тяжёлая зависимость нужна только на редком пути, не грузи её на уровне модуля — делай require() внутри функции, которая её использует, чтобы стоимость платилась только тогда (и если) этот путь выполнится.

// ❌ Жадно: весь SDK грузится на каждом холодном старте, даже если exportReport
//    вызывают раз в месяц. Тысячи файлов парсятся впустую.
const { S3 } = require("aws-sdk");

function exportReport(data) {
  return new S3().putObject({ /* ... */ });
}

// ✅ Лениво: SDK грузится, только когда отчёт реально экспортируется.
//    Холодный старт больше не платит за путь, который большинство вызовов не берут.
function exportReport(data) {
  const { S3 } = require("aws-sdk"); // кэшируется после первого вызова (кэш модулей)
  return new S3().putObject({ /* ... */ });
}

Это сердцевина боевой истории. Холодный старт serverless-функции раздулся до 3–5 секунд, потому что весь AWS SDK плюс гигантское транзитивное дерево зависимостей грузились жадно в начале файла хендлера — тысячи stat() и несколько тысяч модулей парсились на каждом холодном вызове, пробивая таймаут в 5 с и роняя запросы. Режим отказа жесток, потому что невидим под тёплой нагрузкой (контейнер переиспользуется) и кусается только на холодных стартах, которые всплескивают ровно во время деплоев и наплывов трафика. Фикс был слоистым: ленивый require SDK за хендлером, чтобы он грузился только при нужде; сборка функции через esbuild, чтобы оставшийся граф схлопнулся с тысяч stat() до одного чтения — холодный старт упал с ~3 с до менее 1 с; а для по-настоящему горячего пути CLI кэш компиляции или снапшот убрали остаточный парсинг и компиляцию. Современные рантаймы AWS поставляют урезанный, модульный SDK отчасти ровно по этой причине — а tree-shaking мёртвых зависимостей из сборки удерживает выигрыш от размывания по мере роста проекта.

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

CLI вызывают тысячи раз в день на одной и той же установленной машине. Профиль старта показывает время, поделённое примерно поровну между I/O разрешения и парсингом+компиляцией V8, при дешёвом eval. Можешь отгрузить одно изменение первым. Что даст самый крупный и надёжный выигрыш?

Викторина

Ты включаешь NODE_COMPILE_CACHE и запускаешь serverless-функцию. ПЕРВЫЙ холодный старт после каждого деплоя не быстрее прежнего. Почему?

Вспомните перед уходом
  1. 01
    Почему сборка serverless-функции так резко режет холодный старт и какой фазе старта она НЕ помогает?
  2. 02
    Когда снапшот старта — верный инструмент против кэша компиляции и каковы его жёсткие ограничения?
Итог

Холодный старт — это время до того, как побежит твоя первая строка, и оно живёт в трёх фазах, которые надо различать перед оптимизацией: I/O разрешения, где каждый «голый» спецификатор запускает прощупывание файловой системы и большой граф стоит десятков тысяч вызовов stat(); парсинг и компиляция V8, где исходник каждого загруженного модуля превращается в байткод на каждой загрузке — часто 20–40% старта CLI; и eval, где реально бежит код верхнего уровня. Сначала профилируй через --cpu-prof и NODE_DEBUG=module, потому что каждый фикс бьёт по своей фазе. Кэш компиляции V8 (module.enableCompileCache() или NODE_COMPILE_CACHE=dir) сериализует байткод по ключу содержимого исходника, так что тёплые загрузки пропускают парсинг+компиляцию и эта фаза может упасть на порядок — но первый прогон платит полную цену, чтобы его наполнить, а свежий serverless-контейнер стартует пустым, если каталог кэша не переживает старты. Сборка через esbuild или ncc схлопывает stat-шторм разрешения в одно чтение, поэтому собранные serverless-артефакты стартуют куда быстрее. Ленивый require() откладывает редко используемую тяжёлую зависимость, чтобы путь, который большинство вызовов пропускают, не грузился на каждой загрузке. А снапшот старта (--build-snapshot + --snapshot-blob) замораживает кучу после инициализации, чтобы полностью пропустить eval — мощно для CLI и serverless, но жёстко (никаких открытых дескрипторов), экспериментально и привязано к версии Node. Боевая история — это синтез: холодный старт Lambda в 4 с, вызванный жадной загрузкой всего SDK плюс гигантского дерева зависимостей на каждом холодном вызове, починен ленивым require + сборкой (с ~3 с до менее 1 с) + кэшем компиляции, и каждый рычаг нацелен на фазу, которую профилирование доказало как стоимость. Теперь, когда увидишь подозрительно долгий первый вызов или таймаут Init Duration в Lambda, — знаешь: сначала открыть CPU-профиль, а потом выбрать точный инструмент под ту фазу, на которую он укажет.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.