Опирается на правила: R-SHUT-CFG-1R-SHUT-CFG-4 и R-SHUT-CFG-X1 из Graceful Shutdown Style Guide → раздел 1. Runtime/конфигурация.

Важно знать

  • app.enableShutdownHooks() обязателен — без него Nest не перехватывает SIGTERM, процесс умирает мгновенно, активные HTTP → 502.
  • ShutdownStateService.isDraining() — единственный источник состояния; /health/ready читает именно его, k8s узнаёт о начале дрейна через probe.
  • server.close() ждёт in-flight неограниченно — нужен force-таймаут через Promise.race с setTimeout(...).unref(), иначе pod не уложится в 60-секундный бюджет.
  • server.closeIdleConnections() (Node ≥ 18.2) обязателен при keep-alive соединениях — без него drain зависает на пустых сокетах.
  • beforeApplicationShutdown запускается до закрытия HTTP-сервера, onApplicationShutdown — после; порядок фаз нельзя нарушать вручную.
  • Свой let shuttingDown = false в случайном модуле — не интегрируется с terminus, k8s не узнает.
  • Без правильной конфигурации rolling deploy = шторм 502 на клиентах в момент смены pod.

Graceful shutdown — не «одна опция», а последовательность: переключить readiness → дать k8s 10 секунд убрать pod из endpoints → дожать активные запросы → закрыть пул БД → exit. NestJS реализует эту последовательность через lifecycle-хуки: beforeApplicationShutdownserver.close()onApplicationShutdown. UCP формулирует минимальный набор настроек, которые покрывают последовательность без потерь.

app.enableShutdownHooks()

R-SHUT-CFG-1: первое и главное — Nest должен слушать системные сигналы.

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableShutdownHooks();

  await app.listen(3000);
}
bootstrap();

Что делает:

  • Nest подписывается на SIGTERM, SIGINT, SIGHUP.
  • На сигнале вызывает app.close(), который проходит lifecycle-фазы.
  • HTTP-сервер получает server.close() — новые соединения не принимаются, активные ответы завершаются.

Без enableShutdownHooks() — Node получает SIGTERM и завершается немедленно через process.exit. Lifecycle-хуки не запускаются, beforeApplicationShutdown не вызывается, активные HTTP-запросы прерываются на клиенте с 502 Bad Gateway или Connection reset.

Force-deadline поверх server.close()

R-SHUT-CFG-2: явный graceful-deadline ~30s.

В отличие от Tomcat, где timeout-per-shutdown-phase настраивается в application.yml, Node's server.close() ждёт в полёте неограниченно — если один keep-alive клиент не закрыл соединение, дрейн не завершится. Нужен принудительный таймаут:

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableShutdownHooks();

  const server = app.getHttpServer();

  // Force-deadline: если server.close() не завершился за 30s — принудительно
  const originalClose = app.close.bind(app);
  app.close = async () => {
    await Promise.race([
      originalClose(),
      new Promise<void>((resolve) => {
        const t = setTimeout(resolve, 30_000);
        t.unref(); // не держит event loop
      }),
    ]);

    if (server.listening) {
      server.closeIdleConnections(); // Node >= 18.2: закрыть idle keep-alive
    }
  };

  await app.listen(3000);
}

Trade-off по таймауту:

  • < 20s — мало для дрейна под нагрузкой; долгие запросы (p99 ~5s) прерываются.
  • > 45s — риск SIGKILL внутри terminationGracePeriodSeconds: 60 (см. Kubernetes).
  • 30s — рабочий баланс для типичных REST-сервисов.

Если есть долгие синхронные эндпоинты — декомпозируйте их (R-SHUT-HTTP-3, HTTP drain), не увеличивайте таймаут.

ShutdownStateService — единственный источник состояния

R-SHUT-CFG-3: readiness переключается в 503 первым на SIGTERM.

// 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 — k8s уберёт pod из endpoints
  }
}

Terminus-проба читает этот флаг:

// health.module.ts
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 } 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.resolve({ readiness: { status: 'down' } })
          : Promise.resolve({ readiness: { status: 'up' } }),
    ]);
  }
}

Что происходит при SIGTERM:

  1. beforeApplicationShutdown устанавливает draining = true.
  2. /health/ready начинает возвращать 503.
  3. K8s readiness probe (опрос каждые 5s) видит fail.
  4. K8s endpoints-controller убирает pod из Service routing.
  5. Через preStop-задержку (10s) новый трафик перестаёт поступать.
  6. server.close() дожимает оставшиеся in-flight запросы.

Пример использования ShutdownStateService в сервисе для отказа от новых задач на shutdown:

// order.service.ts
import { Injectable } from '@nestjs/common';
import { ShutdownStateService } from '../shutdown/shutdown-state.service';

@Injectable()
export class OrderService {
  constructor(private readonly shutdownState: ShutdownStateService) {}

  async createOrder(customerId: string, productId: string): Promise<void> {
    if (this.shutdownState.isDraining()) {
      throw new Error('Сервис завершает работу, новые заказы не принимаются');
    }
    // бизнес-логика создания заказа Customer → Product
  }
}

Раздельные /health/live и /health/ready

R-SHUT-CFG-4: две отдельные пробы с разной семантикой.

// k8s/deployment.yaml (фрагмент)
livenessProbe:
  httpGet:
    path: /health/live
    port: 3000
  initialDelaySeconds: 10
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /health/ready
    port: 3000
  initialDelaySeconds: 5
  periodSeconds: 5

На shutdown нужен именно readiness=503, а не liveness=503:

  • Readiness=503 → k8s убирает pod из endpoints, трафик перестаёт поступать.
  • Liveness=503 → k8s перезапускает pod — что разрушит graceful shutdown.

ShutdownStateService влияет только на readiness; liveness всегда возвращает 200, пока процесс жив.

Что запрещено

АнтипаттернПравилоЧто взамен
Нет app.enableShutdownHooks()R-SHUT-CFG-1обязателен в main.ts
let shuttingDown = false в модуле, не связанный с terminusR-SHUT-CFG-X1ShutdownStateService.isDraining()
server.close() без force-таймаутаR-SHUT-CFG-2Promise.race с setTimeout(30_000).unref()
Keep-alive drain без closeIdleConnections()R-SHUT-HTTP-1server.closeIdleConnections() (Node ≥ 18.2)
process.exit(0) в собственном SIGTERM-обработчикеR-SHUT-HTTP-X1Nest lifecycle через enableShutdownHooks()
Раздельные live/ready отсутствуютR-SHUT-CFG-4отдельные /health/live + /health/ready через terminus
pool.end() в beforeApplicationShutdownR-SHUT-DB-X1только в onApplicationShutdown, после дрейна HTTP

Куда дальше

  • HTTP drain — что происходит с активными запросами, closeIdleConnections, keep-alive.
  • Kafka shutdown — consumer.disconnect() с таймаутом, producer.disconnect().
  • БД и persistence — pool.end() в правильной фазе.
  • Kubernetes — preStop sleep, terminationGracePeriodSeconds, maxUnavailable.
  • Фоновые задачи и outbox — @nestjs/schedule, BullMQ, outbox-relay.
  • Бюджеты и observability — раскладка 60-секундного бюджета.