12k
All articles

Корректное завершение работы Node-сервера

Грамотное завершение Node при SIGTERM: обработайте readiness, дождитесь server.close, закройте ресурсы по порядку и избегайте 502 при rollout.

OpenReplay Team
OpenReplay Team
Корректное завершение работы Node-сервера

Graceful shutdown в Node обрабатывает SIGTERM в строго определённом порядке: провалить readiness-проверку, дождаться, пока балансировщик перестанет направлять трафик, дождаться завершения обрабатываемых запросов через server.close(), закрыть ресурсы в порядке зависимостей и только затем выйти.

Если вы уже делаете почти всё из этого, но при каждом раскатывании релиза всё равно видите 502-е и сброшенные соединения, проблема, как правило, не в самом обработчике. Её обычно вызывают три вещи вокруг него: сигнал вообще не доходит до Node, drain никогда не завершается, либо оркестратор убивает под до окончания drain. В этой статье мы соберём один небольшой обработчик, разберём все три случая и закончим тестом, который вы сможете запустить у себя.

Ключевые выводы

  • Без обработчика SIGTERM Node завершается немедленно, и каждый обрабатываемый запрос заканчивается сбросом соединения или обрезанным ответом.
  • Дождитесь server.close() перед закрытием пула соединений с базой данных; если закрыть пул первым, каждый выполняющийся запрос превратится в ошибку.
  • Начиная с Node 19.0.0 server.close() сам закрывает простаивающие keep-alive-соединения; классическое зависание с «колбэк close никогда не вызывается» осталось в прошлом.
  • Если CMD в Dockerfile записан в shell-форме, SIGTERM получает шелл, а не Node, и код обработчика вообще не выполняется.
  • Задайте внутрипроцессный таймер принудительного выхода со значением меньше terminationGracePeriodSeconds, чтобы процесс сам выбирал момент выхода, а не получал SIGKILL.

Что происходит без обработчика SIGTERM?

Реакция Node на SIGTERM по умолчанию — завершить процесс, как описано в документации по событиям сигналов. Всё, что обрабатывалось в этот момент, обрывается на середине ответа: клиент видит сброс соединения или неполное тело ответа, а балансировщик пишет в лог 502. В session replay такой сбой имеет узнаваемую форму: действие пользователя, которое «крутится», а затем падает с ошибкой, причём такие случаи сгруппированы в узком временном окне, совпадающем с выкаткой релиза, а не с чем-либо, что делал пользователь. Именно с этой клиентской стороны баг обычно и замечают первым.

Правильный порядок graceful shutdown в Node

Корректный обработчик делает пять вещей по порядку: переключает флаг завершения, чтобы readiness начал падать, коротко ждёт реакции балансировщика, дожидается server.close(), чтобы обрабатываемые запросы завершились, закрывает остальные ресурсы и затем выходит. Ключевой момент здесь — именно await на закрытии. Обработчик, который вызывает server.close(), не дожидаясь колбэка, чистит ресурсы и выходит, воспроизводит ровно тот сбой, ради предотвращения которого его и писали.

  1. Переключите флаг завершения, чтобы readiness-проба возвращала 503.
  2. Коротко подождите, пока балансировщик перестанет направлять новый трафик.
  3. Дождитесь server.close(), чтобы обрабатываемые запросы завершились.
  4. Закройте оставшиеся ресурсы в порядке зависимостей.
  5. Завершите процесс.
// server.js (Express)
const { setTimeout: sleep } = require('node:timers/promises');

let shuttingDown = false;

app.get('/health/live', (req, res) => res.sendStatus(200));
app.get('/health/ready', (req, res) => res.sendStatus(shuttingDown ? 503 : 200));

