Когда Kubernetes останавливает контейнер, он отправляет процессу сигнал SIGTERM. Если приложение не обработало этот сигнал — оно умирает немедленно, обрывая все активные HTTP-запросы. Клиент получает 502 Bad Gateway.
Graceful shutdown — это способность приложения корректно завершить уже начатые запросы перед выходом. В NestJS это не работает само по себе: нужно явно включить несколько механизмов.
Почему процесс умирает мгновенно
По умолчанию Node.js при получении SIGTERM просто вызывает process.exit(). NestJS не перехватывает этот сигнал и не запускает никаких хуков завершения.
Чтобы NestJS начал слушать сигналы операционной системы, нужно вызвать app.enableShutdownHooks() до запуска сервера:
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // без этой строки — мгновенный выход
await app.listen(3000);
}
bootstrap();
После этого при получении SIGTERM, SIGINT или SIGHUP Nest вызовет app.close(), который пройдёт через lifecycle-фазы: сначала beforeApplicationShutdown на всех модулях, потом server.close(), потом onApplicationShutdown.
Почему server.close() недостаточен
server.close() останавливает приём новых соединений и ждёт, пока закроются текущие. Звучит правильно — но есть проблема: если клиент держит keep-alive соединение открытым, server.close() будет ждать бесконечно.
В Kubernetes контейнер должен завершиться за terminationGracePeriodSeconds (обычно 60 секунд). Если дрейн завис — Kubernetes убьёт процесс принудительно через SIGKILL, и активные запросы всё равно прервутся.
Решение — обернуть дрейн в Promise.race с таймаутом:
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
const server = app.getHttpServer();
const originalClose = app.close.bind(app);
app.close = async () => {
// Node >= 18.2: сразу закрыть пустые keep-alive соединения
server.closeIdleConnections();
await Promise.race([
originalClose(),
new Promise<void>((resolve) => {
const t = setTimeout(resolve, 30_000); // 30 секунд — рабочий баланс
t.unref(); // не мешает завершению event loop
}),
]);
};
await app.listen(3000);
}
Несколько замечаний по таймауту:
- Менее 20 секунд — мало для нагруженного сервиса: запросы с временем выполнения 5+ секунд будут прерываться.
- Более 45 секунд — рискованно: не уложится в стандартный 60-секундный бюджет Kubernetes.
- 30 секунд — рабочий баланс для обычных REST-сервисов.
closeIdleConnections() появился в Node.js 18.2 и сразу закрывает keep-alive соединения без активных запросов. Без него дрейн затягивается: server.close() ждёт, пока клиент сам закроет пустой сокет.
Как Kubernetes узнаёт, что pod уходит
При остановке pod важен порядок: Kubernetes должен убрать pod из балансировки до того, как приложение перестанет принимать запросы. Иначе часть трафика придёт на уже завершающийся экземпляр.
Kubernetes использует readiness probe — периодический запрос к /health/ready. Если он вернул 503 — pod исключается из списка активных endpoint'ов.
Алгоритм:
- Pod получает
SIGTERM. - Приложение немедленно переключает readiness в «не готов».
- Kubernetes видит 503 на
/health/readyи убирает pod из балансировки. - Новый трафик перестаёт поступать на этот pod.
- Приложение дожимает уже начатые запросы.
- Процесс завершается.
Для этого нужен единственный источник состояния дрейна — ShutdownStateService:
// shutdown-state.service.ts
import { Injectable, BeforeApplicationShutdown, Logger } from '@nestjs/common';
@Injectable()
export class ShutdownStateService implements BeforeApplicationShutdown {
private readonly logger = new Logger(ShutdownStateService.name);
private draining = false;
isDraining(): boolean {
return this.draining;
}
beforeApplicationShutdown(signal: string): void {
this.logger.log(`Получили ${signal}, начинаем graceful shutdown`);
this.draining = true;
// с этого момента /health/ready вернёт 503
}
}
BeforeApplicationShutdown — интерфейс NestJS. Метод beforeApplicationShutdown вызывается первым на app.close(), ещё до server.close(). Именно здесь нужно переключить состояние, пока HTTP-сервер ещё принимает запросы от probe.
Подключение readiness probe через Terminus
@nestjs/terminus — официальная библиотека для health check в NestJS. Она предоставляет HealthCheckService и декоратор @HealthCheck.
Подключаем ShutdownStateService в модуль и контроллер:
// health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
import { ShutdownStateService } from '../shutdown/shutdown-state.service';
@Module({
imports: [TerminusModule],
controllers: [HealthController],
providers: [ShutdownStateService],
exports: [ShutdownStateService],
})
export class HealthModule {}
// health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, HealthCheckResult, HealthCheckError } from '@nestjs/terminus';
import { ShutdownStateService } from '../shutdown/shutdown-state.service';
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly shutdownState: ShutdownStateService,
) {}
@Get('live')
@HealthCheck()
liveness(): Promise<HealthCheckResult> {
return this.health.check([]); // процесс жив — всегда 200
}
@Get('ready')
@HealthCheck()
readiness(): Promise<HealthCheckResult> {
return this.health.check([
() =>
this.shutdownState.isDraining()
? Promise.reject(new HealthCheckError('draining', { readiness: { status: 'down' } }))
: Promise.resolve({ readiness: { status: 'up' } }),
]);
}
}
Два разных endpoint'а — не случайность. Liveness и readiness означают разные вещи:
/health/live— процесс работает и не завис. Kubernetes перезапускает pod, если liveness возвращает ошибку. Переключать его в 503 при дрейне нельзя — Kubernetes сочтёт это сбоем и перезапустит pod, убив всё незавершённое./health/ready— pod готов принимать трафик. Kubernetes убирает pod из балансировки при 503, но не перезапускает. Именно этот endpoint переключаетShutdownStateService.
Конфигурация в Kubernetes:
# фрагмент deployment.yaml
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
Частые ошибки
Флаг shuttingDown прямо в сервисе. Локальная переменная не интегрируется с lifecycle-хуками NestJS. Terminus не знает о ней, readiness probe не переключится, Kubernetes не уберёт pod из балансировки.
pool.end() в beforeApplicationShutdown. Соединение с базой нужно закрывать только после дрейна HTTP — в onApplicationShutdown. Если закрыть пул раньше, запросы, которые ещё обрабатываются, потеряют соединение с базой и завершатся с ошибкой.
process.exit(0) в собственном обработчике SIGTERM. Это обходит весь lifecycle NestJS. Вызывать process.exit самостоятельно не нужно — enableShutdownHooks делает это правильно и в нужный момент.
Коротко
app.enableShutdownHooks()обязателен: без него NestJS не перехватываетSIGTERMи lifecycle-хуки не запускаются.server.close()ждёт бесконечно при открытых keep-alive соединениях — нуженPromise.raceс таймаутом 30 секунд.closeIdleConnections()(Node ≥ 18.2) ускоряет дрейн, сразу освобождая пустые keep-alive сокеты.ShutdownStateServiceреализуетBeforeApplicationShutdownи переключает readiness в 503 первым делом наSIGTERM.readiness=503говорит Kubernetes убрать pod из балансировки;liveness=503сигнализирует о сбое и вызывает перезапуск — их нельзя путать.- Соединение с базой данных закрывается в
onApplicationShutdown, после завершения HTTP-дрейна, не раньше.
Что почитать дальше
- HTTP drain в NestJS — что происходит с активными запросами, closeIdleConnections, keep-alive.
- Kubernetes и graceful shutdown — preStop sleep, terminationGracePeriodSeconds, maxUnavailable.
- Закрытие базы данных — pool.end() в правильной фазе.
- Фоновые задачи и Kafka — consumer.disconnect(), producer.disconnect() с таймаутом.