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

Включить 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, два процесса стартуют параллельно:

  1. выполняется preStop-хук (если задан);
  2. 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 секунд между фазами.