Lifecycle-хуки и graceful shutdown
Nest запускает init-хуки снизу вверх (onModuleInit, затем onApplicationBootstrap) и shutdown-хуки в обратном порядке (onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown) — но только если вызвать enableShutdownHooks(), чтобы он слушал SIGTERM.
Деплой уходит в 14:02. Kubernetes перекатывает поды, и на девяносто секунд дашборд ошибок загорается: ECONNRESET, наполовину записанные строки, всплеск 502 от балансировщика. Новые поды здоровы; плохо ведут себя именно старые на выходе. Открываешь сервис и находишь PrismaService, у которого $connect() живёт в конструкторе, а $disconnect() — нигде, и main.ts, который никогда не вызывает enableShutdownHooks(). Так что когда k8s шлёт SIGTERM, Node просто умирает: запросы в полёте обрываются посреди записи, а пул соединений никогда не сливается. У Nest был хук на каждый из этих шагов. Никто их не подключил.
У lifecycle две фазы, у каждой фиксированный порядок
У Nest-приложения есть рождение и смерть, и оба — упорядоченные события, в которые можно встроиться. Знание порядка — это вся суть: оно говорит, когда твои внедрённые зависимости гарантированно готовы и когда их безопасно закрывать.
Init идёт снизу вверх. Когда ты вызываешь app.listen() (или app.init()), Nest резолвит граф зависимостей и затем запускает, на провайдер/модуль:
onModuleInit()— вызывается для модуля после того, как разрешены собственные зависимости этого модуля. Это первый момент, когда твои внедрённые провайдеры гарантированно сконструированы и связаны.onApplicationBootstrap()— вызывается после того, как каждый модуль закончил инициализацию, прямо перед тем, как сервер начнёт слушать. Это первый момент, когда готово всё приложение, включая провайдеры из других модулей.
Teardown идёт в обратном порядке. На app.close() или сигнал завершения Nest разматывает в обратном порядке зависимостей:
onModuleDestroy()— пришёл сигнал завершения; начинаем разбирать.beforeApplicationShutdown(signal)— выполняется после всех обработчиковonModuleDestroy(), до закрытия соединений; получает сигнал ("SIGTERM","SIGINT", …).onApplicationShutdown(signal)— выполняется после закрытия соединений; последний вздох, где ты освобождаешь пулы, consumer’ы очередей и клиентов.
Обратный порядок важен: модуль, зависящий от базы данных, разбирается до модуля базы — так что он никогда не пытается использовать соединение, которого уже нет. Каждый интерфейс вносит один метод — OnModuleInit, OnApplicationBootstrap, OnModuleDestroy, BeforeApplicationShutdown, OnApplicationShutdown — и ты реализуешь только нужные. Асинхронные хуки ожидаются: возврат Promise приостанавливает последовательность, пока он не зарезолвится.
onModuleInit против конструктора
Конструктор провайдера выполняется при инстанцировании, пока Nest ещё связывает граф. В этот момент твои внедрённые зависимости существуют (они сконструированы первыми), но любая нужная им асинхронная настройка — TCP-соединение, прогретый кэш, подтянутый конфиг — ещё не выполнилась. Делать настоящий I/O в конструкторе — ловушка: конструкторы не могут быть async, так что ты либо блокируешь синхронно, либо стреляешь и забываешь висящий промис, и у тебя нет чистого места обработать сбой.
onModuleInit() — это ответ. Когда нужно открыть соединение, прогреть кэш или убедиться, что внешний сервис доступен до прихода трафика, — именно сюда принадлежит этот код. Он выполняется один раз, сразу после разрешения зависимостей модуля, он может быть async, и Nest ожидает его перед тем, как двигаться дальше. Это делает его верным домом для прогрева, которому нужны готовые зависимости:
import { Injectable, OnModuleInit, OnApplicationShutdown } from '@nestjs/common';
import { Pool } from 'pg';
@Injectable()
export class DatabaseService implements OnModuleInit, OnApplicationShutdown {
private pool!: Pool;
constructor(private readonly config: ConfigService) {
// конструктор: зависимости есть, но НИКАКОГО async I/O здесь
this.pool = new Pool({ connectionString: this.config.get('DB_URL') });
}
async onModuleInit(): Promise<void> {
// прогрев: зависимости готовы, await учитывается
await this.pool.query('SELECT 1'); // докажем, что пул коннектится, до приёма трафика
}
async onApplicationShutdown(signal: string): Promise<void> {
// слив: соединения закрываются последними, после завершения работы в полёте
console.log(`shutting down on ${signal}`);
await this.pool.end(); // закрыть каждый клиент в пуле
}
}Разделение намеренное: конструируй дёшево, коннекться в onModuleInit, освобождай в onApplicationShutdown. Если запрос прогрева выбросит, Nest прервёт старт с понятной ошибкой, вместо того чтобы поднять сервис, который не может достучаться до базы. Используй onApplicationBootstrap вместо onModuleInit только когда прогрев зависит от того, что полностью поднят другой модуль — например, прогрев кэша из сервиса, объявленного в другом модуле, который сначала должен закончить свой onModuleInit.
Graceful shutdown, сделанный правильно
Shutdown-хуки не делают ничего, пока ты не подпишешься. app.enableShutdownHooks() регистрирует слушателей процесса на SIGTERM/SIGINT, чтобы Nest мог запустить teardown-последовательность; это выключено по умолчанию, потому что навешивание слушателей сигналов имеет цену, и Nest не станет навязывать её молча.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
// также принудительно закрыть простаивающие Keep-Alive сокеты, чтобы процесс реально вышел
forceCloseConnections: true,
});
app.enableShutdownHooks(); // ОБЯЗАТЕЛЬНО: иначе SIGTERM просто убьёт процесс
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();Теперь фактическая последовательность на SIGTERM: перестать принимать новую работу, слить запросы в полёте, затем закрыть ресурсы. HTTP-адаптер Nest останавливает сервер и ждёт завершения открытых ответов, прежде чем зарезолвить закрытие, — но долгоживущие сокеты Connection: Keep-Alive могут держать процесс в заложниках, и поэтому существует forceCloseConnections. Как только запросы слиты, твой onApplicationShutdown закрывает пул базы, consumer’ы очередей и Kafka-клиентов — в обратном порядке зависимостей, так что ничто не закрывает ресурс, который ещё нужен другому провайдеру.
| Хук | Фаза | Срабатывает когда | Для чего |
|---|---|---|---|
| onModuleInit | Init (1-й) | После разрешения зависимостей этого модуля | Коннект, прогрев кэша, warm-up, которому нужны внедрённые зависимости |
| onApplicationBootstrap | Init (2-й) | После инициализации ВСЕХ модулей, до listen | Кросс-модульный прогрев, которому нужно всё приложение |
| onModuleDestroy | Down (1-й) | Сигнал пришёл; teardown начинается | Остановить consumer’ы, сбросить буферы |
| beforeApplicationShutdown | Down (2-й) | После onModuleDestroy, до закрытия соединений | Работа последнего шанса с живыми соединениями; читает сигнал |
| onApplicationShutdown | Down (3-й) | После закрытия соединений | Освободить пулы, клиентов, сокеты; читает сигнал |
▸Почему это работает
Почему Kubernetes делает это срочным? На rolling-обновлении k8s шлёт SIGTERM и ждёт terminationGracePeriodSeconds (по умолчанию 30с), прежде чем послать неостановимый SIGKILL. Если ты никогда не включал shutdown-хуки, ничего не сливается, и SIGKILL обрывает каждое соединение. Но есть и более тонкая гонка: k8s убирает под из endpoints сервиса и шлёт SIGTERM примерно параллельно, так что на мгновение балансировщик может всё ещё направлять новые запросы в завершающийся под. Сеньорский фикс — сначала переключить readiness-пробу в провал: под выходит из ротации балансировщика, трафик перестаёт приходить, и только потом ты сливаешь и закрываешь. Поэтому graceful shutdown — это вопрос сети не меньше, чем вопрос lifecycle.
Провайдеру нужно открыть соединение с базой на старте. Где должен жить вызов connect(), чтобы соединение было готово до прихода трафика, а сбои чисто прерывали загрузку?
В каком порядке срабатывают init- и shutdown-хуки Nest?
Твой onApplicationShutdown никогда не срабатывает, когда Kubernetes шлёт SIGTERM. Какова самая вероятная причина?
- 01Назови полный порядок lifecycle Nest для init и shutdown и объясни, почему teardown идёт в обратном порядке.
- 02Почему нужен enableShutdownHooks() и как graceful shutdown связан с Kubernetes?
У Nest-приложения есть упорядоченные рождение и смерть, в которые можно встроиться. Init идёт снизу вверх: onModuleInit срабатывает для каждого модуля в момент разрешения его собственных зависимостей — верный, осведомлённый об async дом для прогрева вроде открытия соединения или прогрева кэша, — а onApplicationBootstrap срабатывает один раз после того, как поднят каждый модуль, для кросс-модульного прогрева, которому нужно всё приложение. Конструктор — неверное место для этой работы, потому что он не может быть async и выполняется, пока граф ещё связывается. Teardown идёт в обратном порядке зависимостей: onModuleDestroy, затем beforeApplicationShutdown(signal) до закрытия соединений, затем onApplicationShutdown(signal) после их закрытия — слот для освобождения пулов, consumer’ов очередей и клиентов. Ни один shutdown-хук не сработает, пока ты не вызовешь app.enableShutdownHooks(), который опционален, потому что навешивает слушателей SIGTERM/SIGINT; цена — причина, по которой Nest не делает это молча. Graceful shutdown, сделанный правильно, — это: перестать принимать новую работу, слить запросы в полёте (forceCloseConnections для упрямых Keep-Alive сокетов), затем закрыть ресурсы — а в Kubernetes ты сначала переключаешь readiness-пробу в провал, чтобы балансировщик вывел под до слива, всё в пределах terminationGracePeriodSeconds до SIGKILL. Классический баг — коннект в конструкторе и закрытие нигде, со shutdown-хуками, которые никогда не включены, так что деплой обрывает запросы и теряет соединения. Теперь, когда увидишь всплеск ошибок на rolling-обновлении, ты знаешь, где искать: нет enableShutdownHooks(), а connect() живёт в конструкторе — и знаешь, как это починить.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.