Dockerfiles
Transformer une recette en image reproductible.
Création de Dockerfiles
Jusqu’ici vous avez consommé des images écrites par d’autres. Le moment vient d’en produire, et c’est là que Docker cesse d’être un utilitaire pour devenir une méthode de travail : le Dockerfile est le document où l’environnement d’exécution de votre application passe du statut de savoir tribal à celui de code source, relu en revue et versionné avec le reste.
La tentation, au début, est de le traiter comme un script d’installation qu’on aurait juste déplacé dans un fichier. On empile les commandes qui marchaient sur le poste, on construit, ça fonctionne, on passe à autre chose. Le résultat tourne. Il pèse aussi 1,4 Go pour une application Node de trois fichiers, se reconstruit en quatre minutes à chaque virgule modifiée, tourne en root, et embarque dans une de ses couches le jeton d’accès privé que vous croyiez avoir supprimé à la ligne suivante.
Aucun de ces quatre défauts n’est une fatalité, et aucun ne demande de connaissances avancées. Ils viennent tous d’une même méconnaissance : le Dockerfile n’est pas un script exécuté de haut en bas dans un environnement unique. C’est une suite de transformations, dont chacune produit une couche immuable, mise en cache et empilée sur la précédente. Comprendre cette mécanique, c’est obtenir presque gratuitement des images dix fois plus légères et des builds dix fois plus rapides.
Cette leçon part d’un Dockerfile naïf mais fonctionnel, puis le corrige défaut par défaut, en expliquant à chaque étape ce que le moteur de construction fait réellement.
Un premier Dockerfile, ligne par ligne
Prenons une petite application Node. À la racine du projet, un fichier nommé exactement Dockerfile, sans extension :
# Image de depart : tout Dockerfile commence par FROM.
# La version est epinglee : ni "latest", ni "node" tout court.
FROM node:22-slim
# Repertoire de travail pour les instructions suivantes.
# WORKDIR cree le dossier s'il n'existe pas.
WORKDIR /app
# Copier les manifestes de dependances SEULS, avant le code.
# La raison est le cache : elle est detaillee plus bas.
COPY package.json package-lock.json ./
# "npm ci" respecte strictement le lock file, contrairement a "npm install".
RUN npm ci --omit=dev
# Le reste du code applicatif
COPY . .
# EXPOSE ne publie RIEN : c'est une metadonnee documentaire.
# La publication se fait avec -p au lancement.
EXPOSE 3000
# Commande executee au demarrage du conteneur
CMD ["node", "server.js"]
# -t etiquette l'image (nom:tag). Le "." final est le CONTEXTE
# de build : le dossier envoye au daemon, pas l'emplacement du Dockerfile.
docker build -t mon-api:1.0 .
docker images mon-api
docker run --rm -p 3000:3000 mon-api:1.0
Ce Dockerfile fonctionne, et il est déjà correct sur un point important : l’ordre des COPY. Avant d’aller plus loin, il faut lever l’ambiguïté sur ce point final.
Le contexte de build est l’ensemble des fichiers que le client envoie au daemon avant que la construction ne commence. Le daemon n’a aucun accès à votre disque : il ne voit que ce paquet. D’où deux conséquences que tout le monde rencontre une fois. Un COPY ../secrets/config.json échoue avec path outside of build context, parce que le chemin sort du paquet. Et si votre node_modules local pèse 800 Mo, ces 800 Mo sont transférés à chaque build même s’ils ne sont jamais copiés — d’où le .dockerignore, plus loin.
Le contexte et l’emplacement du Dockerfile sont d’ailleurs indépendants : docker build -f docker/Dockerfile.prod -t mon-api:prod . lit la recette dans un sous-dossier tout en gardant le dossier courant comme contexte.
Les instructions essentielles
FROM définit l’image de base et peut apparaître plusieurs fois (voir multi-stage). FROM scratch désigne une image entièrement vide.
RUN exécute une commande pendant la construction et fige son résultat dans une couche. Chaque RUN crée une couche, donc on les chaîne.
# Mauvais : trois couches, et le cache apt reste dans l'image
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
# Bon : une couche, nettoyage dans la MEME instruction
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
Le rm -rf doit impérativement se trouver dans le même RUN. Placé dans un RUN suivant, il masquerait les fichiers sans réduire la taille de l’image, puisque les couches sont additives. Le --no-install-recommends évite des dizaines de paquets suggérés dont vous n’avez pas besoin.
COPY copie depuis le contexte vers l’image. ADD fait la même chose en ajoutant deux comportements implicites — décompresser une archive locale, télécharger une URL — souvent surprenants. La règle admise est COPY par défaut, ADD seulement pour extraire une archive.
COPY ./src /app/src
COPY --chown=node:node ./src /app/src # copie en fixant le proprietaire
ADD app-v2.tar.gz /opt/ # extrait automatiquement
ENV définit une variable persistante dans l’image et les conteneurs ; ARG une variable disponible uniquement pendant le build, surchargeable en ligne de commande.
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim
ARG APP_ENV=production
ENV NODE_ENV=${APP_ENV}
docker build --build-arg NODE_VERSION=20 --build-arg APP_ENV=staging -t mon-api:test .
# Un ARG n'est PAS un secret : sa valeur reste lisible ici
docker history --no-trunc mon-api:test
WORKDIR fixe le répertoire courant : préférez-le toujours à RUN cd /app, sans effet durable puisque chaque RUN démarre un nouveau processus. EXPOSE documente un port et permet docker run -P, qui publie automatiquement les ports déclarés. Enfin LABEL org.opencontainers.image.source="..." ajoute des métadonnées de traçabilité que l’outillage sait exploiter.
Le cache de couches : l’ordre décide du temps de build
C’est le point qui produit les gains les plus spectaculaires, et il tient en une règle.
Pour chaque instruction, le moteur calcule une clé de cache. Si l’instruction et ses entrées sont inchangées, la couche est réutilisée telle quelle. Mais dès qu’une couche est invalidée, toutes les suivantes le sont aussi. Le cache est une chaîne : une rupture au maillon 3 casse les maillons 4 à 12. Pour RUN, la clé est le texte de la commande ; pour COPY, c’est le contenu des fichiers copiés.
D’où le contre-exemple classique :
FROM node:22-slim
WORKDIR /app
COPY . . # invalide des qu'un SEUL fichier source change
RUN npm ci --omit=dev # ...donc reinstalle tout, a chaque virgule modifiee
CMD ["node", "server.js"]
Et la version correcte, qui exploite la stabilité relative des dépendances :
FROM node:22-slim
WORKDIR /app
# Les manifestes seuls : ils changent rarement
COPY package.json package-lock.json ./
# Couche coûteuse, mais conservee tant que les manifestes sont identiques
RUN npm ci --omit=dev
# Le code en dernier : ce qui change le plus souvent, et coûte le moins cher
COPY . .
CMD ["node", "server.js"]
La règle générale, valable pour tous les langages : du plus stable au plus volatil. Installation système, puis dépendances, puis code. Seuls les noms de fichiers changent : requirements.txt pour Python (pip install --no-cache-dir -r), go.mod et go.sum pour Go, pom.xml pour Maven, Cargo.toml pour Rust.
Pendant un build, les lignes CACHED signalent les couches réutilisées. Deux options servent à contourner le cache : --no-cache force une reconstruction complète, ce qui vérifie que le Dockerfile fonctionne encore à froid, et --pull récupère une image de base à jour au lieu de celle du cache local.
BuildKit, moteur de construction par défaut depuis Docker 23, offre en plus des caches montés qui survivent aux invalidations. Ils exigent la directive de syntaxe en première ligne :
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
# Le cache npm est monte pendant le RUN puis n'est PAS
# inclus dans la couche : rapide, sans grossir l'image.
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
.dockerignore : indispensable, jamais optionnel
À côté du Dockerfile, ce fichier exclut des chemins du contexte de build. Sa syntaxe reprend celle de .gitignore.
# Dependances : reinstallees dans l'image, et celles de l'hote
# sont peut-etre compilees pour un autre OS
node_modules
__pycache__
vendor
.git # souvent des centaines de Mo, jamais utile
# Secrets et configuration locale : le risque le plus grave
.env
.env.*
*.pem
*.key
# Artefacts, couverture, fichiers d'editeur
dist
build
coverage
.vscode
.idea
Dockerfile*
docker-compose*.yml
Il agit sur trois plans à la fois. Il accélère : le contexte transféré peut passer de plusieurs centaines de mégaoctets à quelques centaines de kilooctets. Il protège : sans lui, un COPY . . embarque votre .env et vos clés privées dans une couche de l’image, d’où elles resteront extractibles. Et il stabilise le cache : un fichier de log qui change à chaque exécution n’invalidera plus vos couches.
Le cas node_modules mérite d’être souligné : le copier depuis l’hôte n’est pas seulement inutile, c’est risqué, car les modules natifs sont compilés pour l’architecture et l’OS de la machine où ils ont été installés.
CMD contre ENTRYPOINT
CMD fournit une commande par défaut, entièrement remplaçable par les arguments de docker run. ENTRYPOINT fixe l’exécutable, et les arguments de docker run lui sont ajoutés.
Avec CMD ["echo", "bonjour"] seul, docker run mon-image affiche « bonjour » et docker run mon-image ls / liste la racine : la commande par défaut a été purement remplacée. Le duo ENTRYPOINT + CMD se comporte tout autrement, et c’est le patron recommandé pour une application.
FROM alpine:3.20
ENTRYPOINT ["ping"]
CMD ["-c", "3", "localhost"]
docker run --rm mon-image # ping -c 3 localhost
docker run --rm mon-image -c 5 8.8.8.8 # ping -c 5 8.8.8.8
# ENTRYPOINT ne se remplace qu'explicitement
docker run --rm --entrypoint sh -it mon-image
Reste une distinction plus subtile, aux conséquences bien réelles : la forme exec contre la forme shell.
# Forme exec (tableau JSON) : le processus est PID 1 directement
CMD ["node", "server.js"]
# Forme shell (chaine) : Docker execute /bin/sh -c "node server.js"
CMD node server.js
Dans la forme shell, PID 1 est sh, et sh ne transmet pas SIGTERM à son enfant. Votre application ne reçoit donc jamais le signal d’arrêt : docker stop attend dix secondes puis la tue brutalement. Utilisez toujours la forme exec pour un processus durable. Les variables d’environnement n’y sont pas interprétées ; si vous en avez besoin, dites-le explicitement :
CMD ["sh", "-c", "node server.js --port=$PORT"]
Multi-stage : construire dans une image, livrer dans une autre
C’est le plus grand levier disponible sur la taille des images. L’idée : les outils nécessaires pour construire une application — compilateur, en-têtes, dépendances de développement — n’ont rien à faire dans l’image qui l’exécute.
Un Dockerfile peut contenir plusieurs FROM. Chacun ouvre une étape, et l’étape finale copie des fichiers depuis une étape précédente avec COPY --from. Tout ce qui n’est pas copié est jeté.
# syntax=docker/dockerfile:1
# ---------- Etape 1 : construction ----------
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
# Ici on installe TOUT : TypeScript, bundlers, outils de test
RUN npm ci
COPY . .
RUN npm run build # produit /app/dist
# ---------- Etape 2 : execution ----------
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
# UNIQUEMENT l'artefact compile. Le compilateur, les sources
# et les devDependencies restent derriere.
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
L’effet est encore plus net avec un langage compilé, où l’image finale peut se passer de tout environnement d’exécution :
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO desactive : binaire statique, sans dependance a la libc
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/api ./cmd/api
# scratch est litteralement vide : ni shell, ni libc, ni gestionnaire de paquets
FROM scratch
# Les certificats racines sont necessaires a tout appel HTTPS sortant
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /bin/api /api
EXPOSE 8080
ENTRYPOINT ["/api"]
On passe d’environ 1 Go à quelques mégaoctets, et la surface d’attaque fond dans la même proportion : sans shell ni utilitaires, un attaquant qui obtiendrait l’exécution de code n’a presque rien à sa disposition. Le revers est qu’on ne peut plus diagnostiquer par docker exec ... sh. Les images distroless de Google sont un compromis fréquent : pas de shell, mais un environnement d’exécution complet.
Les étapes sont aussi utilisables séparément, ce qui rend le même fichier exploitable en intégration continue :
docker build --target builder -t mon-api:build .
docker run --rm mon-api:build npm test
Ne jamais tourner en root
Par défaut, le processus d’un conteneur s’exécute en root. Ce n’est pas le root de l’hôte, mais la combinaison de cette élévation avec une faille du noyau ou un volume monté depuis l’hôte suffit à transformer une vulnérabilité applicative en compromission de la machine. Le correctif tient en deux instructions.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Utilisateur systeme, sans mot de passe ni shell de connexion
RUN groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid appgroup --no-create-home appuser \
&& chown -R appuser:appgroup /app
# Tout ce qui suit s'execute sous cet utilisateur
USER appuser
EXPOSE 8000
CMD ["python", "-m", "app"]
Sur une base Alpine, les outils diffèrent : addgroup -S -g 1001 appgroup && adduser -S -u 1001 -G appgroup -H appuser. Et certaines images officielles fournissent déjà un compte non privilégié : les images node ont un utilisateur node, il suffit d’écrire USER node.
Deux points de vigilance. L’ordre compte : le USER vient après les installations qui exigent des privilèges, et le chown précède le changement d’utilisateur, sinon l’application ne pourra pas écrire dans son propre répertoire. Et un utilisateur non privilégié ne peut pas se lier à un port inférieur à 1024 : faites écouter votre application sur 8080, puis publiez-la où vous voulez avec -p 80:8080.
La vérification tient en une commande : docker run --rm mon-api:1.0 id doit répondre uid=1001(appuser), et non uid=0(root).
Les couches sont additives : les secrets ne s’effacent pas
Une erreur qui paraît anodine et ne l’est pas du tout :
# NE FAITES PAS CECI
COPY .npmrc /root/.npmrc
RUN npm ci
RUN rm /root/.npmrc # illusoire : le fichier reste dans la couche du COPY
Le rm crée une couche qui masque le fichier ; il ne le retire pas de l’image. Quiconque dispose de l’image peut extraire la couche antérieure et récupérer le jeton. Le même raisonnement vaut pour un ARG TOKEN, visible dans docker history.
BuildKit fournit la solution propre : un secret monté le temps d’un RUN, qui n’appartient à aucune couche.
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
# Le fichier existe le temps du RUN, puis disparait sans trace
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
docker build --secret id=npmrc,src=$HOME/.npmrc -t mon-api:1.0 .
Construire, étiqueter, vérifier
Le nom donné à une image permettra de savoir, six mois plus tard, quel commit tourne en production. Étiquetez donc au moment du build, et contrôlez trois choses avant de considérer l’image comme finie.
# Plusieurs tags pour une meme construction, dont un derive du commit Git
docker build -t mon-api:1.2.3 -t mon-api:"$(git rev-parse --short HEAD)" .
# Etiqueter pour un registre prive, prealable au push
docker tag mon-api:1.2.3 registre.exemple.com/equipe/mon-api:1.2.3
docker images mon-api # la taille est-elle raisonnable ?
docker run --rm mon-api:1.2.3 id # tourne-t-elle sans privileges ?
docker history mon-api:1.2.3 # ou est passe le poids ?
À retenir
- Un
Dockerfileproduit une suite de couches immuables, pas un script linéaire. Ordonnez les instructions du plus stable au plus volatil : dépendances avant code, sinon vous payez une réinstallation complète à chaque caractère modifié. - Une couche invalidée invalide toutes les suivantes. Le cache est une chaîne, et
COPY . .placé trop haut la brise systématiquement. - Le
.dockerignoren’est pas optionnel : il accélère le build, stabilise le cache et empêche vos.envet vos clés privées de finir dans une couche publiable. ENTRYPOINTfixe l’exécutable,CMDfournit des arguments par défaut surchargeables. Utilisez la forme exec pour que votre processus soit PID 1 et reçoiveSIGTERM.- Le multi-stage est le plus grand levier disponible : les outils de construction restent dans l’étape
builder, seul l’artefact est copié. Gains typiques d’un facteur 10 sur la taille comme sur la surface d’attaque. - Ajoutez toujours un
USERnon privilégié, après les installations et après lechown, et faites écouter l’application au-dessus du port 1024. - Un fichier supprimé dans une couche ultérieure reste extractible. Pour les jetons de build, utilisez
RUN --mount=type=secret, jamaisCOPYpuisrm, jamaisARG.
Conseil : après chaque modification, lancez
docker historysur votre image. Une couche anormalement grosse pointe presque toujours vers un cache de paquets oublié ou unCOPYtrop large.
Commentaires
Les commentaires sont alimentés par GitHub Discussions
Connectez-vous avec GitHub pour participer à la discussion