Жизненный цикл процесса Node.js: bootstrap, сигналы и завершение¶
Источник: theNodeBook — Node.js Process Lifecycle
Жизненный цикл процесса Node.js начинается до запуска вашего entry‑файла. Исполняемый файл node разбирает флаги CLI, инициализирует V8, создаёт isolate и context, поднимает libuv, регистрирует нативные модули, выполняет внутренний bootstrap‑скрипт, загружает entry‑модуль и удерживает процесс живым, пока существуют referenced handles, requests, таймеры, сокеты, workers или дочерние процессы.
Старт, runtime и shutdown разделяют состояние через объект process и нативные handles под ним. Медленный путь require() откладывает готовность. Referenced‑таймер не даёт процессу завершиться. Отсутствие обработки SIGTERM приводит к тому, что Kubernetes или systemd убивают процесс после grace‑периода. Необработанное исключение делает состояние приложения недоверенным: обработчик должен залогировать ошибку, запустить shutdown и позволить процессу выйти.
Практический смысл запроса node.js process lifecycle: порядок bootstrap, стоимость загрузки модулей, обработка сигналов, graceful shutdown, очистка active handles, поведение памяти и exit codes.
Последовательность запуска процесса Node.js¶
Команда node my_app.js запускает цепочку событий задолго до первой строки вашего JavaScript. Обычно кажется, что Node «просто стартует» — на деле это согласованная работа C++, V8 и внутреннего bootstrap‑скрипта. Медленный старт и странности окружения часто коренятся именно здесь.
Точка входа — не ваш .js, а C++‑код в исходниках Node.
Упрощённая последовательность в C++:
main. Парсинг аргументов CLI (--inspect,--max-old-space-sizeи т.д.), базовые свойства процесса.- Инициализация V8. Общие ресурсы (в т.ч. потоки для фоновых задач вроде GC). Выполняется один раз.
- V8 Isolate. Изолированный экземпляр движка со своим heap и GC. Тяжёлая операция; сразу резервируется значительный объём памяти под heap.
- V8 Context внутри isolate. Среда выполнения с
Object,Array,JSON; здесь живётglobal. - Инициализация libuv Event Loop. Основа неблокирующего I/O. Цикл создаётся, но пока не крутится.
- Настройка libuv Threadpool. Пул потоков для операций, которые ОС может выполнять блокирующе (
fs, DNS, частьcrypto/zlib), не блокируя главный event loop. node::Environment. Связывает isolate, context и libuv loop.- Регистрация Native Modules. Встроенные модули (
fs,http,crypto) — C++‑компоненты; на этом этапе они регистрируются для последующегоrequire(). - Bootstrap Script. Первый запуск JavaScript — не вашего:
lib/internal/bootstrap/node.jsстроит объектprocess, функциюrequireи JS‑оболочку API. - Загрузка вашего кода. Только после всего выше loader читает и выполняет
my_app.js.
1 2 3 4 5 6 7 8 | |
Это не бесплатно: сотни миллисекунд, иногда секунды до первой строки приложения. В serverless cold start каждая миллисекунда на счету; снимки heap V8 и предкомпиляция могут сократить часть этих шагов.
Инициализация V8 и нативных модулей¶
После C++‑каркаса задаётся профиль производительности и памяти всего приложения.
Выделение heap и JIT¶
Создание isolate — запрос к V8 на большой непрерывный блок памяти под JavaScript heap. Размер настраивается (--max-old-space-size); дефолт заметный. Запрос памяти у ОС под нагрузкой может быть медленным.
Распространённое заблуждение: JIT «прогревается» при старте. Нет — JIT ленивый; оптимизированный машинный код появляется после «разогрева» функций на реальном трафике. При bootstrap V8 в основном интерпретирует внутренний скрипт.
V8 часто резервирует большой виртуальный диапазон под heap и применяет лимиты, но ОС может физически не коммитить всю память сразу. Поведение зависит от платформы и флагов (--max-old-space-size, --initial-old-space-size).
Подключение нативных модулей¶
fs, http, crypto — мост между JS и ОС (обычно C++).
При bootstrap Node не загружает все встроенные модули сразу — только регистрирует их: карта имён ('fs') → указатели на C++‑функции.
Первый require('fs'):
requireвидит built-in.- Поиск в внутренней карте.
- Вызов C++‑инициализации.
- Создание JS‑объекта модуля с обёртками (
readFileSync,createReadStreamи т.д.). - Кэширование в
require.cacheи возврат.
Lazy load экономит старт: без crypto — без его полной инициализации. Но первый require('crypto') в hot path запроса может добавить 100+ ms (OpenSSL, контексты). Решение: require('crypto') на этапе bootstrap в server.js — предсказуемость важнее, чем +100 ms к cold start.
Загрузка модулей при старте Node.js¶
require() кажется мгновенным — опасное допущение. Алгоритм разрешения и кэш сильно влияют на время старта и память.
Типичный инцидент: в production старт ~60 с, на ноутбуке — 3 с. Оркестратор убивает pod → crash loop.
Флаг node --trace-sync-io показывает синхронный I/O на главном потоке. Часто виновник — fs.readFileSync внутри require().
require() — синхронная операция с файловой системой.
Для './utils' или 'express':
- core (
'fs') — быстро; .//../— перебор.js,.mjs,.json,.node,package.json"main",index.js;- bare name (
'express') — обходnode_modulesвверх по дереву; каждая проверка — sync FS.
Затем require.cache:
- hit — возврат
exports(lookup в hash map); - miss — новый
Module,fs.readFileSync, компиляция и выполнение.
Обёртка модуля:
1 2 3 4 5 6 7 8 9 | |
45‑секундный старт часто = огромное node_modules + сотни sync‑проверок на медленном NFS.
Исправления: bundler для production (Webpack/esbuild — только нужные части), аудит и уплощение зависимостей.
Не бандлите весь сервер Node.js целиком без необходимости: ломаются dynamic import и native addons. Для точечных правок — esbuild только на критичных участках.
«Бомба» require.cache. Динамический require с уникальным путём:
1 2 3 4 5 | |
Каждый путь — новый модуль в кэше навсегда → гигабайты RAM. Вместо этого — движки шаблонов с precompile и eviction, или fs.readFile + vm с короткоживущими контекстами.
Записи require.cache можно удалять (delete require.cache[path]), но require для пользовательского динамического кода небезопасен. Для шаблонов — fs.readFile + vm с явными лимитами кэша и мониторингом.
Каждый require() — потенциальный bottleneck и постоянный вклад в память процесса.
ES‑модули при старте процесса¶
CommonJS (require) vs ESM (import/export) — разные жизненные циклы загрузки.
import не «синтаксис для require»: асинхронный, статический, с фазами. require — синхронный, динамический, смешивает поиск, загрузку и выполнение.
Три фазы ESM¶
- Construction (парсинг). Node читает только
import/export, строит граф зависимостей без выполнения логики. Ошибки видны до старта приложения. - Instantiation. Выделение памяти под экспорты, «проводка» import → export (live bindings, не копии). Значений ещё нет.
- Evaluation. Выполнение кода снизу вверх по графу.
Динамический import(), условные импорты и loaders меняют граф в runtime — не всё известно на этапе Construction.
Подводные камни CJS → ESM¶
__filename и __dirname в ESM — ReferenceError:
1 2 3 4 5 6 7 | |
Top-level await:
1 2 3 4 5 6 7 8 9 10 | |
Процесс ждёт на этапе Evaluation, пока promise не разрешится.
Практический эффект¶
- Теоретически параллельная загрузка по графу (в отличие от «конга» sync
require). - Статический анализ и tree shaking (Rollup/Webpack).
- Module Map вместо публичного
require.cache— стабильнее, без «хаков» перезагрузки.
Экосистема ещё в переходе: пакеты только под CJS — dynamic import(). Направление — ESM для крупных приложений.
Паттерны bootstrap приложения¶
После внутреннего bootstrap Node передаёт управление вашему entry‑файлу: конфиг, БД, HTTP‑сервер.
Типичный, но проблемный паттерн:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | |
Проблемы:
- Top-level
require()блокируют старт. - Падение БД →
exit(1)→ KubernetesCrashLoopBackOffи нагрузка на БД. - Порядок
requireсоздаёт гонки, если./appожидает подключённую БД.
Async initializer¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | |
Для retry используйте exponential backoff с jitter, библиотеки вроде p-retry, идемпотентность или блокировки, circuit breaker — не бесконечный цикл одинаковых попыток.
Плюсы: явный порядок, устойчивость к сбоям сети, dependency injection, сигнал «ready» по событию listening.
Bootstrap — не «запустить сервер», а запустить предсказуемо, устойчиво и наблюдаемо.
Обработка сигналов в Node.js¶
Остановка идёт через сигналы ОС.
SIGINT—Ctrl+C.SIGTERM— «завершитесь корректно»; основной сигнал Kubernetes. Главный shutdown‑сигнал.SIGHUP— перезагрузка конфига у демонов.SIGKILL— нельзя перехватить; мгновенное убийство после истечения grace.SIGUSR1/SIGUSR2— пользовательские (heap dump и т.д.).
Для кроссплатформенного shutdown обрабатывайте SIGINT и SIGTERM; SIGUSR1 на Windows не поддерживается. Для Windows‑сервисов добавьте программный триггер (IPC).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
Тест: kill -s SIGTERM <PID>.
Если в обработчике сигнала не вызвать process.exit(), процесс может не завершиться по SIGINT/SIGTERM. Ctrl+Z отправляет SIGTSTP (приостановка).
Проблемы обработки сигналов¶
Обработчик SIGTERM «не срабатывает» → через terminationGracePeriodSeconds приходит SIGKILL.
Причина может быть в библиотеке, которая делает process.removeAllListeners('SIGTERM') перед своим handler.
Без removeAllListeners каждый process.on добавляет обработчик; при сигнале выполняются все по порядку. Опасность — библиотека, которая удаляет чужие listeners.
В signal handler не делайте тяжёлую async‑работу — только флаг; shutdown выполняет основная логика:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | |
Таймаут — страховка до SIGKILL.
Один центральный shutdown manager / event bus вместо десятка process.on в модулях. Можно обернуть process.on и логировать удаление listeners.
Graceful shutdown в Node.js¶
Контролируемое завершение: доделать работу, сохранить целостность данных, закрыть соединения. Обратный bootstrap — освобождение ресурсов.
Состояния: Accepting Traffic → Draining → Closed.
- Прекратить приём новой работы. Для HTTP —
server.close()(новые соединения не принимаются; текущие дорабатывают). - Draining. Дождаться in-flight запросов, транзакций, сообщений очереди.
server.close()callback — закрытие TCP, не обязательно конец логики handler. - Очистка ресурсов. Пулы БД, Redis, RabbitMQ, flush логов — после draining.
- Выход с кодом 0 — успешное завершение.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | |
process.exit() — не «чистый» shutdown, а обрыв event loop и отмена pending async. Вызывать только в конце цепочки после очистки. Для статуса предпочтительнее process.exitCode.
Active handles и управление ресурсами¶
Процесс «висит» после закрытия сервера — почти всегда утекающий handle (libuv): сервер, сокет, setTimeout/setInterval, child process.
По умолчанию handles referenced — event loop не завершится, пока они есть.
1 2 3 4 | |
.unref() — «можно выходить без меня»:
1 2 3 4 5 | |
EMFILE: too many open files часто от сокетов в CLOSE_WAIT после SIGKILL без корректного shutdown.
Демонстрация «зависшего» shutdown¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | |
Сценарий: curl http://localhost:8080 (ждёт 20 с) → kill <PID> → через 5 с принудительный socket.destroy().
В Node 18+ для keep-alive: server.closeAllConnections() / server.closeIdleConnections() плюс application-level draining.
Отладка утечек handles¶
process._getActiveHandles() — только для отладки, API нестабилен; в production — пакеты вроде wtfnode.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
GC освобождает память, но не file descriptors и сокеты. Открыли — закройте. createServer → .close() в shutdown.
Память: жизненный цикл и heap¶
RSS растёт при старте: инициализация V8 heap и рост require.cache (для крупных приложений 100–500 MB только кэш модулей).
1 2 3 4 5 6 7 8 9 10 | |
Логируйте process.memoryUsage() до и после массовых require.
В runtime — «пила»: heapUsed растёт на запросах, GC опускает. Утечка — когда минимумы пилы со временем растут.
External memory (Buffer вне V8 heap): RSS может быть огромным при «нормальном» heap — OOM при смотрении только на heap snapshots.
Коды выхода и состояния процесса¶
0 — успех; иначе — ошибка. По умолчанию необработанное исключение → 1.
process.exit(code)— немедленное завершение; для серверов избегать.process.exitCode = code— код при естественном выходе после закрытия handles.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Зачем коды выхода в production¶
Kubernetes смотрит exit code: non-zero → restart (по restartPolicy).
Собственные коды упрощают алерты:
70— БД недоступна при старте;71— невалидный конфиг;72— порт занят.
Выход с 0 при падении подключения к БД обманывает оркестратор — тихие сбои до жалоб пользователей.
Дочерние процессы и cluster¶
Подробно child_process, worker_threads и cluster — в отдельных главах. Здесь — границы ответственности родителя.
cluster: master получает SIGTERM, вызывает worker.disconnect(), workers делают свой graceful shutdown; master выходит после exit всех workers — без «thundering herd».
child_process: дочерние процессы не умирают при гибели родителя без явной очистки — становятся сиротами у PID 1.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | |
Завершайте дочерние процессы при shutdown родителя — это не edge case, а обязанность.
Отладка проблем жизненного цикла¶
| Проблема | Инструмент |
|---|---|
| Медленный старт | node --cpu-prof --cpu-prof-name=startup.cpuprofile server.js → Chrome DevTools Performance |
| Блокирующий I/O при старте | node --trace-sync-io server.js |
| Рост памяти | Heap snapshots (node:v8), сравнение в DevTools Memory |
| Процесс не выходит | process._getActiveHandles(), lsof -p <PID> |
| Внезапная смерть | uncaughtException, unhandledRejection → лог и shutdown, не продолжать работу |
1 2 3 4 5 6 | |
Делайте¶
- Профилируйте старт (
--cpu-prof). - Lazy load редких зависимостей внутри handler, не top-level.
- Реальный graceful shutdown:
SIGTERM→ stop accept → drain → cleanup. - Каждый
createServer/connect— парныйclose/disconnectв shutdown. - Осмысленные exit codes.
- Завершайте child processes.
Не делайте¶
- Sync I/O и тяжёлый CPU на top-level при старте.
process.exit()как «shutdown» для серверов.- Динамический
require(variable). - Игнорировать
SIGTERM. - Слепо доверять библиотекам с signal handlers.
- Продолжать после
uncaughtException.
Чеклист production¶
- Измерен ли startup time?
- Стратегия модулей (bundle / lazy-load)?
- Обработчики
SIGTERMиSIGINT? - Все ресурсы закрываются при shutdown?
- Корректные exit codes для разных сбоев?
- Очистка children при spawn?
Процесс — граница runtime, которую платформа стартует, наблюдает, сигналит и завершает. Node даёт hooks на каждом этапе; production‑код должен использовать их осознанно.
Связанное чтение¶
- Предыдущая: Event loop Node.js: фазы, микрозадачи и libuv
- Далее: Что такое Buffer в Node.js