Skip to content
Images Docker pour Node en 2026 : multi-étapes, distroless, et l'image de 1,1 Go devenue 140 Mo
← ← Retour aux Réflexions Development

Images Docker pour Node en 2026 : multi-étapes, distroless, et l'image de 1,1 Go devenue 140 Mo

Le premier Dockerfile de la plupart des projets Node fait onze lignes, part de node:20, copie le dépôt, lance npm install et npm run build, et livre. Ça marche. Ça fait aussi 1,1 Go, contient un compilateur C, Python, git, toutes les dépendances de développement, le répertoire .git, et la source de l'application à côté de son build. C'est ce que nous avons hérité sur une plateforme Next.js que nous exploitons pour un client américain, et ça coûtait de vraies minutes à chaque déploiement et de vrais euros en stockage ECR et en temps de pull App Runner.

Voici le Dockerfile que nous utilisons maintenant, à 140 Mo, avec le raisonnement de chaque étape, les deux choses qui ont cassé au passage à distroless, et la note honnête sur ce que vous abandonnez.

D'où venaient les 1,1 Go

Couche Taille Pourquoi elle était là
Base node:20 (Debian) 1 000 Mo Debian complet avec la chaîne de build, Python, git
node_modules avec les dépendances de dev 420 Mo TypeScript, ESLint, Jest, Playwright, tous livrés en prod
Arborescence source + .git 80 Mo Copiée avant que .dockerignore existe
Sortie du build 60 Mo La seule partie dont la production a besoin

Quatre de ces cinq lignes sont du gaspillage dans le conteneur qui tourne. L'image de base seule fait sept fois l'application. Et chaque couche est repoussée vers ECR et retirée par chaque instance App Runner à chaque montée en charge, ce qui ajoutait vingt à quarante secondes à chaque démarrage à froid.

Ce qu'il y a dans l'image avant · 1,1 Go base node:20 Debian · 1 000 Mo dép. de dev · 420 Mo après · 140 Mo distroless 20 Mo · dép. de prod 60 Mo · sortie standalone 60 Mo échelle : 1 px ≈ 1,5 Mo · l'application elle-même (vert) a la même taille dans les deux OS de basenode_modulessource + .gitsortie de buildruntime distroless Sept huitièmes de l'ancienne image ne tournaient jamais. Ils étaient poussés, stockés et tirés quand même.

Le Dockerfile

# syntax=docker/dockerfile:1.7

# ---- 1. deps : installer avec le lockfile, mettre le store en cache ----
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN corepack enable
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/
COPY packages/*/package.json packages/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

# ---- 2. build : compiler, produire la sortie standalone ----
FROM deps AS build
COPY . .
ARG RELEASE_SHA
ENV NEXT_TELEMETRY_DISABLED=1 RELEASE_SHA=$RELEASE_SHA
RUN pnpm turbo build --filter=web
# ne garder que les dépendances de production, pour les paquets dont la sortie standalone a besoin
RUN pnpm --filter=web deploy --prod /out

# ---- 3. runtime : rien d'autre que node et la sortie ----
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build --chown=nonroot:nonroot /app/apps/web/.next/standalone ./
COPY --from=build --chown=nonroot:nonroot /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build --chown=nonroot:nonroot /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["apps/web/server.js"]

Et le .dockerignore qui fait plus de travail que n'importe quelle ligne du Dockerfile :

.git
node_modules
**/node_modules
**/.next
**/dist
**/coverage
.env*
*.md

Étape 1, deps

Seuls les manifestes et le lockfile sont copiés avant pnpm install, donc la clé de cache de cette couche est le lockfile. Changez un fichier source et la couche d'installation est réutilisée. Changez le lockfile et elle se relance, ce qui est correct. Le --mount=type=cache conserve le store adressé par contenu de pnpm entre les builds sur le même builder, donc même un changement de lockfile touche surtout le store local plutôt que le registre. Sur CodeBuild avec le cache de couches du registre, cette étape est restaurée en une dizaine de secondes.

node:22-bookworm-slim plutôt que l'image complète : 200 Mo au lieu d'un gigaoctet, avec encore un shell et un gestionnaire de paquets pour les rares modules natifs qui doivent compiler. Nous n'avons besoin ni de Python ni de gcc pour cette application ; si vous en avez besoin, installez-les uniquement dans cette étape.

Étape 2, build

Copie la source par-dessus l'étape deps et lance le build. output: 'standalone' dans next.config.js est le réglage clé : il trace quels fichiers de node_modules le serveur importe réellement et ne copie que ceux-là dans .next/standalone, avec un server.js minimal. C'est ce qui transforme 420 Mo de node_modules en 60 Mo.

pnpm deploy --prod, c'est la ceinture et les bretelles : pour tout ce que le traceur standalone rate (nous avions un require dynamique dans une bibliothèque PDF), il produit une installation propre, uniquement de production, des dépendances de l'application.

Étape 3, runtime

