Опирается на правила:
R-SHUT-OBS-1…R-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_seconds—prom-clientGauge+ финальная запись в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) | 10s | lifecycle.preStop |
beforeApplicationShutdown (readiness 503 + kafkajs disconnect) | до 15s | lifecycle hook |
HTTP drain (server.close() + force-deadline) | до 25s | app.close() |
onApplicationShutdown (BullMQ + pg pool.end) | до 20s | lifecycle hook |
| Total max | до 70s | terminationGracePeriodSeconds = 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-X1 | logger.log() (INFO) |
Нет метрики app_shutdown_duration_seconds | R-SHUT-OBS-2 | prom-client Gauge обязательно |
| Нет структурного лога «получили SIGTERM» | R-SHUT-OBS-3 | beforeApplicationShutdown лог первым |
terminationGracePeriodSeconds: 90+ | R-SHUT-OBS-1 | 60s, сокращать scope операций |
| Все таймауты на максимум одновременно | R-SHUT-OBS-1 | реалистичная раскладка по хукам |
| Попытка определить причину SIGTERM в коде | R-SHUT-OBS-3 | kubectl describe pod |
Gauge без service label | R-SHUT-OBS-2 | стандартные labels в prom-client |
Нет алерта на shutdown_duration > 50s | R-SHUT-OBS-2 | proactive 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.