Skip to content

Health checks dans NestJS

TL;DR@nestjs/terminus fournit HealthCheckService et des indicators (HTTP, TypeORM, Mongoose, Sequelize, disk, memory, microservices) pour exposer /healthz, /readyz, /livez. Le travail senior consiste à distinguer liveness / readiness / startup, à choisir entre shallow et deep checks, à brancher les bonnes probes Kubernetes, et à ne pas écrouler des dépendances en hammer-checking.

🧠 Mental model

Un health check répond à une question précise. Confondre les trois questions est la principale cause de fausses alertes :

+----------------+---------------------------+---------------------------------+
| Probe          | Question                  | Conséquence si KO              |
+----------------+---------------------------+---------------------------------+
| liveness       | "Suis-je vivant ?"        | kubelet kill + restart pod      |
| readiness      | "Puis-je servir du trafic"| retiré du load balancer         |
| startup        | "Ai-je fini de booter ?"  | délais liveness/readiness       |
+----------------+---------------------------+---------------------------------+

Analogie avion :

  • liveness : le pilote est-il conscient ? Si non, on déroute vers le copilote.
  • readiness : la cabine est-elle prête à accueillir des passagers ? Si non, on retarde l'embarquement.
  • startup : l'avion a-t-il fini le pré-vol ? Si non, on attend, mais on n'éjecte pas le pilote.

Règles d'or :

  1. Liveness doit être presque toujours OK. Si tu fais échouer la liveness parce que Redis est down, Kubernetes va redémarrer ton pod, ça ne réparera rien, et tu auras un crash loop.
  2. Readiness reflète les dépendances critiques. Si tu ne peux pas servir, retire-toi du load balancer.
  3. Startup couvre les boots longs. Migration DB, warmup de cache, sinon liveness peut tuer pendant le boot.

🛠️ Code minimal

Installation :

bash
npm i @nestjs/terminus @nestjs/axios

Module dédié, séparé du contrôleur principal :

ts
// src/health/health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HttpModule } from '@nestjs/axios';
import { HealthController } from './health.controller';

@Module({
  imports: [
    TerminusModule.forRoot({
      logger: true,
      errorLogStyle: 'pretty', // 'pretty' | 'json'
      gracefulShutdownTimeoutMs: 5_000,
    }),
    HttpModule,
  ],
  controllers: [HealthController],
})
export class HealthModule {}

Controller distinct exposant 3 endpoints :

ts
// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
  HealthCheck,
  HealthCheckService,
  HttpHealthIndicator,
  TypeOrmHealthIndicator,
  MemoryHealthIndicator,
  DiskHealthIndicator,
} from '@nestjs/terminus';

@Controller()
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private http: HttpHealthIndicator,
    private db: TypeOrmHealthIndicator,
    private memory: MemoryHealthIndicator,
    private disk: DiskHealthIndicator,
  ) {}

  @Get('livez')
  @HealthCheck()
  liveness() {
    return this.health.check([
      () => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024),
    ]);
  }

  @Get('readyz')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('database', { timeout: 1500 }),
      () => this.http.pingCheck('auth_provider', 'https://auth.example.com/.well-known/openid-configuration', { timeout: 2000 }),
      () => this.disk.checkStorage('storage', { path: '/', thresholdPercent: 0.9 }),
    ]);
  }

  @Get('startupz')
  @HealthCheck()
  startup() {
    return this.health.check([
      () => this.db.pingCheck('database', { timeout: 5000 }),
    ]);
  }
}

⚡ Correctness — l'API custom indicator a changé en v11

Lis ça avant de copier-coller du code de blog. @nestjs/terminus v11 déprécie HealthIndicator (classe de base héritée) et HealthCheckError. Ils fonctionnent encore en v11 mais sont supprimés en v12. L'API moderne injecte HealthIndicatorService (composition, pas héritage) et tu retournes .up() / .down() au lieu de throw new HealthCheckError(...).

AspectLegacy (≤ v10, déprécié en v11)Moderne (v11+)
Mécanismeextends HealthIndicatorconstructor(private hi: HealthIndicatorService)
Statut OKreturn this.getStatus(key, true, meta)return indicator.up(meta)
Statut KOthrow new HealthCheckError(msg, status)return indicator.down(meta) (pas de throw)
Couplagehéritage (fragile, casse au refactor base)composition (testable, DI propre)

Un staff engineer migre maintenant : pas de throw signifie pas de stack trace coûteuse sur le chemin chaud des probes (rappel : /readyz est ping toutes les 5 s sur N pods), et le code devient mockable sans instancier la classe de base.

🎯 Patterns courants

1. Indicator custom pour Redis

@nestjs/terminus n'a pas d'indicator Redis natif. Tu en écris un en 30 lignes.

Version moderne (v11+, recommandée) — composition via HealthIndicatorService, jamais de throw :

ts
// src/health/redis.health.ts
import { Injectable, Inject } from '@nestjs/common';
import {
  HealthIndicatorService,
  type HealthIndicatorResult,
} from '@nestjs/terminus';
import Redis from 'ioredis';

@Injectable()
export class RedisHealthIndicator {
  constructor(
    @Inject('REDIS_CLIENT') private readonly redis: Redis,
    private readonly hi: HealthIndicatorService,
  ) {}

