Опирается на правила: R-RES-HC-1R-RES-HC-4 и R-RES-HC-X1R-RES-HC-X2 из Resilience Rules → раздел 10. Health checks.

Важно знать

  • На каждую внешнюю систему — отдельный HealthIndicator-класс: SberHealthIndicator, OrderServiceHealthIndicator.
  • Probe cached с TTL ~30s через { result, at } + Date.now(). Не каждый /health запрос ходит в Sber.
  • Probe-метод — light: GET /health или OPTIONS / на внешней системе, не бизнес-вызов (registerOrder, confirmPayment).
  • Health отражается в /health/ready (readiness включает внешние системы) и /health/live (liveness — только приложение).
  • Внешние системы — только в readiness, не в liveness. k8s не должен убивать pod из-за того, что Sber лежит.
  • Sync-probe без кеша = DDoS внешней системы: k8s probes каждые 10s × N pod'ов = десятки RPS только от health-check'ов.
  • Business-операция в probe изменяет состояние, плодит тест-данные, искажает метрики.

Health check внешней системы — это способ k8s узнать, что pod не готов принимать трафик, не «глобальный pinger Sber». Поэтому он должен быть дешёвым (и для нашего приложения, и для внешней системы) и точным (отражать реальную готовность). Раскрытие раздела 10 гайда.

HealthIndicator на каждую внешнюю систему

R-RES-HC-1: на каждую внешнюю систему — отдельный класс, реализующий HealthIndicator из @nestjs/terminus.

import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult } from '@nestjs/terminus';
import { Agent, request } from 'undici';

@Injectable()
export class SberHealthIndicator extends HealthIndicator {
  private cached?: { up: boolean; at: number };

  constructor(private readonly agent: Agent) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    if (!this.cached || Date.now() - this.cached.at > 30_000) {
      this.cached = { up: await this.probe(), at: Date.now() };
    }
    return this.getStatus(key, this.cached.up);
  }

  private async probe(): Promise<boolean> {
    try {
      const { statusCode } = await request('https://sber.example.com/health', {
        dispatcher: this.agent,
        method: 'GET',
        headersTimeout: 3_000,
        bodyTimeout: 3_000,
      });
      return statusCode >= 200 && statusCode < 300;
    } catch {
      return false;
    }
  }
}

@nestjs/terminus автоматически подберёт этот индикатор через HealthCheckService. Per-system подход — отдельный класс на sber, receipt, insurance. Единое имя системы для провайдера клиента, policy-набора и индикатора (R-RES-ISO-3).

Регистрация в HealthModule:

@Module({
  imports: [TerminusModule],
  controllers: [HealthController],
  providers: [SberHealthIndicator, ReceiptHealthIndicator],
})
export class HealthModule {}

Cached probe с TTL 30s

R-RES-HC-2: probe cached, не ходит во внешнюю систему на каждый запрос.

Зачем кеш:

  • k8s по умолчанию probes каждые 10s. С 5 pod'ами — 30 запросов в минуту к Sber только от health-check'ов.
  • При TTL 30s — реальный probe к Sber раз в 30s независимо от частоты /health-запросов.
  • 30s — компромисс: достаточно свежо чтобы заметить деградацию за разумное время, не нагружает внешнюю систему.

Реализация через { result, at } + Date.now() — самый простой вариант без внешних зависимостей. Подходит для большинства случаев.

private cached?: { up: boolean; at: number };
private readonly TTL_MS = 30_000;

private async cachedProbe(): Promise<boolean> {
  if (!this.cached || Date.now() - this.cached.at > this.TTL_MS) {
    this.cached = { up: await this.probe(), at: Date.now() };
  }
  return this.cached.up;
}

Альтернатива — NestJS CacheModule с TTL-конфигом, если кеш-инфраструктура уже есть в проекте. Для probe-кеша это избыточно.

Light probe — GET /health, не business

R-RES-HC-3: probe-метод — лёгкий технический endpoint, не бизнес-операция.

