У розподілених хмарних середовищах (Kubernetes, AWS ECS, Docker Swarm) контейнери є ефемерними сутностями. Вони безперервно створюються, переміщуються між вузлами кластера та видаляються під час регулярних експлуатаційних подій: автоматичного масштабування (Horizontal Pod Autoscaler, 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 не транслюють вхідні POSIX-сигнали дочірнім процесам 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–30с очікування)
Docker Daemon ──SIGKILL──► [Знищення процесу ядром: обрив сокетів]
[Правильна архітектура: Прямий запуск Exec-form]
Docker Daemon ──SIGTERM──► [PID 1: node server.js]
│
▼
Явний обробник process.on('SIGTERM')
• Інвалідація Readiness проби (503)
• Дренаж активних HTTP-запитів
• Закриття пулів БД (Postgres, Redis)
• Чистий вихід: process.exit(0)Друга ментальна модель розділяє перевірки працездатності на два ортогональні вектори:
Liveness Probe (Життєздатність): «Чи живий процес Node.js і чи не заблокований його V8 Event Loop?». Якщо відповідь не 200, под визнається завислим або мертвим і перезапускається оркестратором.
Readiness Probe (Готовність приймати трафік): «Чи здатний цей інстанс прямо зараз обробляти бізнес-трафік?». Перевіряє доступність PostgreSQL та Redis. Якщо база перевантажена або додаток перейшов у фазу завершення (Shutting Down), Readiness повертає 503, і балансувальник тимчасово відключає подачу трафіку на цей под без його перезапуску.
Під час розгортання нового релізу Kubernetes одночасно запускає нові поди та плавно виводить з експлуатації старі. Впровадження початкової затримки дренажу на 3–5 секунд дає час таблицям маршрутизації Ingress-контролера оновитися, повністю виключаючи появу помилок 502 Bad Gateway у користувачів.
У білінгових сервісах обробка платежів або багатоетапні транзакції списання коштів можуть тривати до 3–5 секунд. Механізм Graceful Shutdown дозволяє активним HTTP-запитам коректно зафіксувати зміни в базі даних, унеможливлюючи порушення консистентності через раптову зупинку процесу.
При одночасному перезапуску 50 реплік Next.js без коректного закриття з'єднань PostgreSQL утримує 50 TCP-сокетів у стані CLOSE_WAIT / TIME_WAIT. Нові репліки намагаються встановити нові 50 з'єднань, швидко перевищуючи ліміт max_connections СУБД. Виклик явного закриття пулів запобігає каскадному падінню бази.
Для реалізації enterprise-відмовостійкості будуємо цілісну систему управління життєвим циклом, що складається з обробника маршруту в App Router та координатора зупинки на рівні Node.js.
Node.js перехоплює SIGTERM від оркестратора. Фіксується час початку зупинки та ініціалізується сторожовий таймер.
Прапорець isShuttingDown перемикається в true. Ендпоінт /api/health?type=ready негайно повертає HTTP 503.
Очікування 3–5 секунд для оновлення таблиць Ingress-контролерів та вилучення IP поду з активних endpoints.
Виклик server.close(), завершення активних HTTP-запитів та м'яке відключення клієнтів Prisma і Redis.
Очищення неблокуючих сторожових таймерів та завершення процесу Node.js із кодом 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 (тільки доступність Event Loop)
* /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: Перевірка здатності обслуговувати вхідний трафік
// Якщо контейнер перебуває у процесі виведення з експлуатації — миттєва відмова
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) із таймаутом у 2 секунди
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 для зупинки прийому нових TCP-з'єднань
*/
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;
// Реєстрація слухачів стандартних POSIX-сигналів зупинки
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. Termination in progress.`);
return;
}
this._isShuttingDown = true;
console.log(`\n🛑 [Shutdown] Received signal ${signal}. Starting Graceful Shutdown sequence...`);
// СТОРОЖОВИЙ ТАЙМЕР (Watchdog): примусове завершення при зависанні очищення понад 25с.
// Процес має вийти до того, як оркестратор надішле невідворотний SIGKILL (зазвичай 30с).
const watchdog = setTimeout(() => {
console.error('💥 [Shutdown] Watchdog timeout reached (25s). Forcing process termination with code 1.');
process.exit(1);
}, 25000);
// unref() гарантує, що цей таймер не утримуватиме Event Loop, якщо всі завдання завершаться раніше
watchdog.unref();
try {
// 1. ЗАТРИМКА ДЛЯ ДРЕНАЖУ МАРШРУТИЗАЦІЇ INGRESS:
// Даємо час Ingress-контролерам зафіксувати код 503 на Readiness-пробі
// та вилучити IP-адресу цього поду з активних таблиць маршрутизації
const ingressDrainDelayMs = 4000;
console.log(`⏳ [Shutdown] Pausing ${ingressDrainDelayMs}ms for Ingress endpoint synchronization...`);
await new Promise((resolve) => setTimeout(resolve, ingressDrainDelayMs));
// 2. ЗУПИНКА ВХІДНОГО HTTP-ТРАФІКУ:
// Припиняємо прийом нових з'єднань на мережевому сокеті
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. ПОСЛІДОВНЕ ВИКОНАННЯ ЗАВДАНЬ ОЧИЩЕННЯ (PostgreSQL, Redis, черги)
for (const task of this.tasks) {
console.log(`🔌 [Shutdown] Executing cleanup task: ${task.name}...`);
try {
await task.action();
console.log(`✅ [Shutdown] Cleanup task completed: ${task.name}`);
} catch (err: unknown) {
console.error(`❌ [Shutdown] Cleanup 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();Використання виклику через менеджер пакетів в інструкції CMD Dockerfile: CMD ["npm", "start"] або CMD ["pnpm", "start"].
Чому так роблять: Інженери сприймають команду npm start як найбільш природну точку входу для Node.js проєктів.
Наслідки у продакшені: npm запускається з PID 1 і працює як непроникний екран для сигналів POSIX. Коли Kubernetes надсилає SIGTERM, утиліта npm не транслює його дочірньому процесу Node.js. Після 30 секунд мовчання Kubernetes примусово знищує контейнер за допомогою SIGKILL, що призводить до обриву активних сесій та сплеску помилок 502 Bad Gateway.
# ❌ АНТИПАТЕРН: npm блокує та поглинає сигнали SIGTERM
CMD ["npm", "start"]
# ✅ ПРАВИЛЬНО: Прямий запуск бінарника Node для скомпільованого standalone сервера
CMD ["node", "server.js"]Виконання запиту SELECT 1 до PostgreSQL всередині маршруту /api/health?type=live.
Чому так роблять: Розробники вважають, що універсальна перевірка "всього стека" гарантує максимальну надійність.
Наслідки у продакшені: Якщо база даних PostgreSQL зазнає пікового навантаження і не встигає відповісти за відведений тайм-аут, Liveness Probe зазнає збою на всіх репліках додатка одночасно. Kubernetes вважає, що всі інстанси зависли, і починає синхронно перезапускати весь пул подів (Thundering Herd Reboot Loop), повністю знищуючи доступність сервісу.
// ❌ КАТАСТРОФІЧНИЙ АНТИПАТЕРН: Перевірка БД всередині Liveness Probe
export async function GET() {
await db.$queryRaw`SELECT 1`; // Якщо Postgres затримається, контейнер буде вбито!
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.exit(0) безпосередньо в обробнику process.on('SIGTERM').
Чому так роблять: Прагнення завершити роботу інстансу якомога швидше для економії ресурсів.
Наслідки у продакшені: Між відправкою сигналу SIGTERM оркестратором і оновленням таблиць маршрутизації в Ingress-контролерах та балансувальниках навантаження (Kube-Proxy, AWS ALB) минає від 1 до 3 секунд. Миттєве завершення призводить до того, що клієнтські HTTP-запити, які вже прямують до поду, натикаються на закриті TCP-сокети, спричиняючи помилки Connection Reset by Peer.
// ❌ АНТИПАТЕРН: Миттєвий вихід без паузи синхронізації Ingress
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() без встановлення жорсткого тайм-ауту на завислі з'єднання клієнтів.
Чому так роблять: Забувають, що метод server.close() очікує закриття всіх активних TCP-сокетів клієнтів перед викликом свого callback.
Наслідки у продакшені: Якщо хоча б один клієнт утримує відкрите streaming-з'єднання, WebSocket або завислий запит без тайм-ауту, server.close() ніколи не завершиться. Додаток зависає у фазі зупинки, доки оркестратор жорстко не вб'є його за допомогою SIGKILL.
// ✅ ПРАВИЛЬНО: Сторожовий таймер із примусовим виходом до приходу SIGKILL
const watchdog = setTimeout(() => {
console.error('Watchdog timeout reached! Forcing process termination.');
process.exit(1);
}, 25000);
watchdog.unref(); // Дозволяє процесу завершитися природно, якщо всі очищення пройшли успішноВідмова від npm/sh оболонок у CMD Dockerfile. Використання прямого запуску CMD ['node', 'server.js'].
Створення незалежних ендпоінтів /api/health?type=live та /api/health?type=ready з таймаутами залежностей.
Миттєве переведення проби Readiness у статус HTTP 503 при отриманні SIGTERM для відсікання нового трафіку.
Очікування 3–5 секунд для оновлення маршрутів Ingress та kube-proxy перед закриттям сокетів HTTP-сервера.
Закриття пулів Prisma і Redis, скасування сторожових таймерів та чистий вихід із process.exit(0).