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

Health-проверки и сквозные interceptors

@nestjs/terminus собирает health-индикаторы за /health. Сеньорское разделение: liveness обязан быть без зависимостей (он рестартит под), readiness проверяет зависимости и гейтит LB. Сверху — TimeoutInterceptor + interceptor метрик.

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

У первичной базы был сорокасекундный сбой — failover, ничего драматичного. Через сорок секунд весь твой флот лежал — и оставался лежать ещё долго после того, как база восстановилась. Постмортем был короткий и жёсткий: кто-то вписал db.pingCheck('database') в livenessProbe. Когда база отвалилась, каждый под провалил liveness, и Kubernetes сделал ровно то, что предписано при проваленном liveness-пробе, — убил и перезапустил поды. Свежие поды тоже не могли достучаться до базы, снова проваливали liveness и снова убивались. Транзиентный сбой зависимости превратился в самоиндуцированный, кластерный шторм рестартов. Проблемой был не health-эндпоинт. Проблемой было то, что проверку зависимости поставили за liveness-пробу.

Terminus: один эндпоинт, много индикаторов

@nestjs/terminus — это health-check тулкит Nest. Ты инжектишь HealthCheckService и вызываешь check([...]) с массивом функций-индикаторов; check здоров, только если каждый индикатор отдаёт up. Terminus поставляет индикаторы под частые зависимости — TypeOrmHealthIndicator (гоняет SELECT 1 через pingCheck), HttpHealthIndicator.pingCheck(name, url), MemoryHealthIndicator.checkHeap(key, bytes), плюс варианты под Mongoose/Prisma/Redis/Disk — а кастомные ты пишешь через HealthIndicatorService. Декоратор @HealthCheck() навешивает Swagger/форму ответа.

import { Controller, Get } from '@nestjs/common';
import {
  HealthCheckService, HealthCheck,
  TypeOrmHealthIndicator, MemoryHealthIndicator,
} from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
    private memory: MemoryHealthIndicator,
  ) {}

  // LIVENESS: "процесс жив и не заклинил?" — БЕЗ downstream-зависимостей.
  @Get('live')
  @HealthCheck()
  liveness() {
    return this.health.check([
      () => this.memory.checkHeap('memory_heap', 512 * 1024 * 1024),
    ]);
  }

  // READINESS: "могу ли я обслуживать трафик прямо сейчас?" — зависимости тут МОЖНО.
  @Get('ready')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('database'),
    ]);
  }
}

Кастомный индикатор живёт по тому же контракту — HealthIndicatorService.check(key) даёт индикатор, который ты помечаешь up() или down({ ...detail }):

import { Injectable } from '@nestjs/common';
import { HealthIndicatorService } from '@nestjs/terminus';

@Injectable()
export class QueueHealthIndicator {
  constructor(private readonly hi: HealthIndicatorService) {}

  async isHealthy(key: string) {
    const indicator = this.hi.check(key);
    const depth = await this.queue.depth();
    return depth < 10_000 ? indicator.up({ depth }) : indicator.down({ depth });
  }
}

Liveness vs readiness: различие, предотвращающее аварии

Это и есть сеньорская суть, и хук — вся причина, почему она важна. Kubernetes гоняет две пробы с совершенно разной семантикой провала, и их смешение — это как небольшой сбой зависимости превращается в аварию на весь флот.

  • Liveness отвечает на «процесс жив и не заклинил?» Проваленная liveness-проба заставляет kubelet перезапустить под. Поэтому liveness никогда не должен зависеть от downstream-сервиса. Если твоя база, кэш или очередь на миг недоступны — это не повод убивать процесс: рестарт не починит внешний сбой, он лишь усилит его в crash loop. Держи liveness на уровне «event loop отвечает и процесс не в дедлоке».
  • Readiness отвечает на «могу ли я обслуживать трафик прямо сейчас?» Проваленная readiness-проба убирает под из endpoints Service / load balancer, но оставляет процесс работать. Так что readiness может и должен проверять критические зависимости, нужные, чтобы реально обслужить запрос. Когда база возвращается, readiness снова проходит и под ставят обратно в ротацию — без рестарта, без потери процесса, без шторма.