  async isHealthy(key: string, timeoutMs = 1000): Promise<HealthIndicatorResult> {
    const indicator = this.hi.check(key);
    const t0 = performance.now();
    try {
      // ioredis: commandTimeout protège déjà, mais on garde une course explicite
      const pong = await Promise.race([
        this.redis.ping(),
        new Promise<never>((_, reject) =>
          setTimeout(() => reject(new Error('redis ping timeout')), timeoutMs),
        ),
      ]);
      const latencyMs = Math.round(performance.now() - t0);
      return pong === 'PONG'
        ? indicator.up({ latencyMs })
        : indicator.down({ latencyMs, reason: `unexpected reply: ${pong}` });
    } catch (e) {
      return indicator.down({
        latencyMs: Math.round(performance.now() - t0),
        message: (e as Error).message,
      });
    }
  }
}

Note performance.now() (monotone) au lieu de Date.now() : un saut NTP ne fausse pas ta latence. Le legacy snippet de ce guide hardcodait latencyMs: 0 — bug silencieux corrigé ici.

Version legacy (≤ v10) — encore vue partout, à reconnaître et migrer :

ts
// src/health/redis.health.legacy.ts  — DÉPRÉCIÉ en v11
import { Injectable, Inject } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import Redis from 'ioredis';

@Injectable()
export class RedisHealthIndicatorLegacy extends HealthIndicator {
  constructor(@Inject('REDIS_CLIENT') private redis: Redis) { super(); }

  async isHealthy(key: string, timeoutMs = 1000): Promise<HealthIndicatorResult> {
    try {
      const pong = await Promise.race([
        this.redis.ping(),
        new Promise((_, r) => setTimeout(() => r(new Error('timeout')), timeoutMs)),
      ]);
      const ok = pong === 'PONG';
      const result = this.getStatus(key, ok, {});
      if (!ok) throw new HealthCheckError('Redis ping failed', result);
      return result;
    } catch (e) {
      throw new HealthCheckError('Redis ping failed', this.getStatus(key, false, {
        message: (e as Error).message,
      }));
    }
  }
}

Usage (identique pour les deux versions, côté contrôleur) :

ts
@Get('readyz')
@HealthCheck()
readiness() {
  return this.health.check([
    () => this.redis.isHealthy('redis'),
  ]);
}

2. Indicator pour queues BullMQ

ts
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { HealthIndicatorService, type HealthIndicatorResult } from '@nestjs/terminus';
import { Queue } from 'bullmq';

@Injectable()
export class QueueHealthIndicator {
  constructor(
    @InjectQueue('emails') private readonly queue: Queue,
    private readonly hi: HealthIndicatorService,
  ) {}

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    const indicator = this.hi.check(key);
    const counts = await this.queue.getJobCounts('active', 'waiting', 'failed');
    const isPaused = await this.queue.isPaused();
    const ok = !isPaused && counts.failed < 1000;
    return ok
      ? indicator.up({ ...counts, paused: isPaused })
      : indicator.down({ ...counts, paused: isPaused });
  }
}

Piège de readiness. Ne fais PAS échouer la readiness parce que la queue a des jobs failed : une queue saturée n'empêche pas le pod de servir du HTTP — tu te retirerais du LB pour rien et tu aggraverais le backlog. La santé d'une queue appartient à un endpoint /health/deep ou à une métrique alertée, pas à /readyz. Sur readiness, ne vérifie que isPaused (le worker ne traite plus du tout) ou la joignabilité Redis, jamais la profondeur du backlog.

3. Deep check d'une dépendance downstream

Un shallow check ping un endpoint trivial. Un deep check exécute une opération métier représentative. Le deep check coûte plus cher mais détecte les pannes silencieuses.

ts
@Injectable()
export class PaymentProviderHealth {
  constructor(
    private readonly http: HttpService,
    private readonly hi: HealthIndicatorService,
  ) {}

  async deepCheck(): Promise<HealthIndicatorResult> {
    const indicator = this.hi.check('payments');
    const t0 = performance.now();
    try {
      const res = await firstValueFrom(
        this.http.get('https://payments.example.com/v1/healthz/deep', {
          timeout: 3000,
          headers: { 'x-health-token': process.env.PAYMENT_HEALTH_TOKEN! },
        }),
      );
      const latencyMs = Math.round(performance.now() - t0);
      const ok = res.status === 200 && res.data?.status === 'ok';
      return ok
        ? indicator.up({ latencyMs, version: res.data?.version })
        : indicator.down({ latencyMs, status: res.status });
    } catch (e) {
      // timeout / réseau : down explicite, jamais une exception qui remonte brute
      return indicator.down({ message: (e as Error).message });
    }
  }
}

Règle : deep check uniquement sur readiness, jamais sur liveness. Et idéalement pas trop fréquemment (chaque 10-30s suffit).

4. Endpoint /healthz séparé du routage applicatif

Tu ne veux pas que la probe Kubernetes traverse ton middleware d'auth, ton rate limiter, ton circuit breaker. Solution : route exclue dans main.ts ou middleware skip.

ts
// main.ts
app.use((req, res, next) => {
  if (['/healthz', '/livez', '/readyz', '/startupz'].includes(req.path)) {
    res.setHeader('Cache-Control', 'no-store');
  }
  next();
});

// dans les guards / interceptors
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(ctx: ExecutionContext): boolean {
    const path = ctx.switchToHttp().getRequest().path;
    if (/^\/(healthz|livez|readyz|startupz)$/.test(path)) return true;
    // ...
  }
}

Encore mieux : exposer les probes sur un autre port (admin server), invisible du LB public :

ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000); // public

  const admin = await NestFactory.create(AdminModule);
  await admin.listen(9090); // probes + metrics
}

5. Timeouts agressifs + circuit breaker