Distroless. Pas de shell, pas de gestionnaire de paquets, pas d'apt, pas de curl, pas de sh. Juste le binaire Node, ses bibliothèques d'exécution, les certificats CA, et les fichiers que nous copions. Tourne par défaut sous l'utilisateur nonroot. Toute la base fait 20 Mo.

C'est l'étape qui compte pour les scanners de sécurité : la base Debian traînait environ 180 CVE dans des paquets que l'application n'appelait jamais, la plupart dans des outils comme perl et git. Distroless en porte une poignée, toutes dans Node lui-même, que vous corrigez en montant le tag.

Les deux choses qui ont cassé

1. Le health check utilisait curl. L'ancienne définition de tâche avait un health check de conteneur curl -f http://localhost:3000/api/health. Il n'y a pas de curl dans distroless. Il n'y a pas de shell non plus, donc CMD-SHELL échoue aussi. Deux correctifs, et nous avons utilisé le second : soit utiliser le health check HTTP de la plateforme plutôt qu'un check de conteneur (App Runner et les target groups ALB font tous deux du HTTP nativement, et la route devrait de toute façon être conçue pour ça), soit livrer un minuscule script de health en Node et le lancer avec le binaire Node :

HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD ["/nodejs/bin/node", "apps/web/healthcheck.js"]

2. Le débogage dans le conteneur a cessé de fonctionner. Pas de shell veut dire pas de docker exec -it app sh. La première fois que quelqu'un a eu besoin d'inspecter un conteneur en cours d'exécution en staging, il n'a pas pu. La réponse qui est restée : docker debug (Docker Desktop) ou un sidecar avec un shell qui partage l'espace de noms des processus, pour l'occasion rare. En pratique, ce que les gens voulaient d'exec, c'était lire une configuration ou vérifier une variable d'environnement, et les deux trouvent une meilleure réponse dans le mode profond de la route de health et dans les logs structurés. Nous n'avons pas eu besoin d'un shell dans un conteneur de production depuis cinq mois.

1 · deps · node:22-slimcopier lockfile + manifestes seulementpnpm install --frozen-lockfile--mount=type=cache pour le storeclé de cache : le lockfilerestauré en ~10 s depuis le cache du registre 2 · buildcopier la sourcenext build · output: standalonepnpm deploy --prod /outle traceur ne garde que les modules importés420 Mo → 60 Mo de node_modules 3 · runtime · distrolessCOPY --from=build standaloneCOPY static + publicCMD ["apps/web/server.js"]pas de shell · pas d'apt · nonroot · base 20 Mo~5 CVE au lieu de ~180 Seule la dernière étape est livrée. Les deux premières existent pour être mises en cache.

Les chiffres

Avant Après
Taille de l'image 1,1 Go 140 Mo
Stockage ECR, 30 tags conservés 33 Go, 3,30 $/mois 4 Go, 0,40 $/mois
Temps de push depuis CodeBuild 70 s 9 s
Pull à froid App Runner à la montée en charge 25–40 s 4–6 s
CVE rapportées par le scanner ~180 5
Temps de build, cache chaud, une page modifiée 4 min 1 min 40 s

Le chiffre du pull à froid est celui que les utilisateurs ont senti. La montée en charge sous un pic de trafic voulait dire une demi-minute avant que la nouvelle instance puisse servir ; c'est maintenant sous les dix secondes, ce qui fait la différence entre un pic absorbé et un pic qui produit une page de 503.

Ce que vous abandonnez

  • Pas de shell en production. Couvert plus haut. C'est une qualité jusqu'au jour où vous en voulez un, et alors vous voulez un sidecar.
  • Les modules natifs doivent être précompilés. Tout ce qui a une étape node-gyp compile à l'étape 1 contre la glibc de Debian, que distroless utilise aussi, donc ça marche. Si vous allez plus loin vers une base Alpine ou musl, ça ne marchera pas. Nous sommes restés sur distroless basé sur Debian pour cette raison.
  • sharp et ses semblables ont besoin de leurs bibliothèques partagées. Le traceur standalone copie le binaire .node mais pas libvips. sharp embarque la sienne depuis la version 0.33 ; les versions plus anciennes ont besoin des bibliothèques copiées depuis l'étape de build. Vérifiez la première requête qui touche des images après le changement.
  • L'utilisateur nonroot ne peut pas se lier aux ports sous 1024. Utilisez 3000 et laissez la plateforme le mapper. Si quelque chose insiste sur le 80, c'est le travail de la plateforme, pas du conteneur.

La version courte

Base slim pour construire, distroless pour exécuter, output: 'standalone' pour que le traceur fasse l'élagage, un .dockerignore qui empêche la source et .git d'entrer dans le contexte de build, et une couche d'installation indexée sur le lockfile que le cache du registre restaure. De 1,1 Go à 140 Mo, de 40 secondes de pull à froid à 5, de 180 CVE à 5.

Si votre image Node dépasse 500 Mo, nous pouvons la ramener sous 200 en une journée, et la journée se rembourse généralement dès le premier mois de temps de pull.