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

Хуки жизненного цикла и graceful shutdown

Nest зовёт хуки снизу вверх на старте и в обратном порядке на остановке — но shutdown-хуки ВЫКЛЮЧЕНЫ, пока ты не вызвал enableShutdownHooks(). Пропусти его — и SIGTERM убьёт процесс мгновенно: пул оборван на запросе, консьюмер не закоммитил. Дренируй под 30с.

NEST Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Деплой выглядел чисто. Kubernetes поднял новый ReplicaSet, послал старым подам SIGTERM, и дашборд позеленел. Потом пошли тикеты в поддержку: тонкая струйка Connection terminated unexpectedly и ECONNRESET на оформлении заказа плюс очередь Kafka-сообщений, которые ни с того ни с сего переобрабатывались. В новых подах ошибок не было — урон был в старых, в той полусекунде между SIGTERM и выходом процесса. Node получил сигнал и вышел немедленно, потому что никто не вызвал app.enableShutdownHooks(). Открытые соединения пула pg были оборваны посреди запроса, HTTP-запросы в полёте просто уронили на пол, а Kafka-консьюмер так и не закоммитил свои оффсеты, так что на следующем опросе эти сообщения выглядели необработанными. Каждая чистка, которую наш код аккуратно прописал в onModuleDestroy, не выполнилась ни разу. Этот урок — про жизненный цикл, который даёт тебе Nest, и про ту одну строку, что решает, будет ли остановка graceful или гильотиной.

Старт: снизу вверх, дочерние модули раньше родителей

Задаёшься вопросом «можно ли запустить поллер в onModuleInit?» или «почему соединение соседнего модуля выглядело неготовым?» — ответ определяется порядком старта. Пойми его — и запуск детерминирован; ошибись — и стреляешь в полу-инициализированный граф.

Nest стартует в два прохода. Сначала он конструирует весь DI-граф — каждый провайдер, про который тебе рассказал L02. Потом он обходит модули и зовёт init-хуки. Здесь важны два хука, и порядок между ними — то, что путают чаще всего.

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

import { Injectable, OnModuleInit, OnApplicationBootstrap } from '@nestjs/common';

@Injectable()
export class IndexerService implements OnModuleInit, OnApplicationBootstrap {
  // runs when THIS module's providers are ready — children before parents.
  // Хуки могут быть async; Nest их ожидает перед продолжением.
  async onModuleInit() {
    await this.warmLocalCache(); // нужны только зависимости ЭТОГО модуля
  }

  // срабатывает, когда ВЕСЬ граф приложения жив — безопасно обращаться к другим модулям.
  async onApplicationBootstrap() {
    this.startPoller(); // зависит от инициализации соседнего модуля
  }
}

Различие не косметическое. В onModuleInit модуля A гарантированно готовы только сам A и модули, которые A импортирует. Соседний модуль B, к которому ты тянешься поперёк, — скажем, шина сообщений, в которую ты публикуешь, — может ещё не отработать свой onModuleInit. Запусти поллер там — и он может стрельнуть в полу-инициализированный граф.

Почему это работает

Почему запускать поллер в onApplicationBootstrap, а не в onModuleInit? Потому что onModuleInit гарантирует готовность только этого модуля и его прямых импортов — Nest зовёт его в тот же миг, как собственные провайдеры модуля зарезолвились, что может быть задолго до того, как соседние модули, на которые ты завязан поперёк графа, инициализировались. Поллеру, крону, «опубликовать стартовое событие» — всему, что тянется через граф модулей, — нужно, чтобы было живо всё приложение. onApplicationBootstrap — единственная точка, где Nest гарантирует, что каждый модуль закончил onModuleInit. Используй onModuleInit для самодостаточного прогрева (кэш этого модуля, проверка подключения этого модуля); используй onApplicationBootstrap в тот момент, когда работа касается другой части графа.

Остановка: обратный порядок и ВЫКЛ по умолчанию

Остановка прогоняет жизненный цикл задом наперёд. Порядок такой: onModuleDestroy()beforeApplicationShutdown(signal?)onApplicationShutdown(signal?), а модули разрушаются в обратном порядке к старту: зависящие раньше зависимостей. Этот порядок намеренный — модуль успевает прибраться до того, как исчезнут модули, от которых он зависит, так что твой UsersModule может сбросить буферы прежде, чем DatabaseModule, на котором он стоит, закроет свой пул. Последние два хука получают строку сигнала ('SIGTERM', 'SIGINT'), так что ты можешь ветвиться по причине остановки.

import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { Pool } from 'pg';

@Injectable()
export class Database implements OnModuleDestroy {
  private pool = new Pool(/* ... */);

  // Nest ЖДЁТ это при остановке — дренируй в-полёте, потом закрой чисто.
  async onModuleDestroy() {
    await this.pool.end(); // ждёт активные запросы, затем освобождает сокеты
    // для брокера: await this.consumer.disconnect() — коммит оффсетов, отписка
  }
}

Вот та ловушка, что породила инцидент. Ни один из этих хуков не выполняется по умолчанию. Nest не слушает сигналы завершения ОС, пока ты явно не подпишешься, — потому что регистрация слушателей сигналов на уровне процесса имеет небольшую цену на каждый слушатель, и фреймворк отказывается навязывать её каждому приложению молча. Включаешь это одной строкой в main.ts:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // WITHOUT this line, SIGTERM exits the process immediately and NO
  // onModuleDestroy / onApplicationShutdown ever runs — pools severed,
  // requests dropped, broker offsets uncommitted.
  app.enableShutdownHooks();

  await app.listen(3000);
}
bootstrap();