Соответствующие пробы Kubernetes указывают на два раздельных эндпоинта:

livenessProbe:
  httpGet: { path: /health/live, port: 3000 }
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /health/ready, port: 3000 }
  periodSeconds: 5
  failureThreshold: 2

Readiness как рычаг graceful shutdown

Тот же readiness-эндпоинт — это твой рычаг слива на шатдауне. На SIGTERM ты хочешь, чтобы load balancer перестал слать новый трафик до того, как ты начнёшь рушить процесс, — иначе in-flight запросы обрежутся. Так что верный порядок шатдауна такой: переключить readiness в down первым, дождаться, пока LB заметит и сольёт, затем закрыть сервер и соединения. Shutdown hooks Nest дают тебе этот шов:

@Injectable()
export class ReadinessState {
  private ready = true;
  isReady() { return this.ready; }
  setDraining() { this.ready = false; } // readiness-индикатор читает это
}

// В readiness-проверке гейти на этом состоянии, чтобы SIGTERM переключил её в 503 первой.
async beforeApplicationShutdown() {
  this.readinessState.setDraining();      // провалить readiness -> LB сливает нас
  await sleep(this.drainGraceMs);         // дай k8s несколько интервалов пробы
}

Именно этот порядок — readiness down, слив, затем закрытие — заставляет rolling deploy не потерять ни одного запроса. (Хук beforeApplicationShutdown срабатывает только если был вызван app.enableShutdownHooks(); app-level обвязка шатдауна живёт в уроке про graceful shutdown. Здесь суть — какой сигнал переключает readiness и почему он должен предшествовать teardown.)

Сквозные interceptors для устойчивости и наблюдаемости

Когда health-пробы расставлены верно, остаётся целый класс задач уровня запроса — что делать, если хендлер завис навсегда? кто измеряет задержку каждого запроса? — которые не принадлежат ни одному конкретному хендлеру. Если ты добавляешь один и тот же try/catch или таймер в десяток маршрутов — это сигнал: пора взять interceptor. Interceptors оборачивают next.handle() (RxJS Observable) с обеих сторон, что делает их верным домом для сквозного слоя, обрамляющего каждый запрос: таймаут, чтобы заклинивший downstream не держал запрос открытым вечно, метрики, чтобы видеть латентность и статус, и кэширование для горячих GET’ов. Канонический TimeoutInterceptor гоняет хендлер наперегонки с RxJS timeout() и конвертирует получившийся TimeoutError в нормальный RequestTimeoutException (408):

import {
  Injectable, NestInterceptor, ExecutionContext,
  CallHandler, RequestTimeoutException,
} from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    return next.handle().pipe(
      timeout(5000),
      catchError((err) =>
        err instanceof TimeoutError
          ? throwError(() => new RequestTimeoutException())
          : throwError(() => err),
      ),
    );
  }
}

Interceptor метрик пишет длительность и исход в гистограмму prom-client, тапая и успешный, и ошибочный путь:

@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  constructor(private readonly httpDuration: Histogram) {}
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const route = ctx.switchToHttp().getRequest().route?.path ?? 'unknown';
    const stop = this.httpDuration.startTimer({ route });
    return next.handle().pipe(
      tap(() => stop({ status: 'ok' })),
      catchError((err) => { stop({ status: 'error' }); return throwError(() => err); }),
    );
  }
}

Тонкость, которую сеньор обязан держать: глобальные interceptors композируются, а исходящий путь разматывается в обратном порядке. На входе они идут global → controller → route; стрим ответа разматывается route → controller → global, как возвраты вложенных функций. Так что порядок важен. Если хочешь, чтобы метрики писали реальную отданную латентность, включая таймаут, interceptor метрик должен оборачивать снаружи timeout-interceptor — иначе он остановит таймер до того, как сработает таймаут, и недосчитает хвостовую латентность.

