← назад к разделу

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-ы.