
Astuces Dockerfile : du build qui marche au build qu'on ne touche plus
Tout Dockerfile commence pareil : FROM, COPY . ., RUN l’installation,
CMD - et ça marche. Puis un jour, on remarque que chaque build retélécharge
toutes les dépendances, que l’image fait 1,2 Go, qu’elle tourne en root et pire, que des secrets ont été stockés dans une couche.
Cet article passe en revue les astuces qui corrigent tout ça, de la plus
simple à la plus avancée.
Le point de départ (à ne pas imiter)
FROM eclipse-temurin:21
COPY . .
RUN ./mvnw package
CMD ["java", "-jar", "target/app.jar"]Quatre lignes, cinq problèmes : cache inefficace, image obèse, outils de
build embarqués en production, exécution en root, et tout le répertoire -
.git compris - copié dans l’image. Corrigeons dans l’ordre.
Astuce 1 - ordonner les couches du stable vers le volatile
Docker met en cache chaque instruction sous forme de couche, et invalide tout ce qui suit la première couche modifiée. La conséquence pratique : copiez d’abord ce qui change rarement (le descripteur de dépendances), installez, puis copiez le code qui change tout le temps.
# ✗ Mauvais : le moindre changement de code réinstalle tout
COPY . .
RUN pip install -r requirements.txt
# ✓ Bon : les dépendances ne se réinstallent que si requirements.txt change
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .Sur un projet réel, c’est la différence entre un rebuild de 4 minutes et
un rebuild de 8 secondes. La même logique vaut pour Maven ( avec son pom.xml), npm (avec package*.json) ou Go (avec go.mod et go.sum).
Astuce 2 - .dockerignore, le fichier qu’on oublie toujours
COPY . . envoie tout le contexte au démon Docker : .git, les
node_modules locaux, les artefacts de build, vos fichiers .env. Un
.dockerignore réduit le contexte, accélère le build et surtout, évite d’envoyer des fichiers non voulus dans une couche (comme des secrets, par exemple) :
.git
target/
node_modules/
__pycache__/
*.env
Dockerfile
docker-compose*.ymlAstuce dans l’astuce (pour vous montrer l’importance de contrôler les fichiers envoyés dans les couches) : sans .dockerignore, un simple git commit
(qui modifie .git/) invalide le cache de COPY . . alors qu’aucun
fichier source n’a changé.
Astuce 3 - le multi-stage build
La technique qui change tout : compiler dans une image outillée, ne livrer que le résultat dans une image minimale.
# ── Étape 1 : build ──────────────────────────────
FROM maven:3-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline # couche cachée tant que pom.xml ne change pas
COPY src ./src
RUN mvn package -DskipTests
# ── Étape 2 : image finale ───────────────────────
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]L’image finale ne contient ni Maven, ni JDK, ni sources : on passe de ~750 Mo à ~180 Mo, et la surface d’attaque se réduit d’autant. Le même motif en Python, où l’étape de build fabrique un wheel :
FROM python:3.12 AS build
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-deps --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /wheels /wheels
RUN pip install --no-index --find-links=/wheels /wheels/* && rm -rf /wheels
COPY src ./src
CMD ["python", "-m", "src.main"]Bonus : docker build --target build . permet de ne construire que la
première étape - pratique pour lancer les tests en CI dans l’image
outillée, tout en publiant l’image légère.
Astuce 4 - les cache mounts de BuildKit
Le multi-stage protège l’image finale, mais chaque build repart de zéro côté téléchargements dès que le descripteur de dépendances change. Les cache mounts montent un répertoire de cache persistant entre les builds, sans qu’il ne finisse jamais dans l’image :
RUN --mount=type=cache,target=/root/.m2 mvn package -DskipTestsRUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txtUn pom.xml (ou un requirements.txt ) modifié ne retélécharge plus que les nouvelles dépendances.
Dans la même famille, --mount=type=secret donne accès à un secret
(Token pour l’accès à un repository, clé SSH, etc..) pendant le RUN sans le stocker
dans aucune couche :
RUN --mount=type=secret,id=pip_token \
PIP_INDEX_URL=https://user:$(cat /run/secrets/pip_token)@pypi.interne/simple \
pip install -r requirements.txtdocker build --secret id=pip_token,src=.pip_token .C’est LA bonne réponse au réflexe dangereux du ARG TOKEN - les ARG
restent visibles dans docker history ainsi un secret positionné dans un ARG est un secret de que vous donnés aux futurs utilisateurs de votre image.
Astuce 5 - ne pas tourner en root
Par défaut, le processus du conteneur tourne en root. Une évasion de conteneur ou une faille applicative devient nettement moins grave avec un utilisateur dédié :
RUN addgroup -S app && adduser -S app -G app
USER appPlacez le USER après les RUN d’installation (qui ont besoin des
droits), avant le ENTRYPOINT. Si l’application écrit quelque part,
préparez le répertoire : RUN mkdir /data && chown app:app /data.
Astuce 6 - ENTRYPOINT, CMD et le signal qui n’arrive jamais
Deux règles évitent 90 % des surprises :
Toujours la forme exec (
["java", "-jar", "app.jar"]), jamais la forme shell (java -jar app.jar). En forme shell, c’est/bin/shqui a le PID 1 : votre application ne reçoit jamais leSIGTERMdedocker stop, et se fait tuer brutalement après 10 secondes - adieu l’arrêt propre.ENTRYPOINTpour l’exécutable,CMDpour les arguments par défaut :dockerfile ENTRYPOINT ["python", "-m", "src.main"] CMD ["--port", "8000"]docker run mon-image --port 9000remplace alors juste les arguments.
Astuce 7 - les détails qui montrent un Dockerfile soigné
Épingler les versions de base :
python:3.12-slim, n’utilisez pas depython:latest- si vous voulez un build reproductible (et je vous garanti que vous le voulez), c’est la clé.COPYplutôt queADD, sauf besoin explicite d’extraire une archive ;ADDavec une URL télécharge sans vérification ni cache.Fusionner les
apt-getpour ne pas figer les index de paquets dans une couche :dockerfile RUN apt-get update && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*HEALTHCHECKpour que l’orchestrateur sache si l’application est vivante - pas seulement le processus :dockerfile HEALTHCHECK --interval=30s --timeout=3s \ CMD curl -sf http://localhost:8000/health || exit 1Linter le tout avec hadolint :
docker run --rm -i hadolint/hadolint < Dockerfileattrape la plupart des points ci-dessus, en CI comme en local.
Le résultat assemblé
FROM maven:3-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn package -DskipTests
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
USER app
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]Un bon Dockerfile se reconnaît à trois choses : il se reconstruit en secondes (couches ordonnées, cache mounts), il livre une image minimale (multi-stage, base slim épinglée), et il tourne sans droits superflus (USER dédié, forme exec, healthcheck). Tout le reste est du détail - que hadolint vérifiera pour vous.