Опирается на правила:
R-SHUT-K8S-1…R-SHUT-K8S-3иR-SHUT-K8S-X1…R-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
| Probe | Endpoint | Действие 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:
- NestJS вызывает
beforeApplicationShutdown()—draining = true. /health/readyвозвращает 503.- K8s через
periodSeconds: 5×failureThreshold: 2= 10s фиксирует fail. - 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%.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Нет preStop | R-SHUT-K8S-X1 | exec: sleep 10 обязателен |
terminationGracePeriodSeconds: 30 (default) | R-SHUT-K8S-X2 | 60 |
Один /health endpoint для обеих probes | R-SHUT-K8S-2 | отдельные /health/live и /health/ready |
| Liveness зависит от БД / внешнего сервиса | R-SHUT-K8S-2 | только readiness проверяет зависимости |
maxUnavailable > 0 без обоснования | R-SHUT-K8S-3 | 0 для zero-downtime |
maxSurge: 0 | R-SHUT-K8S-3 | maxSurge: 1 минимум |
initialDelaySeconds: 0 на liveness | R-SHUT-K8S-2 | 20–30s для прогрева NestJS |
preStop через httpGet (flaky при завершении) | R-SHUT-K8S-X1 | exec sleep |
Свой let shuttingDown = false вместо ShutdownStateService | R-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-дедуп.