При каждом деплое сервис перезапускается. Если в этот момент какой-то пользователь делал запрос — он получит ошибку 502. Это выглядит как случайный сбой, но на самом деле это предсказуемая и решаемая проблема.
Разберём, почему это происходит и как настроить NestJS, чтобы сервис корректно завершал текущие запросы перед остановкой.
Почему при деплое появляются 502
Когда Kubernetes решает остановить pod (например, при обновлении), он отправляет процессу сигнал SIGTERM. По умолчанию Node.js процесс завершается сразу — вместе со всеми незавершёнными запросами. Клиент, который ждал ответа, получает обрыв соединения — браузер или API-клиент показывают 502.
Graceful shutdown — это способность сервиса подождать завершения текущих запросов, прежде чем выключиться. Вместо «умереть сразу» — «доделать начатое, потом выключиться».
Для этого нужно:
- Настроить NestJS, чтобы он слушал SIGTERM.
- При получении сигнала — перестать принимать новые запросы, но дождаться завершения текущих.
- Добавить небольшую задержку в Kubernetes, чтобы трафик успел переключиться на другие поды.
Шаг 1: включить обработку SIGTERM в NestJS
По умолчанию NestJS не слушает SIGTERM. Одна строка это меняет:
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // без этого SIGTERM = мгновенная смерть
await app.listen(3000);
}
bootstrap();
enableShutdownHooks() подписывает NestJS на SIGTERM и запускает lifecycle-хуки при получении сигнала. Не добавляйте рядом ручной process.once('SIGTERM', ...) — два обработчика создадут гонку.
Шаг 2: правильно закрыть HTTP-сервер
Когда NestJS получил SIGTERM, он вызывает app.close(), который останавливает HTTP-сервер. Но здесь есть тонкость.
server.close() перестаёт принимать новые соединения, но ждёт завершения активных запросов — без ограничения по времени. Если какой-то запрос завис, сервер будет ждать вечно.
closeIdleConnections() (Node.js 18.2+) закрывает пустые keep-alive соединения. Без этого браузеры и HTTP-клиенты, которые держат соединение открытым «на будущее», будут блокировать завершение сервера.
Реализуем оба с ограничением по времени:
// http-drain.service.ts
import { Injectable, BeforeApplicationShutdown } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import * as http from 'http';
@Injectable()
export class HttpDrainService implements BeforeApplicationShutdown {
private readonly DRAIN_TIMEOUT_MS = 25_000;
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
async beforeApplicationShutdown(): Promise<void> {
const server: http.Server = this.httpAdapterHost.httpAdapter.getHttpServer();
server.closeIdleConnections();
await Promise.race([
new Promise<void>((resolve) => server.close(() => resolve())),
new Promise<void>((resolve) =>
setTimeout(resolve, this.DRAIN_TIMEOUT_MS).unref(),
),
]);
}
}
Здесь Promise.race выбирает что случится раньше: все запросы завершились или прошло 25 секунд. .unref() означает, что таймер не удерживает процесс — если запросы завершились раньше, Node.js спокойно выйдет.
Шаг 3: сообщить Kubernetes, что сервис готовится к остановке
При остановке нужно быстро убрать pod из ротации — чтобы Kubernetes перестал направлять на него новые запросы. Для этого используют readiness probe.
Создаём сервис, который отслеживает состояние:
@Injectable()
export class ShutdownStateService implements BeforeApplicationShutdown {
private draining = false;
isDraining(): boolean {
return this.draining;
}
beforeApplicationShutdown(): void {
this.draining = true;
}
}
И используем его в health-endpoint:
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly shutdownState: ShutdownStateService,
) {}
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() =>
this.shutdownState.isDraining()
? Promise.reject(new Error('draining'))
: { ready: { status: 'up' } },
]);
}
@Get('live')
@HealthCheck()
liveness() {
return this.health.check([]);
}
}
Как только NestJS получает SIGTERM, draining становится true, и /health/ready начинает возвращать 503. Kubernetes видит это и убирает pod из списка активных. Новые запросы больше не придут.
Важно иметь два отдельных endpoint: /health/live (живой ли процесс) и /health/ready (готов ли принимать запросы). Если сервис завис — перезапускаем. Если готовится к остановке — убираем из роутинга, но не перезапускаем.
Шаг 4: preStop sleep в Kubernetes
Даже при правильно настроенном NestJS есть проблема на уровне Kubernetes.
Когда pod удаляется, Kubernetes делает два дела одновременно:
- Отправляет процессу SIGTERM.
- Убирает pod из Service endpoints.
Но обновление iptables на других нодах — это асинхронная операция. Она занимает несколько секунд. В этот промежуток другие поды всё ещё могут отправлять трафик на остановившийся pod — и получать ошибки.
Решение — preStop hook. Это задержка перед отправкой SIGTERM:
spec:
containers:
- name: order-service
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
terminationGracePeriodSeconds: 60
Что происходит с preStop:
T=0 Kubernetes решил удалить pod
T=0+ Запускается preStop (sleep 10)
Kubernetes начинает убирать pod из endpoints и обновлять iptables
T=10s preStop завершился — теперь отправляется SIGTERM
T=10s+ NestJS начинает graceful shutdown
К этому моменту iptables на других нодах уже обновились
10 секунд хватает для большинства кластеров: обновление kube-proxy занимает 1–5 секунд, обновление load balancer — ещё 1–3 секунды. За время preStop сервис продолжает нормально работать — SIGTERM ещё не пришёл.
Долгие запросы
Иногда endpoint работает 30–40 секунд: экспорт данных, сложная обработка. При обычном подходе такой запрос просто оборвётся при деплое — force-таймер истечёт раньше.
Решение: не делать долгих синхронных запросов. Вместо «подожди 40 секунд» — «получи задачу и проверяй статус»:
@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' }; // возвращает за <100ms
}
@Get('export/:jobId')
async getExport(@Param('jobId') jobId: string): Promise<ExportStatusResponse> {
return this.dispatcher.dispatch(new GetOrderExportQuery(jobId));
}
}
POST /orders/export ставит задачу в очередь и сразу возвращает ID. Клиент периодически спрашивает GET /orders/export/:jobId и получает статус. При деплое короткий POST завершится за доли секунды — дрейн не блокируется.
Заголовок Idempotency-Key позволяет клиенту безопасно повторить запрос, если соединение прервалось.
Если endpoint медленный не из-за архитектуры, а из-за N+1 запросов к базе — это стоит исправить: оптимизированный запрос обычно укладывается в 2–3 секунды и не создаёт проблем.
Частые ошибки
process.exit(0) в обработчике SIGTERM. Это немедленно убивает процесс, рвёт активные соединения. Используйте app.close() — он запустит lifecycle-хуки и дождётся завершения.
server.closeAllConnections() при старте дрейна. Это рвёт все соединения, включая активные запросы. Используйте closeIdleConnections() — он закрывает только пустые keep-alive соединения.
Нет preStop sleep. Трафик продолжает идти на pod ещё несколько секунд после SIGTERM, пока iptables не обновится. Без задержки — гарантированные 502 при каждом деплое.
Нет app.enableShutdownHooks(). Без этого NestJS вообще не слушает SIGTERM. Процесс падает мгновенно.
Коротко
- При деплое Kubernetes посылает SIGTERM — без настройки процесс умирает сразу, активные запросы обрываются.
app.enableShutdownHooks()вmain.ts— обязательно, без этого SIGTERM игнорируется.server.close()ждёт завершения активных запросов,closeIdleConnections()освобождает пустые keep-alive сокеты.- Ставьте force-таймер (25 секунд) — иначе
server.close()может ждать бесконечно. ShutdownStateService+/health/ready→ 503 сразу при получении SIGTERM, чтобы Kubernetes убрал pod из ротации.preStop sleep 10в манифесте — обязателен: даёт время kube-proxy обновить iptables до SIGTERM.- Долгие синхронные запросы (>10 секунд) переводите на схему 202 Accepted + polling.
Что почитать дальше
- Бюджеты и observability — как расчитать бюджет по фазам и добавить метрики времени завершения.
- БД и persistence — в каком порядке закрывать пул соединений.
- Фоновые задачи и outbox — как корректно останавливать BullMQ worker'ы.
- Kafka shutdown — disconnect с таймаутом и commit-семантика.