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.
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.
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-gypcompile à 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. sharpet ses semblables ont besoin de leurs bibliothèques partagées. Le traceur standalone copie le binaire.nodemais paslibvips.sharpembarque 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
nonrootne 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.