Health-проверки и сквозные interceptors
@nestjs/terminus собирает health-индикаторы за /health. Сеньорское разделение: liveness обязан быть без зависимостей (он рестартит под), readiness проверяет зависимости и гейтит LB. Сверху — TimeoutInterceptor + interceptor метрик.
У первичной базы был сорокасекундный сбой — 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: 2Readiness как рычаг 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 включал запросы, которые отвалились по таймауту. Как их упорядочить?
- 01Объясни различие liveness-vs-readiness и почему проверка зависимости (db.pingCheck) принадлежит только одной из них.
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.