Опирается на правила:
R-SHUT-HTTP-1…R-SHUT-HTTP-3иR-SHUT-HTTP-X1из Graceful Shutdown Style Guide → раздел 2. HTTP drain.
Важно знать
app.enableShutdownHooks()вmain.ts— без него NestJS не слушает SIGTERM; процесс умирает сразу, in-flight → 502.server.close()перестаёт принимать новые соединения, но ждёт завершения активных ответов — неограниченно без force-таймера.closeIdleConnections()(Node ≥ 18.2) освобождает пустые keep-alive сокеты — без него drain зависает.preStop sleep 10в k8s обязателен даже при правильном NestJS graceful — k8s шлёт SIGTERM до того, как kube-proxy на других нодах обновит iptables.- Без
preStop— 5–15 секунд нового трафика на pod, который уже не принимает соединения → гарантированные 502.- Долгие синхронные endpoints (>10 сек) —
202 Accepted + pollingили задача сIdempotency-Key; синхронный handler съедает весь graceful timeout или обрывается на полпути.process.exit(0)в SIGTERM-хендлере аннулирует graceful — рвёт активные ответы немедленно.
HTTP drain — самая видимая часть graceful shutdown: любая 502 при rolling deploy — инцидент в метриках. NestJS предоставляет lifecycle-хуки (beforeApplicationShutdown → закрытие HTTP-сервера → onApplicationShutdown), но корректный дрейн требует двух слоёв защиты: правильно настроенного server.close() и preStop в k8s.
In-flight requests дожимаются
R-SHUT-HTTP-1: что происходит при app.close().
T=0 SIGTERM получен
T=0+ NestJS вызывает beforeApplicationShutdown() у всех injectable
T=0+ ShutdownStateService: draining = true → /health/ready → 503
T=0+ app.close(): server.close() — новые соединения не принимает
closeIdleConnections() — keep-alive сокеты освобождены
Активные ответы продолжают обрабатываться
T=5s k8s readiness probe видит 503, убирает pod из endpoints
(на других нодах kube-proxy ещё может не обновиться — см. preStop)
T=N Все активные ответы завершились или сработал force-таймер
T=N onApplicationShutdown() — pool.end(), disconnect()
T=N+ Процесс завершается
server.close() в Node.js ждёт in-flight неограниченно — нужен явный force-таймер, иначе shutdown будет висеть. Корректная связка:
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
const server: http.Server = app.getHttpServer();
const DRAIN_TIMEOUT_MS = 25_000;
server.on('listening', () => {
process.once('SIGTERM', async () => {
server.closeIdleConnections();
await Promise.race([
new Promise<void>((resolve) => server.close(() => resolve())),
new Promise<void>((resolve) =>
setTimeout(resolve, DRAIN_TIMEOUT_MS).unref(),
),
]);
});
});
await app.listen(3000);
}
bootstrap();
setTimeout(...).unref() — таймер не держит event loop живым, если все остальные задачи завершились раньше.
ShutdownStateService — единый источник readiness
R-SHUT-CFG-3: readiness → 503 ставится первым действием в beforeApplicationShutdown. Никакого разрозненного let shuttingDown по модулям.
@Injectable()
export class ShutdownStateService implements BeforeApplicationShutdown {
private draining = false;
isDraining(): boolean {
return this.draining;
}
beforeApplicationShutdown(): void {
this.draining = true;
}
}
@Injectable()
export class HealthService {
constructor(private readonly shutdownState: ShutdownStateService) {}
isReady(): boolean {
return !this.shutdownState.isDraining();
}
}
terminus-конфигурация:
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly healthService: HealthService,
) {}
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() =>
this.healthService.isReady()
? { ready: { status: 'up' } }
: Promise.reject(new Error('draining')),
]);
}
@Get('live')
@HealthCheck()
liveness() {
return this.health.check([]);
}
}
R-SHUT-CFG-4: /health/live и /health/ready — раздельные endpoints. liveness-падение перезапускает pod, readiness-падение убирает его из роутинга.
preStop sleep — обязательно
R-SHUT-HTTP-2: даже при идеальном NestJS graceful.
spec:
containers:
- name: order-service
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
terminationGracePeriodSeconds: 60
Почему preStop нужен
Последовательность k8s на удалении pod:
T=0 kubelet получил команду delete pod
T=0+ kubelet вызывает preStop hook (sleep 10)
Параллельно: endpoints-controller убирает pod из Service
Параллельно: kube-proxy на других нодах обновляет iptables
T=10s preStop завершился
T=10s kubelet отправляет SIGTERM процессу
T=10s+ NestJS graceful начинается
Без preStop:
T=0 SIGTERM отправлен сразу
T=0+ draining = true → /health/ready → 503
T=0+ k8s readiness probe: ещё не успела опросить (period 5s)
T=0+ Pod ещё в endpoints на других нодах (kube-proxy не обновился)
T=0..15s Новый трафик продолжает идти на этот pod
NestJS уже не принимает → 502 / connection refused
10 секунд закрывают типичное окно:
- kube-proxy iptables update — 1–5 секунд.
- Load balancer cache flush — 1–3 секунды.
- DNS TTL — единицы секунд.
На больших кластерах (1000+ nodes) — до 20 секунд. Для большинства сервисов 10 секунд достаточно.
За время preStop процесс продолжает принимать запросы: SIGTERM ещё не отправлен. После 10 секунд NestJS graceful занимается только in-flight запросами, новых больше нет.
Долгие endpoints
R-SHUT-HTTP-3: синхронный endpoint длиннее 10 секунд.
Сценарий поломки: POST /orders/export обрабатывается 40 секунд. В момент SIGTERM — 3 таких запроса в обработке. Force-таймер 25 секунд истекает раньше — обработка обрывается на полпути, клиент получает 500.
Вариант 1: 202 Accepted + polling
@Controller('orders')
export class OrderController {
constructor(private readonly dispatcher: OrderUseCaseDispatcher) {}
@Post('export')
async requestExport(
@Headers('Idempotency-Key') idempotencyKey: string,
@Body() dto: ExportOrdersDto,
): Promise<ExportRequestResponse> {
const jobId = await this.dispatcher.dispatch(
new RequestOrderExportCommand(idempotencyKey, dto),
);
return { jobId, status: 'QUEUED' };
}
@Get('export/:jobId')
async getExport(@Param('jobId') jobId: string): Promise<ExportStatusResponse> {
return this.dispatcher.dispatch(new GetOrderExportQuery(jobId));
}
}
POST возвращает 202 мгновенно (< 100ms). Клиент polling-ает GET до status: READY. На shutdown короткий POST не блокирует дрейн; фоновая задача продолжается до завершения или следующего деплоя (через outbox).
Вариант 2: задача с Idempotency-Key
Для CustomerService — долгая синхронизация профиля:
@Controller('customers')
export class CustomerController {
constructor(private readonly dispatcher: CustomerUseCaseDispatcher) {}
@Post(':id/sync')
async syncProfile(
@Param('id') customerId: string,
@Headers('Idempotency-Key') idempotencyKey: string,
@Body() dto: SyncProfileDto,
): Promise<SyncResponse> {
const taskId = await this.dispatcher.dispatch(
new SyncCustomerProfileCommand(customerId, idempotencyKey, dto),
);
return { taskId, status: 'ACCEPTED' };
}
}
Фоновая задача через @nestjs/bull (BullMQ) завершает работу при shutdown через worker.close() — подробнее в Фоновые задачи и outbox.
Вариант 3: декомпозиция запроса
Если endpoint ProductService медленный из-за N+1 запросов — это не архитектурная особенность, а ошибка. Оптимизированный запрос часто укладывается в 2–3 секунды и не создаёт проблем при дрейне.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
process.exit(0) в SIGTERM-хендлере | R-SHUT-HTTP-X1 | app.close() + lifecycle-хуки NestJS |
server.closeAllConnections() сразу при shutdown | R-SHUT-HTTP-X1 | server.close() + closeIdleConnections() для idle keep-alive |
Отсутствие preStop sleep | R-SHUT-HTTP-2 | sleep 10 обязателен в lifecycle |
preStop sleep < 5s на multi-node кластере | R-SHUT-HTTP-2 | минимум 10 секунд |
Отсутствие app.enableShutdownHooks() | R-SHUT-CFG-1 | включить в main.ts, без этого SIGTERM игнорируется |
| Синхронный endpoint длиннее 10 секунд | R-SHUT-HTTP-3 | 202 Accepted + polling или задача с Idempotency-Key |
let shuttingDown в случайном модуле вместо ShutdownStateService | R-SHUT-CFG-X1 | единый ShutdownStateService, связанный с /health/ready |
Долгие endpoints без Idempotency-Key | R-SHUT-HTTP-3 | заголовок обязателен для retry-безопасности |
Куда дальше
- Бюджеты и observability — раскладка 60-секундного бюджета по фазам.
- БД и persistence — порядок закрытия пула в
onApplicationShutdown. - Идемпотентность in-flight — retry-безопасность для операций, которые SIGTERM может прервать.
- Фоновые задачи и outbox —
SchedulerRegistry, BullMQworker.close(), outbox-relay с draining-флагом. - Kafka shutdown —
consumer.disconnect()с таймаутом, commit-семантика kafkajs. - Kubernetes —
terminationGracePeriodSeconds, probes,maxUnavailable: 0.