Deployment & Docker — multi-stage, healthchecks, graceful shutdown
TL;DR — Une image Nest prod = multi-stage build, distroless ou alpine slim, non-root user, healthcheck distinct (liveness ≠ readiness), graceful shutdown via
enableShutdownHooks(), secrets injectés au runtime (jamais bakés), et tu vises < 200 MB. K8s requiertterminationGracePeriodSeconds≥preStop sleep + close timeoutpour des restarts propres. Si ton image fait 1.5 GB etkubectl delete podperd des requêtes en vol, tu n'es pas prêt pour la prod.
🧠 Mental model — ASCII diagram + analogy
Dockerfile multi-stage
┌────────────────────────────────────┐
│ Stage 1: deps (cache pnpm/npm) │
│ Stage 2: build (tsc, prune) │
│ Stage 3: runtime (distroless, app) │
└────────────────────────────────────┘
│
▼
┌─── K8s Pod lifecycle ────────────────────────┐
│ liveness (am I dead?) → restart │
│ readiness (can I serve?) → traffic on/off │
│ startup (am I booted?) → delays liveness │
│ preStop (drain) → sleep + close upstream │
│ SIGTERM → enableShutdownHooks → drain reqs │
│ SIGKILL (after gracePeriod) │
└──────────────────────────────────────────────┘Analogie : la livraison de ton app à la cuisine du restaurant. Multi-stage = tu n'amènes pas la balance et le four à la table, juste l'assiette. Liveness = "le chef respire-t-il ?". Readiness = "peut-il prendre une commande maintenant ?". Graceful shutdown = on annonce "dernier service", on finit les commandes en cours, on ferme — sans jeter le client à la porte.
🛠️ Code minimal
Dockerfile multi-stage avec pnpm
# syntax=docker/dockerfile:1.7
# ───────── deps ─────────
FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# ───────── build ─────────
FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
RUN pnpm prune --prod
# ───────── runtime ─────────
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production NODE_OPTIONS="--enable-source-maps"
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/package.json ./
USER nonroot
EXPOSE 3000
CMD ["dist/main.js"]Résultat typique : ~150 MB. Avec node:20-slim au lieu de distroless : ~200 MB. Avec alpine : ~120 MB mais musl libc → vérifier les binaires natifs (argon2, bcrypt).
Graceful shutdown
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // appelle onModuleDestroy / onApplicationShutdown sur SIGINT / SIGTERM
await app.listen(3000);
}// orders.module.ts
@Injectable()
export class OrdersWorker implements OnApplicationShutdown {
async onApplicationShutdown(signal?: string) {
this.logger.log({ signal }, 'shutting down worker');
await this.queue.close({ timeout: 10_000 });
}
}Piège senior #1 —
enableShutdownHooks()ne draine PAS les connexions HTTP en vol. Beaucoup pensent queenableShutdownHooks()suffit. Faux. Il déclenche les hooksonModuleDestroy/onApplicationShutdown(fermer la queue, la DB, les sockets), puis appellehttpServer.close(). Maisserver.close()arrête seulement d'accepter de nouvelles connexions — il attend que les keep-alive existants se ferment d'eux-mêmes, ce qui peut bloquer indéfiniment, ou tuer brutalement si leterminationGracePeriodSecondsexpire. Les deux échecs (hang ou kill) sont silencieux.La séquence réellement propre, ordonnée par un staff engineer :
// main.ts — graceful shutdown DURCI
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
const logger = app.get(Logger);
// 1. Readiness flip : un flag mémoire que la readiness probe lit.
// On le passe à "draining" AVANT de fermer quoi que ce soit,
// pour que K8s retire le pod du pool de service.
const shutdown = app.get(ShutdownState); // { draining = false }
app.enableShutdownHooks();
// 2. Intercepter SIGTERM nous-même pour orchestrer l'ordre.
let shuttingDown = false;
const onSignal = async (signal: string) => {
if (shuttingDown) return; // idempotent : un 2e SIGTERM ne relance pas
shuttingDown = true;
logger.log(`${signal} reçu — readiness → draining`);
shutdown.draining = true;
// 3. Laisser le LB/kube-proxy observer le readiness=fail.
// (Ce sleep DOUBLE le preStop ; voir note ci-dessous.)
await new Promise((r) => setTimeout(r, 5_000));
// 4. app.close() : ferme le serveur HTTP + déclenche tous les hooks.
// Nest 10/11 attend les connexions actives ; on borne avec un timeout.
await Promise.race([
app.close(),
new Promise((_, rej) => setTimeout(() => rej(new Error('close timeout')), 15_000)),
]).catch((e) => logger.error(`shutdown forcé: ${e.message}`));
process.exit(0);
};
process.on('SIGTERM', () => void onSignal('SIGTERM'));
process.on('SIGINT', () => void onSignal('SIGINT'));
await app.listen(3000, '0.0.0.0');
}Pourquoi le
preStop sleep 10ET le flagdraining? Ce sont deux mécanismes de drainage à des couches différentes, et un seul ne suffit pas :
- Le
preStop sleepcouvre la latence de propagation du LB externe (Ingress, ALB, Scaleway LB) qui ne connaît pas/health/readinessmais retire l'endpoint quand il voit le podTerminating.- Le flag
draining+ readiness=503 couvrekube-proxy/Service interne qui route encore tant que la readiness n'a pas basculé. Sans le flag, un appel inter-services dans le cluster (un autre pod qui appelle ton Service) continue de t'envoyer du trafic pendant lepreStop sleep→ 5xx. LeterminationGracePeriodSecondsdoit englober :preStop sleep+draining sleep+app.close()timeout. D'où35sdans l'exemple end-to-end (10 + 5 + 15 + marge).
// shutdown.state.ts — flag lu par la readiness probe
@Injectable()
export class ShutdownState {
draining = false;
}// dans la readiness probe :
@Get('readiness')
@HealthCheck()
readiness() {
if (this.shutdown.draining) {
throw new ServiceUnavailableException('draining'); // 503 → K8s retire du pool
}
return this.health.check([/* ... */]);
}Healthcheck endpoints (Terminus)
// health.controller.ts
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
private redis: RedisHealthIndicator,
) {}
@Get('liveness')
liveness() { return { status: 'ok' }; } // just alive, no deps
@Get('readiness')
@HealthCheck()
readiness() {
return this.health.check([
() => this.db.pingCheck('db', { timeout: 1500 }),
() => this.redis.pingCheck('redis'),
]);
}
}Kubernetes Deployment (extrait)
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: api
image: registry/orders-api:1.4.0
ports: [{ containerPort: 3000 }]
env:
- name: DATABASE_URL
valueFrom: { secretKeyRef: { name: db, key: url } }
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 1, memory: 512Mi }
startupProbe:
httpGet: { path: /health/liveness, port: 3000 }
failureThreshold: 30
periodSeconds: 2
livenessProbe:
httpGet: { path: /health/liveness, port: 3000 }
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /health/readiness, port: 3000 }
periodSeconds: 5
failureThreshold: 2
lifecycle:
preStop:
exec: { command: ["sh", "-c", "sleep 10"] }preStop sleep 10 laisse au load balancer le temps de retirer le pod du pool avant que Nest reçoive SIGTERM. Sinon : requêtes routées vers un pod en cours d'arrêt → 5xx.
🎯 Patterns courants
- Non-root user — distroless
:nonrootouUSER nodesur alpine. Sécurité K8srunAsNonRoot: true. .dockerignorestrict —node_modules,.git,dist,coverage,*.md. Sinon contexte de build = 500 MB envoyés au daemon.- Build args pour la version —
ARG VERSIONpuisLABEL org.opencontainers.image.version=$VERSION. Inspect possible en prod. - Healthcheck séparés : liveness (ne dépend de rien), readiness (deps OK). Liveness qui ping la DB = boucle de restart si la DB hoquette.
- Secrets via runtime — ExternalSecrets Operator (sync depuis Vault/SSM vers K8s Secrets), jamais hardcodés ni en env vars literales.
- Image scanning en CI — Trivy / Grype sur l'image. Bloque si vuln critical.
🧭 Choix de l'image de base — tradeoffs
Le choix runtime est une décision d'architecte : tu arbitres taille / sécurité / debuggabilité / compatibilité ABI. Aucune option n'est "la bonne" — elle dépend de ton modèle de menace et de ta culture d'ops.
| Base runtime | Taille typique | libc | Shell | Surface CVE | Native addons | Quand la choisir |
|---|---|---|---|---|---|---|
gcr.io/distroless/nodejs20-debian12:nonroot | ~120–150 MB | glibc | ❌ aucun | minimale (pas d'apt, pas de shell) | OK (glibc) | Prod par défaut, compliance, secteur régulé |
node:20-slim (Debian) | ~180–220 MB | glibc | ✅ bash | moyenne | OK | Compromis : debug facile, glibc, pas de surprise ABI |
node:20-alpine | ~110–130 MB | musl | ✅ sh | faible | ⚠️ recompile (argon2, bcrypt, sharp) | Taille minimale, équipe à l'aise avec musl |
node:20 (full Debian) | ~1.1 GB | glibc | ✅ | large | OK | ❌ Jamais en prod. CI/build uniquement |
chainguard/node (Wolfi) | ~80–110 MB | glibc | ❌ | quasi-zéro CVE, SBOM signé | OK | Prod hardened, exigence "0 CVE" |
Le piège musl (Alpine). Alpine n'utilise pas glibc mais musl. Conséquences concrètes :
- Les addons natifs (
argon2,bcrypt,sharp,@node-rs/*,better-sqlite3) doivent être compilés pour musl, pas téléchargés en prebuilt glibc → builds plus lents, ou crashError: Cannot find module ...nodeau runtime. - Résolution DNS différente (pas de
getaddrinfoglibc) : sous forte charge, musl peut se comporter différemment sur les lookups multi-A. Rare, mais traque-le si tu vois des timeouts DNS sporadiques. tini/initrequis explicitement (voir Pitfall #5).
"Mais je ne peux pas kubectl exec dans une distroless !" — Exact, et c'est voulu. Pour debug une distroless en prod, utilise un ephemeral debug container (K8s 1.25+) qui s'attache au même namespace process sans modifier l'image :
kubectl debug -it pod/esign-api-abc --image=busybox --target=api -- sh
# tu as un shell busybox qui voit /proc/1/root, les sockets, l'env du process apiTu gardes une image de prod minimale ET tu peux debug. Le pire anti-pattern est de mettre un shell dans l'image de prod "au cas où".
🧭 Où faire tourner ce conteneur — la décision de topologie
Avant d'écrire un Dockerfile, un staff engineer choisit la topologie de déploiement, parce qu'elle dicte tout le reste (probes, secrets, autoscaling, coût). Le réflexe junior est "K8s pour tout". Le réflexe senior est de matcher la cible à la charge réelle et à la maturité ops de l'équipe. K8s est un multiplicateur de force si tu as déjà une plateforme ; sinon c'est une taxe de complexité qui te coûte plus que ce qu'elle rapporte.
| Cible | Bon pour | Mauvais pour | Coût d'ops | Cold start | Le piège |
|---|---|---|---|---|---|
| K8s managé (EKS, GKE, Kapsule) | fleet multi-services, équipe plateforme, compliance | équipe < 5, 1 seul service, budget serré | élevé (cluster + ops) | n/a (toujours chaud) | tu paies un control plane + des nœuds idle 24/7 |
| PaaS conteneur (Fly, Railway, Render) | startup, scale-to-zero, déploiement Git push | latence sub-10ms garantie, réseau complexe | faible | 0.5–2 s | scale-to-zero = 1ère requête lente ; observe avec un keep-warm si SLA strict |
| Serverless conteneur (Cloud Run, Lambda container) | charge en pics, idle long, pay-per-request | connexions longues / SSE / WebSocket (timeouts), état en mémoire | très faible | 1–3 s | incompatible avec un stream LLM de 90 s sur Lambda (15 min max mais facturé tout du long) ; Cloud Run tolère mieux (timeout req jusqu'à 60 min) |
| VM + systemd (un seul gros host) | latence ultra-basse, contrôle total, legacy | scale horizontal, résilience nœud | moyen | n/a | un nœud = un SPOF ; pas de rolling update gratuit |
Comment je tranche en 3 questions :
- Ai-je déjà une plateforme K8s ? Oui → ship en K8s, le coût marginal d'un service de plus est faible. Non → ne monte PAS un cluster pour un service ; PaaS.
- Mes requêtes sont-elles longues (SSE/WebSocket/jobs IA) ? Oui → évite le serverless à timeout court ; vise K8s ou PaaS avec timeouts ajustables (cf. annotations Ingress plus bas).
- Ma charge est-elle bursty avec des creux profonds ? Oui → scale-to-zero (Fly/Cloud Run) écrase la facture (cf. Scénario 3, 10× moins cher). Non, charge plate → K8s avec HPA classique.
Mental model du coût. Un cluster K8s "vide" coûte déjà ~150–300 €/mois (control plane + 2 nœuds minimum pour la HA). En dessous de ~4–5 services et ~3k req/min soutenus, le PaaS gagne presque toujours sur le TCO (Total Cost of Ownership = infra + temps humain). Le coût caché de K8s n'est pas la facture cloud, c'est l'ingénieur qui passe ses vendredis à débugger un CrashLoopBackOff.
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- 7 :
enableShutdownHooks()existe, maisonApplicationShutdownpeut être appelé deux fois sur certains cas edge. Évite. - 8 : behavior stabilisée, signal passé en argument.
- 9 :
@nestjs/terminusv9, support Mongoose/Microservices indicators. - 10 : node 16 dropped → Node 18+.
enableShutdownHooksnon bloquant sur les modules transient (clean). - 11 : Node 18 dropped → Node 20+.
@nestjs/terminusv11. Express 5 + Fastify 5 supportés. C'est la baseline prod en 2026.
Node side : Node 20 LTS EOL avril 2026 → ne base plus une nouvelle image dessus. Node 22 LTS ("Jod") est la cible par défaut en 2026 (EOL ~avril 2027). Node 24 LTS ("Krypton") promu LTS fin 2025 : adopte-le si tu veux le runtime le plus long-support, en vérifiant que tes addons natifs et @nestjs/* sont validés dessus. Règle d'architecte : base ton image sur une LTS active, jamais sur une LTS en Maintenance (les CVE n'y sont plus backportées aussi vite). Donc en 2026 : node:22 (ou node:24), pas node:20. Les Dockerfiles ci-dessous montrent node:20 à des fins de stabilité de l'exemple — remplace par node:22/node:24 en pratique, et aligne la distroless (gcr.io/distroless/nodejs22-debian12).
⚠️ Pitfalls
npm installau runtime dans un container — temps de boot lent + surface d'attaque (npm registry compromis). Always build, then ship.- Pas de
--frozen-lockfile→ builds non reproductibles. Toujourspnpm install --frozen-lockfile/npm ci. COPY . .avantpnpm install— invalide tout le cache au moindre changement de code. ToujoursCOPY package*.jsond'abord.USER rooten runtime — un RCE = root container. K8ssecurityContext: runAsNonRoot: truedoit bloquer.- Pas de
tiniouinit: true→nodeest PID 1, ne reap pas les zombies, SIGTERM mal géré. Distroless gère, alpine non par défaut. enableShutdownHooksoublié — SIGTERM tue brutalement, requêtes en vol perdues, connexions DB pas fermées.- Probes trop agressives — readiness à 1s timeout sur une DB lente sous charge → flapping. Tune par environnement.
- Logs vers fichier dans un container — disque éphémère, perdu. Toujours stdout/stderr, scrappé par DaemonSet (Fluent Bit, Vector).
🧪 Testing
- Build local :
docker build -t app:test .puisdocker run --rm -p 3000:3000 app:test+ smoke curl. - Scan :
trivy image app:test— vérifier zero high/critical. - Test du graceful shutdown :
docker run -d --name app app:test
sleep 5
# envoie une requête longue, puis SIGTERM
curl --max-time 30 http://localhost:3000/slow &
sleep 1
docker stop --time 30 app
# vérifier que la requête termine en 200, pas en connection reset- Test des probes : kubectl describe pod doit montrer "Ready" en < 10s.
🎬 Cas d'usage concrets
Scénario 1 — Déploiement sur Scaleway pour souveraineté française
Qui : SaaS RH qui héberge les données de paie de 1 200 PME françaises. Obligation contractuelle : données stockées et traitées en France, fournisseur français. Problème : l'app était sur AWS Frankfurt, ce qui ne suffisait pas légalement à certains clients du secteur public. Migration vers Scaleway Kapsule (K8s managed) Paris. Le Dockerfile historique était basé sur node:18 non-slim (800 MB) et root.
# Dockerfile — multi-stage, distroless, non-root
FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG VERSION
RUN pnpm build && pnpm prune --prod
RUN echo "{\"version\":\"$VERSION\",\"builtAt\":\"$(date -Iseconds)\"}" > dist/build-info.json
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production NODE_OPTIONS="--enable-source-maps --max-old-space-size=384"
LABEL org.opencontainers.image.source="https://github.com/acme/hrsaas" \
org.opencontainers.image.licenses="UNLICENSED"
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/package.json ./
USER nonroot
EXPOSE 3000
CMD ["dist/main.js"]# kapsule/deployment.yaml — Scaleway Kapsule
apiVersion: apps/v1
kind: Deployment
metadata: { name: hr-api, labels: { app: hr-api } }
spec:
replicas: 4
template:
spec:
terminationGracePeriodSeconds: 30
securityContext: { runAsNonRoot: true, fsGroup: 65532 }
containers:
- name: api
image: rg.fr-par.scw.cloud/acme/hr-api:1.4.0
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { cpu: 1, memory: 512Mi }
envFrom: [{ secretRef: { name: hr-api-secrets } }]
livenessProbe: { httpGet: { path: /health/liveness, port: 3000 } }
readinessProbe: { httpGet: { path: /health/readiness, port: 3000 } }
lifecycle:
preStop: { exec: { command: ["sh", "-c", "sleep 10"] } }Gains : image passée de 800 MB à 145 MB (push 5× plus rapide, pull cold start 3× plus rapide). Données 100% en France (Paris). runAsNonRoot + distroless = surface d'attaque minimale. Le client public a validé la conformité après audit Scaleway.
Scénario 2 — Industrie on-prem avec K8s air-gapped
Qui : éditeur d'un MES (Manufacturing Execution System) pour usines aéronautiques. Déploiement on-prem sur K8s air-gapped (aucune connexion internet sortante, pour des raisons de sécurité). Problème : les images doivent être disponibles dans un registry interne (Harbor), aucun pull depuis Docker Hub. Toutes les deps doivent être vendoredisées. Healthchecks doivent fonctionner sans DNS externe.
# Base image proxy via internal Harbor mirror
FROM harbor.intra.aero.example/library/node:20-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
# Use internal npm registry (Verdaccio)
RUN npm config set registry https://npm.intra.aero.example
RUN corepack enable && corepack prepare pnpm@9 --activate
RUN pnpm install --frozen-lockfile --prefer-offline
COPY . .
RUN pnpm build
FROM harbor.intra.aero.example/distroless/nodejs20-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER nonroot
EXPOSE 3000
CMD ["dist/main.js"]// health.controller.ts — readiness avoids external calls (no DNS)
@Get('readiness')
@HealthCheck()
readiness() {
return this.health.check([
() => this.db.pingCheck('db', { timeout: 1500 }),
() => this.redis.pingCheck('redis'),
// NO external HTTP — would fail in air-gapped env
]);
}Gains : déploiement reproductible dans 4 usines (Toulouse, Hambourg, Wichita, Tianjin) avec le même manifest. Pas de surprise réseau. Les audits de sécurité (DGAC, équivalent FAA) acceptent le SBOM généré (docker buildx + syft) pour traçabilité supply chain.
Scénario 3 — Déploiement Vercel-style pour un chat e-commerce
Qui : startup e-commerce qui propose un chat support IA pour boutiques Shopify. Charge irrégulière (pics aux heures de bureau, calme la nuit). Petite équipe sans expertise K8s. Problème : K8s sur AWS coûtait 1 200 €/mois pour un service qui ne génère que 800 €/mois en MRR. Besoin d'auto-scaling vrai (down à 0 la nuit), zero-config infra, déploiement Git push.
# Optimized for Fly.io / Railway / Render
FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build && pnpm prune --prod
FROM node:20-alpine AS runtime
RUN apk add --no-cache tini
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
USER node
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/main.js"]# fly.toml — Fly.io config
app = "shopify-chat-api"
primary_region = "cdg" # Paris
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = true # scale to 0
auto_start_machines = true
min_machines_running = 0
[http_service.checks]
grace_period = "10s"
interval = "15s"
method = "GET"
path = "/health/liveness"
[vm]
cpu_kind = "shared"
cpus = 1
memory_mb = 512Gains : passage de 1 200 €/mois à 180 €/mois (10× moins). Déploiement = fly deploy. Auto-scale à 0 la nuit, démarrage en 1.2s sur la première requête (cold start négligeable pour un chat). L'équipe (2 devs) n'a pas à comprendre K8s — la complexité est encapsulée par Fly.
🛠️ Exemple end-to-end
Mise en situation : tu déploies une API Nest eSignature en production sur K8s (EKS ou Kapsule), avec : multi-stage Dockerfile distroless, build CI GitHub Actions avec cache layers, scan Trivy, signed images via cosign, manifests K8s avec probes et graceful shutdown, et secrets sync depuis Vault via External Secrets Operator.
# Dockerfile
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG VERSION=dev
ARG GIT_SHA=unknown
ENV APP_VERSION=$VERSION APP_GIT_SHA=$GIT_SHA
RUN pnpm build && pnpm prune --prod
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production NODE_OPTIONS="--enable-source-maps --max-old-space-size=384"
ARG VERSION
ARG GIT_SHA
LABEL org.opencontainers.image.title="esign-api" \
org.opencontainers.image.version=$VERSION \
org.opencontainers.image.revision=$GIT_SHA \
org.opencontainers.image.source="https://github.com/acme/esign-api"
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/package.json ./
USER nonroot
EXPOSE 3000
CMD ["dist/main.js"]// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.enableShutdownHooks(); // SIGTERM → OnApplicationShutdown
await app.listen(3000, '0.0.0.0');
}
bootstrap().catch((err) => {
console.error('Fatal bootstrap error', err);
process.exit(1);
});// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';
import { RedisHealthIndicator } from './redis.health';
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
private readonly redis: RedisHealthIndicator,
) {}
@Get('liveness')
liveness() { return { status: 'ok', version: process.env.APP_VERSION ?? 'dev' }; }
@Get('readiness')
@HealthCheck()
readiness() {
return this.health.check([
() => this.db.pingCheck('db', { timeout: 1500 }),
() => this.redis.pingCheck('redis'),
]);
}
}# .github/workflows/release.yml
name: release
on: { push: { tags: ['v*'] } }
jobs:
build-publish:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write, id-token: write }
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
- name: Build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/acme/esign-api:${{ github.ref_name }}
build-args: |
VERSION=${{ github.ref_name }}
GIT_SHA=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/acme/esign-api:${{ github.ref_name }}
severity: HIGH,CRITICAL
exit-code: '1'
- name: Sign with cosign
uses: sigstore/cosign-installer@v3
- run: cosign sign --yes ghcr.io/acme/esign-api:${{ github.ref_name }}# k8s/external-secrets.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata: { name: esign-api-secrets, namespace: esign }
spec:
refreshInterval: 1h
secretStoreRef: { name: vault-prod, kind: ClusterSecretStore }
target: { name: esign-api-secrets }
data:
- secretKey: DATABASE_URL
remoteRef: { key: prod/esign-api, property: database_url }
- secretKey: JWT_ACCESS_SECRET
remoteRef: { key: prod/esign-api, property: jwt_access_secret }
- secretKey: STRIPE_KEY
remoteRef: { key: prod/esign-api, property: stripe_key }# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: esign-api, namespace: esign }
spec:
replicas: 3
strategy: { rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } }
selector: { matchLabels: { app: esign-api } }
template:
metadata:
labels: { app: esign-api }
annotations:
# Force un rollout quand le contenu du Secret change : on injecte
# le sha256 du Secret au templating (Helm: {{ ... | sha256sum }},
# ou Kustomize secretGenerator qui suffixe le nom → nouveau hash).
# Sans ça, un Secret muté n'est PAS re-lu : les pods existants gardent
# l'ancienne valeur en mémoire (envFrom est lu une seule fois au boot).
rollme/secrets-checksum: "<sha256 du Secret, injecté par Helm/Kustomize/CI>"
spec:
terminationGracePeriodSeconds: 35
securityContext: { runAsNonRoot: true, runAsUser: 65532, fsGroup: 65532, seccompProfile: { type: RuntimeDefault } }
containers:
- name: api
image: ghcr.io/acme/esign-api:1.4.0
imagePullPolicy: IfNotPresent
ports: [{ containerPort: 3000, name: http }]
envFrom: [{ secretRef: { name: esign-api-secrets } }]
env:
- name: NODE_ENV
value: production
- name: APP_VERSION
value: "1.4.0"
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { cpu: 1500m, memory: 512Mi }
startupProbe:
httpGet: { path: /health/liveness, port: http }
failureThreshold: 30
periodSeconds: 2
livenessProbe:
httpGet: { path: /health/liveness, port: http }
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /health/readiness, port: http }
periodSeconds: 5
failureThreshold: 2
lifecycle:
preStop:
exec: { command: ["sh", "-c", "sleep 10"] }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }Effets concrets : image finale 152 MB, signée Cosign (vérifiable par cosign verify). Trivy bloque toute vuln HIGH/CRITICAL. ExternalSecrets pulle Vault toutes les heures et propage les rotations de secrets. terminationGracePeriodSeconds: 35 + preStop sleep 10 garantit qu'aucune requête en vol n'est coupée pendant un rolling update (LB retire le pod, puis Nest reçoit SIGTERM, puis ferme proprement). readOnlyRootFilesystem + drop ALL caps ferment les portes en cas de RCE.
🤖 Déployer un service NestJS qui SERT des agents IA (Anthropic)
Conteneuriser une API REST classique et conteneuriser une API qui stream des tokens LLM et orchestre une boucle agentique tool-use ne posent pas les mêmes problèmes d'ops. Un service IA a trois propriétés qui cassent les hypothèses de déploiement par défaut :
- Connexions longues — une réponse Opus streamée peut durer 30–120 s. Tes timeouts de probes, de LB et de
terminationGracePeriodSecondssont calibrés pour des requêtes de 50 ms. - Travail coûteux et non-idempotent — un appel à
claude-opus-4-8coûte de l'argent. Un pod tué au milieu d'une génération = tokens facturés, output perdu. Le retry naïf double la facture. - Annulation bidirectionnelle — si le client ferme l'onglet, tu dois abort l'appel upstream à Anthropic, sinon tu continues de payer pour des tokens que personne ne lira.
Le client LLM doit être un provider DI, pas un new Anthropic() dans un champ
Anti-pattern fréquent : private client = new Anthropic() dans un service. Tu perds le mock en test, la config par env, le partage de pool de connexions, et tu instancies un client par requête. Le pattern propre est forRootAsync avec la clé injectée depuis le Secret K8s (jamais bakée dans l'image).
// anthropic.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC = Symbol('ANTHROPIC');
@Global()
@Module({
providers: [
{
provide: ANTHROPIC,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new Anthropic({
apiKey: config.getOrThrow<string>('ANTHROPIC_API_KEY'), // vient d'envFrom secretRef
maxRetries: 3, // le SDK retry les 429/5xx avec backoff
timeout: 60_000,
}),
},
],
exports: [ANTHROPIC],
})
export class AnthropicModule {}La clé
ANTHROPIC_API_KEYarrive parenvFrom: { secretRef: ... }synchronisé depuis Vault par External Secrets (cf. section secrets). Elle n'est jamais unARG/ENVdu Dockerfile — sinon elle fuit dans l'historique de layers de l'image (docker historyla révèle).
Streamer les tokens en SSE — et abort proprement
// chat.controller.ts
import { Controller, Post, Body, Res, Req, Inject } from '@nestjs/common';
import type { Response, Request } from 'express';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from './anthropic.module';
@Controller('chat')
export class ChatController {
constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) {}
@Post('stream')
async stream(@Body() body: { prompt: string }, @Req() req: Request, @Res() res: Response) {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no', // désactive le buffering nginx/ingress
});
res.flushHeaders();
// Annulation bidirectionnelle : si le client coupe (onglet fermé,
// navigation, Stop button), on abort l'appel upstream Anthropic.
const ac = new AbortController();
req.on('close', () => ac.abort());
try {
const stream = await this.anthropic.messages.stream(
{
model: 'claude-sonnet-4-6',
max_tokens: 1024,
messages: [{ role: 'user', content: body.prompt }],
},
{ signal: ac.signal },
);
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
res.write(`data: ${JSON.stringify({ token: event.delta.text })}\n\n`);
}
}
res.write('event: done\ndata: {}\n\n');
} catch (err) {
if (ac.signal.aborted) return; // client parti, rien à signaler
res.write(`event: error\ndata: ${JSON.stringify({ message: 'llm_error' })}\n\n`);
} finally {
res.end();
}
}
}Impact sur le déploiement (le vrai sujet de ce fichier) :
- Probes — Ne route jamais la liveness/readiness sur un endpoint qui appelle Anthropic. La probe doit rester locale (
/health/livenessretourne{status:'ok'}). Si tu pingues le LLM dans la readiness, une dégradation Anthropic (429 global) déclenche un restart-loop de TOUT ton fleet alors que ton app va bien. - Timeouts LB/Ingress — Le défaut nginx-ingress est 60 s (
proxy-read-timeout). Un stream Opus long se fait couper à 60 s → le client voit une coupure nette. Annotation à poser :
metadata:
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
nginx.ingress.kubernetes.io/proxy-buffering: "off" # sinon SSE bufferisé, pas de stream
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"- Graceful shutdown des streams en vol — c'est le point clé qui relie ce fichier à l'IA. Pendant un rolling update, un pod en
draininga peut-être 40 streams SSE actifs de 90 s. Le flagdraining(cf. section graceful shutdown) bascule readiness=503 → plus de nouveaux streams, mais les streams existants doivent finir. D'oùterminationGracePeriodSeconds≥ durée max d'un stream + marge, ou une politique explicite "on coupe les streams en cours, le client reconnecte sur le nouveau pod". Décision d'architecte : un chat tolère une coupure + reconnect ; un job de génération de document long ne le tolère pas → passe-le en BullMQ (ci-dessous), pas en requête HTTP synchrone.
Travail IA lourd → BullMQ, pas une requête HTTP
Une génération longue (rapport, batch d'embeddings, agent multi-tours) ne doit pas vivre dans le cycle de vie d'une requête HTTP : elle survit mal aux rolling updates, aux timeouts LB, et bloque un worker pendant des minutes. On la pousse en job BullMQ, ce qui change la donne côté déploiement (le worker peut être un Deployment séparé, scalé indépendamment, sans Ingress).
// ai-generation.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { Inject } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from './anthropic.module';
@Processor('ai-generation', { concurrency: 4 })
export class AiGenerationProcessor extends WorkerHost {
constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) {
super();
}
async process(job: Job<{ generationId: string; prompt: string }>) {
// 1. Idempotence : keyée sur generationId (PAS sur job.id, qui change au retry).
// Si une génération a déjà produit un output, on ne re-paie pas Anthropic.
const existing = await this.store.get(job.data.generationId);
if (existing?.status === 'done') return existing.result;
// 2. Cost guard : on plafonne max_tokens et on coupe les retries si le coût
// cumulé dépasse un budget. Un retry-loop sur Opus peut coûter cher.
const result = await this.anthropic.messages.create({
model: 'claude-opus-4-8',
max_tokens: 4096,
messages: [{ role: 'user', content: job.data.prompt }],
});
const text = result.content
.filter((b): b is Anthropic.TextBlock => b.type === 'text')
.map((b) => b.text)
.join('');
// 3. Persiste AVANT de retourner → si le pod meurt après ce write,
// le retry voit status=done et ne re-génère pas.
await this.store.set(job.data.generationId, { status: 'done', result: text });
return text;
}
}// queue config — retry cost-aware
await queue.add('generate', { generationId, prompt }, {
jobId: generationId, // déduplication BullMQ par generationId
attempts: 3,
backoff: { type: 'exponential', delay: 2_000 },
removeOnComplete: { age: 3600 },
removeOnFail: false, // garde les échecs pour audit/coût
});Déploiement du worker : c'est un Deployment distinct (kind: Deployment, pas d'Ingress, pas de readiness HTTP — readiness = "connecté à Redis"). Sur SIGTERM, le worker BullMQ doit finir le job en cours avant de quitter (worker.close() attend la fin du process() courant, jusqu'au lock duration). D'où encore terminationGracePeriodSeconds ≥ durée max d'un job IA. Si un pod worker est évincé (spot instance, OOM), le job est repris par un autre worker grâce au lock BullMQ + l'idempotence keyée generationId empêche la double-facturation Anthropic. C'est exactement pourquoi l'idempotence n'est pas optionnelle pour les jobs IA : l'eviction est normale en K8s, et chaque re-exécution non gardée coûte de l'argent réel.
Modèles Anthropic à connaître :
claude-opus-4-8(flagship, raisonnement/agents complexes),claude-sonnet-4-6(équilibre coût/qualité, défaut pour du chat streamé),claude-haiku-4-5(rapide/économique, classification, routing). Choisis le modèle par job : router en Haiku, générer le rapport final en Opus. Côté déploiement, ça veut dire des budgets de timeout/mémoire différents par type de job — d'où des queues (et potentiellement des Deployments worker) séparées par modèle. Utilise toujours l'Async/streaming du SDK et laisse les retries du SDK (maxRetries) gérer 429/5xx avec backoff plutôt que de réinventer une boucle de retry.
Observabilité d'un service IA — ce que tu DOIS mesurer
Un service IA observé "comme une API REST" est aveugle sur ses deux risques principaux : le coût et la latence de streaming. Un p99 de latence de requête ne veut rien dire quand la réponse dure 90 s par design — ce qui compte, c'est le TTFT (time-to-first-token). Métriques à émettre (Prometheus / OTel), dérivées de l'objet usage que renvoie le SDK :
// chat.controller.ts — instrumentation (extrait, dans le finally du stream)
const finalMessage = await stream.finalMessage();
this.metrics.observe('llm_tokens_input', finalMessage.usage.input_tokens, { model });
this.metrics.observe('llm_tokens_output', finalMessage.usage.output_tokens, { model });
this.metrics.observe('llm_ttft_ms', ttftMs, { model }); // mesuré au 1er delta reçu
this.metrics.inc('llm_requests_total', { model, outcome }); // outcome: done|aborted|error
// coût estimé = tokens × tarif/Mtok du modèle → métrique business, pas juste technique
this.logger.log({ generationId, model, ...finalMessage.usage, ttftMs, outcome }, 'llm_call');Les 4 signaux qu'un staff engineer met en dashboard/alerte :
- TTFT (time-to-first-token) — la métrique d'UX réelle d'un chat streamé. Si elle dérive, c'est upstream (Anthropic) ou ton Ingress qui bufferise (
proxy-buffering: offoublié). - Tokens out / requête + coût € — détecte une boucle agentique qui s'emballe (tool-use infini) AVANT la facture de fin de mois. Pose une alerte sur le coût cumulé/heure.
- Taux d'abort — un taux d'abort élevé peut signifier des streams trop lents (clients qui abandonnent) → corrèle avec le TTFT.
- Saturation de la queue BullMQ (jobs IA) — backlog qui grossit = signal pour scaler le Deployment worker. C'est ta vraie métrique d'autoscaling, pas le CPU (un worker qui attend Anthropic est idle CPU mais saturé en débit).
Piège observabilité. Ne logue jamais le prompt/la réponse en clair par défaut : c'est de la donnée potentiellement sensible (PII, secrets collés par l'utilisateur) et ça explose le volume de logs. Logue le
generationId, leusage, le modèle, l'outcome— pas le contenu. Si tu as besoin du contenu pour debug, mets-le derrière un flag + une rétention courte + un masquage PII.
🔁 Quand utiliser / éviter
- Distroless : prod, sécurité élevée. Pas de shell pour debug — accepte ce trade-off.
- Alpine : si tu veux shell et taille minimale. Vérifie compatibilité musl.
- node:slim : compromis raisonnable, glibc, debug facile.
- Évite
node:latestou tag mouvant — toujours version pin. - Évite Docker Compose en prod — c'est pour dev/CI. Prod = K8s, Nomad, ou PaaS (Render, Fly.io, Railway).
Env injection — patterns
Plusieurs sources, ordre de priorité dans Nest :
process.env(le plus haut).envfiles (via@nestjs/config)- Defaults dans le code
En K8s, l'env vient :
envliteral (visible en clair → jamais pour secrets)envFrom: secretRef(depuis un Secret, base64 décodé)envFrom: configMapRef(config non-secret)- Init container qui pull Vault → écrit dans un emptyDir partagé → l'app lit au boot
Pattern recommandé (External Secrets Operator) :
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata: { name: orders-secrets }
spec:
refreshInterval: 1h
secretStoreRef: { name: vault-backend, kind: ClusterSecretStore }
target: { name: orders-secrets }
data:
- secretKey: JWT_SECRET
remoteRef: { key: prod/orders, property: jwt_secret }
- secretKey: DATABASE_URL
remoteRef: { key: prod/orders, property: db_url }Le Deployment consomme via envFrom: { secretRef: { name: orders-secrets } }. Rotation Vault = secret K8s mis à jour automatiquement (refreshInterval). Pour que l'app prenne en compte sans restart, il faut soit un rolling restart, soit un reload signal géré côté app.
.dockerignore exemple
node_modules
.git
.github
dist
coverage
*.log
.env*
!.env.example
*.md
!README.md
.vscode
.idea
testRéduit le contexte de build typique de 300MB à < 10MB → push plus rapide vers le builder, layer cache plus stable.
🏋️ Exercices
Progressifs : implémenter → durcir pour la prod → casser puis réparer. Fais-les dans l'ordre, chacun construit sur le précédent.
Exercice 1 — Image distroless < 150 MB, reproductible
Objectif : produire une image Nest prod distroless, non-root, sous 150 MB, avec un build reproductible (lockfile gelé).
Indice/Solution : multi-stage deps (cache mount pnpm) → build (pnpm build && pnpm prune --prod) → gcr.io/distroless/nodejs20-debian12:nonroot. Vérifie la taille et l'absence de shell (docker run id doit échouer sur distroless), puis fais deux builds d'affilée sans changer le code — le second doit être 100 % cache hits :
docker images app:test --format '{{ "{{.Size}}" }}' # attendu : < 150 MB
docker run --rm app:test id # doit échouer : pas de shell → c'est bien distrolessExercice 2 — Graceful shutdown qui ne perd AUCUNE requête
Objectif : prouver, mesure à l'appui, qu'un docker stop (ou kubectl delete pod) pendant une requête longue ne renvoie jamais un connection reset.
Indice/Solution : implémente le flag draining + la séquence main.ts durcie de ce fichier. Endpoint /slow qui dort 8 s. Lance 20 requêtes /slow en parallèle, puis docker stop --time 30 au milieu. Toutes doivent finir en 200, zéro ECONNRESET. Si tu en perds : ton terminationGracePeriodSeconds/--time est < (drain + close), ou tu n'as pas borné app.close() avec un timeout.
Exercice 3 — Probes correctes : liveness ne dépend de rien
Objectif : démontrer qu'une DB qui hoquette ne déclenche PAS un restart-loop, mais retire bien le pod du pool de service.
Indice/Solution : liveness = {status:'ok'} pur ; readiness = pingCheck DB + flag draining. Coupe la DB (docker pause postgres) : la readiness passe 503 (pod retiré du Service) mais la liveness reste 200 (pas de restart). Remets la DB : readiness repasse 200. Si ton pod redémarre quand la DB tombe, tu as mis une dépendance dans la liveness — le piège classique qui transforme une panne DB de 30 s en cascade de restarts.
Exercice 4 — Streamer Claude en SSE avec annulation bidirectionnelle
Objectif : exposer /chat/stream qui stream claude-sonnet-4-6 en SSE, et prouver que fermer le client abort l'appel upstream Anthropic (plus de tokens facturés).
Indice/Solution : AbortController lié à req.on('close'), passé en { signal } à anthropic.messages.stream(...). Pour prouver l'abort : logue chaque delta reçu, lance le stream, ferme le client (curl Ctrl-C) au bout de 1 s, vérifie que les logs s'arrêtent immédiatement côté serveur (pas 30 s plus tard). Bonus déploiement : pose proxy-buffering: off sur l'Ingress et vérifie que les tokens arrivent en flux, pas en bloc à la fin.
Exercice 5 (casse-puis-répare) — Job IA idempotent qui survit à l'eviction
Objectif : un job BullMQ de génération Opus qui, quand le worker est tué en plein traitement, ne re-facture pas Anthropic au retry.
Indice/Solution : d'abord, casse-le exprès — process() qui appelle Anthropic puis throw avant de persister. Observe : 3 attempts = 3 appels Anthropic = 3× le coût. Répare : jobId: generationId (dédup) + check status==='done' en tête de process() + persist avant le return. Tue le worker (docker kill) juste après le write Anthropic mais avant le return : le retry doit voir done et ne PAS rappeler le modèle. Mesure le nombre d'appels API avant/après — c'est la métrique qui compte.
Exercice 6 (durcissement supply-chain) — Sign + verify + SBOM
Objectif : pipeline CI qui build, scanne (Trivy, bloque HIGH/CRITICAL), signe (cosign keyless OIDC) et génère un SBOM ; puis un admission controller qui refuse une image non signée.
Indice/Solution : reprends le release.yml du fichier, ajoute syft pour le SBOM (syft <image> -o spdx-json). Côté cluster, installe la policy Sigstore (Kyverno verifyImages ou policy-controller) qui exige la signature cosign. Pousse une image non signée → le pod doit être rejeté à l'admission, pas au runtime. C'est la différence entre "on scanne" et "on garantit que seul du signé tourne".
Exercice 7 (le streaming casse au déploiement) — diagnostiquer un SSE bufferisé
Objectif : reproduire puis réparer le bug classique "le chat IA streame en local mais arrive en un bloc à la fin en prod", et instrumenter le TTFT pour le prouver.
Indice/Solution : déploie /chat/stream derrière un nginx-ingress par défaut. Symptôme : curl -N reçoit toute la réponse d'un coup à la fin, pas token par token. Cause : proxy-buffering actif + proxy-read-timeout: 60 coupe les longs streams. Répare avec les 3 annotations Ingress de ce fichier (proxy-buffering: off, proxy-read-timeout: 300, et côté app X-Accel-Buffering: no). Mesure le TTFT avant/après (timestamp du 1er data: reçu) : il doit passer de "≈ durée totale" (bufferisé) à "< 1 s" (streamé). Bonus casse-puis-répare : déclenche un rolling update pendant 20 streams actifs et vérifie, via la métrique outcome, combien finissent done vs sont coupés — puis ajuste terminationGracePeriodSeconds jusqu'à zéro coupure.
🎤 En entretien
Q : enableShutdownHooks() suffit-il pour un zéro-downtime deploy ? Justifie. R : Non. Il déclenche les hooks de cycle de vie et server.close() (stop d'accepter), mais ne draine ni le LB externe ni le Service interne. Il faut en plus un flag draining qui bascule la readiness en 503 + un preStop sleep pour la propagation LB, et borner app.close() par un timeout ≤ terminationGracePeriodSeconds. Sinon : hang ou kill brutal, requêtes en vol perdues.
Q : Pourquoi ne jamais router la liveness probe sur un endpoint qui touche la DB (ou un LLM) ? R : Liveness = "redémarre-moi". Une dépendance externe qui hoquette ferait redémarrer en boucle un pod parfaitement sain, transformant une panne transitoire en cascade de restarts (et potentiellement un thundering herd au reboot). La readiness est faite pour ça : elle retire du pool sans tuer le process. Liveness doit rester strictement locale.
Q : Une génération LLM est facturée par token. Comment garantir qu'une eviction de pod K8s ne double pas la facture Anthropic ? R : Ne pas la faire en requête HTTP synchrone — la passer en job BullMQ avec idempotence keyée sur un generationId métier (pas le job.id), persister l'output avant de retourner, et checker status==='done' en tête de process(). L'eviction étant normale en K8s, le lock BullMQ fait reprendre le job par un autre worker, et le check d'idempotence empêche le re-appel au modèle. Plus un cost-guard (plafond max_tokens, budget de retries).
Q : Distroless vs Alpine en prod — comment tu tranches, et quel est le piège caché d'Alpine ? R : Distroless par défaut pour la prod régulée (surface minimale, pas de shell, non-root) ; debug via kubectl debug ephemeral container, pas via un shell baké. Alpine si la taille prime et que l'équipe assume musl. Le piège caché d'Alpine : musl libc — les addons natifs (argon2, bcrypt, sharp) doivent être recompilés pour musl, sinon crash au runtime, et la résolution DNS musl peut diverger sous charge. Et Alpine n'a pas d'init par défaut → tini/init:true obligatoire pour le reaping des zombies et SIGTERM.
Q : Une startup de 2 devs veut déployer une seule API. Tu recommandes K8s ? Justifie le TCO. R : Non. Un cluster K8s coûte ~150–300 €/mois à vide (control plane + 2 nœuds HA) plus le coût humain réel — un dev qui débugge des CrashLoopBackOff au lieu de livrer. Pour 1 service et une petite équipe, un PaaS (Fly/Render/Railway) avec scale-to-zero divise la facture et supprime l'ops. K8s devient rentable quand tu as déjà une plateforme et un fleet multi-services : le coût marginal d'un service de plus y est faible. La décision est un calcul de TCO (infra + temps humain), pas un choix de hype.
Q : Mon chat IA streame token par token en local mais arrive en un bloc en prod derrière l'Ingress. Diagnostic ? R : Buffering au niveau du proxy. Le nginx-ingress par défaut bufferise la réponse (proxy-buffering: on) et coupe à proxy-read-timeout: 60. Fix : nginx.ingress.kubernetes.io/proxy-buffering: "off", proxy-read-timeout/proxy-send-timeout à 300, et côté app le header X-Accel-Buffering: no. Vérifie par la métrique TTFT : bufferisé, le 1er token arrive à ≈ la durée totale ; streamé, en < 1 s. Pense aussi à terminationGracePeriodSeconds ≥ durée max d'un stream pour ne pas couper les streams en vol au rolling update.
Q : Comment autoscale un Deployment de workers BullMQ qui appellent un LLM ? Sur quelle métrique ? R : Pas sur le CPU — un worker qui attend Anthropic est idle CPU mais saturé en débit, donc l'HPA CPU ne déclencherait jamais. La bonne métrique est la profondeur de la queue (jobs waiting dans BullMQ/Redis), exposée en Prometheus et consommée par un HPA custom-metrics ou KEDA (scaler Redis/BullMQ). Tu scales sur le backlog, avec une borne concurrency par worker calibrée sur la mémoire/timeout du modèle, et terminationGracePeriodSeconds ≥ durée max d'un job pour un drain propre.