Une probe qui dure 30s à cause d'une dépendance lente est elle-même un problème. Toujours timeout court (< 2s par indicator) et envelopper les deep checks dans un circuit breaker (opossum).

ts
import CircuitBreaker from 'opossum';
import { HealthIndicatorService, type HealthIndicatorResult } from '@nestjs/terminus';

@Injectable()
export class GuardedPaymentHealth {
  private readonly breaker: CircuitBreaker<[], HealthIndicatorResult>;

  constructor(
    private readonly payments: PaymentProviderHealth,
    private readonly hi: HealthIndicatorService,
  ) {
    this.breaker = new CircuitBreaker(() => this.payments.deepCheck(), {
      timeout: 2000,
      errorThresholdPercentage: 50,
      resetTimeout: 30_000,
    });
    // breaker ouvert => on ne hammer plus la dépendance, on répond "down" instantanément
    this.breaker.fallback(() => this.hi.check('payments').down({ breaker: 'open' }));
  }

  isHealthy(): Promise<HealthIndicatorResult> {
    return this.breaker.fire();
  }
}

Le fallback est le point clé : quand le breaker est ouvert, la probe répond down en microsecondes sans toucher la dépendance fragile. Sans ça, chaque probe relance un appel timeout-2s vers un partenaire déjà à terre — tu transformes ta probe en amplificateur de panne (retry storm).

6. Graceful shutdown et probe

Quand Kubernetes envoie SIGTERM, ton pod doit immédiatement répondre 503 à readyz pendant qu'il finit les requêtes en cours. Sinon le LB continue à router des requêtes vers un pod qui ferme.

ts
// main.ts
import { ShutdownSignal } from '@nestjs/common';

const app = await NestFactory.create(AppModule);
app.enableShutdownHooks([ShutdownSignal.SIGTERM, ShutdownSignal.SIGINT]);
ts
@Injectable()
export class ShutdownTracker implements OnModuleDestroy {
  private shuttingDown = false;
  onModuleDestroy() { this.shuttingDown = true; }
  isShuttingDown() { return this.shuttingDown; }
}

@Get('readyz')
@HealthCheck()
readiness() {
  if (this.shutdown.isShuttingDown()) {
    throw new ServiceUnavailableException({ status: 'shutting_down' });
  }
  return this.health.check([/* ... */]);
}

🔄 Versions — Nest 7 / 8 / 9 / 10 / 11

  • Nest 7 : @nestjs/terminus v7. API basée sur terminus (lib OpenTracing). Indicators stables : DNSHealthIndicator, TypeOrmHealthIndicator, MongooseHealthIndicator, MemoryHealthIndicator, DiskHealthIndicator. Décorateur @HealthCheck().
  • Nest 8 : @nestjs/terminus v8. Décorateur @HealthCheckService toujours via DI. Apparition d'options de timeout configurables par indicator.
  • Nest 9 : @nestjs/terminus v9. API stable. MicroserviceHealthIndicator plus complet (TCP, NATS, gRPC). Improved error log style.
  • Nest 10 : @nestjs/terminus v10. Breaking : TerminusModule.forRoot() accepte une nouvelle option logger (boolean ou class). errorLogStyle: 'pretty' | 'json'. gracefulShutdownTimeoutMs officialisé.
  • Nest 11 : @nestjs/terminus v11. Support officiel de prisma health indicator (PrismaHealthIndicator dans @nestjs/terminus). Suppression des indicators dépréciés (DNSHealthIndicator -> renommé HttpHealthIndicator.pingCheck).

Libs tierces à connaître :

  • @nestjs/terminus est wrapper sur la lib terminus qui gère SIGTERM, drainage, listening.
  • prom-client souvent associé pour exposer les compteurs de check.

⚠️ Pitfalls

  1. Liveness qui vérifie la DB. Si Postgres flap, Kubernetes restart le pod, le pod refait sa connexion, ça ne répare rien et tu cascade. Liveness doit vérifier que le process Node est vivant (heap, event loop).
  2. Readiness sans timeout. Une dépendance lente bloque la réponse 30s, et la probe est considérée KO sur kubelet timeout. Toujours timeout explicite par indicator.
  3. Endpoint health derrière l'auth. Kubernetes envoie une probe sans token, reçoit 401, considère le pod KO. Soit whitelist, soit endpoint sur un port admin.
  4. Health check trop fréquent. Probe toutes les 1s avec deep checks vers une API externe = tu te DDoS toi-même et tu pollutes les logs de partenaires.
  5. Liveness identique à readiness. Pareil mauvais que de ne pas en avoir.
  6. Ne pas exposer le shutdown. Lors d'un déploiement, ton pod accepte du trafic pendant qu'il ferme. Doit-on l'expulser du LB ? Oui, via le readiness flag.
  7. Memory check inutile. checkHeap avec un seuil arbitraire (par exemple 300 MB) sur un service qui peut légitimement utiliser 600 MB déclenche un faux positif. Aligne sur --max-old-space-size.
  8. Disk check sur container sans accès aux volumes. / est éphémère, ne reflète rien. Vérifie le bon path (/data, /var/lib/postgresql).
  9. Ne pas tester en charge. Le check passe à vide mais sous load, la connexion DB est saturée et le ping prend 5s. Tester avec un loadgen avant de configurer les seuils.
  10. Cascade de timeouts. Probe timeout 2s -> kubelet timeout 1s. Le pod est tué avant que ta probe réponde. Aligne timeoutSeconds Kubernetes >= timeout interne + marge.

🧪 Testing

Tests unitaires sur un indicator custom :

