Опирается на правила: R-SHUT-HTTP-1R-SHUT-HTTP-3 и R-SHUT-HTTP-X1 из Graceful Shutdown Style Guide → раздел 2. HTTP drain.

Важно знать

  • app.enableShutdownHooks() в main.ts — без него NestJS не слушает SIGTERM; процесс умирает сразу, in-flight → 502.
  • server.close() перестаёт принимать новые соединения, но ждёт завершения активных ответов — неограниченно без force-таймера.
  • closeIdleConnections() (Node ≥ 18.2) освобождает пустые keep-alive сокеты — без него drain зависает.
  • preStop sleep 10 в k8s обязателен даже при правильном NestJS graceful — k8s шлёт SIGTERM до того, как kube-proxy на других нодах обновит iptables.
  • Без preStop — 5–15 секунд нового трафика на pod, который уже не принимает соединения → гарантированные 502.
  • Долгие синхронные endpoints (>10 сек) — 202 Accepted + polling или задача с Idempotency-Key; синхронный handler съедает весь graceful timeout или обрывается на полпути.
  • process.exit(0) в SIGTERM-хендлере аннулирует graceful — рвёт активные ответы немедленно.

HTTP drain — самая видимая часть graceful shutdown: любая 502 при rolling deploy — инцидент в метриках. NestJS предоставляет lifecycle-хуки (beforeApplicationShutdown → закрытие HTTP-сервера → onApplicationShutdown), но корректный дрейн требует двух слоёв защиты: правильно настроенного server.close() и preStop в k8s.

In-flight requests дожимаются

R-SHUT-HTTP-1: что происходит при app.close().

T=0    SIGTERM получен
T=0+   NestJS вызывает beforeApplicationShutdown() у всех injectable
T=0+   ShutdownStateService: draining = true → /health/ready → 503
T=0+   app.close(): server.close() — новые соединения не принимает
       closeIdleConnections() — keep-alive сокеты освобождены
       Активные ответы продолжают обрабатываться
T=5s   k8s readiness probe видит 503, убирает pod из endpoints
       (на других нодах kube-proxy ещё может не обновиться — см. preStop)
T=N    Все активные ответы завершились или сработал force-таймер
T=N    onApplicationShutdown() — pool.end(), disconnect()
T=N+   Процесс завершается

server.close() в Node.js ждёт in-flight неограниченно — нужен явный force-таймер, иначе shutdown будет висеть. Корректная связка:

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

  const server: http.Server = app.getHttpServer();

  const DRAIN_TIMEOUT_MS = 25_000;

  server.on('listening', () => {
    process.once('SIGTERM', async () => {
      server.closeIdleConnections();

      await Promise.race([
        new Promise<void>((resolve) => server.close(() => resolve())),
        new Promise<void>((resolve) =>
          setTimeout(resolve, DRAIN_TIMEOUT_MS).unref(),
        ),
      ]);
    });
  });

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

setTimeout(...).unref() — таймер не держит event loop живым, если все остальные задачи завершились раньше.

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

R-SHUT-CFG-3: readiness → 503 ставится первым действием в beforeApplicationShutdown. Никакого разрозненного let shuttingDown по модулям.

@Injectable()
export class ShutdownStateService implements BeforeApplicationShutdown {
  private draining = false;

  isDraining(): boolean {
    return this.draining;
  }

  beforeApplicationShutdown(): void {
    this.draining = true;
  }
}
@Injectable()
export class HealthService {
  constructor(private readonly shutdownState: ShutdownStateService) {}

  isReady(): boolean {
    return !this.shutdownState.isDraining();
  }
}

terminus-конфигурация:

@Controller('health')
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly healthService: HealthService,
  ) {}

  @Get('ready')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () =>
        this.healthService.isReady()
          ? { ready: { status: 'up' } }
          : Promise.reject(new Error('draining')),
    ]);
  }

  @Get('live')
  @HealthCheck()
  liveness() {
    return this.health.check([]);
  }
}

R-SHUT-CFG-4: /health/live и /health/ready — раздельные endpoints. liveness-падение перезапускает pod, readiness-падение убирает его из роутинга.

preStop sleep — обязательно

R-SHUT-HTTP-2: даже при идеальном NestJS graceful.

spec:
  containers:
    - name: order-service
      lifecycle:
        preStop:
          exec:
            command: ["sh", "-c", "sleep 10"]
  terminationGracePeriodSeconds: 60

Почему preStop нужен

Последовательность k8s на удалении pod:

T=0    kubelet получил команду delete pod
T=0+   kubelet вызывает preStop hook (sleep 10)
       Параллельно: endpoints-controller убирает pod из Service
       Параллельно: kube-proxy на других нодах обновляет iptables
T=10s  preStop завершился
T=10s  kubelet отправляет SIGTERM процессу
T=10s+ NestJS graceful начинается

Без preStop:

