Node.js graceful shutdown: корректно завершаем сервер в Kubernetes
Деплой новой версии — и часть пользователей получает 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 при остановке пода:
- Убирает под из Service endpoints (трафик перестаёт приходить).
- Посылает SIGTERM контейнеру.
- Ждёт
terminationGracePeriodSeconds(по умолчанию 30 с). - Если процесс жив — посылает 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 делает большую часть работы за вас через хуки.