ts
// redis.health.spec.ts — teste l'API MODERNE (HealthIndicatorService) : on
// assert sur le RÉSULTAT up/down, jamais sur un throw (le modern indicator ne
// throw plus). Note : on fournit un vrai HealthIndicatorService du module Terminus.
import { Test } from '@nestjs/testing';
import { TerminusModule } from '@nestjs/terminus';
import { RedisHealthIndicator } from './redis.health';

describe('RedisHealthIndicator', () => {
  async function build(fake: { ping: jest.Mock }) {
    const moduleRef = await Test.createTestingModule({
      imports: [TerminusModule],
      providers: [RedisHealthIndicator, { provide: 'REDIS_CLIENT', useValue: fake }],
    }).compile();
    return moduleRef.get(RedisHealthIndicator);
  }

  it('returns up on PONG', async () => {
    const indicator = await build({ ping: jest.fn().mockResolvedValue('PONG') });
    const result = await indicator.isHealthy('redis');
    expect(result.redis.status).toBe('up');
  });

  it('returns down (no throw) on timeout', async () => {
    // ping qui ne résout jamais => la course de timeout gagne, l'indicator
    // renvoie un résultat down au lieu de lever une exception.
    const indicator = await build({ ping: jest.fn(() => new Promise(() => {})) });
    const result = await indicator.isHealthy('redis', 100);
    expect(result.redis.status).toBe('down');
    expect(result.redis.message).toMatch(/timeout/i);
  });
});

Test e2e des trois endpoints :

ts
// health.e2e-spec.ts
describe('Health probes (e2e)', () => {
  let app: INestApplication;
  beforeAll(async () => { /* boot app */ });
  afterAll(() => app.close());

  it('/livez should always be 200', async () => {
    await request(app.getHttpServer()).get('/livez').expect(200);
  });

  it('/readyz returns 503 if DB down', async () => {
    await stopPostgres();
    await request(app.getHttpServer()).get('/readyz').expect(503);
    await startPostgres();
  });

  it('/livez stays 200 even if DB down', async () => {
    await stopPostgres();
    await request(app.getHttpServer()).get('/livez').expect(200);
    await startPostgres();
  });
});

Intégration Kubernetes

Manifest typique d'un Deployment :

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3
  template:
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: api
          image: ghcr.io/me/api:1.2.3
          ports:
            - name: http
              containerPort: 3000
            - name: admin
              containerPort: 9090
          startupProbe:
            httpGet:
              path: /startupz
              port: admin
            periodSeconds: 5
            failureThreshold: 30   # 30 * 5 = 150 s max boot
          livenessProbe:
            httpGet:
              path: /livez
              port: admin
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /readyz
              port: admin
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 2
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 10"]   # laisse le LB drainer

Trois leviers à régler ensemble :

  • terminationGracePeriodSeconds (>= temps de drainage).
  • preStop sleep (le temps que le LB voie le readiness rouge avant que SIGTERM tombe).
  • failureThreshold (combien d'échecs avant action).

Métriques associées

Au-delà des probes, expose un compteur Prometheus :

ts
import { Counter, Histogram } from 'prom-client';

const checkCounter = new Counter({
  name: 'healthcheck_total',
  help: 'Total health checks',
  labelNames: ['probe', 'status'],
});
const checkLatency = new Histogram({
  name: 'healthcheck_duration_seconds',
  help: 'Health check duration',
  labelNames: ['probe'],
});

@Get('readyz')
@HealthCheck()
async readiness() {
  const stop = checkLatency.startTimer({ probe: 'readiness' });
  try {
    const r = await this.health.check([...]);
    checkCounter.inc({ probe: 'readiness', status: 'ok' });
    return r;
  } catch (e) {
    checkCounter.inc({ probe: 'readiness', status: 'error' });
    throw e;
  } finally { stop(); }
}

Tu pourras alors alerter sur rate(healthcheck_total{status="error"}[5m]) > 0.1.

Shallow vs deep

AspectShallowDeep
CoûtTrès faibleModéré
DétectePannes franchesPannes silencieuses, dérives
FréquenceHaute (chaque s)Basse (toutes 30-60s)
ExempleSELECT 1SELECT count(*) FROM orders WHERE status='pending'
Probe associéeliveness/readinessendpoint séparé /healthz/deep polled par monitoring

Pattern courant : le LB ping /readyz (shallow), tandis qu'un job dédié (DataDog, Pingdom) ping /healthz/deep toutes les minutes.

🎬 Cas d'usage concrets

SaaS RH — probes Kubernetes pour rolling updates sans downtime

Qui : éditeur SaaS RH multi-tenant déployé sur GKE, 4 environnements (dev/staging/preprod/prod), 12 pods en prod derrière un Ingress NGINX. Les RH consultent les fiches paie en début de mois avec un pic d'usage à 9h.

Problème : un déploiement à 9h05 a coupé le service pendant 90 secondes parce que les nouveaux pods étaient marqués Ready avant que la connexion Postgres soit chaude et que le cache JWT soit warmé. Les RH ont signalé "tableau blanc" pendant les rolling updates.

ts
@Controller()
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
    private readonly redis: RedisHealthIndicator,
    private readonly warmup: WarmupService,
  ) {}

  @Get('/livez')
  @HealthCheck()
  liveness() {
    return this.health.check([
      () => ({ event_loop: { status: this.eventLoopLag() < 1000 ? 'up' : 'down' } }),
    ]);
  }

  @Get('/readyz')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('postgres', { timeout: 1500 }),
      () => this.redis.pingCheck('redis', { timeout: 800 }),
      () => ({ warmup: { status: this.warmup.isReady() ? 'up' : 'down' } }),
    ]);
  }

  @Get('/startupz')
  @HealthCheck()
  startup() {
    return this.health.check([
      () => this.db.pingCheck('postgres', { timeout: 5000 }),
      () => ({ migrations: { status: this.warmup.migrationsDone() ? 'up' : 'down' } }),
    ]);
  }
}

