← назад к разделу

Когда 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'ов.

Алгоритм:

  1. Pod получает SIGTERM.
  2. Приложение немедленно переключает readiness в «не готов».
  3. Kubernetes видит 503 на /health/ready и убирает pod из балансировки.
  4. Новый трафик перестаёт поступать на этот pod.
  5. Приложение дожимает уже начатые запросы.
  6. Процесс завершается.

Для этого нужен единственный источник состояния дрейна — 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() с таймаутом.