Health checks dans NestJS
TL;DR —
@nestjs/terminusfournitHealthCheckServiceet 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 :
- 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.
- Readiness reflète les dépendances critiques. Si tu ne peux pas servir, retire-toi du load balancer.
- Startup couvre les boots longs. Migration DB, warmup de cache, sinon liveness peut tuer pendant le boot.
🛠️ Code minimal
Installation :
npm i @nestjs/terminus @nestjs/axiosModule dédié, séparé du contrôleur principal :
// 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 :
// 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/terminusv11 déprécieHealthIndicator(classe de base héritée) etHealthCheckError. Ils fonctionnent encore en v11 mais sont supprimés en v12. L'API moderne injecteHealthIndicatorService(composition, pas héritage) et tu retournes.up()/.down()au lieu dethrow new HealthCheckError(...).
| Aspect | Legacy (≤ v10, déprécié en v11) | Moderne (v11+) |
|---|---|---|
| Mécanisme | extends HealthIndicator | constructor(private hi: HealthIndicatorService) |
| Statut OK | return this.getStatus(key, true, meta) | return indicator.up(meta) |
| Statut KO | throw new HealthCheckError(msg, status) | return indicator.down(meta) (pas de throw) |
| Couplage | hé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 :
// 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 deDate.now(): un saut NTP ne fausse pas ta latence. Le legacy snippet de ce guide hardcodaitlatencyMs: 0— bug silencieux corrigé ici.
Version legacy (≤ v10) — encore vue partout, à reconnaître et migrer :
// 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) :
@Get('readyz')
@HealthCheck()
readiness() {
return this.health.check([
() => this.redis.isHealthy('redis'),
]);
}2. Indicator pour queues BullMQ
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/deepou à une métrique alertée, pas à/readyz. Sur readiness, ne vérifie queisPaused(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.
@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.
// 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 :
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).
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.
// main.ts
import { ShutdownSignal } from '@nestjs/common';
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks([ShutdownSignal.SIGTERM, ShutdownSignal.SIGINT]);@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/terminusv7. API basée surterminus(lib OpenTracing). Indicators stables :DNSHealthIndicator,TypeOrmHealthIndicator,MongooseHealthIndicator,MemoryHealthIndicator,DiskHealthIndicator. Décorateur@HealthCheck(). - Nest 8 :
@nestjs/terminusv8. Décorateur@HealthCheckServicetoujours via DI. Apparition d'options de timeout configurables par indicator. - Nest 9 :
@nestjs/terminusv9. API stable.MicroserviceHealthIndicatorplus complet (TCP, NATS, gRPC). Improved error log style. - Nest 10 :
@nestjs/terminusv10. Breaking :TerminusModule.forRoot()accepte une nouvelle optionlogger(boolean ou class).errorLogStyle: 'pretty' | 'json'.gracefulShutdownTimeoutMsofficialisé. - Nest 11 :
@nestjs/terminusv11. Support officiel deprismahealth indicator (PrismaHealthIndicatordans@nestjs/terminus). Suppression des indicators dépréciés (DNSHealthIndicator-> renomméHttpHealthIndicator.pingCheck).
Libs tierces à connaître :
@nestjs/terminusest wrapper sur la libterminusqui gère SIGTERM, drainage, listening.prom-clientsouvent associé pour exposer les compteurs de check.
⚠️ Pitfalls
- 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).
- Readiness sans timeout. Une dépendance lente bloque la réponse 30s, et la probe est considérée KO sur kubelet timeout. Toujours
timeoutexplicite par indicator. - 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.
- 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.
- Liveness identique à readiness. Pareil mauvais que de ne pas en avoir.
- 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.
- Memory check inutile.
checkHeapavec 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. - 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). - 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.
- Cascade de timeouts. Probe timeout 2s -> kubelet timeout 1s. Le pod est tué avant que ta probe réponde. Aligne
timeoutSecondsKubernetes >= timeout interne + marge.
🧪 Testing
Tests unitaires sur un indicator custom :
// 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 :
// 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 :
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 drainerTrois leviers à régler ensemble :
terminationGracePeriodSeconds(>= temps de drainage).preStopsleep (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 :
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
| Aspect | Shallow | Deep |
|---|---|---|
| Coût | Très faible | Modéré |
| Détecte | Pannes franches | Pannes silencieuses, dérives |
| Fréquence | Haute (chaque s) | Basse (toutes 30-60s) |
| Exemple | SELECT 1 | SELECT count(*) FROM orders WHERE status='pending' |
| Probe associée | liveness/readiness | endpoint 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.
@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.
@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.
@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.
// 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 :
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: 30Liveness 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
| Cas | Probe à utiliser |
|---|---|
| Pod doit redémarrer après deadlock event loop | liveness shallow (memory + ping process) |
| Service derrière LB doit se retirer si DB tombe | readiness |
| Boot lent (migrations, warmup) | startupProbe ou readiness avec failureThreshold haut |
| Monitoring externe d'une intégration partenaire | endpoint /healthz/deep polled par outil tiers |
| Endpoint pour debugger un incident | endpoint 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 :
// 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 toujoursclaude-haiku-4-5: tu veux mesurer la joignabilité, pas payer un raisonnement Opus. EtmaxRetries: 0sur 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 :
// 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 :
| Signal | liveness | readiness | /health/deep | mé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'histogramhealthcheck_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 :
# 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
- Doc officielle : https://docs.nestjs.com/recipes/terminus
@nestjs/terminusGitHub : https://github.com/nestjs/terminus- Kubernetes probes : https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
- "Health Checks" — Microsoft Azure architecture guide
opossumcircuit breaker : https://github.com/nodeshift/opossum- Article "Liveness vs Readiness vs Startup probes" — Google Cloud
- Prometheus exporter
prom-client: https://github.com/siimon/prom-client