Gains : zéro coupure visible sur les 3 derniers déploiements en heure de pointe. Kubernetes attend que readyz retourne 200 (warmup terminé + DB joignable) avant de basculer le trafic, et livez reste shallow pour éviter les restart loops sur des incidents Redis transitoires.

Banque retail — monitoring core banking via deep check chaîné

Qui : DSI d'une banque mutualiste, API de consultation comptes connectée au core banking mainframe via un middleware MQ. SLA contractuel 99,95%, incident majeur si le core est inaccessible.

Problème : le /healthz historique pingait juste le pod, indiquait OK même quand le mainframe ne répondait plus depuis 20 min. La supervision a manqué 3 incidents le trimestre dernier.

ts
@Get('/health/deep')
@HealthCheck()
@UseGuards(InternalNetworkGuard)
async deep() {
  return this.health.check([
    () => this.db.pingCheck('oracle', { timeout: 3000 }),
    () => this.mq.pingCheck('ibmmq-core', { timeout: 5000 }),
    () => this.http.responseCheck('core-banking', `${this.cfg.coreUrl}/heartbeat`,
      (res) => res.status === 200 && res.data?.mainframe === 'UP'),
    () => this.kafka.pingCheck('kafka-events', { timeout: 2000 }),
  ]);
}

Gains : le deep check tourne toutes les 30 s depuis Prometheus Blackbox. Détection des incidents core en moins d'1 min, alerting PagerDuty immédiat. Le shallow /livez reste intact pour Kubernetes pour ne pas restart les pods quand le mainframe a un hoquet.

Industrie 4.0 — bridge SCADA avec healthcheck composite

Qui : intégrateur industriel exposant une API REST vers un système SCADA via OPC-UA. Lignes de production qui ne tolèrent pas plus de 60 secondes d'indisponibilité de l'API supervision.

Problème : la liaison OPC-UA peut tomber sans que le pod Nest le remarque (socket TCP ouvert mais session expirée). On veut un health check qui exerce vraiment la connexion.

ts
@Injectable()
export class OpcUaHealthIndicator {
  constructor(
    private readonly opc: OpcUaClient,
    private readonly hi: HealthIndicatorService,
  ) {}

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    const indicator = this.hi.check(key);
    try {
      const value = await this.opc.readNode('ns=2;s=Heartbeat', { timeout: 2000 });
      const ageMs = Date.now() - value.serverTimestamp.getTime();
      return ageMs < 5000
        ? indicator.up({ lastSeen: value.serverTimestamp, ageMs })
        : indicator.down({ reason: 'heartbeat stale', ageMs });
    } catch (e) {
      return indicator.down({ error: (e as Error).message });
    }
  }
}

Gains : alertes en moins de 10 s sur une session OPC-UA expirée (au lieu de 5-10 min via heartbeat passif). Le bridge se reconnecte automatiquement et le SCADA reste visible côté supervision Grafana.

🛠️ Exemple end-to-end

Contexte : API SaaS de gestion de notes de frais multi-tenant déployée sur AWS EKS. Dépendances : Postgres RDS, Redis ElastiCache, S3 (justificatifs), SES (notifications), une API de change tierce (taux FX). Probes Kubernetes calibrées sur les contraintes opérationnelles.

ts
// src/health/expense-health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HttpModule } from '@nestjs/axios';
import { ExpenseHealthController } from './expense-health.controller';
import { S3HealthIndicator } from './indicators/s3.indicator';
import { SesHealthIndicator } from './indicators/ses.indicator';
import { FxRateHealthIndicator } from './indicators/fx.indicator';

@Module({
  imports: [TerminusModule, HttpModule.register({ timeout: 5000 })],
  controllers: [ExpenseHealthController],
  providers: [S3HealthIndicator, SesHealthIndicator, FxRateHealthIndicator],
})
export class ExpenseHealthModule {}

// src/health/expense-health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
  HealthCheck,
  HealthCheckService,
  HttpHealthIndicator,
  TypeOrmHealthIndicator,
  MemoryHealthIndicator,
  DiskHealthIndicator,
} from '@nestjs/terminus';

