Опирается на правила: R-SHUT-OBS-1R-SHUT-OBS-3 и R-SHUT-OBS-X1 из Graceful Shutdown Style Guide → раздел 8. Бюджеты и observability.

Важно знать

  • 60s total budget = preStop 10s + HTTP drain (app.close()) до 25s + фоновые задачи/очереди до 20s + kafkajs consumer до 15s.
  • Фазы NestJS идут последовательноbeforeApplicationShutdown → закрытие HTTP-сервера → onApplicationShutdown; wall clock = сумма активных фаз, не max. Уложиться нужно в 60s минус preStop = 50s.
  • Не помещается? — сократить batch (100 → 20 записей), не увеличивать terminationGracePeriodSeconds.
  • app.enableShutdownHooks() в main.ts — без него Nest не слушает SIGTERM и процесс умирает сразу.
  • Метрика app_shutdown_duration_secondsprom-client Gauge + финальная запись в onApplicationShutdown.
  • Лог факта SIGTERM — первым, до draining = true: logger.log('получили SIGTERM, начинаем graceful shutdown').
  • Нормальное закрытие pool.end()/consumer.disconnect()INFO, не ERROR; иначе каждый деплой генерирует ложный алерт.

Graceful shutdown — это distributed coordination. Если он сломался в проде, нужно понять где именно: сколько секунд занял HTTP drain, сколько kafkajs, сколько BullMQ-джобы? Без метрик и структурного лога каждый инцидент расследуется через kubectl logs и догадки. R-SHUT-OBS-* фиксирует минимум: один gauge + structured log как нижнюю планку.

Раскладка 60s budget

R-SHUT-OBS-1: cumulative timeline для NestJS.

ЭтапДлительностьМеханизм
preStop sleep (kube-proxy distribution)10slifecycle.preStop
beforeApplicationShutdown (readiness 503 + kafkajs disconnect)до 15slifecycle hook
HTTP drain (server.close() + force-deadline)до 25sapp.close()
onApplicationShutdown (BullMQ + pg pool.end)до 20slifecycle hook
Total maxдо 70sterminationGracePeriodSeconds = 60

70s > 60s — но у NestJS последовательность детерминированная: если kafkajs укладывается в 15s и HTTP drain — в 25s, а onApplicationShutdown — в 5s (pool.end быстрый), реальный wall clock 35-40s. Бюджет 60s оставляет запас на нагруженный кластер и медленные shutdown-ы.

T=0     SIGTERM
T=0     app.enableShutdownHooks() → NestJS начинает shutdown
T=0     beforeApplicationShutdown():
        ├── ShutdownStateService.draining = true  → /health/ready 503
        ├── kafkajs consumer.disconnect() (timeout 15s)
        └── kafkajs producer.disconnect()
T=15s   app.close() → server.close() + closeIdleConnections()
T=40s   HTTP drain завершён (25s)
T=40s   onApplicationShutdown():
        ├── BullMQ worker.close() (ожидает активные джобы)
        └── pool.end() (pg)
T=45s   exit(0)

Если не помещается

Не увеличивать budget до 90s, 120s — длинный shutdown = длинный rolling deploy, окно несовместимости схем растёт.

Сократить scope операций:

  • kafkajs maxBytesPerPartition + maxWaitTimeInMs → меньше batch, handler завершается быстрее.
  • BullMQ-воркер с heavy-джобами → разбить на короткие шаги через Chaining.
  • @nestjs/schedule-интервал с heavy iteration → batch 50 вместо 500.

Метрика app_shutdown_duration_seconds

R-SHUT-OBS-2: prom-client Gauge + структурный лог.

import { Injectable, Logger, OnApplicationShutdown, BeforeApplicationShutdown } from '@nestjs/common';
import { Gauge, Registry } from 'prom-client';

@Injectable()
export class ShutdownObserverService implements BeforeApplicationShutdown, OnApplicationShutdown {
  private readonly logger = new Logger(ShutdownObserverService.name);
  private readonly shutdownDuration: Gauge<string>;
  private shutdownStartMs = 0;

  constructor(registry: Registry) {
    this.shutdownDuration = new Gauge({
      name: 'app_shutdown_duration_seconds',
      help: 'Duration of graceful shutdown in seconds',
      labelNames: ['service'],
      registers: [registry],
    });
  }

  beforeApplicationShutdown(): void {
    this.shutdownStartMs = Date.now();
    this.logger.log('получили SIGTERM, начинаем graceful shutdown');
  }