async function shutdown() {
  if (shuttingDown) return;
  shuttingDown = true;                    // readiness now returns 503

  await sleep(LB_WAIT_MS);                // matches your preStop sleep

  await new Promise((resolve, reject) =>
    server.close((err) => (err ? reject(err) : resolve()))
  );

  await closeResources();                 // next section
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Почему drain может застопориться?

На любой поддерживаемой версии Node server.close() прекращает приём новых соединений, закрывает простаивающие keep-alive-сокеты и ждёт завершения соединений, по которым выполняется запрос. Широко тиражируемое предупреждение о том, что простаивающие keep-alive-сокеты не дают колбэку close когда-либо сработать, относится к Node до версии 19.0.0, так что на поддерживаемых версиях такое зависание больше не возникает. Если вам приходится работать на более старых рантаймах, вызывайте server.closeIdleConnections() (добавлено в Node 18.2.0) сразу после инициации закрытия, а не до него — иначе возможна гонка с только что созданными соединениями.

Что действительно продолжает тормозить drain, так это законная активная работа: медленные обработчики, server-sent events и сокеты, переключённые на другой протокол. Именно для этого и нужен таймер принудительного выхода, описанный ниже.

Закрывайте ресурсы в порядке зависимостей

Сначала закрывайте HTTP-сервер, затем воркеры очередей и фоновые задачи, затем Redis и в последнюю очередь пул соединений с базой. Порядок следует цепочке зависимостей: обработчики запросов и задачи используют Redis и пул, поэтому закрытие пула, пока они ещё выполняются, превращает каждый выполняющийся запрос к базе в ошибку — а это сводит на нет тот самый drain, которого вы только что дождались.

async function closeResources() {
  await worker.close();   // BullMQ or similar: finish the active job
  await redis.quit();
  await pool.end();       // pg pool last: nothing queries after this
}

Задачам, которые выполняются дольше grace-периода, нужен не drain, а чекпоинтинг, чтобы их можно было возобновить.

Какие настройки контейнера ломают graceful shutdown?

Сигнал должен получать именно процесс Node, иначе всё вышесказанное не имеет значения. В справочнике по Dockerfile прямо сказано, что shell-форма запускает вашу команду под /bin/sh -c, а тот не передаёт сигналы дочернему процессу, — значит, при CMD в shell-форме SIGTERM от docker stop никогда не дойдёт до Node.

# Broken: /bin/sh -c receives SIGTERM, node never does
CMD node server.js

# Correct: node is the signal target
CMD ["node", "server.js"]

Если вам нужен init-процесс для сбора зомби-процессов, tini пробрасывает сигналы дочернему процессу, так что обработчик всё равно отработает. Учтите, что docker stop по умолчанию на Linux эскалирует до SIGKILL через 10 секунд; это настраивается флагом -t.

В Kubernetes задайте terminationGracePeriodSeconds (по умолчанию 30) больше общего времени drain и добавьте preStop-паузу: удаление эндпоинта выполняется параллельно с отправкой SIGTERM и распространяется асинхронно через EndpointSlices, поэтому какое-то время после сигнала запросы продолжают приходить. Следите за тем, чтобы liveness-проба продолжала проходить, пока readiness падает; liveness-проба, привязанная к тому же падающему эндпоинту, приведёт к перезапуску контейнера прямо посреди drain.

terminationGracePeriodSeconds: 30   # > preStop + LB wait + drain + cleanup
lifecycle:
  preStop:
    exec:
      command: ["sleep", "5"]       # LB_WAIT_MS should match

Выберите свой выход раньше, чем это сделает SIGKILL

В начале обработчика взведите таймер принудительного выхода со значением меньше grace-периода оркестратора, чтобы застрявший drain завершался вашей строкой в логе и вашим кодом выхода, а не SIGKILL. В качестве последнего средства он вызывает server.closeAllConnections() (добавлено в Node 18.2.0), который разрывает все открытые соединения, включая те, что ещё обрабатывают запрос. Соединения, переключённые на другой протокол, при этом выживают, так что для WebSocket-ов нужна собственная рассылка close-фреймов.

const forceExit = setTimeout(() => {
  server.closeAllConnections();
  process.exit(1);
}, GRACE_MS - 2_000);   // grace period minus a buffer, never a fixed default
forceExit.unref();

Как доказать, что shutdown работает?

Приёмочный тест механический: запускаете сервер, отправляете запрос на заведомо медленный маршрут, посылаете SIGTERM в процессе его обработки и проверяете, что ответ всё равно приходит с кодом 200, а процесс завершается с кодом 0.

// verify-shutdown.js
const { spawn } = require('node:child_process');
const assert = require('node:assert');

const child = spawn('node', ['server.js'], { stdio: ['ignore', 'pipe', 'inherit'] });
child.stdout.on('data', async (chunk) => {
  if (!chunk.toString().includes('listening')) return;
  const pending = fetch('http://localhost:3000/slow'); // route awaits ~2s
  setTimeout(() => child.kill('SIGTERM'), 100);
  const res = await pending;
  assert.equal(res.status, 200);
  const code = await new Promise((r) => child.on('exit', r));
  assert.equal(code, 0);
  console.log('graceful shutdown verified');
});

Если локально тест проходит, но при выкатках запросы всё равно теряются, оставшиеся подозреваемые — уровень контейнера: CMD в shell-форме или grace-период меньше вашего бюджета на drain.

Сам обработчик занимает сорок строк; надёжность обеспечивают порядок операций и окружение вокруг него. Встройте тест в CI, чтобы следующий рефакторинг не смог незаметно вернуть тот самый баг со сбросом соединений при деплое, который вы только что починили.

Частые вопросы

Может ли процесс Node.js перехватить или обработать SIGKILL?

Нет. Node не позволяет повесить слушатель на SIGKILL, и этот сигнал завершает процесс на любой платформе, что бы ни было написано в вашем коде, — никакая очистка не выполняется. На SIGSTOP подписаться тоже нельзя. Именно поэтому важен внутрипроцессный таймер принудительного выхода: он должен сработать до истечения grace-периода оркестратора, чтобы процесс выполнил drain и вышел по SIGTERM, а не был убит без шанса отреагировать.

В чём разница между closeIdleConnections и closeAllConnections?

Оба метода добавлены в Node 18.2.0. closeIdleConnections закрывает только сокеты, простаивающие между запросами, так что всё, что находится в процессе обработки запроса, доводится до конца. closeAllConnections — грубый инструмент: он разрывает все открытые соединения, включая те, что ещё обрабатывают запрос, хотя соединения, переключённые на другой протокол, например WebSocket, при этом выживают. Начиная с Node 19.0.0 server.close сам закрывает простаивающие соединения, поэтому вызывать closeIdleConnections имеет смысл только если вы всё ещё поддерживаете старые рантаймы.

Дожидается ли process.exit обрабатываемых запросов или незавершённой асинхронной работы?

Нет. process.exit немедленно завершает процесс и отбрасывает всю асинхронную работу, которая ещё стоит в очереди, вплоть до вывода, который не успел дописаться в stdout или stderr. Именно поэтому обработчик завершения дожидается server.close и очистки ресурсов перед вызовом exit; вызов exit раньше воспроизводит тот самый сбой с потерянными запросами, ради предотвращения которого обработчик и существует. Вне обработчика лучше устанавливать process.exitCode и позволять процессу завершиться естественным образом.

Работают ли обработчики SIGTERM в Windows?

Не так, как в Linux. В Windows нет POSIX-сигналов, и документация Node.js указывает SIGTERM как неподдерживаемый там, хотя ваш код всё равно может зарегистрировать для него слушатель. Ctrl+C генерирует SIGINT на всех платформах — поэтому обработчик также слушает SIGINT для локальной разработки. Тестируйте сценарий drain по SIGTERM внутри Linux-контейнера, где docker stop и Kubernetes действительно доставляют сигнал.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.