T=0    SIGTERM отправлен сразу
T=0+   draining = true → /health/ready → 503
T=0+   k8s readiness probe: ещё не успела опросить (period 5s)
T=0+   Pod ещё в endpoints на других нодах (kube-proxy не обновился)
T=0..15s  Новый трафик продолжает идти на этот pod
           NestJS уже не принимает → 502 / connection refused

10 секунд закрывают типичное окно:

  • kube-proxy iptables update — 1–5 секунд.
  • Load balancer cache flush — 1–3 секунды.
  • DNS TTL — единицы секунд.

На больших кластерах (1000+ nodes) — до 20 секунд. Для большинства сервисов 10 секунд достаточно.

За время preStop процесс продолжает принимать запросы: SIGTERM ещё не отправлен. После 10 секунд NestJS graceful занимается только in-flight запросами, новых больше нет.

Долгие endpoints

R-SHUT-HTTP-3: синхронный endpoint длиннее 10 секунд.

Сценарий поломки: POST /orders/export обрабатывается 40 секунд. В момент SIGTERM — 3 таких запроса в обработке. Force-таймер 25 секунд истекает раньше — обработка обрывается на полпути, клиент получает 500.

Вариант 1: 202 Accepted + polling

@Controller('orders')
export class OrderController {
  constructor(private readonly dispatcher: OrderUseCaseDispatcher) {}

  @Post('export')
  async requestExport(
    @Headers('Idempotency-Key') idempotencyKey: string,
    @Body() dto: ExportOrdersDto,
  ): Promise<ExportRequestResponse> {
    const jobId = await this.dispatcher.dispatch(
      new RequestOrderExportCommand(idempotencyKey, dto),
    );
    return { jobId, status: 'QUEUED' };
  }

  @Get('export/:jobId')
  async getExport(@Param('jobId') jobId: string): Promise<ExportStatusResponse> {
    return this.dispatcher.dispatch(new GetOrderExportQuery(jobId));
  }
}

POST возвращает 202 мгновенно (< 100ms). Клиент polling-ает GET до status: READY. На shutdown короткий POST не блокирует дрейн; фоновая задача продолжается до завершения или следующего деплоя (через outbox).

Вариант 2: задача с Idempotency-Key

Для CustomerService — долгая синхронизация профиля:

@Controller('customers')
export class CustomerController {
  constructor(private readonly dispatcher: CustomerUseCaseDispatcher) {}

  @Post(':id/sync')
  async syncProfile(
    @Param('id') customerId: string,
    @Headers('Idempotency-Key') idempotencyKey: string,
    @Body() dto: SyncProfileDto,
  ): Promise<SyncResponse> {
    const taskId = await this.dispatcher.dispatch(
      new SyncCustomerProfileCommand(customerId, idempotencyKey, dto),
    );
    return { taskId, status: 'ACCEPTED' };
  }
}

Фоновая задача через @nestjs/bull (BullMQ) завершает работу при shutdown через worker.close() — подробнее в Фоновые задачи и outbox.

Вариант 3: декомпозиция запроса

Если endpoint ProductService медленный из-за N+1 запросов — это не архитектурная особенность, а ошибка. Оптимизированный запрос часто укладывается в 2–3 секунды и не создаёт проблем при дрейне.

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

АнтипаттернПравилоЧто взамен
process.exit(0) в SIGTERM-хендлереR-SHUT-HTTP-X1app.close() + lifecycle-хуки NestJS
server.closeAllConnections() сразу при shutdownR-SHUT-HTTP-X1server.close() + closeIdleConnections() для idle keep-alive
Отсутствие preStop sleepR-SHUT-HTTP-2sleep 10 обязателен в lifecycle
preStop sleep < 5s на multi-node кластереR-SHUT-HTTP-2минимум 10 секунд
Отсутствие app.enableShutdownHooks()R-SHUT-CFG-1включить в main.ts, без этого SIGTERM игнорируется
Синхронный endpoint длиннее 10 секундR-SHUT-HTTP-3202 Accepted + polling или задача с Idempotency-Key
let shuttingDown в случайном модуле вместо ShutdownStateServiceR-SHUT-CFG-X1единый ShutdownStateService, связанный с /health/ready
Долгие endpoints без Idempotency-KeyR-SHUT-HTTP-3заголовок обязателен для retry-безопасности

Куда дальше

  • Бюджеты и observability — раскладка 60-секундного бюджета по фазам.
  • БД и persistence — порядок закрытия пула в onApplicationShutdown.
  • Идемпотентность in-flight — retry-безопасность для операций, которые SIGTERM может прервать.
  • Фоновые задачи и outbox — SchedulerRegistry, BullMQ worker.close(), outbox-relay с draining-флагом.
  • Kafka shutdown — consumer.disconnect() с таймаутом, commit-семантика kafkajs.
  • Kubernetes — terminationGracePeriodSeconds, probes, maxUnavailable: 0.