@Controller()
export class ExpenseHealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
    private readonly http: HttpHealthIndicator,
    private readonly memory: MemoryHealthIndicator,
    private readonly disk: DiskHealthIndicator,
    private readonly s3: S3HealthIndicator,
    private readonly ses: SesHealthIndicator,
    private readonly fx: FxRateHealthIndicator,
    private readonly warmup: WarmupService,
  ) {}

  // Shallow — Kubernetes liveness, must almost always succeed
  @Get('/livez')
  @HealthCheck()
  liveness() {
    return this.health.check([
      () => this.memory.checkHeap('heap', 600 * 1024 * 1024),
      () => ({ self: { status: 'up' } }),
    ]);
  }

  // Critical deps — Kubernetes readiness, retire from LB if degraded
  @Get('/readyz')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('postgres', { timeout: 1500 }),
      () => this.s3.pingCheck('s3-receipts', { timeout: 2000 }),
      () => ({ warmup: { status: this.warmup.isReady() ? 'up' : 'down' } }),
    ]);
  }

  // Startup — slow boot tolerance (migrations + cache warmup)
  @Get('/startupz')
  @HealthCheck()
  startup() {
    return this.health.check([
      () => this.db.pingCheck('postgres', { timeout: 5000 }),
      () => ({ migrations: { status: this.warmup.migrationsDone() ? 'up' : 'down' } }),
      () => ({ cache_warmup: { status: this.warmup.cacheWarmed() ? 'up' : 'down' } }),
    ]);
  }

  // Deep — only for monitoring (Prometheus blackbox), guarded
  @Get('/health/deep')
  @HealthCheck()
  @UseGuards(InternalNetworkGuard)
  deep() {
    return this.health.check([
      () => this.db.pingCheck('postgres', { timeout: 2000 }),
      () => this.s3.pingCheck('s3-receipts', { timeout: 3000 }),
      () => this.ses.pingCheck('ses', { timeout: 3000 }),
      () => this.fx.checkRateFreshness('fx-eur-usd', { maxAgeSec: 600 }),
      () => this.disk.checkStorage('disk', { path: '/tmp', thresholdPercent: 0.85 }),
      () => this.memory.checkRSS('rss', 800 * 1024 * 1024),
    ]);
  }
}

// src/health/indicators/fx.indicator.ts
@Injectable()
export class FxRateHealthIndicator {
  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly hi: HealthIndicatorService,
  ) {}

  async checkRateFreshness(key: string, opts: { maxAgeSec: number }): Promise<HealthIndicatorResult> {
    const indicator = this.hi.check(key);
    const ts = await this.cache.get<number>('fx:eur-usd:ts');
    const ageSec = ts ? (Date.now() - ts) / 1000 : Infinity;
    return ageSec < opts.maxAgeSec
      ? indicator.up({ ageSec })
      : indicator.down({ ageSec, maxAgeSec: opts.maxAgeSec });
  }
}

Côté Kubernetes manifest, trois probes distinctes calibrées :

yaml
livenessProbe:
  httpGet: { path: /livez, port: 3000 }
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /readyz, port: 3000 }
  periodSeconds: 5
  failureThreshold: 2
startupProbe:
  httpGet: { path: /startupz, port: 3000 }
  periodSeconds: 5
  failureThreshold: 30

Liveness shallow (heap + self), readiness avec dépendances critiques uniquement, startup long pour absorber les migrations, deep check séparé exposé seulement au monitoring interne. Cette répartition a éliminé 100% des crash loops liés aux dépendances tierces dégradées tout en gardant une détection fine côté Prometheus.


🔁 Quand utiliser / éviter

CasProbe à utiliser
Pod doit redémarrer après deadlock event loopliveness shallow (memory + ping process)
Service derrière LB doit se retirer si DB tombereadiness
Boot lent (migrations, warmup)startupProbe ou readiness avec failureThreshold haut
Monitoring externe d'une intégration partenaireendpoint /healthz/deep polled par outil tiers
Endpoint pour debugger un incidentendpoint admin protégé, pas une probe

À éviter :

  • Brancher Datadog APM ou un middleware lourd sur le path des probes.
  • Mélanger probes et endpoints métiers.
  • Activer la liveness pendant les premières secondes de boot (utiliser startupProbe).

🤖 Health checks pour un service qui SERT de l'IA (NestJS + LLM)

Quand ton NestJS expose des agents Claude (streaming SSE, boucle tool-use, jobs BullMQ), la santé n'est plus "ma DB répond". Tu dépends d'un fournisseur externe payant, à quota, à latence p99 de plusieurs secondes. Trois erreurs de débutant ici, et le raisonnement senior pour chacune.

Erreur 1 — pinger l'API LLM dans la readiness. Un appel messages.create coûte de l'argent et ajoute 1-3 s de latence. Multiplié par N pods × probe toutes les 5 s, tu brûles du budget et du rate-limit juste pour les probes. Pire : si Anthropic a un hoquet de 30 s, tous tes pods sortent du LB alors que ton API HTTP fonctionne parfaitement (l'utilisateur pourrait recevoir une erreur 503 propre au lieu d'un timeout). Règle : la disponibilité du LLM n'est PAS une condition de readiness. C'est une métrique et un circuit breaker, pas une probe Kubernetes.

Le bon design — readiness shallow (le process peut accepter du trafic), et la santé LLM exposée sur /health/deep (monitoring) + circuit breaker en interne :

ts
// src/health/llm.health.ts
import { Injectable } from '@nestjs/common';
import { HealthIndicatorService, type HealthIndicatorResult } from '@nestjs/terminus';
import { ANTHROPIC } from '../llm/anthropic.tokens';
import type Anthropic from '@anthropic-ai/sdk';

@Injectable()
export class LlmHealthIndicator {
  constructor(
    // client DI'd via forRootAsync — JAMAIS `new Anthropic()` dans un champ
    @Inject(ANTHROPIC) private readonly anthropic: Anthropic,
    private readonly hi: HealthIndicatorService,
  ) {}

