Un Dockerfile bien écrit est le cœur de la recette qui fait tourner votre application de la même façon partout. Pourtant, la plupart des Dockerfiles produisent des images plus volumineuses que nécessaire et reconstruisent tout au moindre changement. Dans ce guide, nous allons parcourir pas à pas le modèle de couches (layers) et de cache de Docker, et construire des images plus petites, plus rapides et reproductibles.
Le modèle de couches : chaque instruction est une couche
Une image est faite de couches empilées, en lecture seule. Chaque instruction d'un Dockerfile — FROM, RUN, COPY, ADD — produit une nouvelle couche. Docker met ces couches en cache : si une instruction et ses entrées n'ont pas changé, Docker ne recalcule pas la couche, il réutilise celle du cache.
Le point essentiel : quand une couche change, le cache de toutes les couches en dessous est invalidé aussi. Placer ce qui change souvent vers la fin du Dockerfile, et ce qui change rarement vers le début, influe donc directement sur le temps de build. L'ordre, c'est la moitié du travail.
Commençons par un Dockerfile simple
Un exemple typique mais imparfait pour une application Node.js ressemble à ceci :
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
Cela fonctionne, mais il y a un problème : comme COPY . . copie tout le projet, modifier une seule ligne de code source invalide le cache de la couche RUN npm install en dessous, et les dépendances sont réinstallées à chaque fois. Or si package.json n'a pas changé, les dépendances n'ont pas besoin de changer.
Bien utiliser le cache : séparer les dépendances
La solution consiste à copier d'abord uniquement les manifestes de dépendances et à les installer, en laissant le code applicatif pour la fin :
FROM node:20
WORKDIR /app
# D'abord seulement les fichiers de dépendances
COPY package.json package-lock.json ./
RUN npm ci
# Ensuite le code applicatif
COPY . .
CMD ["node", "server.js"]
Désormais, seule la dernière couche COPY . . est rejouée quand le code source change ; la couche npm ci vient du cache. Tant que package-lock.json n'a pas changé, les dépendances ne sont pas réinstallées. Ce petit changement d'ordre peut faire passer le temps de build de minutes à secondes.
Utiliser npm ci plutôt que npm install est aussi un choix délibéré : ci respecte exactement le fichier de verrouillage, est déterministe et plus fiable pour les environnements d'intégration continue.
Exclure l'inutile avec .dockerignore
COPY . . copie tout — node_modules, .git, fichiers de logs, votre .env local... Cela gonfle l'image et casse inutilement le cache. Ajoutez un fichier .dockerignore à la racine du projet :
node_modules
.git
.gitignore
Dockerfile
*.log
.env
dist
coverage
Ce fichier fonctionne comme .gitignore et liste les chemins qui ne doivent jamais entrer dans le contexte de build (les fichiers envoyés au démon Docker). Un contexte plus petit, c'est des builds plus rapides et une image plus propre.
Builds multi-stage : se débarrasser des outils de build
Les outils nécessaires pour construire une application (compilateurs, dépendances de dev) ne sont pas nécessaires pour l'exécuter. Un build multi-stage permet de construire dans une étape et de ne transporter que le résultat dans une petite image finale :
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Une étape finale légère ne portant que la sortie de production
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
Ici, --from=build ne copie que le dossier dist compilé dans la deuxième étape. L'image finale ne contient ni outils de build ni dépendances de dev, ce qui réduit souvent sa taille de moitié, voire plus. Choisir une image de base légère comme node:20-slim diminue aussi la taille et la surface d'attaque.
Règles pratiques et erreurs courantes
- Combinez les commandes RUN : chaque
RUNest une couche. Chaînez les installations de paquets avec&&et nettoyez dans la même couche ; par exemple sur une base Debianapt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*. - Fixez les versions :
FROM node:20vaut mieux queFROM node:latest; utilisez une version précise pour la reproductibilité. - Écrivez CMD et ENTRYPOINT en forme exec :
CMD ["node", "server.js"]transmet correctement les signaux (SIGTERM) par rapport à la forme shell, ce qui permet un arrêt propre. - Ne tournez pas en root : en production, définissez un
USERpour que le conteneur s'exécute avec un utilisateur sans privilèges. - Utilisez WORKDIR : préférez
WORKDIRàcd; c'est lisible et persistant entre les couches.
Construire et inspecter l'image
Une fois le Dockerfile prêt, construire l'image et vérifier sa taille est simple :
docker build -t mon-app:1.0 .
docker images mon-app
docker history mon-app:1.0
docker history montre, couche par couche, quelle instruction occupe quelle place ; c'est l'outil le plus pratique pour trouver ce qui gonfle l'image. Avec BuildKit (par défaut dans Docker moderne), la sortie de build indique aussi quelles étapes étaient CACHED.
Questions fréquentes
Dois-je utiliser un Dockerfile ou Compose ?
Ils font des choses différentes. Un Dockerfile définit comment une seule image est construite ; docker compose définit et lance plusieurs conteneurs ensemble (une appli plus une base de données, par exemple). Compose référence généralement votre Dockerfile via build: — l'un ne remplace donc pas l'autre, ils s'utilisent ensemble.
Quelle est la différence entre COPY et ADD ?
Les deux copient des fichiers, mais ADD a deux capacités supplémentaires : télécharger depuis des URL distantes et extraire automatiquement des archives tar locales. Comme ce comportement peut surprendre, préférez toujours COPY pour une simple copie de fichiers, et réservez ADD aux besoins particuliers comme l'extraction d'une archive.
Pourquoi mon image est-elle encore si volumineuse ?
Les causes les plus fréquentes : une image de base non légère (essayez slim ou alpine), ne pas utiliser de build multi-stage, ne pas nettoyer dans la même couche, et l'absence de .dockerignore. Inspectez les couches avec docker history et ciblez la plus grosse.
Besoin d'une image efficace, petite et rapide ? Je peux optimiser vos Dockerfiles existants pour les couches et le cache et accélérer vos pipelines CI/CD — contactez-moi.