В распределенных облачных средах (Kubernetes, AWS ECS, Docker Swarm) контейнеры являются эфемерными сущностями. Они непрерывно создаются, перемещаются между нодами и уничтожаются в ходе плановых событий: автоматического масштабирования (HPA), ротации версий при Rolling Update или освобождения ресурсов ноды (Node Draining).
Если приложение не обучено корректно реагировать на сигналы оркестратора, любой деплой новой версии сопровождается всплеском ошибок у пользователей:
502 Bad Gateway или 504 Gateway Timeout.Для обеспечения нулевого времени простоя (Zero-Downtime Deployment) и высокой отказоустойчивости необходима согласованная работа двух механизмов: Healthcheck Probes и Graceful Shutdown.
[Жизненный цикл вывода контейнера из эксплуатации (Graceful Eviction)]
1. Orchestrator (K8s/Docker) 2. Next.js App / Pod 3. Ingress / Load Balancer
│ │ │
├────── 1. Отправка SIGTERM ───────►│ │
│ │ │
│ ├─ 2. Readiness = 503 Service Unavail│
│ │ │
├────── 3. Исключение пода из Endpoints ────────────────────────────────►│
│ │ │
│ │ 4. Трафик перенаправлен на │
│ │ соседние поды │
│ │ │
│ ├─ 5. Пауза 3-5с (Drain In-flight) │
│ │ │
│ ├─ 6. server.close() (стоп новых TCP)│
│ │ │
│ ├─ 7. prisma.$disconnect() │
│ │ redis.quit() │
│ │ │
│ ├─ 8. process.exit(0) │
│ ▼ │
│◄───── 9. Контейнер завершен штатно ────────────────────────────────────┘
│
(Если таймаут 30с истек: Orchestrator шлет неотвратимый SIGKILL)В основе управления жизненным циклом лежат стандартные сигналы операционной системы POSIX:
Ключевой фундаментальной проблемой при запуске Node.js в контейнере является проблема процесса с идентификатором 1 (The PID 1 Init Problem).
В операционных системах на базе Linux процесс, запускаемый первым (PID 1), играет роль супервизора системы (традиционно init или systemd). Ядро Linux наделяет PID 1 особым поведением:
SIGTERM немедленно завершается ОС, то процесс с PID 1 полностью игнорирует сигнал, если в коде приложения не зарегистрирован явный слушатель process.on('SIGTERM').CMD npm start или CMD node server.js), процессом с PID 1 становится командная оболочка /bin/sh или утилита npm. Ни /bin/sh, ни npm не пересылают входящие сигналы дочерним процессам Node.js.В результате при получении команды docker stop или ротации пода оркестратор ждет окончания тайм-аута (по умолчанию 10 секунд в Docker или 30 секунд terminationGracePeriodSeconds в Kubernetes), после чего ядро уничтожает контейнер сигналом SIGKILL, грубо разрывая все пользовательские сессии.
[Антипаттерн: Shell оборачивает Node.js]
Docker Daemon ──SIGTERM──► [PID 1: /bin/sh] ──X (сигнал подавлен, не доходит)
│
[PID 8: node server.js] (продолжает работать)
│ (спустя 10 секунд ожидания)
Docker Daemon ──SIGKILL──► [Убийство процесса ядром: обрыв сокетов]
[Правильная архитектура: Прямой запуск Exec-form]
Docker Daemon ──SIGTERM──► [PID 1: node server.js]
│
▼
Явный обработчик process.on('SIGTERM')
• Закрытие HTTP listeners
• Дренаж активных коннектов
• Сброс пулов БД
• process.exit(0)Вторая ментальная модель разделяет проверки работоспособности на два независимых вектора:
Liveness Probe (Жизнеспособность): «Не завис ли процесс Node.js на уровне Event Loop?». Если ответ не 200, под признается мертвым и перезапускается.
Readiness Probe (Готовность принимать трафик): «Может ли сейчас этот инстанс обрабатывать бизнес-запросы?». Проверяет доступность PostgreSQL и Redis. Если база перегружена или приложение находится в фазе завершения (Shutting Down), Readiness отдает 503, и балансировщик временно отключает подачу трафика на этот узел без его перезапуска.
Отказ от npm/sh в CMD Dockerfile. Использование строгого синтаксиса CMD ['node', 'server.js'].
Создание независимых маршрутов /api/health?type=live и /api/health?type=ready с проверкой зависимостей.
Перевод Readiness пробы в статус 503 при первом получении SIGTERM для отсечения нового трафика.
Ожидание распространения сетевых маршрутов (3–5с), завершение активных запросов и сброс пулов соединений.
Освобождение файловых дескрипторов, отмена сторожевых таймеров и выход с кодом process.exit(0).
Почему при запуске контейнера через инструкцию CMD ['npm', 'start'] процесс Node.js часто не реагирует на сигнал SIGTERM и завершается аварийно по SIGKILL?
Во время выкатки нового релиза Kubernetes запускает новые поды и плавно гасит старые. Реализация фазы дренажа трафика с задержкой 3–5 секунд дает время таблицам маршрутизации Ingress-контроллера обновиться, полностью исключая появление ошибок 502 Bad Gateway в клиентских браузерах.
В биллинговых системах обработка платежного коллбека от банка может занимать до 4–6 секунд. Механизм Graceful Shutdown позволяет активному запросу безопасно зафиксировать запись в PostgreSQL, не допуская расхождения балансов из-за внезапного убийства процесса.
При резком перезапуске 50 реплик Next.js без мягкого закрытия соединений сервер PostgreSQL удерживает 50 TCP-сокетов в статусе CLOSE_WAIT / TIME_WAIT. Новые реплики пытаются открыть еще 50 соединений, мгновенно исчерпывая лимит max_connections СУБД. Вызов pool.end() предотвращает каскадный отказ базы.
Построим законченную архитектуру отказоустойчивости, включающую Route Handlers для Liveness/Readiness и серверный менеджер завершения процессов.
Процесс получает SIGTERM от ядра. Регистрация времени старта остановки, включение сторожевого таймера.
Флаг isShuttingDown переводится в true. Эндпоинт /api/health/ready немедленно отдает HTTP 503.
Ожидание 3-5 секунд для того, чтобы Ingress и kube-proxy исключили IP пода из таблиц маршрутизации.
Вызов server.close(). Ожидание завершения текущих запросов, мягкое отключение пулов Prisma и Redis.
Очистка сторожевых таймеров и завершение процесса с системным кодом успешного завершения 0.
/api/health#В Next.js App Router создадим два изолированных эндпоинта для разделения логики Liveness и Readiness:
import { NextResponse } from 'next/server';
import { db } from '@/shared/lib/db';
import { redis } from '@/shared/lib/redis';
import { shutdownCoordinator } from '@/shared/lib/shutdown';
// Форсируем динамическое выполнение без кэширования
export const dynamic = 'force-dynamic';
/**
* Комплексный эндпоинт проверки здоровья контейнера:
* /api/health?type=live -> Liveness Probe
* /api/health?type=ready -> Readiness Probe (по умолчанию)
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const probeType = searchParams.get('type') || 'ready';
// 1. LIVENESS PROBE: проверка доступности Event Loop
if (probeType === 'live') {
return NextResponse.json(
{ status: 'alive', uptime: process.uptime() },
{ status: 200 }
);
}
// 2. READINESS PROBE: проверка способности обслуживать пользователей
// Если процесс уже находится в стадии завершения (получен SIGTERM) — немедленный отказ
if (shutdownCoordinator.isShuttingDown) {
return NextResponse.json(
{ status: 'terminating', message: 'Container is shutting down' },
{ status: 503 }
);
}
const checks: Record<string, 'ok' | 'failed'> = {
postgres: 'failed',
redis: 'failed',
};
try {
// Проверка соединения с Postgres с жестким таймаутом 2 секунды
await Promise.race([
db.$queryRaw`SELECT 1`,
new Promise((_, reject) => setTimeout(() => reject(new Error('Postgres timeout')), 2000)),
]);
checks.postgres = 'ok';
} catch (err: unknown) {
console.error('[Healthcheck Error] PostgreSQL unreachable:', err instanceof Error ? err.message : err);
}
try {
// Проверка соединения с Redis (PING -> PONG)
const pong = await Promise.race([
redis.ping(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Redis timeout')), 2000)),
]);
if (pong === 'PONG') checks.redis = 'ok';
} catch (err: unknown) {
console.error('[Healthcheck Error] Redis unreachable:', err instanceof Error ? err.message : err);
}
const isHealthy = checks.postgres === 'ok' && checks.redis === 'ok';
return NextResponse.json(
{
status: isHealthy ? 'ready' : 'degraded',
timestamp: new Date().toISOString(),
checks,
},
{ status: isHealthy ? 200 : 503 }
);
}import type { Server } from 'node:http';
interface ShutdownTask {
name: string;
action: () => Promise<void>;
}
class ShutdownCoordinator {
private _isShuttingDown = false;
private httpServer: Server | null = null;
private tasks: ShutdownTask[] = [];
private isInitialized = false;
public get isShuttingDown(): boolean {
return this._isShuttingDown;
}
/**
* Регистрация нативного HTTP сервера Node.js для остановки приема соединений
*/
public registerHttpServer(server: Server): void {
this.httpServer = server;
this.initSignalHandlers();
}
/**
* Добавление асинхронных задач очистки (закрытие пула БД, отключение Redis)
*/
public registerCleanupTask(name: string, action: () => Promise<void>): void {
this.tasks.push({ name, action });
}
private initSignalHandlers(): void {
if (this.isInitialized) return;
this.isInitialized = true;
// Регистрация слушателей на стандартные сигналы операционной системы
process.on('SIGTERM', () => this.handleSignal('SIGTERM'));
process.on('SIGINT', () => this.handleSignal('SIGINT'));
// Перехват фатальных необработанных ошибок
process.on('uncaughtException', (err) => {
console.error('[FATAL] Uncaught Exception:', err);
this.handleSignal('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
console.error('[FATAL] Unhandled Promise Rejection:', reason);
});
}
private async handleSignal(signal: string): Promise<void> {
if (this._isShuttingDown) {
console.warn(`[Shutdown] Signal ${signal} received again. Force termination already scheduled.`);
return;
}
this._isShuttingDown = true;
console.log(`\n🛑 [Shutdown] Received signal ${signal}. Starting Graceful Shutdown...`);
// СТОРОЖЕВОЙ ТАЙМЕР (Watchdog): если процесс не завершится за 25 секунд,
// выходим принудительно до того, как Kubernetes пришлет аварийный SIGKILL (30с)
const watchdog = setTimeout(() => {
console.error('💥 [Shutdown] Watchdog timeout reached (25s). Forcing termination with exit 1.');
process.exit(1);
}, 25000);
// Разрешаем процессу завершиться, даже если сторожевой таймер еще тикает
watchdog.unref();
try {
// 1. ПАУЗА ДЛЯ СИНХРОНИЗАЦИИ СЕТИ:
// Даем время внешним Ingress-контроллерам заметить, что наш Readiness Probe
// начал возвращать 503, и удалить данный под из списков активных Endpoints
const ingressDrainDelayMs = 4000;
console.log(`⏳ [Shutdown] Waiting ${ingressDrainDelayMs}ms for Ingress route withdrawal...`);
await new Promise((resolve) => setTimeout(resolve, ingressDrainDelayMs));
// 2. ОСТАНОВКА HTTP СЕРВЕРА:
// Прекращаем принимать новые входящие TCP-соединения
if (this.httpServer) {
console.log('🔒 [Shutdown] Closing HTTP server listener...');
await new Promise<void>((resolve, reject) => {
this.httpServer?.close((err) => {
if (err) {
console.error('❌ [Shutdown] Error closing HTTP server:', err);
return reject(err);
}
console.log('✅ [Shutdown] HTTP server closed successfully.');
resolve();
});
});
}
// 3. ПОСЛЕДОВАТЕЛЬНОЕ ВЫПОЛНЕНИЕ ЗАДАЧ ОЧИСТКИ (БД, Redis, очереди)
for (const task of this.tasks) {
console.log(`🔌 [Shutdown] Running cleanup task: ${task.name}...`);
try {
await task.action();
console.log(`✅ [Shutdown] Task completed: ${task.name}`);
} catch (err: unknown) {
console.error(`❌ [Shutdown] Task failed: ${task.name}`, err instanceof Error ? err.message : err);
}
}
console.log('🎉 [Shutdown] Graceful shutdown completed cleanly. Terminating process.');
process.exit(0);
} catch (err: unknown) {
console.error('💥 [Shutdown] Critical failure during shutdown sequence:', err);
process.exit(1);
}
}
}
export const shutdownCoordinator = new ShutdownCoordinator();Почему проверка подключения к внешней базе данных PostgreSQL категорически запрещена внутри эндпоинта Liveness Probe?
В инструкции CMD манифеста Dockerfile используется запуск через npm: CMD ["npm", "start"] или CMD ["pnpm", "start"].
Почему так делают: разработчики считают это наиболее естественным способом запуска Node.js проектов.
Что происходит в продакшене: npm запускается под PID 1 и выступает «глухим экраном» для сигналов POSIX. Когда оркестратор шлет SIGTERM, утилита npm не транслирует его процессу Node.js. Спустя 30 секунд Kubernetes убивает под через SIGKILL, приводя к обрыву сессий и ошибкам 502.
# ❌ АНТИПАТТЕРН: npm глушит сигналы SIGTERM
CMD ["npm", "start"]
# ✅ ПРАВИЛЬНО: Прямой запуск бинарника Node скомпилированного сервера
CMD ["node", "server.js"]В эндпоинте /api/health/live проверяется SELECT 1 к базе данных PostgreSQL.
Почему так делают: разработчики хотят «комплексно» убедиться, что всё работает.
Что происходит в продакшене: если база данных PostgreSQL испытывает кратковременную пиковую перегрузку и не успевает ответить за таймаут, Liveness Probe падает на всех подах приложения одновременно. Kubernetes решает, что все инстансы зависли, и начинает синхронно перезапускать сотни подов (Thundering Herd Reboot Loop), окончательно уничтожая доступность сервиса.
// ❌ КРИТИЧЕСКАЯ ОШИБКА: Проверка БД в Liveness Probe
export async function GET() {
await db.$queryRaw`SELECT 1`; // Если БД лагает, под будет убит!
return NextResponse.json({ status: 'ok' });
}
// ✅ ПРАВИЛЬНО: Liveness проверяет только локальный Event Loop, а БД проверяется в Readiness
export async function GET(req: Request) {
const isLive = new URL(req.url).searchParams.get('type') === 'live';
if (isLive) return NextResponse.json({ status: 'alive' }, { status: 200 });
// Проверка БД только для Readiness...
}При перехвате process.on('SIGTERM') разработчик немедленно закрывает соединения и вызывает выход из процесса.
Почему так делают: думают, что «чем быстрее завершился под, тем лучше».
Что происходит в продакшене: между отправкой сигнала SIGTERM и обновлением таблиц маршрутизации в сетевых балансировщиках (Kube-Proxy, Ingress, AWS ALB) проходит от 1 до 3 секунд. В течение этого окна балансировщик продолжает направлять новые пользовательские HTTP-запросы в останавливающийся под, который уже закрыл сокеты, вызывая мгновенные ошибки подключения Connection Reset by Peer.
// ❌ АНТИПАТТЕРН: Мгновенная остановка
process.on('SIGTERM', () => {
server.close();
process.exit(0); // Ошибки у пользователей, чьи запросы были в пути!
});
// ✅ ПРАВИЛЬНО: Пауза 3-5 секунд для завершения маршрутизации, затем закрытие
process.on('SIGTERM', async () => {
isShuttingDown = true; // Readiness возвращает 503
await sleep(4000); // Ждем обновления таблиц Ingress
server.close(); // Закрываем слушатель
await cleanup(); // Сбрасываем пулы БД
process.exit(0);
});В коде настроен server.close(), но на сервере открыты долгоживущие WebSockets или зависший внешний HTTP-запрос без таймаута.
Почему так делают: забывают, что метод server.close() ожидает закрытия всех активных клиентских соединений.
Что происходит в продакшене: если хотя бы один клиент удерживает открытый сокет или зависший streaming-запрос, server.close() никогда не вызовет свой callback. Приложение «зависает» в фазе завершения, пока оркестратор жестко не прибьет его по SIGKILL.
// ✅ ПРАВИЛЬНО: Сторожевой таймер с принудительным выходом
const watchdog = setTimeout(() => {
console.error('Watchdog timeout! Forcing exit.');
process.exit(1);
}, 25000);
watchdog.unref(); // Не блокирует Event Loop сам по себе