Жизненный цикл процесса и graceful shutdown
Процесс завершается, когда цикл событий опустошается или вы его форсируете; сеньорский навык — умереть чисто: поймать SIGTERM, выключить readiness, перестать принимать соединения, слить запросы в полёте под жёстким таймаутом и выйти — тогда деплои стоят ноль 502.
Каждый деплой ваши дашборды на пару-тройку секунд загораются аккуратным всплеском 502 и ECONNRESET, а потом снова зеленеют. Бюджет ошибок это переживает, так что никто не чинит — пока выкатка платёжного сервиса не оборвёт POST /charge посреди записи и клиента не спишут дважды, потому что ретрай прилетел на свежий под. Сервис был здоров. Деплой был чистый. Эти запросы убило то, что старому поду сказали остановиться, а он не умел умирать вежливо: Kubernetes послал SIGTERM, у вашего процесса Node не было обработчика, оркестратор выждал grace-период, и SIGKILL (137) разорвал процесс с ещё открытыми сокетами. Graceful shutdown — это разница между деплоем, которого никто не замечает, и деплоем, который теряет деньги.
Жизненный цикл: старт, цикл событий, выход
У процесса Node три фазы. Он стартует (парсинг, загрузка модулей, выполнение кода верхнего уровня), он работает — цикл событий обрабатывает таймеры, I/O и микрозадачи столько, сколько есть ref-нутая работа, — и он завершается. Чистый путь — тот, о котором почти никто не думает: когда у цикла событий не остаётся ссылочной работы — нет открытого сервера, нет ожидающего таймера, нет сокета в полёте — он просто возвращается, и процесс сам выходит с кодом 0. Вы ничего не вызываете. Простаивающий HTTP-сервер держит цикл живым именно потому, что слушающий сокет — это ref-нутый хэндл; server.close() снимает ref, и как только завершается последнее соединение, цикл опустошается и процесс уходит естественно. Это поведение слива-до-выхода — фундамент graceful shutdown: вы не убиваете процесс, вы даёте ему остаться без работы.
Код выхода — это контракт с тем, кто вами управляет. 0 означает успех; любое ненулевое — провал. Процессы, завершённые сигналом, рапортуют 128 + N: SIGKILL (сигнал 9) показывает 137, SIGTERM (15), который вы не обработали, показывает 143, а Ctrl-C / SIGINT (2) показывает 130. Когда вы видите 137 в событиях пода у оркестратора — это не крах в вашем коде, а ядро докладывает «пришлось взяться за молоток», то есть grace-период истёк и кто-то послал SIGKILL.
// Два хука выхода и ловушка, которая делает один из них бесполезным
process.on("exit", (code) => {
// ТОЛЬКО СИНХРОННО. Loop уже остановлен.
console.log(`exiting with ${code}`);
setTimeout(() => console.log("never runs"), 0); // dropped — no more loop
});
process.on("beforeExit", () => {
// Срабатывает, когда loop ЕСТЕСТВЕННО опустошается. Можно запланировать async-работу.
// НЕ срабатывает на process.exit() или неперехваченный фатальный сбой.
});Событие 'exit' — самая острая ловушка здесь: его обработчик выполняется только синхронно, потому что к моменту срабатывания цикл событий уже остановлен. Любой setTimeout, fs.writeFile или await, который вы запланируете внутри, молча отбрасывается. 'exit' — для синхронных последних обрядов (записать флаг, освободить синхронный ресурс), но никогда не для сброса сетевого буфера. 'beforeExit', наоборот, срабатывает, когда цикл сливается сам, и может запланировать ещё асинхронную работу (так некоторые библиотеки авто-флашат), но он не срабатывает на явный process.exit() или фатальную ошибку. Два события, два совершенно разных условия запуска — путаница между ними и есть причина, по которой очистка тихо никогда не происходит.
Сигналы: вежливая просьба и неостановимый
Оркестраторы не лезут к вам в код; они шлют POSIX-сигналы. SIGTERM — универсальное «пожалуйста, остановись»: его шлёт Kubernetes завершающемуся поду, его шлёт systemd на stop, его шлёт docker stop. SIGINT — терминальное прерывание, то, что поднимает Ctrl-C в foreground. Оба перехватываемы: вы регистрируете process.on("SIGTERM", handler), чтобы перехватить просьбу и запустить собственное завершение вместо дефолта (немедленной остановки). Обрабатывайте SIGTERM и SIGINT одинаково — оркестратор и разработчик просят об одном и том же.
Два сигнала, которые вы не можете тронуть: SIGKILL (9) и SIGSTOP. Ядро обрабатывает их само и никогда не доставляет вашему процессу — нет обработчика, нет очистки, нет сброса. Эта асимметрия и есть вся причина, почему у graceful shutdown есть дедлайн: вам дают вежливый SIGTERM с grace-окном, и если вы засиделись, оркестратор эскалирует до неостановимого SIGKILL. Ваша задача — закончить до того, как опустится молоток.
const onSignal = (sig) => {
console.log(`received ${sig}, starting graceful shutdown`);
shutdown();
};
process.on("SIGTERM", onSignal); // k8s / docker stop / systemd
process.on("SIGINT", onSignal); // Ctrl-C in dev
// SIGKILL and SIGSTOP cannot be intercepted — never reach here.Ловушка process.exit() и корректное завершение
Самый соблазнительный неверный ответ — process.exit(0) внутри обработчика SIGTERM. Это выглядит решительно и это баг с потерей данных. process.exit() завершает немедленно и синхронно — он не ждёт, пока сольётся цикл событий. Любая буферизованная запись в stdout, любая ожидающая запись fs, любой COMMIT в БД в полёте, который ещё не вернулся, обрезаются в тот же миг, как вы его вызвали. Строка лога, которую вы считали записанной, исчезает; транзакция, которая была в одном round-trip от долговечности, бросается. Весь смысл graceful shutdown — дать циклу опустошиться естественно; вызов process.exit() посреди полёта — это противоположность.
Корректный паттерн щёлкает последовательностью переключателей, а затем даёт процессу умереть самому, с жёстким таймаутом как единственным форсированным выходом:
let shuttingDown = false;
let ready = true; // health/readiness probe reads this
app.get("/readyz", (_req, res) => res.status(ready ? 200 : 503).end());
async function shutdown() {
if (shuttingDown) return; // idempotent: a second SIGTERM must not re-enter
shuttingDown = true;
// (a) Fail readiness so the LB / k8s stops routing NEW traffic to us.
ready = false;
// (e) Hard deadline: if drain hangs, force-exit BELOW the grace period.
const forceTimer = setTimeout(() => {
console.error("drain timed out, forcing exit");
process.exit(1); // 137-style escalation, but on OUR terms with a log line
}, 10_000);
forceTimer.unref(); // don't let the timer itself keep the loop alive
try {
// (b) Stop accepting NEW connections; let in-flight requests finish.
await new Promise((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
);
// (c)+(d) In-flight work has drained; now close downstream resources.
await Promise.all([pool.end(), broker.close(), logger.flush()]);
clearTimeout(forceTimer);
process.exit(0); // clean: everything drained and closed
} catch (err) {
console.error("error during shutdown", err);
process.exit(1);
}
}Порядок несущий. Выключайте readiness первым, чтобы балансировщик снял вас с ротации до того, как вы перестанете принимать, — иначе он продолжит слать запросы в сокет, который вы вот-вот закроете. Затем server.close(), который останавливает новые соединения, давая существующим завершиться (он не убивает живые запросы). И лишь после того, как полёт сольётся, вы закрываете пул БД и брокер — закроете слишком рано, и ещё дозавершающиеся запросы упрутся в мёртвое соединение. setTimeout — это предохранительный клапан: он должен сработать ниже grace-периода оркестратора, чтобы вы вышли на своих условиях со строкой лога, а не были молча убиты SIGKILL.
▸Почему это работает
Есть одна ловушка Docker, которая молча сводит на нет всё вышесказанное: PID 1. Когда ваш Dockerfile заканчивается на CMD ["node", "server.js"], Node работает как процесс с ID 1 — init-процесс. Ядро относится к PID 1 особо: оно не ставит для него дефолтные обработчики сигналов. Поэтому если ваш процесс Node — это PID 1 и у него нет явного обработчика SIGTERM, сигнал просто игнорируется. docker stop шлёт SIGTERM, ничего не происходит, Docker ждёт весь 10-секундный grace-период, затем шлёт SIGKILL (137). Вы будете клясться, что обработчик «не работает», тогда как реальная проблема в том, что без явного обработчика у PID 1 никогда не было дефолтного. Решения: запускать с docker run --init (или init: true в compose), чтобы получить tini как PID 1, проксирующий сигналы в Node; использовать настоящий init вроде tini в образе; или — раз уж вы и так пишете обработчики — просто убедиться, что ваш явный process.on("SIGTERM") зарегистрирован, что работает даже на PID 1.
HTTP-сервису Node за Kubernetes (terminationGracePeriodSeconds: 30) нужно перестать ронять запросы в полёте на каждом rolling-деплое. Что делает обработчик SIGTERM?
События пода показывают, что контейнер вышел с кодом 137 после деплоя. О чём это говорит?
- 01Почему readiness-проб нужно выключить в 503 до вызова server.close(), и что ломается, если поменять порядок или пропустить шаг?
- 02Что именно такое «ловушка process.exit()» и как корректное завершение её избегает, всё же гарантируя выход?
Процесс Node работает, пока у цикла событий есть ref-нутая работа, и завершается — кодом 0 — в тот миг, когда эта работа сливается; именно этот механизм эксплуатирует graceful shutdown: вы не убиваете процесс, вы убираете его работу и даёте ему уйти. Коды выхода — контракт с супервизором (0 успех, ненулевое провал, 128+N для сигналов — 137=SIGKILL, 143=SIGTERM, 130=SIGINT), а два хука выхода резко различаются: 'exit' выполняется только синхронно (запланированная там асинхронщина отбрасывается), тогда как 'beforeExit' срабатывает на естественном сливе и не на process.exit(). Оркестраторы просят остановиться перехватываемым SIGTERM (и SIGINT для Ctrl-C); SIGKILL/SIGSTOP перехватить нельзя — поэтому вежливая просьба идёт с дедлайном. Обработчик должен сначала выключить readiness в 503 (чтобы LB снял с ротации и закрылась гонка нового трафика), server.close() для слива запросов в полёте, затем закрыть пулы, брокеры и логи и выйти — никогда не process.exit() посреди полёта, ведь он обрезает буферизованные записи и коммиты в полёте. Подстрахуйте всё жёстким setTimeout-форс-выходом, выставленным ниже grace-периода (дефолт k8s 30с, docker stop 10с), и помните о ловушке PID 1 в Docker: без явного обработчика сигналы к PID 1 игнорируются, поэтому docker stop выждет grace-период и пошлёт SIGKILL — чините через --init/tini или регистрацией обработчика, который вы и так написали.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.