// ХОРОШО — light GET /health
const { statusCode } = await request('https://sber.example.com/health', {
  method: 'GET',
  dispatcher: this.agent,
});

// ХОРОШО — если у системы нет /health: OPTIONS на корень
const { statusCode } = await request('https://receipt.example.com/', {
  method: 'OPTIONS',
  dispatcher: this.agent,
});

Если внешняя система не предоставляет /health:

  • OPTIONS на корневой endpoint — обычно отвечает немедленно без реальной обработки.
  • GET самого дешёвого read-endpoint с минимальной нагрузкой (?limit=1).
  • В крайнем случае — TCP-connect: net.createConnection({ host, port }) — убеждаемся, что порт слушает.

Никаких бизнес-операций:

  • Не POST /orders с тестовыми данными — изменяет состояние, плодит мусор в БД Sber.
  • Не GET /orders?customerId=health-check-test — нагружает систему, портит бизнес-метрики.

Структура /health endpoints в NestJS

R-RES-HC-4: liveness — только приложение, readiness — включая критичные внешние системы.

@Controller('health')
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
    private readonly sber: SberHealthIndicator,
    private readonly receipt: ReceiptHealthIndicator,
  ) {}

  @Get('live')
  @HealthCheck()
  liveness() {
    return this.health.check([]);          // только app alive — без внешних систем
  }

  @Get('ready')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.sber.isHealthy('sber'),
      () => this.receipt.isHealthy('receipt'),
    ]);
  }
}

k8s readinessProbe смотрит на /health/ready (включая внешние системы — pod выходит из Service backend pool, когда не готов принимать трафик). livenessProbe — на /health/live (только «приложение запущено» — k8s не убивает pod из-за того, что Sber лежит).

Манифест k8s:

livenessProbe:
  httpGet:
    path: /health/live
    port: 3000
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /health/ready
    port: 3000
  periodSeconds: 10
  failureThreshold: 2

Какие внешние системы включать в readiness — решение бизнес-домена:

  • Без Sber OrderService физически не может принимать заказы → включаем в readiness.
  • CustomerService использует ReceiptService только при определённых операциях → не включаем; handler сам вернёт 503 на конкретный endpoint.

Что запрещено

АнтипаттернПравилоЧто взамен
Sync-probe без TTL-кеша (isHealthy ходит в Sber на каждый вызов)R-RES-HC-X1{ result, at } + TTL 30s
Probe вызывает бизнес-операцию (registerOrder, getTransactions)R-RES-HC-X2GET /health или OPTIONS /
Внешние системы в /health/live (liveness)R-RES-HC-4Только в /health/ready (readiness)
Один общий HealthIndicator на несколько системR-RES-HC-1Per-system индикатор: SberHealthIndicator, ReceiptHealthIndicator
HealthIndicator кидает исключение наружу (не обёрнут в try/catch)R-RES-HC-1catchreturn this.getStatus(key, false)
probe() без timeout (зависание = весь health-check зависает)R-RES-HC-2headersTimeout + bodyTimeout на undici-агенте

Куда дальше

  • Observability — prom-client метрики для CB и bulkhead; OTel-spans на adapter-методах.
  • Per-system isolation — структура per-system провайдеров, именование DI-токенов.
  • Async и polling — task-queue вместо setTimeout-цикла в handler.
  • Circuit breaker — BrokenCircuitError → port-исключение → 503/409.
  • Timeouts — иерархия connect < headers < body < total.
  • Bulkhead — cockatiel bulkhead(maxConcurrent, queueLimit) per-system.
  • Retry — ExponentialBackoff, только при идемпотентности, не на 4xx.
  • Fallback — деградация без money-операций и тихого «успеха».
  • Configuration — декларативный конфиг, zod/class-validator, per-system override.
  • OpenAPI generator binding — openapi-typescript, mapper DTO → domain.
  • Where protection goes — где именно в слоистой архитектуре NestJS стоит защита.