  /**
   * Shallow : on vérifie la JOIGNABILITÉ + la config (clé présente), pas une vraie génération.
   * 1 token, modèle le moins cher, timeout court. À réserver à /health/deep.
   */
  async reachable(key = 'anthropic'): Promise<HealthIndicatorResult> {
    const indicator = this.hi.check(key);
    const t0 = performance.now();
    try {
      await this.anthropic.messages.create(
        {
          model: 'claude-haiku-4-5', // le moins cher pour un canari
          max_tokens: 1,
          messages: [{ role: 'user', content: 'ping' }],
        },
        { timeout: 4000, maxRetries: 0 }, // pas de retry sur une probe
      );
      return indicator.up({ latencyMs: Math.round(performance.now() - t0) });
    } catch (e: any) {
      // distinguer 429 (quota, mais service vivant) d'une vraie panne
      const status = e?.status;
      return indicator.down({
        status,
        degraded: status === 429 ? 'rate_limited' : 'unavailable',
        message: e?.message,
      });
    }
  }
}

Modèles canari : Anthropic propose claude-opus-4-8 (flagship), claude-sonnet-4-6 (équilibre), claude-haiku-4-5 (rapide/économique). Pour un health-canary, prends toujours claude-haiku-4-5 : tu veux mesurer la joignabilité, pas payer un raisonnement Opus. Et maxRetries: 0 sur une probe — sinon le SDK retry tout seul et ta probe de 4 s devient une probe de 20 s.

Erreur 2 — un job BullMQ d'agent IA qui rejoue une génération entière au retry. Une génération Claude streamée a déjà coûté des tokens (donc de l'argent) avant de planter au token 4000. Si ton retry repart de zéro, tu paies deux fois et tu peux produire un résultat différent (non-déterminisme). Le pattern senior :

ts
// idempotency clé sur le generationId, pas sur le jobId BullMQ
async process(job: Job<AgentJobData>) {
  const { generationId } = job.data;
  // 1. déjà terminé ? (crash après écriture, avant ack)
  const done = await this.store.getCompleted(generationId);
  if (done) return done; // idempotent, coût zéro

  // 2. partial-output : reprendre depuis le dernier checkpoint de tokens
  const partial = await this.store.getPartial(generationId);
  const messages = this.rebuildPrompt(job.data, partial);

  const stream = this.anthropic.messages.stream({ /* ... */ }, {
    signal: job.signal, // AbortController : annule si le job est drainé au shutdown
  });
  for await (const ev of stream) {
    await this.store.appendToken(generationId, ev); // checkpoint incrémental
  }
}

Côté BullMQ : attempts raisonnable, backoff exponentiel (un 429 Anthropic veut du Retry-After, pas un hammer), et un cost-guard qui refuse de relancer si la génération a déjà consommé > budget. La santé de ce worker, pour la readiness, c'est juste "le worker n'est pas paused et Redis répond" — surtout pas "la génération en cours réussit".

Erreur 3 — confondre 'le LLM est lent' et 'mon pod est mort'. Une génération Opus peut légitimement prendre 30 s. Si ta liveness a un timeoutSeconds: 2 et que ton handler IA bloque l'event loop (mauvais : tu dois streamer en async), kubelet tue le pod en plein milieu d'une réponse utilisateur. Liveness = event-loop lag + heap, point. La latence LLM se surveille en métrique (llm_request_duration_seconds histogram), avec alerte sur p99, jamais en probe.

Tableau de décision — où va chaque signal IA :

Signallivenessreadiness/health/deepmétrique Prometheus
Event-loop lag du process
Redis joignable (queue, cache de stream)
Anthropic joignable✅ (canari haiku)
Quota/rate-limit LLM restant⚠️ down si 429✅ (gauge)
Profondeur de la queue d'agents
Coût cumulé / fenêtre✅ (alerte budget)

La ligne directrice : rien d'externe et de payant ne doit pouvoir vider ton LB. Un fournisseur LLM dégradé doit produire des erreurs applicatives propres (503 avec Retry-After, ou file d'attente), pas un crash loop Kubernetes.

📈 Raisonner santé ↔ SLO ↔ alerting (niveau staff)

Une probe binaire (up/down) est le mauvais signal pour un humain de garde. Le raisonnement senior relie trois couches :

  • Probe (Kubernetes, fréquence haute, action automatique) : retirer du LB / restart. Doit être rapide et locale.
  • SLO (fenêtre, error budget) : 99.9% des /readyz < 300 ms sur 30 j. Source : l'histogram healthcheck_duration_seconds, pas la probe elle-même.
  • Alerte (humain, faible bruit) : tu n'alertes jamais sur "1 probe a échoué" (bruit garanti). Tu alertes sur la dérivation du budget d'erreur ou sur un symptôme multi-burn-rate :
promql
# burn-rate rapide (2 % du budget mensuel en 1 h) ET lent (5 % en 6 h)
(
  rate(healthcheck_total{probe="readiness",status="error"}[5m]) > 0.02
  and
  rate(healthcheck_total{probe="readiness",status="error"}[1h]) > 0.05
)

Antipattern fréquent : alerter directement sur kube_pod_status_ready == 0. Pendant un rolling update normal, des pods sont volontairement not-ready — tu réveilles l'astreinte pour un déploiement sain. La bonne cible d'alerte est l'indisponibilité au niveau service (le LB n'a aucun endpoint sain), pas l'état d'un pod isolé.

Trois chiffres qu'un staff garde en tête en réglant des probes : le MTTR que la probe permet (détection + action automatique), le taux de faux positifs (chaque restart inutile est une dette de confiance), et le coût de la probe elle-même (latence + appels downstream × fréquence × pods).

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Chaque exercice suppose @nestjs/terminus v11.

1. Trois probes propres avec l'API moderne — Objectif : ne plus jamais confondre liveness/readiness/startup

