Включить app.enableShutdownHooks() в NestJS — необходимый шаг, но недостаточный. Когда Kubernetes решает завершить pod, между сигналом и прекращением трафика есть окно в несколько секунд. Без правильной конфигурации k8s клиенты получают 502 — не потому что NestJS не умеет завершаться, а потому что kube-proxy не успевает убрать pod из списка доступных адресов.
Эта статья — про три настройки в манифесте Deployment, которые закрывают этот разрыв.
Почему дефолтный таймаут завершения не подходит
Когда Kubernetes удаляет pod, он даёт процессу время завершиться самостоятельно — этот таймаут задаётся полем terminationGracePeriodSeconds. По умолчанию он равен 30 секундам.
Проблема: в этот бюджет входит всё: и задержка kube-proxy (о ней ниже), и собственный дренаж приложения. У NestJS-сервиса, который завершает HTTP-соединения, отключает Kafka-клиент и закрывает пул соединений с базой, 30 секунд может не хватить — и k8s пришлёт SIGKILL посередине.
Правильное значение — 60 секунд:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
image: order-service:2.1.0
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
Зачем нужен preStop sleep
Когда Kubernetes удаляет pod, два процесса стартуют параллельно:
- выполняется
preStop-хук (если задан); - pod убирается из списка endpoints, обновляются iptables-правила через kube-proxy.
Проблема без preStop: Kubernetes сразу шлёт SIGTERM процессу, не дождавшись обновления маршрутизации. Kube-proxy обновляет правила асинхронно — это занимает 5–15 секунд. Всё это время новые запросы ещё идут на завершающийся pod и получают ошибку.
Решение — добавить preStop: exec: sleep 10. Тогда:
T=0s Kubernetes начинает удаление pod:
— запускает preStop (sleep 10)
— параллельно убирает pod из endpoints
T=10s preStop завершён → Kubernetes шлёт SIGTERM процессу
T=10s NestJS получает SIGTERM и начинает завершение:
— readiness probe начинает отдавать 503
— закрываются Kafka-клиенты
— завершаются HTTP-соединения
— закрывается пул базы данных
T=60s Если процесс ещё жив → SIGKILL
После preStop-задержки маршрутизация уже обновлена, новые запросы на pod не попадают, и NestJS может завершаться спокойно.
Важный нюанс: preStop входит в terminationGracePeriodSeconds. При бюджете 60 секунд и preStop в 10 секунд у NestJS остаётся 50 секунд на дренаж — этого достаточно.
Два разных probe для двух разных целей
В Kubernetes есть два вида проверок работоспособности pod:
- readinessProbe — Kubernetes убирает pod из endpoints, если она не проходит. Pod остаётся живым, он просто не получает трафик.
- livenessProbe — Kubernetes перезапускает pod, если она не проходит.
Это принципиально разное поведение, и при завершении pod нужно использовать их по-разному.
spec:
containers:
- name: app
readinessProbe:
httpGet:
path: /health/ready
port: 3000
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
livenessProbe:
httpGet:
path: /health/live
port: 3000
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
initialDelaySeconds: 30
На стороне NestJS endpoint-ы настраивает библиотека @nestjs/terminus:
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
private readonly shutdownState: ShutdownStateService,
) {}
@Get('live')
@HealthCheck()
liveness() {
return this.health.check([]);
}
@Get('ready')
@HealthCheck()
readiness() {
if (this.shutdownState.isDraining()) {
throw new ServiceUnavailableException('draining');
}
return this.health.check([
() => this.db.pingCheck('postgres'),
]);
}
}
ShutdownStateService — один сервис, который хранит состояние завершения:
@Injectable()
export class ShutdownStateService implements BeforeApplicationShutdown {
private draining = false;
isDraining(): boolean {
return this.draining;
}
beforeApplicationShutdown(): void {
this.draining = true;
}
}
Как это работает при завершении
При получении SIGTERM NestJS вызывает beforeApplicationShutdown() — флаг draining становится true. Следующий опрос readinessProbe (раз в 5 секунд) получит 503, и через два неуспешных запроса (failureThreshold: 2) — итого через 10 секунд — Kubernetes окончательно убирает pod из endpoints.
Liveness при этом продолжает отдавать 200: pod жив и корректно завершается, перезапускать его не нужно.
Частая ошибка: один endpoint на обе проверки
Если /health используется и для readiness, и для liveness, то при завершении pod (когда нужно вернуть 503 для readiness) liveness тоже получит 503 — и Kubernetes перезапустит pod вместо корректного завершения. Это разрушает всю логику дренажа.
Задержка старта для liveness
initialDelaySeconds: 30 на livenessProbe даёт NestJS время на запуск. DI-контейнер с TypeORM и миграциями может стартовать 10–20 секунд. Без задержки liveness может сработать до готовности приложения и вызвать бесконечный цикл перезапусков.
Readiness при этом уже активна с первой секунды — просто пока приложение не готово, трафик на него не идёт.
Rolling deploy без потери запросов
По умолчанию при обновлении Kubernetes может завершить старый pod до того, как новый станет готов. Это ведёт к кратковременной нехватке мощности и возможным ошибкам на нагруженных сервисах.
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
maxSurge: 1— Kubernetes создаёт дополнительный pod во время обновления (4 вместо 3). Новый pod принимает трафик до того, как старый завершится.maxUnavailable: 0— ни один pod не уходит из строя без замены.
Последовательность обновления:
Начало: 3 пода v1
1. Создан pod v2. Итого: 3×v1 + 1×v2
2. Pod v2 прошёл readinessProbe → начал принимать трафик
3. Один pod v1 начинает завершение:
— preStop sleep 10s
— SIGTERM → draining = true
— /health/ready → 503 → убран из endpoints
— NestJS завершает соединения и закрывает ресурсы
— exit 0
Итого: 2×v1 + 1×v2 (3 активных)
4. Создан pod v2 #2. Итого: 2×v1 + 2×v2
... и так для каждого пода
Для большого числа реплик (50+) вместо maxSurge: 1 используют maxSurge: 25%.
Частые ошибки
Нет preStop — в окне 5–15 секунд после SIGTERM клиенты получают 502, потому что kube-proxy ещё не обновил маршрутизацию.
terminationGracePeriodSeconds: 30 — при preStop в 10 секунд и дренаже NestJS в 20 секунд бюджет заканчивается впритык. Любой медленный Kafka-клиент или тяжёлый запрос к базе выходит за лимит, SIGKILL обрывает незавершённые соединения.
Один /health для обеих probe — при завершении liveness получает 503 и перезапускает pod вместо корректного дренажа.
livenessProbe проверяет базу данных — если база недоступна, Kubernetes перезапустит pod. Но перезапуск не поможет: база всё ещё недоступна. Liveness должна проверять только, что процесс жив. Внешние зависимости — в readinessProbe.
maxUnavailable: 1 — Kubernetes может завершить старый pod до готовности нового. Для обновления без простоя нужно maxUnavailable: 0.
Свой флаг let shuttingDown = false вместо единого ShutdownStateService — состояние расползается по нескольким местам, terminus не видит его и отдаёт 200 когда нужен 503.
Коротко
- Дефолтный
terminationGracePeriodSeconds: 30не подходит — ставьте60. preStop: sleep 10даёт kube-proxy время убрать pod из endpoints до SIGTERM. Без него — гарантированные 502 в переходном окне.preStopвходит в бюджетterminationGracePeriodSeconds: при 60s и preStop 10s у NestJS остаётся 50s на дренаж.readinessProbeиlivenessProbe— разные endpoints с разным поведением при fail: первая убирает pod из трафика, вторая перезапускает его.- При завершении нужна readiness 503, а не liveness 503 — иначе Kubernetes перезапустит pod вместо дренажа.
maxSurge: 1, maxUnavailable: 0— новый pod принимает трафик до завершения старого.- Один
ShutdownStateServiceс флагомdraining— единый источник состояния для всех проверок.
Что почитать дальше
- HTTP drain в NestJS — как закрыть соединения и поставить force-deadline.
- Конфигурация NestJS для graceful shutdown —
enableShutdownHooks,ShutdownStateService, force-таймер. - Kafka shutdown в NestJS — отключение consumer и producer с таймаутом.
- Бюджет завершения и наблюдаемость — как распределить 60 секунд между фазами.