Kubernetes, балансировщики и облачные платформы постоянно опрашивают ваш сервис: «ты жив?». Ответ на этот вопрос определяет, получит ли контейнер трафик и не запустит ли оркестратор его перезапуск. Неправильно настроенные проверки — одна из главных причин каскадных отказов: небольшая проблема в базе данных превращается в падение всего сервиса.
Зачем нужны два разных endpoint
Интуитивно кажется, что один /health — это всё что нужно. Но у Kubernetes есть два совершенно разных вопроса, и путать ответы на них опасно.
Liveness probe спрашивает: «процесс вообще живой?». Если нет — K8s убивает контейнер и запускает новый. Здесь нужно проверять только сам процесс: отвечает ли event loop, не завис ли Node.js. Внешние зависимости (база данных, Redis, сторонние API) проверять здесь нельзя.
Readiness probe спрашивает: «сервис готов принимать запросы?». Если нет — K8s просто убирает pod из балансировщика, не перезапуская его. Здесь уже проверяют подключение к базе данных и другие критичные внешние системы.
Почему это важно: если база данных лагает 30 секунд из-за служебной операции, правильное поведение — временно снять pod из балансировщика (readiness DOWN), но не перезапускать его (liveness UP). Если liveness тоже зависит от базы, K8s начнёт перезапускать все поды — и каждый новый pod столкнётся с той же лагающей базой. Весь сервис падает из-за временной проблемы.
Установка @nestjs/terminus
npm install @nestjs/terminus
Подключение в модуле:
import { TerminusModule } from '@nestjs/terminus';
@Module({
imports: [TerminusModule],
})
export class HealthModule {}
Базовая структура: liveness и readiness
Создаём индикатор для базы данных и контроллер с двумя разными endpoint-ами:
// src/management/pg-health.indicator.ts
import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import { Pool } from 'pg';
@Injectable()
export class PgHealthIndicator extends HealthIndicator {
constructor(private readonly pool: Pool) {
super();
}
async isHealthy(key: string): Promise<HealthIndicatorResult> {
try {
await this.pool.query('SELECT 1');
return this.getStatus(key, true);
} catch (err) {
const result = this.getStatus(key, false);
throw new HealthCheckError('pg ping failed', result);
}
}
}
// src/management/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
import { PgHealthIndicator } from './pg-health.indicator';
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: PgHealthIndicator,
) {}
@Get('live')
@HealthCheck()
liveness() {
return this.health.check([]); // пустой список — проверяем только сам процесс
}
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() => this.db.isHealthy('postgres'),
]);
}
}
| Endpoint | Что проверяет | Реакция K8s при DOWN |
|---|---|---|
/health/live | Процесс отвечает, event loop жив | Перезапустить pod |
/health/ready | БД, критичные зависимости | Убрать из балансировщика, не перезапускать |
Настройка probe в Kubernetes
spec:
containers:
- name: order-service
livenessProbe:
httpGet:
path: /health/live
port: 9090
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 9090
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 2
Порт 9090 здесь — отдельный management-порт, отделённый от основного бизнес-трафика на 3000. О том, как его поднять, написано ниже.
HealthIndicator с кешем для внешних систем
Kubernetes по умолчанию проверяет readiness каждые 5 секунд. При 10 репликах сервиса это 120 запросов в минуту только от health-check. Если каждая проверка делает реальный запрос к стороннему платёжному шлюзу или внешнему API — можно исчерпать лимиты ещё до прихода настоящих пользователей.
Решение — кешировать результат проверки на несколько секунд:
import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import { SberPaymentClient } from '../payment/sber-payment.client';
interface CachedResult {
result: HealthIndicatorResult;
expiresAt: number;
}
@Injectable()
export class SberPaymentHealthIndicator extends HealthIndicator {
private cache: CachedResult | null = null;
private readonly ttlMs = 10_000;
constructor(private readonly client: SberPaymentClient) {
super();
}
async isHealthy(key: string): Promise<HealthIndicatorResult> {
const now = Date.now();
if (this.cache && this.cache.expiresAt > now) {
return this.cache.result;
}
const result = await this.check(key);
this.cache = { result, expiresAt: now + this.ttlMs };
return result;
}
private async check(key: string): Promise<HealthIndicatorResult> {
try {
await this.client.ping();
return this.getStatus(key, true, { provider: 'sber' });
} catch (err) {
const result = this.getStatus(key, false, { provider: 'sber' });
throw new HealthCheckError('SberPayment ping failed', result);
}
}
}
Проверку SELECT 1 к собственной базе данных кешировать не нужно — она выполняется внутри уже открытого пула соединений и стоит очень дёшево.
/info с версией и git-коммитом
Во время инцидента первый вопрос всегда один: «какая версия задеплоена?». Endpoint /info отвечает на него без лишних движений.
В NestJS нет встроенного /actuator/info как в Spring Boot, поэтому делается простой контроллер, который читает переменные окружения, выставленные при сборке:
import { Controller, Get } from '@nestjs/common';
@Controller('info')
export class InfoController {
@Get()
info() {
return {
service: {
name: process.env.SERVICE_NAME ?? 'order-service',
version: process.env.APP_VERSION ?? 'unknown',
},
git: {
commitId: process.env.GIT_COMMIT_SHA ?? 'unknown',
branch: process.env.GIT_BRANCH ?? 'unknown',
},
build: {
time: process.env.BUILD_TIME ?? 'unknown',
},
};
}
}
В Dockerfile передаём значения через build args:
ARG GIT_COMMIT_SHA
ARG GIT_BRANCH
ARG BUILD_TIME
ARG APP_VERSION
ENV GIT_COMMIT_SHA=$GIT_COMMIT_SHA \
GIT_BRANCH=$GIT_BRANCH \
BUILD_TIME=$BUILD_TIME \
APP_VERSION=$APP_VERSION
Результат запроса к /info:
{
"service": { "name": "order-service", "version": "2.4.1" },
"git": { "commitId": "5380f21abc3d", "branch": "main" },
"build": { "time": "2026-06-18T14:22:00Z" }
}
Отдельный management-порт
Health-endpoint-ы и метрики лучше поднимать на отдельном порту, изолированном от бизнес-трафика. Это позволяет закрыть их сетевой политикой так, чтобы снаружи кластера они были недоступны, а Kubernetes мог опрашивать их изнутри.
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
const mgmt = await NestFactory.create(ManagementModule);
mgmt.setGlobalPrefix('');
await mgmt.listen(9090);
}
ManagementModule содержит только HealthController, InfoController и метрики. Бизнес-роуты на порту 9090 недоступны.
Частые ошибки
Бизнес-состояние в health check. Если pending-заказов больше тысячи — это бизнес-проблема, не техническая. Health DOWN при такой ситуации снимет pod из балансировщика, что только усугубит очередь. Бизнес-метрики нужно мониторить через Prometheus и алерты, а не через проверки здоровья.
Liveness зависит от базы данных. Классическая ловушка. База лагает тридцать секунд — K8s убивает pod, новый поднимается и попадает в ту же лагающую базу — убивает снова. За минуту могут упасть все реплики из-за временной задержки на стороне PostgreSQL.
Probe выполняет бизнес-операцию. «Создадим тестовый продукт и удалим» — это запрос к базе каждые пять секунд, умноженный на количество реплик. Плюс мусор в аналитике. Probe должна быть лёгкой: SELECT 1, ping или закешированный результат.
Коротко
- Liveness и readiness — два разных endpoint с разной семантикой: liveness проверяет только сам процесс, readiness — внешние зависимости.
- Путать их опасно: база лагает → liveness DOWN → K8s перезапускает всё → каскадный отказ.
- Для внешних API и платёжных шлюзов в
HealthIndicatorнужен кеш — без него probe дублирует реальный трафик × количество реплик. SELECT 1к своей базе кешировать не нужно — это дёшево./infoс git-sha и версией — обязательный endpoint для быстрой диагностики в продакшене.- Health-endpoint-ы и метрики выносите на отдельный порт (9090), закрытый от внешнего трафика.
Что почитать дальше
- Метрики в NestJS — prom-client, бизнес-метрики и Prometheus.
- Логирование в NestJS — nestjs-pino, структурные JSON-логи.
- Трейсинг в NestJS — OpenTelemetry, автоинструментация и ручные span-ы.