lenec ru

← все посты

Node.js graceful shutdown: корректно завершаем сервер в Kubernetes

14K

Деплой новой версии — и часть пользователей получает 502. Kubernetes послал SIGTERM, а сервер оборвал соединения на полуслове. Graceful shutdown — обязательная часть production-ready сервиса: завершаем текущие запросы, закрываем соединения к БД и только потом выходим. Разберём сигналы, таймауты и напишем полный пример на Fastify.

Зачем graceful shutdown

Без graceful shutdown при каждом деплое происходит:

  • Активные HTTP-запросы обрываются — клиент получает ECONNRESET или 502 от ingress.
  • Транзакции в БД остаются незакоммиченными — данные в inconsistent state.
  • Сообщения из очереди теряются — consumer получил, но не обработал.
  • WebSocket-соединения рвутся без уведомления клиента.

При rolling update в Kubernetes это происходит на каждом поде по очереди. 10 подов × 5 деплоев в день = 50 потенциальных окон потери данных.

Сигналы: SIGTERM, SIGINT, SIGKILL

Kubernetes при остановке пода:

  1. Убирает под из Service endpoints (трафик перестаёт приходить).
  2. Посылает SIGTERM контейнеру.
  3. Ждёт terminationGracePeriodSeconds (по умолчанию 30 с).
  4. Если процесс жив — посылает SIGKILL (нельзя перехватить).

Важно: между шагами 1 и 2 есть задержка (~1-2 с). Ingress может ещё слать трафик после SIGTERM. Поэтому нужен readiness probe, который сразу возвращает unhealthy.

Реализация: server.close() и drain

Базовый паттерн:

import { createServer } from 'http';

const server = createServer(handler);
let isShuttingDown = false;

function shutdown(signal: string) {
  console.log(`Received ${signal}, starting graceful shutdown`);
  isShuttingDown = true;

  server.close(() => {
    console.log('All connections closed');
    process.exit(0);
  });

  // Принудительный выход если не успели
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 25_000);
}

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

server.close() перестаёт принимать новые соединения, но ждёт завершения текущих. Проблема: keep-alive соединения могут висеть минутами. Решение — отслеживать активные запросы и закрывать idle-соединения:

// Health endpoint для readiness probe
app.get('/health', (req, res) => {
  if (isShuttingDown) {
    res.status(503).json({ status: 'shutting_down' });
  } else {
    res.status(200).json({ status: 'ok' });
  }
});

Таймауты: K8s vs приложение

Критическое правило: таймаут приложения должен быть меньше terminationGracePeriodSeconds:

# pod spec
terminationGracePeriodSeconds: 60

# приложение: shutdown timeout = 50s (запас 10s)

Если приложение завершается за 50 с, а K8s ждёт 60 — всё чисто. Если наоборот — SIGKILL убьёт процесс посреди cleanup. Также учитывайте preStop hook: если он занимает 5 с, приложению остаётся 55 с.

Зависимости: DB, Redis, очереди

Порядок закрытия важен — сначала перестаём принимать работу, потом закрываем ресурсы:

async function gracefulShutdown() {
  // 1. Перестаём принимать новые запросы
  await new Promise<void>((resolve) => server.close(() => resolve()));

  // 2. Ждём завершения фоновых задач
  await backgroundJobRunner.drain();

  // 3. Закрываем очереди (consumer перестаёт брать сообщения)
  await rabbitChannel.close();
  await rabbitConnection.close();

  // 4. Закрываем кэш
  await redis.quit();

  // 5. Закрываем БД последней (другие могут зависеть от неё)
  await pgPool.end();

  console.log('All resources released');
  process.exit(0);
}

Полный пример на Fastify

Fastify имеет встроенную поддержку graceful shutdown через хуки:

import Fastify from 'fastify';
import { Pool } from 'pg';
import Redis from 'ioredis';

const pg = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);

const app = Fastify({
  logger: true,
  forceCloseConnections: 'idle', // закрывает idle keep-alive
});

// Health check
app.get('/health', async () => ({ status: 'ok' }));

// preClose hook — вызывается при app.close()
app.addHook('preClose', async () => {
  app.log.info('Draining background jobs...');
  // Здесь ждём завершения фоновых задач
});

// onClose hook — закрываем ресурсы
app.addHook('onClose', async () => {
  await redis.quit();
  await pg.end();
  app.log.info('Resources released');
});

// Graceful shutdown
const shutdown = async (signal: string) => {
  app.log.info(`${signal} received`);
  const timeout = setTimeout(() => process.exit(1), 25_000);
  await app.close();
  clearTimeout(timeout);
  process.exit(0);
};

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

await app.listen({ port: 3000, host: '0.0.0.0' });

forceCloseConnections: 'idle' — ключевая опция: при shutdown Fastify закрывает соединения без активных запросов, но ждёт завершения активных. Без неё keep-alive клиенты будут держать сервер живым до таймаута.

Graceful shutdown — не опция, а требование для любого сервиса в Kubernetes. Без него каждый деплой — лотерея с потерей данных. Паттерн простой: ловим SIGTERM, перестаём принимать трафик, ждём текущие запросы, закрываем ресурсы в правильном порядке. Fastify делает большую часть работы за вас через хуки.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.