Когда 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 sleep | 10s | lifecycle.preStop (входит в общие 60s) |
| HTTP drain + Kafka disconnect | до 25s | параллельно в beforeApplicationShutdown |
| BullMQ + PostgreSQL | до 20s | onApplicationShutdown |
| Итого после 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-clientGauge — минимальная метрика для расследования проблем с деплоем.- Лог факта 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.