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

Когда Kubernetes удаляет pod, у приложения есть ограниченное время, чтобы завершить дела и закрыться. Это время называют бюджетом завершения. Если не уложиться — Kubernetes принудительно убьёт процесс, и часть операций прервётся на полуслове.

Задача — правильно разложить бюджет по фазам и сделать так, чтобы при проблемах было видно: где именно всё зависло.

Откуда берётся 60 секунд

terminationGracePeriodSeconds: 60 — стандартное значение в Kubernetes. Это общий лимит от начала удаления pod до принудительного SIGKILL.

Но первые 10 секунд уходят на preStop-задержку: она нужна, чтобы kube-proxy успел убрать pod из балансировщика до того, как pod перестанет принимать трафик. После preStop у процесса остаётся 50 секунд на все фазы.

Как выглядит типичная раскладка

NestJS выполняет хуки завершения последовательно, но часть работы внутри хука можно делать параллельно.

T=0     SIGTERM получен, NestJS начинает shutdown
T=0     beforeApplicationShutdown():
        ├── readiness probe → 503 (новые запросы не приходят)
        ├── kafkajs consumer.disconnect() — до 20s  ─┐ параллельно
        └── app.close() → HTTP drain         — до 25s ─┘
T=25s   HTTP drain и Kafka завершены
T=25s   onApplicationShutdown():
        ├── BullMQ worker.close()
        └── pool.end() (PostgreSQL)
T=45s   exit(0)   ← укладываемся в 50s бюджет
ЭтапДлительностьМеханизм
preStop sleep10slifecycle.preStop (входит в общие 60s)
HTTP drain + Kafka disconnectдо 25sпараллельно в beforeApplicationShutdown
BullMQ + PostgreSQLдо 20sonApplicationShutdown
Итого после SIGTERMдо 45sв рамках 50s

Важный момент: kafkajs disconnect и HTTP drain работают параллельно, поэтому их ceiling — не сумма (20+25), а максимум: 25 секунд.

Что делать, если не укладываетесь

Первый инстинкт — поднять terminationGracePeriodSeconds до 90 или 120. Это плохая идея: длинный shutdown увеличивает время деплоя и расширяет окно, когда старая и новая версии сервиса работают одновременно.

Правильный путь — сократить объём работы:

  • Kafka: уменьшить maxBytesPerPartition и maxWaitTimeInMs, чтобы пачки были меньше и обработчик завершался быстрее.
  • BullMQ: разбить тяжёлые задачи на короткие шаги через Chaining вместо одной длинной задачи.
  • Scheduled-задачи: уменьшить размер итерации (50 записей вместо 500).

Метрика app_shutdown_duration_seconds

Без метрики расследование «почему деплой завис» сводится к ручному прокликиванию логов десятков pod-ов. С метрикой сразу видно: shutdown занял 47 секунд — почти весь бюджет.

Реализация через prom-client:

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 — первый хук: фиксируем момент старта и пишем лог до любого cleanup. onApplicationShutdown — последний хук: записываем финальное значение gauge, когда все фазы завершены.

Полезные запросы в Prometheus:

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

# Алерт: приближаемся к бюджету (больше 50s из 60s)
max(app_shutdown_duration_seconds) > 50

Зачем логировать SIGTERM

Важно зафиксировать сам факт получения сигнала — это первая строка в расследовании любого инцидента. NestJS передаёт имя сигнала в хук:

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

Причину сигнала (деплой, scale-down, ручное удаление pod-а) приложение не знает — это информация инфраструктурного уровня. За ней идут в kubectl describe pod <pod-name>: там в разделе Events видно, кто и почему инициировал завершение.

Правильный уровень логирования при завершении

Распространённая ошибка — логировать закрытие соединений с уровнем ERROR:

// Плохо — каждый деплой генерирует ложные алерты
[OrderService] ERROR - pg pool ended
[OrderService] ERROR - kafkajs consumer disconnected

Закрытие пула и отключение Kafka — нормальные события завершения работы. Если писать их как ERROR, команда быстро привыкает игнорировать алерты, и реальная ошибка проходит незамеченной.

Правильно:

@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 закрыт'); // INFO
  }
}

ERROR на этапе завершения — только если что-то реально сломалось: принудительное закрытие до окончания транзакции, необработанное исключение в shutdown-хуке, потеря соединения в процессе сброса.

Коротко

  • Общий бюджет: 60s, из которых 10s — preStop. После preStop у процесса остаётся 50 секунд.
  • kafkajs disconnect и HTTP drain работают параллельно в beforeApplicationShutdown — суммировать их не нужно.
  • Если не укладываетесь — сокращайте объём работы, а не увеличивайте terminationGracePeriodSeconds.
  • app_shutdown_duration_seconds через prom-client Gauge — минимальная метрика для расследования проблем с деплоем.
  • Лог факта SIGTERM пишется первым, до cleanup, в beforeApplicationShutdown.
  • Нормальное закрытие (pool.end(), consumer.disconnect()) — уровень INFO, не ERROR.

Что почитать дальше

  • HTTP drain — server.close(), closeIdleConnections(), preStop-задержка.
  • Kafka shutdown — consumer.disconnect() с таймаутом, commit-семантика kafkajs.
  • БД и persistence — pool.end() и dataSource.destroy() в правильном хуке.
  • Scheduled и async задачи — SchedulerRegistry, worker.close(), флаг draining в outbox-relay.
  • Kubernetes — terminationGracePeriodSeconds, probes, maxUnavailable: 0.