open atlas
↑ К треку
NestJS с нуля до senior NEST · 03 · 03

Lifecycle-хуки и graceful shutdown

Nest запускает init-хуки снизу вверх (onModuleInit, затем onApplicationBootstrap) и shutdown-хуки в обратном порядке (onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown) — но только если вызвать enableShutdownHooks(), чтобы он слушал SIGTERM.

NEST Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Деплой уходит в 14:02. Kubernetes перекатывает поды, и на девяносто секунд дашборд ошибок загорается: ECONNRESET, наполовину записанные строки, всплеск 502 от балансировщика. Новые поды здоровы; плохо ведут себя именно старые на выходе. Открываешь сервис и находишь PrismaService, у которого $connect() живёт в конструкторе, а $disconnect() — нигде, и main.ts, который никогда не вызывает enableShutdownHooks(). Так что когда k8s шлёт SIGTERM, Node просто умирает: запросы в полёте обрываются посреди записи, а пул соединений никогда не сливается. У Nest был хук на каждый из этих шагов. Никто их не подключил.

У lifecycle две фазы, у каждой фиксированный порядок

У Nest-приложения есть рождение и смерть, и оба — упорядоченные события, в которые можно встроиться. Знание порядка — это вся суть: оно говорит, когда твои внедрённые зависимости гарантированно готовы и когда их безопасно закрывать.

Init идёт снизу вверх. Когда ты вызываешь app.listen() (или app.init()), Nest резолвит граф зависимостей и затем запускает, на провайдер/модуль:

  1. onModuleInit() — вызывается для модуля после того, как разрешены собственные зависимости этого модуля. Это первый момент, когда твои внедрённые провайдеры гарантированно сконструированы и связаны.
  2. onApplicationBootstrap() — вызывается после того, как каждый модуль закончил инициализацию, прямо перед тем, как сервер начнёт слушать. Это первый момент, когда готово всё приложение, включая провайдеры из других модулей.

Teardown идёт в обратном порядке. На app.close() или сигнал завершения Nest разматывает в обратном порядке зависимостей:

  1. onModuleDestroy() — пришёл сигнал завершения; начинаем разбирать.
  2. beforeApplicationShutdown(signal) — выполняется после всех обработчиков onModuleDestroy(), до закрытия соединений; получает сигнал ("SIGTERM", "SIGINT", …).
  3. 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-клиентов — в обратном порядке зависимостей, так что ничто не закрывает ресурс, который ещё нужен другому провайдеру.

ХукФазаСрабатывает когдаДля чего
onModuleInitInit (1-й)После разрешения зависимостей этого модуляКоннект, прогрев кэша, warm-up, которому нужны внедрённые зависимости
onApplicationBootstrapInit (2-й)После инициализации ВСЕХ модулей, до listenКросс-модульный прогрев, которому нужно всё приложение
onModuleDestroyDown (1-й)Сигнал пришёл; teardown начинаетсяОстановить consumer’ы, сбросить буферы
beforeApplicationShutdownDown (2-й)После onModuleDestroy, до закрытия соединенийРабота последнего шанса с живыми соединениями; читает сигнал
onApplicationShutdownDown (3-й)После закрытия соединенийОсвободить пулы, клиентов, сокеты; читает сигнал
Почему это работает

Почему Kubernetes делает это срочным? На rolling-обновлении k8s шлёт SIGTERM и ждёт terminationGracePeriodSeconds (по умолчанию 30с), прежде чем послать неостановимый SIGKILL. Если ты никогда не включал shutdown-хуки, ничего не сливается, и SIGKILL обрывает каждое соединение. Но есть и более тонкая гонка: k8s убирает под из endpoints сервиса и шлёт SIGTERM примерно параллельно, так что на мгновение балансировщик может всё ещё направлять новые запросы в завершающийся под. Сеньорский фикс — сначала переключить readiness-пробу в провал: под выходит из ротации балансировщика, трафик перестаёт приходить, и только потом ты сливаешь и закрываешь. Поэтому graceful shutdown — это вопрос сети не меньше, чем вопрос lifecycle.

Выбери лучший вариант

Провайдеру нужно открыть соединение с базой на старте. Где должен жить вызов connect(), чтобы соединение было готово до прихода трафика, а сбои чисто прерывали загрузку?

Викторина

В каком порядке срабатывают init- и shutdown-хуки Nest?

Викторина

Твой onApplicationShutdown никогда не срабатывает, когда Kubernetes шлёт SIGTERM. Какова самая вероятная причина?

Вспомните перед уходом
  1. 01
    Назови полный порядок lifecycle Nest для init и shutdown и объясни, почему teardown идёт в обратном порядке.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.