Проверка / задачаЭндпоинт или компонентЗависит от downstream?k8s / эффект при провале
Liveness/health/live — только память, event loopНет — никогдаПод РЕСТАРТИТСЯ (убит + пересоздан)
Readiness/health/ready — db/cache/queue pingCheckДа — критические зависимостиПод СЛИТ из LB, процесс жив
ТаймаутTimeoutInterceptor (RxJS timeout)Оборачивает хендлер408 RequestTimeoutException, освобождает запрос
МетрикиMetricsInterceptor -> prom-clientОборачивает хендлер (самый внешний)Пишет длительность + статус, на запрос не влияет
Почему это работает

Почему метрики должны оборачивать снаружи таймаута, а не внутри? Входной порядок — это порядок регистрации глобальных interceptors; исходящий разматывается в обратном. Если TimeoutInterceptor самый внешний, timeout() срабатывает, и catchError interceptor’а метрик никогда не видит медленный запрос медленным — таймер уже остановлен, либо стрим зафейлился до того, как tap отработал на реальной длительности. Поставь метрики самыми внешними, и они обрамят весь запрос, включая путь таймаута, так что твой p99 отразит то, что клиенты реально пережили. Порядок — не косметика; он меняет то, что говорят твои дашборды.

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

У тебя Terminus health-эндпоинт, который гоняет db.pingCheck('database'). Куда его вписать, учитывая, что краткий сбой базы НЕ должен перезапускать каждый под во флоте?

Викторина

Проваленная liveness-проба Kubernetes против проваленной readiness-пробы — что вызывает каждая?

Викторина

Ты добавляешь interceptor метрик и timeout-interceptor глобально и хочешь, чтобы p99 включал запросы, которые отвалились по таймауту. Как их упорядочить?

Вспомните перед уходом
  1. 01
    Объясни различие liveness-vs-readiness и почему проверка зависимости (db.pingCheck) принадлежит только одной из них.
  2. 02
    Как Terminus и interceptors складываются в продакшен-слой health + устойчивости и почему порядок interceptors важен?
Итог

@nestjs/terminus отдаёт health за HealthCheckService.check([…]), агрегируя индикаторы — TypeOrmHealthIndicator.pingCheck, HttpHealthIndicator.pingCheck, MemoryHealthIndicator.checkHeap и кастомные через HealthIndicatorService.up()/down() — где check здоров, только если каждый индикатор up. Сеньорское различие — liveness vs readiness, и у него прямые последствия в Kubernetes. Liveness («процесс жив и не заклинил?») обязан быть без зависимостей, потому что проваленная liveness-проба РЕСТАРТИТ под, а рестарт никогда не починит внешний сбой — он лишь усиливает сбой зависимости в кластерный шторм рестартов. Readiness («могу ли я обслуживать трафик прямо сейчас?») проверяет критические зависимости, потому что проваленная readiness-проба лишь СЛИВАЕТ под из load balancer, оставляя процесс живым для восстановления. Так что вписывай db.pingCheck в /health/ready, никогда в /health/live. Readiness — ещё и рычаг graceful shutdown: на SIGTERM переключи его в down первым, чтобы LB слил под до teardown. Сверху сквозные interceptors несут устойчивость и наблюдаемость — TimeoutInterceptor (RxJS timeout() -> RequestTimeoutException), interceptor метрик в prom-client и CacheInterceptor для GET’ов. Глобальные interceptors композируются и разматываются в обратном порядке, так что порядок важен: метрики идут самыми внешними, чтобы p99 включал путь таймаута. Теперь, когда видишь высокий p99 на дашборде, можешь доверять цифре — она отражает то, что реально пережили клиенты, потому что interceptor метрик снаружи таймаута, а не спрятан внутри него.

Практика

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

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

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

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

Примени это

Примени этот урок в реальном проекте.

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

Trademarks belong to their respective owners. Editorial reference only.