Déploiement Node — Docker, K8s, PaaS, graceful shutdown
TL;DR — Déployer un service Node en 2026, c'est cinq préoccupations : une image Docker mince et sûre (multi-stage, distroless ou chiseled, user non-root), un PID 1 correct (tini ou Node lui-même qui gère SIGTERM), graceful shutdown (drain connections, close pools), probes K8s (liveness + readiness distincts), config et secrets externalisés. Au-delà de Docker, le paysage PaaS (Fly, Render, Railway, Vercel, Lambda) a explosé : choisir dépend de la latence requise, du coût, et de la complexité opérationnelle acceptable. Le piège classique reste le PID 1 problem : un Node lancé comme PID 1 ne reçoit pas SIGTERM correctement, et tu te retrouves avec des connexions brutalement coupées en deploy.
🧠 Mental model — ASCII + analogie
┌──────────────────────────────────────────────────────────────┐
│ Code → Image Docker → Registry → Orchestrateur → Pod / VM │
├──────────────────────────────────────────────────────────────┤
│ Multi-stage │ minifier │ scanner │ probes │ shutdown │
│ builder │ image │ supply │ health │ graceful │
│ + runtime │ + deps │ chain │ check │ drain │
└──────────────────────────────────────────────────────────────┘
↓
Observabilité (sidecar OTel collector, sec logs)Cycle de vie d'un pod :
docker run → ENTRYPOINT → process Node → écoute requêtes
│
K8s deploy → SIGTERM ──────────┘
│
▼
stop accepting new conns
│
▼
drain en cours, attente
│
▼
close DB pools, log final
│
▼
process.exit(0) ou SIGKILL après grace periodAnalogie : un déploiement, c'est comme fermer un magasin. Tu ne baisses pas le rideau sur les clients en train de payer (SIGKILL = brutal). Tu annonces la fermeture (readiness=false), attends que les clients finissent (drain), puis tu fermes proprement (shutdown).
🛠️ Code minimal — Dockerfile multi-stage
# syntax=docker/dockerfile:1.7
# ─── Builder ────────────────────────────────────────────────
FROM node:22-bookworm-slim AS builder
WORKDIR /app
# Activer Corepack pour pnpm
RUN corepack enable
# Copier lockfile et package.json en premier (cache layer)
COPY package.json pnpm-lock.yaml ./
COPY .npmrc* ./
# Install avec frozen lockfile
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Copier le reste et builder
COPY . .
RUN pnpm build
# Prune dev deps
RUN pnpm prune --prod
# ─── Runtime ────────────────────────────────────────────────
FROM gcr.io/distroless/nodejs22-debian12 AS runtime
WORKDIR /app
ENV NODE_ENV=production \
NODE_OPTIONS="--enable-source-maps"
# User non-root (distroless a 'nonroot' user 65532)
USER nonroot:nonroot
# Copier uniquement ce qui est nécessaire
COPY --from=builder --chown=nonroot:nonroot /app/dist ./dist
COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=builder --chown=nonroot:nonroot /app/package.json ./
EXPOSE 3000
# Node est lui-même PID 1 — on s'occupe des signaux dans le code
CMD ["dist/server.js"]Cette image fait typiquement 70-100 MB au lieu de 800+ MB avec node:22 standard.
Variantes :
node:22-alpine: très léger (~50 MB) mais musl ≠ glibc, certains binaires natifs (sharp, prisma) galèrent.gcr.io/distroless/nodejs22-debian12: ~150 MB, glibc, pas de shell, le plus sûr.cgr.dev/chainguard/node:latest(Chainguard) : encore plus mince, supply chain auditée, payant pour les versions stables.
🛠️ Code minimal — graceful shutdown
// src/health.ts — un module d'état partagé entre le serveur et les probes
export const health = {
ready: false, // la readiness probe lit ce flag
live: true, // la liveness probe lit ce flag
}// src/server.ts
import http from 'node:http'
import { setTimeout as sleep } from 'node:timers/promises'
import { app } from './app.ts'
import { logger } from './logger.ts'
import { db } from './db.ts'
import { health } from './health.ts'
const port = Number(process.env.PORT ?? 3000)
const server = http.createServer(app)
// Note Node 18+ : par défaut, requestTimeout vaut 300_000ms (300s) et
// headersTimeout 60_000ms (60s). On les resserre pour borner le temps qu'une
// requête peut occuper un socket, sinon une requête lente bloque le drain
// indéfiniment.
server.requestTimeout = 30_000
server.headersTimeout = 35_000
// keepAliveTimeout < le idle timeout du LB amont, sinon races de 502.
server.keepAliveTimeout = 61_000
// 1. Connecte la DB AVANT d'accepter du trafic, puis passe ready=true.
// Sinon le pod accepte des requêtes alors que la DB n'est pas joignable → 500s.
await db.connect()
server.listen(port, () => {
health.ready = true
logger.info({ port }, 'listening, ready=true')
})
// Tracker des connexions actives pour pouvoir les fermer proprement.
const sockets = new Set<import('node:net').Socket>()
server.on('connection', (socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
})
let shuttingDown = false
async function shutdown(signal: string, exitCode = 0): Promise<void> {
if (shuttingDown) return
shuttingDown = true
logger.info({ signal }, 'shutdown initiated')
// 0. Bascule la readiness à false IMMÉDIATEMENT. La readiness probe va
// renvoyer 503, K8s retire le pod du Service → plus de nouveau trafic.
// C'est l'étape la plus importante : sans elle, on draine pendant que
// le LB continue de router des requêtes vers nous.
health.ready = false
// Laisse 1-2 cycles de readiness probe + le LB se mettre à jour avant de
// fermer le listener. (Le preStop hook côté K8s couvre aussi ce délai ;
// ce sleep est une ceinture-bretelles si le hook manque.)
await sleep(5_000)
// 1. Stop accepting new connections (le listener, pas les sockets ouverts).
server.close((err) => {
if (err) logger.error({ err }, 'server.close error')
else logger.info('server closed')
})
// 2. Ferme les sockets keep-alive IDLE tout de suite. Une connexion sans
// requête en cours (_httpMessage === null) n'a aucune raison de rester.
for (const socket of sockets) {
if ((socket as { _httpMessage?: unknown })._httpMessage == null) {
socket.destroy()
}
}
// 3. Drain : attends que les requêtes en vol terminent, avec un hard timeout.
const DRAIN_MS = 25_000
const drained = await Promise.race([
waitUntil(() => sockets.size === 0),
sleep(DRAIN_MS).then(() => 'timeout' as const),
])
if (drained === 'timeout') {
logger.warn({ remaining: sockets.size }, 'drain timeout, forcing close')
for (const socket of sockets) socket.destroy()
}
// 4. Ferme les ressources externes : DB pools, brokers, caches.
try {
await db.end()
logger.info('db closed')
} catch (err) {
logger.error({ err }, 'db close error')
}
logger.info('shutdown complete')
process.exit(exitCode)
}
async function waitUntil(pred: () => boolean): Promise<'ok'> {
while (!pred()) await sleep(100)
return 'ok'
}
// Filet de sécurité absolu : si le shutdown se bloque (un pool qui ne ferme
// jamais, un drain infini), on quitte de force avant le SIGKILL de K8s.
function armWatchdog(ms: number): void {
const t = setTimeout(() => {
logger.fatal('shutdown watchdog fired, force exit')
process.exit(1)
}, ms)
t.unref() // ne maintient pas l'event loop en vie à lui tout seul
}
process.on('SIGTERM', () => { armWatchdog(28_000); void shutdown('SIGTERM') })
process.on('SIGINT', () => { armWatchdog(28_000); void shutdown('SIGINT') })
// uncaughtException : l'état du process est CORROMPU. On peut tenter un
// shutdown ordonné, mais on N'ACCEPTE PLUS de trafic (ready=false) et on
// quitte avec un code ≠ 0 pour que K8s restart.
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'uncaughtException')
health.ready = false
armWatchdog(5_000)
void shutdown('uncaughtException', 1)
})
process.on('unhandledRejection', (reason) => {
logger.fatal({ reason }, 'unhandledRejection')
health.ready = false
armWatchdog(5_000)
void shutdown('unhandledRejection', 1)
})Et côté routes de santé, les deux probes lisent l'état partagé :
// src/health-routes.ts (Express/Fastify-agnostic)
import { health } from './health.ts'
// Liveness : "le process est-il vivant et pas deadlock ?" — JAMAIS de check DB.
export function liveness(_req: unknown, res: { status(c: number): { end(): void } }) {
res.status(health.live ? 200 : 503).end()
}
// Readiness : "puis-je servir du trafic MAINTENANT ?" — passe à 503 dès SIGTERM.
export function readiness(_req: unknown, res: { status(c: number): { end(): void } }) {
res.status(health.ready ? 200 : 503).end()
}C'est verbeux mais essentiel. Sans le health.ready = false en tête de shutdown, un deploy K8s coupe des requêtes en cours et tes utilisateurs voient des 502. Les trois pièces qui doivent s'emboîter : (1) readiness=503 stoppe le nouveau trafic, (2) drain finit les requêtes en vol, (3) watchdog garantit qu'on quitte avant le SIGKILL même si une étape se bloque.
🛠️ K8s — Deployment avec probes
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # zero-downtime
selector:
matchLabels: { app: orders-api }
template:
metadata:
labels: { app: orders-api }
spec:
terminationGracePeriodSeconds: 30 # > drainTimeout
containers:
- name: app
image: registry.example.com/orders-api:v1.42.0
ports: [{ containerPort: 3000 }]
env:
- name: NODE_ENV
value: production
- name: DATABASE_URL
valueFrom:
secretKeyRef: { name: orders-secrets, key: database-url }
# Liveness : process up ?
livenessProbe:
httpGet: { path: /health/liveness, port: 3000 }
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3 # 30s avant restart
# Readiness : prêt à servir ?
readinessProbe:
httpGet: { path: /health/readiness, port: 3000 }
initialDelaySeconds: 3
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2 # 10s avant retiré du LB
# Startup : laisse le boot
startupProbe:
httpGet: { path: /health/liveness, port: 3000 }
failureThreshold: 30
periodSeconds: 2 # 60s pour démarrer
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65532
capabilities: { drop: ["ALL"] }Points clés :
maxUnavailable: 0: zero-downtime garanti.terminationGracePeriodSeconds: 30doit être ≥ ton drain timeout côté Node.- Liveness vs Readiness : liveness
failing→ K8s restart le pod. Readinessfailing→ K8s retire du Service mais laisse vivre. Avant un deploy, set la readiness à false pour drainer. readOnlyRootFilesystem: empêche un attaquant d'écrire des binaires. Mais ton app doit pouvoir écrire ses temp files ailleurs (emptyDirmount).
🛠️ Signal handling avec tini
Si tu lances Node via un wrapper (shell, npm), Node n'est pas PID 1 → ne reçoit pas SIGTERM. Solution : tini.
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
# ...
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/server.js"]Ou utilise --init au docker run (active tini automatiquement). En K8s : securityContext: { allowPrivilegeEscalation: false } mais surtout : lance Node directement comme PID 1 (sans shell wrapper). Si tu lances npm start ou sh -c "...", tu re-introduis le problème.
🎯 Patterns courants
1. .dockerignore pour images minces
node_modules
.git
.github
*.log
.env*
dist/test
coverage
.vscode
.idea
README.md
docsSans ça, tu copies des secrets et 200 MB de junk dans ton image.
2. Build cache discipline
L'ordre des COPY dans le Dockerfile détermine le cache. Copie d'abord les fichiers qui changent le moins :
# Ordre optimal
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile # rebuild seulement si lockfile change
COPY src ./src
RUN pnpm build # rebuild seulement si src change3. Image scanning supply chain
# Trivy (libre, rapide)
trivy image orders-api:v1.42.0
# Grype (Anchore)
grype orders-api:v1.42.0
# Snyk
snyk container test orders-api:v1.42.0Configure ces scans en CI, bloque le merge si vulnérabilité high/critical.
4. SBOM (Software Bill of Materials)
Pour la conformité (SLSA, CIS) :
# Syft génère un SBOM
syft orders-api:v1.42.0 -o spdx-json > sbom.json
# Cosign signe l'image
cosign sign --key cosign.key orders-api:v1.42.0
# Et attaches le SBOM signé
cosign attest --predicate sbom.json --type spdx --key cosign.key orders-api:v1.42.05. Cold start sur Lambda et compagnie
Sur AWS Lambda, Cloud Functions, Vercel :
- Cold start typique Node : 100-500 ms (import + connexion DB).
- Optimisations :
- Provisioned concurrency (Lambda) : tu paies pour pré-chauffer N instances.
- Top-level lazy import :
const db = await import('./db.ts').then(m => m.connect())à l'intérieur du handler vs en top-level — selon ton workload. - Bundle avec esbuild/swc : un seul fichier → moins de
requireà parser. - Pas de heavy frameworks sur Lambda : Express et même Fastify ont un overhead. Préfère un handler nu si latence critique.
// Lambda handler typique
import type { APIGatewayProxyHandler } from 'aws-lambda'
import { z } from 'zod'
let cachedDb: any
const getDb = async () => {
if (!cachedDb) cachedDb = await connectDb()
return cachedDb
}
export const handler: APIGatewayProxyHandler = async (event) => {
const db = await getDb()
const parsed = z.object({ id: z.string() }).safeParse(JSON.parse(event.body ?? '{}'))
if (!parsed.success) return { statusCode: 400, body: JSON.stringify({ error: 'invalid' }) }
const result = await db.findById(parsed.data.id)
return { statusCode: 200, body: JSON.stringify(result) }
}6. Choix PaaS — comparaison rapide
| PaaS | Forte | Faible |
|---|---|---|
| Fly.io | Multi-region, low latency, simple | Coût à l'échelle |
| Render | Setup simple, dashboard clair | Régions limitées |
| Railway | Branche → preview env auto | Tarification opaque parfois |
| Vercel | Edge/serverless, ecosystem JS | Cher pour backends lourds, vendor lock-in |
| AWS Lambda | Scale-to-zero, écosystème AWS | Cold starts, complexité IAM |
| Google Cloud Run | Container serverless, simple | Régions, observabilité GCP |
| Kubernetes managé (EKS/GKE/AKS) | Contrôle total | Coût opérationnel élevé |
Stratégie pragmatique : commence sur Fly ou Render avec un Dockerfile propre. Si tu grandis, migre vers Cloud Run ou K8s. Évite Lambda sauf si vrai besoin de scale-to-zero.
🔄 Versions — Node 18 / 20 / 22 / 24
| Version | Déploiement |
|---|---|
| 18 | Out of LTS. --experimental-default-type=module pour ESM par défaut. |
| 20 | EOL (maintenance terminée avril 2026). Migre. --watch natif (dev). |
| 22 | LTS en maintenance. --run pour npm scripts. Permission model amélioré. SQLite intégré. |
| 24 | LTS actif (depuis oct. 2025). V8 latest, perf++, single-executable apps mieux, type-stripping .ts stable. |
Pour produire mi-2026 : Node 24 LTS est désormais le défaut recommandé (V8 récent, type-stripping .ts stable sans flag). Node 22 LTS reste un choix sûr et conservateur si tu n'es pas pressé de bumper la base ; il reste en maintenance encore plusieurs mois. Évite Node 20 (EOL) en nouveau déploiement.
Image Docker recommandée : node:24-bookworm-slim / gcr.io/distroless/nodejs24-debian12, ou node:22-bookworm-slim / gcr.io/distroless/nodejs22-debian12 si tu restes sur 22.
⚠️ Pitfalls
- PID 1 problem : Node ne gère pas correctement les zombies/signaux quand il est PID 1 sans précaution. Utilise
tiniou--initpour Docker. En K8s : lance Node directement, pas via un script shell. maxUnavailable: 1avec 1 replica : ton service est entièrement down pendant un rolling update. Mets toujoursmaxUnavailable: 0(etmaxSurge: 1) pour zero-downtime.- Readiness = Liveness : si tu utilises le même endpoint, K8s redémarre des pods qui ont juste un pic de latence. Distincts : liveness teste "process vivant", readiness teste "peut servir maintenant" (DB joignable, queues OK).
- DB connexion ouverte avant le ready : ton app accepte des requêtes mais la DB n'est pas encore connectée → 500s. Solution : connecte la DB avant
server.listen, set readiness=true seulement après succès. - Grace period trop courte : K8s
terminationGracePeriodSeconds: 10mais ton drainTimeout Node = 25s. SIGKILL après 10s, requêtes coupées. Aligne (K8s ≥ drain + marge). - Logs sur stdout/stderr non agrégés : tu logs en JSON sur stdout, mais ton orchestrateur ne les collecte pas → boîte noire. Configure une side-car (Vector, Fluent Bit) ou utilise une plateforme qui collecte (Datadog, GKE).
- Secrets dans
ENVde l'image :docker historyles expose. Toujours injectés par K8s Secret / Vault au runtime. COPY . /appsans.dockerignore:.git(donc history, branches, peut-être secrets) finit dans l'image. Layer mining trivial.- Healthcheck qui exécute une vraie requête DB : sous charge, ça surcharge la DB. Sépare un check léger (process running) d'un check lourd (DB), et n'utilise le lourd que pour la readiness.
- Pas de NODE_OPTIONS=--enable-source-maps : stack traces inutilisables si tu transpile. Active source maps dans le build et au runtime.
🧪 Testing — vérifier ton déploiement
# 1. Image scan
trivy image --severity HIGH,CRITICAL orders-api:latest
# 2. Run local en mode prod
docker run --rm -p 3000:3000 -e NODE_ENV=production orders-api:latest
# 3. Test des signaux
docker run --rm --name test -d orders-api:latest
docker kill --signal=SIGTERM test
docker logs test # tu dois voir "shutdown initiated" puis "shutdown complete"
# 4. Test grace period
ab -n 1000 -c 10 http://localhost:3000/ # bombarder
docker kill --signal=SIGTERM container_id
# Vérifier qu'aucune connexion n'est coupée
# 5. K8s dry-run
kubectl apply -f deployment.yaml --dry-run=server --validate=true🎬 Cas d'usage concrets
Scénario 1 — Cabinet juridique "LexFidens" Kubernetes Scaleway souverain
LexFidens manipule des actes notariés et des données client soumises à des obligations de souveraineté (les données doivent rester en France, hébergées par un acteur français/européen). Hors de question d'AWS ou GCP. Choix : Scaleway Kapsule (Kubernetes managé en France, datacenter à Paris), avec un cluster de 5 nœuds (3 control plane managés + 5 worker nodes DEV1-L) pour ~ 20 microservices Node.
L'équipe a investi dans une stack k8s complète : ArgoCD pour le GitOps (chaque commit sur main déclenche un déploiement automatique), Linkerd pour le service mesh (mTLS entre services, observability), cert-manager pour les certificats Let's Encrypt automatiques, et external-dns pour la gestion DNS. Les secrets sont stockés dans Vault déployé en cluster (HA), avec auto-rotation via le sidecar vault-agent. Les déploiements utilisent des PodDisruptionBudget (minimum 2 replicas dispo pendant les drains de noeud) et topologySpreadConstraints pour répartir sur les 3 AZ Scaleway. Bénéfice : compliance souveraineté validée (audit CNIL OK), 99.95 % uptime mesuré en 2025, et coût mensuel de 1400 € pour toute l'infra (vs ~ 3500 € équivalent AWS).
Scénario 2 — E-commerce "ModeCircuit" containers distroless multi-stage
ModeCircuit déploie ses 12 microservices Node sur ECS Fargate. Avant 2024, les images Docker étaient basées sur node:20, ~ 1.2 GB chacune, avec 200+ CVE détectés à chaque scan Trivy (parce que l'image embarque tout l'OS Debian). Migration à distroless en mars 2024.
# Stage 1 — builder
FROM node:22-bookworm AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile --prod=false
COPY . .
RUN pnpm build && pnpm prune --prod
# Stage 2 — runtime distroless
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/dist ./dist
COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=builder --chown=nonroot:nonroot /app/package.json ./
USER nonroot
ENV NODE_ENV=production
EXPOSE 3000
CMD ["dist/server.js"]Résultat : images passées de 1.2 GB à 180 MB (taille -85 %), zéro shell ni binaire OS embarqué (pas de bash, curl, apt — un attaquant qui obtient un RCE ne peut rien faire sans outils), user nonroot par défaut (UID 65532), et CVE par image divisés par 50. Tradeoff : impossible de docker exec -it shell pour débugger en prod (mais c'est une feature, pas un bug — les containers doivent être immutables). Les health checks passent par les endpoints HTTP /health et /ready plutôt que par des scripts shell. Le scan Trivy est passé de 200 CVE à 4 (uniquement dans node binary). Bonus : startup plus rapide (1.8s vs 3.2s) parce que moins de fichiers à charger.
Scénario 3 — SaaS RH "PaySimple Next" déployé sur Vercel
PaySimple Next (la version greenfield Next.js + tRPC du scénario tRPC) est déployée intégralement sur Vercel : frontend SSR + API routes tRPC, le tout en serverless. Pas de Kubernetes, pas de containers à gérer.
Architecture : Vercel Edge Functions pour les routes /api/trpc/* (région cdg1 Paris pour la latence FR), Vercel Postgres (Neon-backed) pour la DB principale, Vercel KV (Upstash Redis) pour le cache et les rate limits, Vercel Blob pour le stockage des fichiers (justificatifs de notes de frais). Le déploiement est déclenché par chaque push GitHub : 90 secondes du push au déploiement preview, 4 minutes pour la prod. Les previews PR sont accessibles via une URL unique, ce qui permet aux PM et clients beta de tester chaque feature avant merge. Bénéfice : zéro infra à gérer, l'équipe de 4 devs ne perd pas de temps sur Terraform/k8s. Coût mensuel : 280 € pour Vercel Pro + Postgres + KV + Blob pour 5000 utilisateurs actifs. Limite : si PaySimple Next atteint 100k utilisateurs, ils devront sortir du serverless pour les hot paths (la facturation Vercel devient prohibitive au-delà de quelques millions d'invocations/mois) — l'équipe a déjà un plan de migration vers Kubernetes Scaleway.
🛠️ Exemple end-to-end
Cas d'usage : "déploiement LexFidens du service api-signature sur Scaleway Kapsule — Dockerfile multi-stage distroless, manifestes k8s avec PDB + topology spread + HPA, GitHub Actions qui build + push vers Scaleway Registry + ArgoCD sync".
# Dockerfile
FROM node:22-bookworm-slim AS builder
WORKDIR /app
RUN corepack enable
COPY pnpm-lock.yaml package.json ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --prod=false
COPY . .
RUN pnpm build && pnpm prune --prod
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/dist ./dist
COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=builder --chown=nonroot:nonroot /app/package.json ./
USER nonroot
ENV NODE_ENV=production NODE_OPTIONS="--enable-source-maps"
EXPOSE 3000
HEALTHCHECK NONE
CMD ["dist/server.js"]# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-signature
labels: { app: api-signature, team: signature }
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
selector: { matchLabels: { app: api-signature } }
template:
metadata:
labels: { app: api-signature }
annotations:
linkerd.io/inject: enabled
vault.hashicorp.com/agent-inject: 'true'
vault.hashicorp.com/role: 'api-signature'
spec:
serviceAccountName: api-signature
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector: { matchLabels: { app: api-signature } }
containers:
- name: app
image: rg.fr-par.scw.cloud/lexfidens/api-signature:GIT_SHA
ports: [{ name: http, containerPort: 3000 }]
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { cpu: 1000m, memory: 512Mi }
env:
- { name: NODE_ENV, value: production }
- { name: PORT, value: '3000' }
- { name: DATABASE_URL, valueFrom: { secretKeyRef: { name: api-signature-secrets, key: database-url } } }
livenessProbe:
httpGet: { path: /health, port: http }
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 3
readinessProbe:
httpGet: { path: /ready, port: http }
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
startupProbe:
httpGet: { path: /health, port: http }
failureThreshold: 30
periodSeconds: 2
securityContext:
runAsNonRoot: true
runAsUser: 65532
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: { drop: [ALL] }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: api-signature-pdb }
spec:
minAvailable: 2
selector: { matchLabels: { app: api-signature } }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: api-signature-hpa }
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: api-signature }
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
- type: Pods
pods:
metric: { name: http_requests_per_second }
target: { type: AverageValue, averageValue: '200' }
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies: [{ type: Percent, value: 50, periodSeconds: 60 }]
scaleUp:
stabilizationWindowSeconds: 0
policies: [{ type: Percent, value: 100, periodSeconds: 30 }]# .github/workflows/deploy.yml
name: deploy-api-signature
on:
push:
branches: [main]
paths: ['apps/api-signature/**']
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions: { contents: read, id-token: write }
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Login to Scaleway Registry
uses: docker/login-action@v3
with:
registry: rg.fr-par.scw.cloud
username: nologin
password: ${{ secrets.SCW_SECRET_KEY }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./apps/api-signature
push: true
tags: |
rg.fr-par.scw.cloud/lexfidens/api-signature:${{ github.sha }}
rg.fr-par.scw.cloud/lexfidens/api-signature:latest
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
- name: Update ArgoCD manifest
run: |
sed -i "s|api-signature:.*|api-signature:${{ github.sha }}|" k8s/overlays/prod/kustomization.yaml
git config user.email "[email protected]"
git config user.name "ci-bot"
git add k8s/overlays/prod/kustomization.yaml
git commit -m "deploy: api-signature ${{ github.sha }}"
git pushCette configuration combine douze pratiques deployment seniors : (1) build multi-stage Docker avec cache pnpm (build à 90 secondes vs 4 minutes sans cache), (2) image runtime distroless nonroot (180 MB, zéro shell, attaque surface minimale), (3) readOnlyRootFilesystem: true + capabilities.drop: ALL + runAsNonRoot (hardening complet), (4) Linkerd sidecar pour mTLS auto entre services, (5) Vault agent qui injecte les secrets en runtime (pas de secret dans le manifest), (6) topologySpreadConstraints pour répartir sur 3 AZ (résilience datacenter), (7) PodDisruptionBudget qui garantit min 2 pods dispo pendant les drains, (8) HPA basé CPU + RPS custom metric (scale réactif à la charge réelle, pas juste CPU), (9) startupProbe séparée de livenessProbe (l'app a 60s pour démarrer sans risquer un kill prématuré), (10) rolling update maxSurge: 1, maxUnavailable: 0 (zero downtime, même avec un pod en plus pendant 30s), (11) GitOps via ArgoCD (le CI ne kubectl apply jamais directement, il commit sur le repo manifests, ArgoCD sync), (12) provenance + SBOM générés au build pour la supply chain (audit chain Scaleway → registry → k8s). En production, les déploiements LexFidens prennent 5 minutes du git push au pod healthy, avec 99.95 % uptime mesuré sur 14 mois.
🔁 Quand utiliser / éviter
| Cible | Quand | Éviter quand |
|---|---|---|
| Kubernetes managé | App stateful, multi-services, contrôle requis | Équipe < 10, MVP, pas de SRE |
| Cloud Run / Fly | App stateless, autoscale, simplicité | Besoin de queues internes complexes |
| Lambda / serverless | Burst-y workload, scale-to-zero | Long-running, état en mémoire, latence p99 critique |
| PaaS Vercel/Netlify | Front + API simples | Backend lourd, stateful |
| VPS classique | Coût ultra bas, contrôle total | Scale-out nécessaire |
🛠️ Autoscaling — HPA et triggers
K8s Horizontal Pod Autoscaler (HPA) ajuste les replicas selon des métriques.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: orders-api
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: orders-api
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
- type: Resource
resource:
name: memory
target: { type: Utilization, averageUtilization: 80 }
- type: Pods
pods:
metric: { name: http_requests_per_second }
target: { type: AverageValue, averageValue: "100" }
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # 5 min avant scale down
policies:
- type: Percent
value: 50
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Pods
value: 4
periodSeconds: 30Bonnes pratiques :
- CPU 70% comme cible : sous 70% sous-utilisé, au-dessus 80% les latences explosent.
- Scale down lent (5min), scale up rapide (0s) : tu préfères payer un peu plus que rejeter du trafic.
- Métriques custom (KEDA pour les queues, requêtes par seconde, lag Kafka) sont souvent plus pertinentes que CPU.
Pour Node-spécifique : event loop lag est un excellent signal de saturation. Tu peux l'exposer en métrique et autoscale dessus.
🛠️ Single-process vs clustering
Question fréquente : faut-il cluster Node pour utiliser tous les cores ?
Réponse 2026 :
- Single process Node + multiple replicas K8s : préféré dans 90% des cas. K8s s'occupe du load balancing, isolation des crashs, autoscaling. Plus simple.
clusternatif : utile sur VPS classique où tu veux exploiter tous les cores d'une même machine sans orchestrateur. Mais attention au state partagé.worker_threads: pas le même cas d'usage. C'est pour le CPU-bound parallèle dans un même process, pas pour scaler les requêtes HTTP.
// cluster minimal (si vraiment nécessaire)
import cluster from 'node:cluster'
import { cpus } from 'node:os'
if (cluster.isPrimary) {
for (let i = 0; i < cpus().length; i++) cluster.fork()
cluster.on('exit', () => cluster.fork()) // restart worker mort
} else {
await import('./server.ts')
}En K8s, ne fais pas ça. Tu casses les probes, les métriques, la simplicité.
🛠️ Zero-downtime deployment — checklist
Pour qu'un deploy ne coupe aucune requête :
- App : SIGTERM handler qui ferme le server, drain les sockets, ferme DB pools.
- App : readiness probe qui retourne 503 dès que SIGTERM reçu (pour que K8s arrête de router avant le drain).
- K8s :
maxUnavailable: 0,maxSurge: 1. - K8s :
terminationGracePeriodSeconds ≥ drainTimeout + closeDuration + marge. - K8s :
preStophook qui sleep N secondes (laisse le LB enlever le pod avant SIGTERM).
spec:
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sleep", "10"] # le LB met ~5-10s à enlever le pod
# ...Le preStop sleep est contre-intuitif mais essentiel : si SIGTERM arrive avant que le LB n'enlève le pod du pool, des requêtes continuent d'arriver pendant que tu shutdown.
🛠️ Build reproductible
Un build reproductible signifie : à partir du même code source, tu obtiens le même artefact bit-à-bit. Important pour la sécurité (vérification supply chain) et le debug.
# 1. Pin exact des versions
FROM node:22.11.0-bookworm-slim@sha256:abcdef...
# 2. Lockfile frozen
RUN pnpm install --frozen-lockfile
# 3. Pas de timestamps dans l'image
ENV SOURCE_DATE_EPOCH=0
# 4. Tri stable des fichiers
COPY --link src ./src
# 5. Pas de cache build
RUN pnpm build && rm -rf ~/.npm ~/.cacheOutils : kaniko (build sans Docker daemon), buildkit reproducible mode, nix pour la reproductibilité absolue.
🛠️ Multi-arch — x86 + ARM
L'ARM gagne du terrain (Graviton, M-series, Ampere). Build multi-arch :
# Buildx
docker buildx create --use --name multi
docker buildx build --platform linux/amd64,linux/arm64 -t orders-api:v1.42.0 --push .Vérifier qu'aucune dep native ne casse sur l'autre arch :
# Test ARM depuis x86 (via QEMU)
docker run --rm --platform linux/arm64 orders-api:v1.42.0 node -e "console.log(process.arch)"ARM est typiquement 20-40% moins cher en cloud pour la même perf Node. Migration souvent rentable.
🔗 Liens
- Docker best practices Node : https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md
- Distroless images : https://github.com/GoogleContainerTools/distroless
- tini : https://github.com/krallin/tini
- K8s probes : https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
- Cosign : https://docs.sigstore.dev/cosign/overview/
- Trivy : https://aquasecurity.github.io/trivy/
- KEDA : https://keda.sh/
- buildx multi-arch : https://docs.docker.com/build/building/multi-platform/
🏋️ Exercices
Escalade de l'implémentation au production-grade au break-then-fix. Chaque exercice suppose un service Node/TS minimal avec un endpoint /work qui fait un await sleep(2000) puis répond 200.
Exercice 1 — Graceful shutdown qui draine vraiment (implémentation)
Objectif : un SIGTERM ne doit couper aucune requête en vol ; les requêtes lancées après le signal doivent être refusées proprement.
Écris un serveur HTTP qui (a) tracke ses sockets, (b) sur SIGTERM passe ready=false, ferme le listener, draine les requêtes en cours avec un timeout de 10s, puis process.exit(0). Prouve-le : lance 5 requêtes /work concurrentes, envoie SIGTERM à 1s, vérifie que les 5 reçoivent un 200 et qu'une 6e requête envoyée à 1.5s est refusée (connection refused, car le listener est fermé).
Indice / Solution
Réutilise la structure du shutdown() de la section dédiée. Le test : Promise.allSettled sur 5 fetch('/work'), puis process.kill(child.pid, 'SIGTERM'). Sous node:test, spawn le serveur en child process et compte les status. Le piège : si tu fais server.close() sans d'abord fermer les sockets keep-alive idle, close() ne se résout jamais tant qu'un client garde une connexion ouverte — d'où l'étape 2 (socket.destroy() sur les idle) et le hard timeout.
Exercice 2 — Le preStop qui sauve les 502 (production-grade)
Objectif : comprendre pourquoi un shutdown parfait côté app coupe quand même des requêtes en K8s, et corriger via le timing LB.
Modélise le décalage : un load balancer met ~5-10s à retirer un pod de son pool après que la readiness passe à 503. Écris un script qui simule ce décalage (un "LB" qui continue d'envoyer du trafic 8s après avoir vu 503). Montre que sans preStop sleep, le pod ferme son listener pendant que le LB route encore → connection refused. Puis ajoute un preStop: exec: ["sleep", "10"] et un health.ready=false + await sleep(10s) avant server.close(), et montre que le trou disparaît.
Indice / Solution
L'ordre canonique au SIGTERM : ready=false → attendre que le LB observe le 503 et dépeuple (c'est ce que couvre le preStop sleep ou le await sleep) → puis seulement server.close(). Le preStop hook s'exécute avant que K8s n'envoie SIGTERM, donc il donne au LB le temps de réagir pendant que le pod sert encore. Le terminationGracePeriodSeconds doit englober preStop + drain + close DB + marge (ex : 10 + 25 + 2 + 3 = 40s). Si grace < cette somme → SIGKILL au milieu du drain.
Exercice 3 — Image distroless de moins de 120 MB + scan propre (production-grade)
Objectif : produire une image multi-stage distroless nonroot qui passe trivy image --severity HIGH,CRITICAL avec 0 finding applicatif, et readOnlyRootFilesystem sans casser au runtime.
Pars d'une image node:22 naïve (~1 GB, 150+ CVE). Migre vers gcr.io/distroless/nodejs22-debian12:nonroot, prune les dev deps, pin la base par digest @sha256:. Lance ensuite avec --read-only et corrige tout ce qui casse (tmp files, source maps).
Indice / Solution
Sépare builder (node:22-bookworm-slim + pnpm install --prod=false + build + pnpm prune --prod) du runtime distroless qui ne COPY que dist, node_modules, package.json. Avec --read-only, monte un tmpfs sur /tmp (docker run --read-only --tmpfs /tmp) ; en K8s c'est un emptyDir monté sur /tmp + readOnlyRootFilesystem: true. Source maps : NODE_OPTIONS=--enable-source-maps au runtime, et build avec sourcemaps activées. Vérifie : trivy image → les seuls findings restants sont dans le binaire node lui-même (impossible à patcher sans bump de la base distroless).
Exercice 4 — HPA réactif sur event loop lag (production-grade)
Objectif : autoscaler sur un signal Node-pertinent (saturation event loop) plutôt que sur le CPU, qui ment sous charge I/O-bound.
Expose une métrique Prometheus nodejs_eventloop_lag_p99_seconds (via perf_hooks.monitorEventLoopDelay()), branche-la dans le HPA via Prometheus Adapter / KEDA, et configure un scale-up quand le lag p99 dépasse 50ms. Charge le service (workload mixte I/O + un peu de CPU) et montre que le HPA CPU-seul ne scale pas alors que les latences explosent, tandis que le HPA lag-based réagit.
Indice / Solution
import { monitorEventLoopDelay } from 'node:perf_hooks'
const h = monitorEventLoopDelay({ resolution: 20 })
h.enable()
// p99 en secondes pour Prometheus, reset la fenêtre après scrape
function lagP99(): number { const v = h.percentile(99) / 1e6 / 1000; return v }Le CPU reste bas sur un workload I/O-bound (le process attend des I/O, pas le CPU), mais l'event loop lag grimpe dès que des callbacks s'empilent → c'est LE signal de saturation d'un process Node single-thread. KEDA ScaledObject avec trigger prometheus sur la query du lag p99. Garde un trigger CPU en secours pour les workloads CPU-bound.
Exercice 5 — Break-then-fix : le deploy qui perd des requêtes silencieusement
Objectif : diagnostiquer un zero-downtime cassé à partir des symptômes, sans accès au code au départ.
On te donne un manifest avec terminationGracePeriodSeconds: 10, un drain Node de 25s, maxUnavailable: 1 sur 2 replicas, et une readiness probe qui pointe sur le même endpoint /health que la liveness (et qui ne tient pas compte de SIGTERM). En prod, chaque deploy génère un pic de 502/504 et parfois des pods en CrashLoopBackOff. Liste les 4 bugs et corrige chacun.
Indice / Solution
grace(10s) < drain(25s)→ SIGKILL au milieu du drain, requêtes coupées. Fix :terminationGracePeriodSeconds ≥ preStop + drain + closeDB + marge.maxUnavailable: 1sur 2 replicas → jusqu'à 50% de capacité en moins pendant le rollout. Fix :maxUnavailable: 0, maxSurge: 1.- Readiness == Liveness sur
/health→ un pic de latence fait échouer la liveness et K8s restart un pod sain (d'où le CrashLoop si le boot est lent). Fix : endpoints distincts, liveness = "process vivant" (jamais de DB), readiness = "peut servir". - Readiness ignore SIGTERM → le pod reste
Readypendant le drain, le LB continue de router → 502. Fix :health.ready=falseen tête de shutdown +preStop sleeppour laisser le LB dépeupler.
Bonus break : ajoute un npm start comme CMD (au lieu de node dist/server.js) → Node n'est plus PID 1, ne reçoit pas SIGTERM, le drain ne se déclenche jamais et tu manges le SIGKILL à chaque deploy. Fix : CMD ["dist/server.js"] (PID 1 direct) ou --init/tini.
🎤 En entretien
Q : Pourquoi un preStop: sleep 10 est-il souvent nécessaire même avec un graceful shutdown parfait côté application ? Parce que la propagation de l'endpoint K8s vers les load balancers / kube-proxy est asynchrone : quand SIGTERM arrive, le pod peut encore recevoir du trafic pendant 5-10s le temps que le LB amont le retire de son pool. Le preStop (qui s'exécute avant SIGTERM) introduit ce délai pendant que le pod sert encore, supprimant la fenêtre de race où l'app a fermé son listener mais le LB route encore. C'est un problème de timing distribué, pas de code applicatif.
Q : Liveness et readiness probes — quelle est la différence concrète et que se passe-t-il si tu les confonds ?Liveness = "le process est-il vivant / non-deadlock ?" → un échec déclenche un restart du pod. Readiness = "puis-je servir du trafic maintenant ?" → un échec retire le pod du Service sans le tuer. Si tu utilises le même endpoint (avec un check DB), un pic de latence DB ou une DB momentanément injoignable fait échouer la liveness et K8s redémarre des pods sains en cascade, transformant un incident de dépendance en panne totale (thundering restart). Règle : liveness ne touche jamais de dépendance externe ; readiness peut.
Q : Faut-il utiliser le module cluster de Node en production sur Kubernetes ? En général non. K8s fournit déjà le load balancing, l'isolation des crashs et l'autoscaling au niveau pod ; un process Node single-thread par pod + N replicas est plus simple et plus observable (un PID = un set de métriques, des probes propres). cluster a du sens sur un VPS classique sans orchestrateur pour exploiter tous les cœurs d'une machine, mais en K8s il casse les probes (sur quel worker ?), brouille les métriques et duplique la complexité du shutdown. worker_threads est un autre sujet : c'est pour paralléliser du CPU-bound dans un process, pas pour scaler des requêtes HTTP.
Q : Pourquoi le CPU est-il un mauvais signal d'autoscaling pour un service Node I/O-bound, et que mesurer à la place ? Un service Node passe l'essentiel de son temps à attendre des I/O (DB, HTTP sortant), pendant lequel le CPU reste bas — donc tu peux saturer l'event loop (latences p99 qui explosent, requêtes qui s'empilent) avec un CPU à 30%. Le signal pertinent est l'event loop lag (perf_hooks.monitorEventLoopDelay) et/ou les requêtes en vol / RPS par pod : ils mesurent la capacité réelle du process single-thread à traiter du travail. On expose ces métriques en Prometheus et on autoscale dessus via KEDA / Prometheus Adapter, en gardant le CPU comme garde-fou secondaire pour les workloads CPU-bound.
🗓️ Récap final
Déployer Node en 2026, c'est une chaîne : image multi-stage minimaliste (distroless), user non-root, PID 1 correct (tini ou Node direct), graceful shutdown qui draine vraiment, probes K8s distinctes (liveness vs readiness), config et secrets externalisés, image scannée et signée. Sur PaaS, le tradeoff change : Fly/Render pour la simplicité, Lambda pour le scale-to-zero, Vercel pour le edge JS, K8s quand tu maîtrises l'opérationnel. La règle d'or qui sauve : terminationGracePeriodSeconds K8s ≥ drain timeout Node, sinon les deploys cassent silencieusement les requêtes en cours et tes utilisateurs paient le prix.