Забудь эту строку — и SIGTERM в части твоей чистки ведёт себя как kill -9: цикл событий так и не получает шанса выполнить твой pool.end(), консьюмер не отключается, а Kubernetes радостно рапортует об успешном раскате, пока старые поды текут недоделанной работой.

Два режима отказа: нет дренажа и дренаж, который не кончается

Первый режим отказа — это инцидент выше: нет enableShutdownHooks(), так что SIGTERM обрубает всё. Фикс — та самая одна строка плюс настоящая чистка в onModuleDestroyawait pool.end(), await consumer.disconnect(), — чтобы пулы дренировались, а брокер закоммитил до того, как процесс уйдёт.

Но есть симметричный второй режим отказа, и сеньоры попадают в него после того, как включили хуки: shutdown-хук, который зависает. Если onModuleDestroy ждёт промис, который никогда не резолвится, или ты написал 60-секундный дренаж, Kubernetes не ждёт вечно. Он ждёт terminationGracePeriodSecondsпо умолчанию 30 секунд, — затем посылает SIGKILL и всё равно обрубает твою чистку. Хук, который блокируется дольше grace-периода, не безопаснее, чем отсутствие хука; он просто падает позже и запутаннее. Так что дренаж обязан укладываться с запасом под 30 секунд: вменяемый бюджет — 5–15 секунд. И дренажа запросов самого по себе мало — нужно ещё, чтобы балансировщик перестал слать новые, прежде чем ты перестанешь принимать. Переключи readiness-пробу в unready на SIGTERM и добавь preStop sleep около 5 секунд, чтобы дерегистрация эндпоинта успела распространиться через kube-proxy до того, как приложение реально ляжет. Иначе ты идеально сдренируешь старые запросы и потом уронишь новые, которые пришли в это окно.

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

У тебя Nest-сервис в Kubernetes с пулом pg и Kafka-консьюмером. Rolling-деплой шлёт старым подам SIGTERM. Как сделать так, чтобы сервис дренировался чисто, не роняя запросы и не теряя оффсеты брокера?

Викторина

Твои провайдеры реализуют onModuleDestroy, чтобы закрыть пул pg, но main.ts ни разу не вызывает app.enableShutdownHooks(). Под получает SIGTERM. Что выполнится?

Викторина

Тебе нужно запустить фоновый поллер, который публикует в шину сообщений, принадлежащую СОСЕДНЕМУ модулю. Какой хук жизненного цикла верный и почему?

Вспомните перед уходом
  1. 01
    Пройди весь жизненный цикл Nest: какие init-хуки срабатывают и в каком порядке, какие shutdown-хуки срабатывают и в каком порядке, и что вообще пускает сторону остановки в ход.
  2. 02
    Rolling-деплой Kubernetes шлёт SIGTERM твоему Nest-поду. Опиши оба способа, которыми остановка идёт не так, и полный фикс с реальными числами.
Итог

Nest прогоняет твои провайдеры через жизненный цикл в два прохода. На старте он строит весь DI-граф, затем зовёт onModuleInit() на каждый модуль — дочерние раньше родителей, как только резолвятся собственные провайдеры модуля — и onApplicationBootstrap() один раз, после того как каждый модуль инициализировался; используй onModuleInit для самодостаточного прогрева, а onApplicationBootstrap для всего, что тянется к соседнему модулю, вроде запуска поллера, потому что onModuleInit гарантирует готовность только этого модуля и его импортов. На остановке порядок обращается: onModuleDestroy() → beforeApplicationShutdown(signal?) → onApplicationShutdown(signal?), разрушая зависящих раньше зависимостей, чтобы модуль прибрался до исчезновения своих зависимостей, со строкой сигнала, передаваемой в последние два. Решающая ловушка в том, что shutdown-хуки ВЫКЛючены, пока ты не вызовешь app.enableShutdownHooks() в main.ts — Nest не регистрирует слушатели сигналов ОС по умолчанию, потому что у каждого небольшая цена, — так что без этой одной строки SIGTERM выходит из процесса немедленно, и никакая чистка не выполняется: пул pg оборван посреди запроса (Connection terminated unexpectedly / ECONNRESET), запросы в полёте уронены, а Kafka-консьюмер не коммитит оффсеты. Фикс — эта строка плюс настоящая чистка в onModuleDestroy (await pool.end(), await consumer.disconnect()). Симметричная ловушка — зависший хук: Kubernetes ждёт лишь terminationGracePeriodSeconds (по умолчанию 30с), затем делает SIGKILL, так что дренаж обязан закончиться сильно раньше (бюджет 5–15с), и тебе нужно ещё переключить readiness-пробу в unready и добавить preStop sleep ~5с, чтобы балансировщик перестал направлять трафик прежде, чем ты перестанешь принимать, — иначе ты сдренируешь старые запросы и уронишь новые, пришедшие в это окно. Теперь, когда после rolling-деплоя увидишь ECONNRESET-ошибки или переобработку Kafka-сообщений, — первым делом смотри на main.ts в поисках enableShutdownHooks(): эта одна пропущенная строка — корневая причина чаще всего остального.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.