Опирается на правила:
R-SHUT-CFG-1…R-SHUT-CFG-4иR-SHUT-CFG-X1из Graceful Shutdown Style Guide → раздел 1. Runtime/конфигурация.
Важно знать
app.enableShutdownHooks()обязателен — без него Nest не перехватывает SIGTERM, процесс умирает мгновенно, активные HTTP → 502.ShutdownStateService.isDraining()— единственный источник состояния;/health/readyчитает именно его, k8s узнаёт о начале дрейна через probe.server.close()ждёт in-flight неограниченно — нужен force-таймаут черезPromise.raceсsetTimeout(...).unref(), иначе pod не уложится в 60-секундный бюджет.server.closeIdleConnections()(Node ≥ 18.2) обязателен при keep-alive соединениях — без него drain зависает на пустых сокетах.beforeApplicationShutdownзапускается до закрытия HTTP-сервера,onApplicationShutdown— после; порядок фаз нельзя нарушать вручную.- Свой
let shuttingDown = falseв случайном модуле — не интегрируется с terminus, k8s не узнает.- Без правильной конфигурации rolling deploy = шторм 502 на клиентах в момент смены pod.
Graceful shutdown — не «одна опция», а последовательность: переключить readiness → дать k8s 10 секунд убрать pod из endpoints → дожать активные запросы → закрыть пул БД → exit. NestJS реализует эту последовательность через lifecycle-хуки: beforeApplicationShutdown → server.close() → onApplicationShutdown. UCP формулирует минимальный набор настроек, которые покрывают последовательность без потерь.
app.enableShutdownHooks()
R-SHUT-CFG-1: первое и главное — Nest должен слушать системные сигналы.
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(3000);
}
bootstrap();
Что делает:
- Nest подписывается на
SIGTERM,SIGINT,SIGHUP. - На сигнале вызывает
app.close(), который проходит lifecycle-фазы. - HTTP-сервер получает
server.close()— новые соединения не принимаются, активные ответы завершаются.
Без enableShutdownHooks() — Node получает SIGTERM и завершается немедленно через process.exit. Lifecycle-хуки не запускаются, beforeApplicationShutdown не вызывается, активные HTTP-запросы прерываются на клиенте с 502 Bad Gateway или Connection reset.
Force-deadline поверх server.close()
R-SHUT-CFG-2: явный graceful-deadline ~30s.
В отличие от Tomcat, где timeout-per-shutdown-phase настраивается в application.yml, Node's server.close() ждёт в полёте неограниченно — если один keep-alive клиент не закрыл соединение, дрейн не завершится. Нужен принудительный таймаут:
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
const server = app.getHttpServer();
// Force-deadline: если server.close() не завершился за 30s — принудительно
const originalClose = app.close.bind(app);
app.close = async () => {
await Promise.race([
originalClose(),
new Promise<void>((resolve) => {
const t = setTimeout(resolve, 30_000);
t.unref(); // не держит event loop
}),
]);
if (server.listening) {
server.closeIdleConnections(); // Node >= 18.2: закрыть idle keep-alive
}
};
await app.listen(3000);
}
Trade-off по таймауту:
- < 20s — мало для дрейна под нагрузкой; долгие запросы (p99 ~5s) прерываются.
- > 45s — риск SIGKILL внутри
terminationGracePeriodSeconds: 60(см. Kubernetes). - 30s — рабочий баланс для типичных REST-сервисов.
Если есть долгие синхронные эндпоинты — декомпозируйте их (R-SHUT-HTTP-3, HTTP drain), не увеличивайте таймаут.
ShutdownStateService — единственный источник состояния
R-SHUT-CFG-3: readiness переключается в 503 первым на SIGTERM.
// shutdown-state.service.ts
import { Injectable, BeforeApplicationShutdown, Logger } from '@nestjs/common';
@Injectable()
export class ShutdownStateService implements BeforeApplicationShutdown {
private readonly logger = new Logger(ShutdownStateService.name);
private draining = false;
isDraining(): boolean {
return this.draining;
}
beforeApplicationShutdown(signal: string): void {
this.logger.log(`Получили ${signal}, начинаем graceful shutdown`);
this.draining = true;
// /health/ready теперь вернёт 503 — k8s уберёт pod из endpoints
}
}
Terminus-проба читает этот флаг:
// health.module.ts
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
import { ShutdownStateService } from '../shutdown/shutdown-state.service';
@Module({
imports: [TerminusModule],
controllers: [HealthController],
providers: [ShutdownStateService],
exports: [ShutdownStateService],
})
export class HealthModule {}
// health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, HealthCheckResult } from '@nestjs/terminus';
import { ShutdownStateService } from '../shutdown/shutdown-state.service';
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly shutdownState: ShutdownStateService,
) {}
@Get('live')
@HealthCheck()
liveness(): Promise<HealthCheckResult> {
return this.health.check([]); // процесс жив — всегда 200
}
@Get('ready')
@HealthCheck()
readiness(): Promise<HealthCheckResult> {
return this.health.check([
() =>
this.shutdownState.isDraining()
? Promise.resolve({ readiness: { status: 'down' } })
: Promise.resolve({ readiness: { status: 'up' } }),
]);
}
}
Что происходит при SIGTERM:
beforeApplicationShutdownустанавливаетdraining = true./health/readyначинает возвращать 503.- K8s readiness probe (опрос каждые 5s) видит fail.
- K8s endpoints-controller убирает pod из Service routing.
- Через preStop-задержку (10s) новый трафик перестаёт поступать.
server.close()дожимает оставшиеся in-flight запросы.
Пример использования ShutdownStateService в сервисе для отказа от новых задач на shutdown:
// order.service.ts
import { Injectable } from '@nestjs/common';
import { ShutdownStateService } from '../shutdown/shutdown-state.service';
@Injectable()
export class OrderService {
constructor(private readonly shutdownState: ShutdownStateService) {}
async createOrder(customerId: string, productId: string): Promise<void> {
if (this.shutdownState.isDraining()) {
throw new Error('Сервис завершает работу, новые заказы не принимаются');
}
// бизнес-логика создания заказа Customer → Product
}
}
Раздельные /health/live и /health/ready
R-SHUT-CFG-4: две отдельные пробы с разной семантикой.
// k8s/deployment.yaml (фрагмент)
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
На shutdown нужен именно readiness=503, а не liveness=503:
- Readiness=503 → k8s убирает pod из endpoints, трафик перестаёт поступать.
- Liveness=503 → k8s перезапускает pod — что разрушит graceful shutdown.
ShutdownStateService влияет только на readiness; liveness всегда возвращает 200, пока процесс жив.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Нет app.enableShutdownHooks() | R-SHUT-CFG-1 | обязателен в main.ts |
let shuttingDown = false в модуле, не связанный с terminus | R-SHUT-CFG-X1 | ShutdownStateService.isDraining() |
server.close() без force-таймаута | R-SHUT-CFG-2 | Promise.race с setTimeout(30_000).unref() |
Keep-alive drain без closeIdleConnections() | R-SHUT-HTTP-1 | server.closeIdleConnections() (Node ≥ 18.2) |
process.exit(0) в собственном SIGTERM-обработчике | R-SHUT-HTTP-X1 | Nest lifecycle через enableShutdownHooks() |
| Раздельные live/ready отсутствуют | R-SHUT-CFG-4 | отдельные /health/live + /health/ready через terminus |
pool.end() в beforeApplicationShutdown | R-SHUT-DB-X1 | только в onApplicationShutdown, после дрейна HTTP |
Куда дальше
- HTTP drain — что происходит с активными запросами,
closeIdleConnections, keep-alive. - Kafka shutdown —
consumer.disconnect()с таймаутом,producer.disconnect(). - БД и persistence —
pool.end()в правильной фазе. - Kubernetes — preStop sleep,
terminationGracePeriodSeconds, maxUnavailable. - Фоновые задачи и outbox —
@nestjs/schedule, BullMQ, outbox-relay. - Бюджеты и observability — раскладка 60-секундного бюджета.