Implémente /livez, /readyz, /startupz avec un RedisHealthIndicator custom écrit en API moderne (HealthIndicatorService, zéro HealthCheckError). Liveness = heap + event-loop lag uniquement ; readiness = Postgres + Redis ; startup = migrations terminées. Indice : pour l'event-loop lag, mesure setImmediate/perf_hooks.monitorEventLoopDelay() et compare à un seuil ; retourne indicator.down() au-delà.

2. Indicator LLM canari + circuit breaker — Objectif : santé d'une dépendance externe payante sans la marteler

Écris un LlmHealthIndicator.reachable() (modèle claude-haiku-4-5, maxRetries: 0, timeout 4 s) exposé uniquement sur /health/deep, enveloppé dans un breaker opossum avec fallback. Vérifie qu'un 429 produit down({ degraded: 'rate_limited' }) et non une exception brute. Indice : le client Anthropic doit être DI'd via forRootAsync (token ANTHROPIC) ; teste en mockant anthropic.messages.create pour qu'il throw { status: 429 }.

3. Graceful shutdown vérifiable — Objectif : zéro requête perdue pendant un déploiement

Implémente un ShutdownTracker (OnApplicationShutdown) qui fait passer /readyz à 503 dès le SIGTERM, tout en laissant /livez à 200. Ajoute preStop: sleep 10 et terminationGracePeriodSeconds: 30. Écris un test e2e qui : envoie SIGTERM, vérifie /readyz === 503 ET /livez === 200 dans la même fenêtre, puis qu'une requête in-flight se termine. Indice : enableShutdownHooks() + un flag booléen ; le test peut process.emit('SIGTERM') et poller les deux endpoints.

4. Production-grade : observabilité + SLO — Objectif : transformer une probe binaire en signal exploitable

Ajoute l'histogram healthcheck_duration_seconds et le counter healthcheck_total{probe,status}. Écris la règle PromQL multi-burn-rate de la section SLO. Bonus : expose une gauge llm_rate_limit_remaining alimentée par les headers de réponse Anthropic. Indice : entoure chaque health.check([...]) d'un startTimer() dans un finally. Pour la gauge, lis response.headers['anthropic-ratelimit-requests-remaining'].

5. Break-then-fix : la cascade de timeouts — Objectif : ressentir un crash loop induit par une probe

Configure volontairement le bug : liveness qui ping Postgres avec timeout: 5000, et livenessProbe.timeoutSeconds: 1. Coupe Postgres. Observe le crash loop (kubelet tue le pod avant la réponse, qui ne répare rien). Puis répare : sors la DB de la liveness, aligne timeoutSeconds ≥ timeout interne + marge. Indice : reproduis avec kind/minikube ou simule en mettant un setTimeout artificiel ; le symptôme est RESTARTS qui grimpe sans cause applicative.

6. Break-then-fix : la probe qui DDoS le partenaire — Objectif : la probe ne doit jamais amplifier une panne

Mets un deep check LLM (maxRetries: 3) dans la readiness, fréquence 5 s, 6 pods. Simule un fournisseur lent (3 s). Calcule le QPS sortant ; observe que tu épuises le rate-limit avec tes seules probes. Répare : déplace en /health/deep polled à 30 s, maxRetries: 0, breaker avec fallback. Indice : 6 pods × (1/5 s) × (1 + 3 retries) = 4.8 req/s de probes seules — fais le calcul avant/après pour matérialiser le gain.

🎤 En entretien

« Pourquoi ne jamais vérifier la base de données dans la liveness ? » Parce que la liveness pilote un restart. Si la DB flap, redémarrer le pod ne répare rien (la DB est toujours down), le pod refait sa connexion à froid et finit en crash loop — tu transformes une panne de dépendance en panne d'application. La DB appartient à la readiness (se retirer du LB) ou à un deep check. Liveness = « ce process est-il vivant ? » = event-loop + heap.

« Comment garantis-tu zéro requête perdue pendant un rolling update ? » Trois leviers réglés ensemble : (1) /readyz passe à 503 au SIGTERM via un shutdown tracker, ce qui sort le pod du LB ; (2) preStop: sleep couvre la fenêtre de propagation du readiness vers le LB/Ingress avant l'arrêt réel ; (3) terminationGracePeriodSeconds ≥ temps de drainage des requêtes in-flight. Sans le preStop, le LB route encore vers un pod qui ferme parce qu'il n'a pas encore vu le readiness rouge.

« Tu sers du Claude derrière ton API ; mets-tu la santé d'Anthropic dans tes probes ? » Non. Une dépendance externe, payante et à quota ne doit pas pouvoir vider mon load balancer ni déclencher des restarts. Un hoquet de 30 s côté fournisseur sortirait tous mes pods du LB alors que mon HTTP fonctionne. Je l'expose sur /health/deep (canari claude-haiku-4-5, maxRetries: 0), je l'enveloppe dans un circuit breaker avec fallback côté requêtes réelles, et je surveille latence/quota en métriques Prometheus. Un LLM dégradé doit produire un 503 applicatif propre avec Retry-After, pas un crash loop Kubernetes.

« Comment alertes-tu sur la santé sans réveiller l'astreinte à chaque déploiement ? » Je n'alerte ni sur une probe isolée qui échoue, ni sur kube_pod_status_ready == 0 (des pods sont volontairement not-ready pendant un rolling update sain). J'alerte au niveau service sur une dérivation du budget d'erreur SLO, avec une condition multi-burn-rate (un burn rapide ET un burn lent simultanés) calculée sur l'histogram de durée des checks — ça filtre le bruit transitoire tout en gardant un MTTR court sur les vraies pannes.

🔗 Liens

Bibliothèque tech perso — Achref