Опирается на правила: R-SHUT-K8S-1R-SHUT-K8S-3 и R-SHUT-K8S-X1R-SHUT-K8S-X2 из Graceful Shutdown Style Guide → раздел 6. Kubernetes.

Важно знать

  • terminationGracePeriodSeconds: 60 обязательно — не k8s default 30.
  • preStop sleep 10 — отдельный бюджет, не входит в terminationGracePeriodSeconds, kubelet запускает его до SIGTERM.
  • readinessProbe на /health/ready, livenessProbe на /health/live (terminus).
  • На shutdown нужен readiness=503 — убирает pod из endpoints; liveness-падение вместо этого перезапускает pod.
  • ShutdownStateService.isDraining() — единый источник состояния; terminus-проба /health/ready читает его.
  • maxSurge: 1, maxUnavailable: 0 на rolling deploy — новый pod принимает трафик до shutdown старого.
  • Без preStop — гарантированные 502 в окне 5–15 секунд после SIGTERM.
  • terminationGracePeriodSeconds: 30 + graceful deadline 30s — SIGKILL посередине дрейна.

K8s — контекст, в котором живёт graceful shutdown. app.enableShutdownHooks() без правильной k8s-конфигурации работает только частично: NestJS корректно завершает своё, но kube-proxy не успевает убрать pod из endpoints — клиенты получают 502 в переходном окне. Правила R-SHUT-K8S-* замыкают цепочку.

terminationGracePeriodSeconds: 60

R-SHUT-K8S-1: полный бюджет завершения.

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"]

Последовательность завершения pod в k8s:

T=0     kubelet начинает shutdown sequence:
        - параллельно запускает preStop hook
        - параллельно убирает pod из Service endpoints
T=10s   preStop завершён → kubelet шлёт SIGTERM процессу
T=10s   NestJS получает SIGTERM:
          1. beforeApplicationShutdown() → ShutdownStateService.draining=true → /health/ready → 503
          2. kafkajs consumer.disconnect() + producer.disconnect()
          3. app.close() → server.close() + closeIdleConnections()
          4. onApplicationShutdown() → pool.end()
T=70s   Если процесс ещё жив → SIGKILL

terminationGracePeriodSeconds отсчитывается после preStop. Дефолт-30 не подходит:

  • 30s = preStop 10s + NestJS drain 30s уже не помещается (40s > 30s).
  • Pod уходит в SIGKILL посередине дренажа, in-flight HTTP прерываются.

Probes — раздельные endpoints

R-SHUT-K8S-2: readiness ≠ liveness.

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
ProbeEndpointДействие k8s при fail
readinessProbe/health/readyУбрать из endpoints (pod не рестартует)
livenessProbe/health/liveРестартовать pod

На стороне NestJS — terminus настраивает эти эндпоинты:

@Module({
  imports: [
    TerminusModule.forRoot({
      errorLogStyle: 'minimal',
    }),
  ],
})
export class HealthModule {}

@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 — единый источник readiness-состояния (R-SHUT-CFG-3):

@Injectable()
export class ShutdownStateService implements BeforeApplicationShutdown {
  private draining = false;

  isDraining(): boolean {
    return this.draining;
  }

  beforeApplicationShutdown(): void {
    this.draining = true;
  }
}

На shutdown работает readiness:

  1. NestJS вызывает beforeApplicationShutdown()draining = true.
  2. /health/ready возвращает 503.
  3. K8s через periodSeconds: 5 × failureThreshold: 2 = 10s фиксирует fail.
  4. Pod убирается из endpoints, трафик прекращается.

Liveness не должна падать на shutdown:

  • Падение liveness → k8s рестартует pod вместо graceful завершения.
  • Restart loop уничтожает graceful: старый pod не успевает дренаж, новый ещё не готов.
  • /health/live возвращает 200 пока процесс жив — это правильно.

initialDelaySeconds для liveness

initialDelaySeconds: 30 на liveness — даёт NestJS время на старт без риска restart-цикла. Node/NestJS стартует быстрее JVM, но DI-контейнер с TypeORM + migrations может занять 10–20s. Readiness начинает срабатывать сразу — пока приложение не готово, трафик просто не идёт.

maxSurge / maxUnavailable

R-SHUT-K8S-3: zero-downtime rolling deploy.

spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  • maxSurge: 1 — k8s создаёт на 1 pod больше во время deploy (4 вместо 3).
  • maxUnavailable: 0 — ни один pod не должен быть unavailable одновременно.

Последовательность rolling deploy для order-service:

Состояние: 3 старых pods (v1)

T=0    Создан новый pod v2. Итого: 3 v1 + 1 v2 = 4
T=N    Pod v2 прошёл readinessProbe → join endpoints
T=N    Старый pod v1 #1 начинает shutdown:
       - preStop sleep 10s
       - SIGTERM → ShutdownStateService.draining=true
       - /health/ready → 503 → k8s убирает из endpoints
       - NestJS дренаж HTTP + kafkajs + pg pool
       - exit 0
       Итого: 2 v1 + 1 v2 = 3 active
T=N+   Создан pod v2 #2. Итого: 2 v1 + 2 v2 = 4
...

Без maxUnavailable: 0 k8s может завершить v1 до создания v2 — в пике capacity просядет, возможны 503 на высоконагруженных сервисах типа product-catalog.

maxSurge: 1 достаточно для малых и средних replicas. Для высоконагруженных deployments (50+ pods) — maxSurge: 25%.

Что запрещено

АнтипаттернПравилоЧто взамен
Нет preStopR-SHUT-K8S-X1exec: sleep 10 обязателен
terminationGracePeriodSeconds: 30 (default)R-SHUT-K8S-X260
Один /health endpoint для обеих probesR-SHUT-K8S-2отдельные /health/live и /health/ready
Liveness зависит от БД / внешнего сервисаR-SHUT-K8S-2только readiness проверяет зависимости
maxUnavailable > 0 без обоснованияR-SHUT-K8S-30 для zero-downtime
maxSurge: 0R-SHUT-K8S-3maxSurge: 1 минимум
initialDelaySeconds: 0 на livenessR-SHUT-K8S-220–30s для прогрева NestJS
preStop через httpGet (flaky при завершении)R-SHUT-K8S-X1exec sleep
Свой let shuttingDown = false вместо ShutdownStateServiceR-SHUT-CFG-X1единый сервис, terminus читает его

Куда дальше

  • Бюджеты и observability — раскладка 60s: preStop + HTTP drain + kafkajs + pg pool.
  • HTTP drain — server.close() + closeIdleConnections(), force-deadline с Promise.race.
  • Runtime/конфигурация NestJS — app.enableShutdownHooks(), ShutdownStateService, force-таймер.
  • Kafka shutdown — consumer.disconnect() + producer.disconnect() с таймаутом в beforeApplicationShutdown.
  • БД и persistence — pool.end() в onApplicationShutdown, порядок фаз.
  • Фоновые задачи и outbox — SchedulerRegistry, worker.close(), outbox смотрит на isDraining().
  • Идемпотентность in-flight — retry-safe операции, Idempotency-Key, outbox-дедуп.