  onApplicationShutdown(): void {
    const durationMs = Date.now() - this.shutdownStartMs;
    this.shutdownDuration.set({ service: process.env.SERVICE_NAME ?? 'order-service' }, durationMs / 1000);
    this.logger.log(`graceful shutdown завершён за ${durationMs}ms`);
  }
}

beforeApplicationShutdown — первый хук: лог SIGTERM и старт таймера до любого cleanup. onApplicationShutdown — последний хук: gauge-запись после всех фаз.

В Prometheus:

# Максимальная длительность shutdown по сервису
max by (service) (app_shutdown_duration_seconds)

# Алерт: близко к budget (50s из 60s)
max(app_shutdown_duration_seconds) > 50

Без gauge расследование «почему deploy таймаут» — это прокликивание логов десятков pod-ов вручную.

Лог причины SIGTERM

R-SHUT-OBS-3: где смотреть.

NestJS не знает, почему получил SIGTERM — это информация инфраструктурного уровня:

  • Rolling deploy?
  • HPA scale-down (Orders упали, Sber-кластер освобождает pod-ы)?
  • Manual kubectl delete pod?
  • OOM killer?
  • Node maintenance?

В коде записываем только факт:

beforeApplicationShutdown(signal: string): void {
  this.logger.log(`получили ${signal}, начинаем graceful shutdown`);
}

Контекст ищем через kubectl describe pod <pod-name>:

Events:
  Type    Reason                  Age    From                    Message
  ----    ------                  ----   ----                    -------
  Normal  Killing                 2m     kubelet                 Stopping container order-service
  Normal  ScalingReplicaSet       10m    deployment-controller   Scaled down replica set order-service-7c8d

Или через k8s audit log для более глубокого расследования. Не пытаемся определить причину в коде — это infrastructure-info, не application-info.

Уровни логирования на shutdown

R-SHUT-OBS-X1: нормальное закрытие — INFO, не ERROR.

// АНТИПАТТЕРН — alert channel заспамлен каждым deploy
[OrderService] ERROR - pg pool ended
[OrderService] ERROR - kafkajs consumer disconnected
[OrderService] ERROR - BullMQ worker closed

Это нормальные события завершения работы. Если они на ERROR — каждый деплой order-service генерирует алерты в Slack/PagerDuty, команда привыкает их игнорировать, и реальный инцидент проходит незамеченным.

Правильный уровень:

@Injectable()
export class DatabaseModule implements OnApplicationShutdown {
  private readonly logger = new Logger(DatabaseModule.name);

  async onApplicationShutdown(): Promise<void> {
    await this.pool.end();
    this.logger.log('pg pool закрыт');
  }
}

ERROR на shutdown — только если что-то реально пошло не так: force-shutdown до завершения транзакций, потеря соединения в процессе дренажа, unhandled rejection в shutdown-хуке.

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

АнтипаттернПравилоЧто взамен
ERROR-логи на pool.end() / consumer.disconnect()R-SHUT-OBS-X1logger.log() (INFO)
Нет метрики app_shutdown_duration_secondsR-SHUT-OBS-2prom-client Gauge обязательно
Нет структурного лога «получили SIGTERM»R-SHUT-OBS-3beforeApplicationShutdown лог первым
terminationGracePeriodSeconds: 90+R-SHUT-OBS-160s, сокращать scope операций
Все таймауты на максимум одновременноR-SHUT-OBS-1реалистичная раскладка по хукам
Попытка определить причину SIGTERM в кодеR-SHUT-OBS-3kubectl describe pod
Gauge без service labelR-SHUT-OBS-2стандартные labels в prom-client
Нет алерта на shutdown_duration > 50sR-SHUT-OBS-2proactive alert в Prometheus

Куда дальше

  • Рантайм и конфигурация — app.enableShutdownHooks(), force-deadline, ShutdownStateService.
  • HTTP drain — server.close(), closeIdleConnections(), preStop 10s.
  • Kafka shutdown — consumer.disconnect() с таймаутом, commit-семантика kafkajs.
  • БД и persistence — pool.end() и dataSource.destroy() в правильном хуке.
  • Scheduled / async / outbox — SchedulerRegistry, worker.close(), draining-флаг в outbox-relay.
  • Идемпотентность in-flight — retry-safe операции при SIGTERM, Idempotency-Key.
  • Kubernetes — terminationGracePeriodSeconds: 60, probes, maxUnavailable: 0.