Aller au contenu
Un premier Dockerfile, propre dès le départ

Un premier Dockerfile, propre dès le départ

200 · Pratiquer ⏱ 1 h dockerpythonbuildkit

À la fin, vous saurez

  • Écrire un Dockerfile avec FROM, ENV, WORKDIR, COPY, RUN, USER, EXPOSE et CMD
  • Limiter le contexte de construction avec un fichier .dockerignore
  • Ordonner les instructions pour tirer parti du cache de construction
  • Choisir la forme exec de CMD et expliquer pourquoi
  • Faire tourner l'application sous un utilisateur non privilégié
  • Diagnostiquer une image trop lourde ou une construction trop lente

Prérequis

Testé avec buildx 0.37.1 docker 29.8.1 flask 3.1.3 gunicorn 26.2.0 python 3.14 , vérifié le 1 octobre 2026

Pourquoi

Jusqu'ici, nous avons lancé des images construites par d'autres. Il est temps d'empaqueter notre application. Le Dockerfile est un fichier court, et c'est un piège : on en écrit un qui « marche » en quatre lignes, et l'on découvre plus tard qu'il produit une image de 600 Mo qui embarque l'historique Git, se reconstruit entièrement à chaque modification d'une ligne de code, tourne en root et met dix secondes à s'arrêter.

Cette leçon part de ce Dockerfile naïf, mesure chacun de ses défauts, puis les corrige un par un. Le résultat n'est pas encore une image optimisée pour la production (images multi-étapes, images minimales, signatures : c'est l'objet du cours Construire des images de conteneurs), mais c'est un Dockerfile sans défaut de conception, sur lequel on peut construire.

L'application du fil conducteur

« Signalements » est une petite API web en Python : les habitants d'une commune y signalent un problème de voirie (lampadaire en panne, nid-de-poule). Elle utilise Flask, est servie par Gunicorn et stocke ses données dans PostgreSQL ; sans base configurée, elle garde les signalements en mémoire, ce qui suffit pour cette leçon. Le répertoire du projet contient :

signalements/
├── app.py              # l'application (environ 90 lignes)
├── requirements.txt    # les dépendances Python, versions épinglées
├── .git/               # l'historique du projet
└── .venv/              # l'environnement virtuel local du développeur
flask==3.1.3
gunicorn==26.2.0
psycopg[binary]==3.3.6

L'application expose GET / (identité et version), GET et POST /signalements, et GET /sante (vérification de santé, qui teste la base si elle est configurée). Elle lit sa configuration dans les variables d'environnement DATABASE_URL et APP_VERSION. Le code complet de app.py est donné dans le lab du cours, Lab : conteneuriser l'application Signalements, pour que vous puissiez rejouer chaque étape.

Les concepts

Un Dockerfile est une recette déterministe

Un Dockerfile décrit, instruction par instruction, comment passer d'une image de base à votre image. Chaque instruction qui modifie le système de fichiers (RUN, COPY, ADD) produit une couche ; les autres (ENV, USER, EXPOSE, CMD...) ne modifient que la configuration de l'image ; WORKDIR produit une petite couche s'il doit créer le répertoire. Vous reconnaîtrez dans docker history (leçon 7) la trace exacte de ces instructions.

Les instructions dont vous aurez besoin dans 90 % des cas :

InstructionRôleProduit une couche
FROM imageImage de départ(reprend ses couches)
ENV CLE=valeurVariable d'environnement, à la construction et à l'exécutionNon
WORKDIR /cheminRépertoire courant des instructions suivantes et du conteneurOui (création du répertoire)
COPY src destCopier des fichiers du contexte vers l'imageOui
RUN commandeExécuter une commande pendant la constructionOui
USER uidUtilisateur des instructions suivantes et du conteneurNon
EXPOSE portDocumenter le port d'écoute (ne publie rien)Non
CMD [...]Commande par défaut du conteneur (leçon 5)Non
ENTRYPOINT [...]Programme de base du conteneur (leçon 5)Non

Le contexte de construction

Quand vous lancez docker build ., le point final désigne le contexte : le répertoire dont le contenu est mis à la disposition du constructeur. Le client envoie ce contexte à BuildKit, le moteur de construction de Docker, qui peut tourner sur une autre machine. Seuls les fichiers du contexte peuvent être copiés dans l'image avec COPY. Et par défaut, tout le répertoire est envoyé : l'historique Git, les environnements virtuels, les fichiers .env contenant des secrets, les archives oubliées.

Le fichier .dockerignore, à la racine du contexte, exclut des fichiers avant l'envoi, avec une syntaxe proche de celle de .gitignore.

Le cache de construction

BuildKit garde le résultat de chaque instruction en cache. Lors d'une nouvelle construction, il réutilise ce résultat tant que l'instruction et tout ce qui la précède n'ont pas changé :

  • pour RUN, le cache est valide si le texte de la commande est identique (BuildKit ne sait pas si apt-get update donnerait un autre résultat aujourd'hui) ;
  • pour COPY, le cache est valide si le contenu des fichiers copiés est identique (BuildKit en calcule l'empreinte) ;
  • dès qu'une instruction est invalidée, toutes les suivantes sont réexécutées.

D'où la règle d'or : ce qui change rarement en haut, ce qui change souvent en bas. Les dépendances changent rarement, le code change à chaque commit.

Forme exec et forme shell

CMD, ENTRYPOINT et RUN acceptent deux écritures :

  • la forme exec, un tableau JSON : CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]. Le programme est lancé directement et devient le PID 1 ;
  • la forme shell, une chaîne : CMD gunicorn --bind 0.0.0.0:8000 app:app. Docker la transforme en ["/bin/sh", "-c", "gunicorn ..."] : le PID 1 est le shell.

Après la leçon 6, vous savez ce que cela implique : avec la forme shell, SIGTERM est envoyé au shell, qui l'ignore. Pour CMD et ENTRYPOINT, utilisez la forme exec. Pour RUN, la forme shell est normale et pratique (enchaînements avec &&, variables).

En pratique

Le Dockerfile naïf

Voici ce qu'on écrit spontanément, et qu'on trouve dans beaucoup de dépôts :

FROM python:3.14-slim
COPY . .
RUN pip install -r requirements.txt
CMD gunicorn --bind 0.0.0.0:8000 app:app

Construisons-le. Pour rendre la situation réaliste, le répertoire contient un environnement virtuel de 150 Mo et un dépôt Git de 30 Mo. --progress=plain affiche le détail de chaque étape au lieu de l'affichage compact :

$ docker build --progress=plain -f Dockerfile.naif -t signalements:naif .
#1 [internal] load build definition from Dockerfile.naif
#1 WARN: JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals (line 4)
...
#5 transferring context: 188.82MB 1.2s done
...
 1 warning found (use docker --debug to expand):
 - JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals (line 4)

(Sortie abrégée.) Deux signaux d'alarme apparaissent déjà. 189 Mo de contexte envoyés au constructeur, pour une application de 2 Ko. Et un avertissement : depuis 2024, BuildKit intègre des vérifications (build checks), et celle-ci signale exactement le problème de forme shell de la leçon 6.

Mesurons les dégâts :

$ docker image ls signalements --format 'table {{.Tag}}\t{{.Size}}'
TAG       SIZE
naif      624MB
$ docker history signalements:naif --format '{{.Size}}\t{{.CreatedBy}}' | head -4
0B	CMD ["/bin/sh" "-c" "gunicorn --bind 0.0.0.0:8000…
49.3MB	RUN /bin/sh -c pip install -r requirements.t…
189MB	COPY . . # buildkit
0B	CMD ["python3"]
$ docker run --rm signalements:naif ls -a /
.  ..  .dockerenv  .git  .venv  Dockerfile.naif  app.py  bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  requirements.txt  root  run  sbin  srv  sys  tmp  usr  var

Une précision sur les chiffres : avec le magasin d'images containerd (leçon 7), la taille affichée par docker image ls est l'occupation disque totale, couches compressées plus couches décompressées. Les 189 Mo de COPY . . comptent donc presque deux fois (des fichiers aléatoires ne se compressent pas). Les comparaisons entre images restent valables tant qu'on les mesure de la même façon.

Les défauts, un par un :

  1. 624 Mo, dont 189 Mo de COPY . . : .venv et .git sont dans l'image. Un environnement virtuel compilé pour le poste du développeur n'a rien à y faire, et l'historique Git peut contenir des secrets supprimés depuis longtemps.
  2. Pas de WORKDIR : tout a été copié à la racine /, mélangé avec bin, etc et usr.
  3. Le PID 1 est un shell, conséquence de la forme shell :
$ docker run -d --name naif signalements:naif
$ docker top naif -o pid,cmd
PID                 CMD
1724212             /bin/sh -c gunicorn --bind 0.0.0.0:8000 app:app
1724258             /usr/local/bin/python3.14 /usr/local/bin/gunicorn --bind 0.0.0.0:8000 app:app
$ /usr/bin/time -f "docker stop : %e s" docker stop naif
naif
docker stop : 10.22 s
$ docker inspect naif --format 'ExitCode={{.State.ExitCode}}'
ExitCode=137
  1. Root :
$ docker run --rm signalements:naif id
uid=0(root) gid=0(root) groups=0(root)
  1. Le cache ne sert à rien. Modifions une ligne de app.py et reconstruisons, en chronométrant :
$ /usr/bin/time -f "durée : %e s" docker build --progress=plain -f Dockerfile.naif -t signalements:naif .
...
#6 [2/3] COPY . .
#6 DONE 0.2s
#7 [3/3] RUN pip install -r requirements.txt
...
#7 DONE 3.9s
...
durée : 12.94 s

COPY . . copie tout, y compris app.py ; comme app.py a changé, la couche est invalidée, et avec elle le pip install qui suit. Chaque modification du code retélécharge et réinstalle toutes les dépendances. Sur un vrai projet avec des centaines de dépendances, cela se compte en minutes, à chaque construction.

Le Dockerfile corrigé

# syntax=docker/dockerfile:1
FROM python:3.14-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_ROOT_USER_ACTION=ignore

RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin appli

WORKDIR /app

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY app.py .

USER 10001
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "app:app"]

Ligne par ligne :

  • # syntax=docker/dockerfile:1 : une directive (pas un commentaire ordinaire) qui demande à BuildKit d'utiliser la dernière version stable de la syntaxe Dockerfile 1.x, indépendamment de la version de Docker installée.
  • ENV regroupe les réglages de Python et de pip : ne pas écrire de fichiers .pyc (inutiles dans une image figée), ne pas mettre la sortie en tampon (pour que docker logs affiche les messages immédiatement, leçon 6), ne pas garder le cache de téléchargement de pip dans l'image, ne pas vérifier la version de pip, et ne pas avertir de l'installation en root (dans une image, c'est voulu).
  • RUN useradd crée un utilisateur système sans privilège, avec un UID fixe et élevé (10001) pour ne pas coïncider avec un utilisateur existant de l'hôte, sans répertoire personnel ni shell de connexion. Cette étape vient tôt : elle ne change jamais, son cache sera toujours valide.
  • WORKDIR /app crée le répertoire et s'y place.
  • COPY requirements.txt . puis RUN pip install : les dépendances d'abord, seules. Cette couche ne sera invalidée que si requirements.txt change.
  • COPY app.py . : le code ensuite. Sa modification n'invalide que cette couche et les suivantes, qui ne coûtent rien.
  • USER 10001 : à partir d'ici, et au lancement du conteneur, les processus tournent sous cet UID. On donne l'UID numérique plutôt que le nom : certains outils (Kubernetes avec runAsNonRoot) ne peuvent vérifier qu'un nombre.
  • EXPOSE 8000 documente le port. Il ne publie rien : la publication reste le choix de celui qui lance le conteneur (leçon 10).
  • CMD en forme exec, avec l'option --no-control-socket expliquée plus bas.

Et le .dockerignore qui l'accompagne :

# Tout ce qui n'a rien à faire dans l'image
.git
.venv
__pycache__/
*.pyc
.env
Dockerfile*
.dockerignore

Construire et mesurer

$ docker build --progress=plain -t signalements:1.0 .
...
#5 [internal] load .dockerignore
#5 transferring context: 146B done
...
#6 transferring context: 2.46kB done
...

Le contexte passe de 188,82 Mo à 2,46 Ko. L'image :

$ docker image ls signalements --format 'table {{.Tag}}\t{{.Size}}'
TAG       SIZE
1.0       217MB
naif      624MB
$ docker history signalements:1.0 --format '{{.Size}}\t{{.CreatedBy}}' | head -9
0B	CMD ["gunicorn" "--bind" "0.0.0.0:8000" "--w…
0B	EXPOSE [8000/tcp]
0B	USER 10001
12.3kB	COPY app.py . # buildkit
30.9MB	RUN /bin/sh -c pip install -r requirements.t…
12.3kB	COPY requirements.txt . # buildkit
8.19kB	WORKDIR /app
41kB	RUN /bin/sh -c useradd --system --uid 10001 …
0B	ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFER…

217 Mo, contre 180 Mo pour l'image de base python:3.14-slim mesurée de la même façon. Les dépendances pèsent 30,9 Mo, contre 49,3 Mo dans la version naïve : la différence est le cache de pip, que PIP_NO_CACHE_DIR évite d'enregistrer. Réduire davantage demande de changer d'image de base ou de séparer construction et exécution : c'est le sujet du cours suivant.

Maintenant, modifions app.py et reconstruisons :

$ /usr/bin/time -f "durée : %e s" docker build --progress=plain -t signalements:1.1 .
...
#8 [3/6] WORKDIR /app
#8 CACHED
#9 [4/6] COPY requirements.txt .
#9 CACHED
#10 [2/6] RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin appli
#10 CACHED
#11 [5/6] RUN pip install -r requirements.txt
#11 CACHED
#12 [6/6] COPY app.py .
...
durée : 1.11 s

Toutes les étapes jusqu'à l'installation des dépendances sont CACHED ; seule la copie de app.py est rejouée. 1,1 seconde au lieu de 13. (BuildKit affiche les étapes dans l'ordre où il les traite, qui n'est pas toujours celui du fichier : il exécute en parallèle ce qui peut l'être.)

Lancer l'application

$ docker run -d --name app -p 127.0.0.1:8000:8000 -e APP_VERSION=1.0 signalements:1.0
$ curl -s localhost:8000/
{"application":"signalements","conteneur":"7f0b98b3126a","stockage":"memoire","version":"1.0"}
$ curl -s -X POST localhost:8000/signalements -H 'Content-Type: application/json' \
    -d '{"lieu":"Rue des Lilas","description":"Lampadaire éteint"}'
{"description":"Lampadaire éteint","id":1,"lieu":"Rue des Lilas"}
$ curl -s localhost:8000/sante
{"etat":"ok"}

L'application renvoie le nom d'hôte du conteneur (son identifiant court) et le mode de stockage. Vérifions les corrections :

$ docker exec app id
uid=10001(appli) gid=999(appli) groups=999(appli)
$ docker top app -o pid,user,cmd
PID                 USER                CMD
1834861             10001               /usr/local/bin/python3.14 /usr/local/bin/gunicorn --bind 0.0.0.0:8000 --workers 2 --no-control-socket app:app
1834926             10001               /usr/local/bin/python3.14 /usr/local/bin/gunicorn --bind 0.0.0.0:8000 --workers 2 --no-control-socket app:app
1834984             10001               /usr/local/bin/python3.14 /usr/local/bin/gunicorn --bind 0.0.0.0:8000 --workers 2 --no-control-socket app:app
$ /usr/bin/time -f "docker stop : %e s" docker stop app
app
docker stop : 0.61 s
$ docker logs --tail 4 app
[2026-10-01 10:12:02 +0000] [1] [INFO] Handling signal: term
[2026-10-01 10:12:02 +0000] [7] [INFO] Worker exiting (pid: 7)
[2026-10-01 10:12:02 +0000] [8] [INFO] Worker exiting (pid: 8)
[2026-10-01 10:12:02 +0000] [1] [INFO] Shutting down: Master
$ docker inspect app --format 'ExitCode={{.State.ExitCode}}'
ExitCode=0

Non-root, Gunicorn en PID 1 (le processus maître, avec ses deux workers), arrêt propre en 0,6 seconde avec le code 0 : Gunicorn gère SIGTERM en terminant ses workers.

Le piège du répertoire personnel

Pourquoi --no-control-socket ? Sans cette option, la première version de ce Dockerfile produisait ce message au démarrage :

[2026-10-01 10:12:00 +0000] [1] [ERROR] Control server error: [Errno 13] Permission denied: '/home/appli'

Gunicorn 26 ouvre par défaut un socket de contrôle dans $HOME/.gunicorn/. Notre utilisateur n'a pas de répertoire personnel (--no-create-home), et le processus n'a pas le droit de le créer. L'application fonctionnait quand même, mais ce genre de message finit par masquer les vraies erreurs. Comme nous n'utilisons pas cette interface de contrôle, on la désactive. C'est un cas typique de ce qui arrive en passant à un utilisateur non privilégié : un logiciel qui supposait pouvoir écrire quelque part. La bonne réponse est de comprendre ce qu'il veut écrire et pourquoi, pas de revenir à root.

Sous le capot

BuildKit est le moteur de construction de Docker depuis la version 23 ; docker build est un alias de docker buildx build. BuildKit transforme le Dockerfile en un graphe d'opérations (le LLB, low-level build), où chaque nœud est identifié par une clé de cache calculée à partir de l'opération et de ses entrées. Il exécute en parallèle les branches indépendantes du graphe, ce qui explique l'ordre d'affichage des étapes, et ne transfère du contexte que les fichiers réellement utilisés par les COPY, en ne renvoyant que ce qui a changé lors des constructions suivantes.

Chaque RUN s'exécute dans un conteneur temporaire créé à partir de l'état précédent, avec les mêmes mécanismes que ceux vus dans ce cours (namespaces, overlay). Ses modifications du système de fichiers deviennent la couche de l'étape. C'est pourquoi un RUN cd /tmp ne sert à rien pour l'étape suivante (chaque RUN repart du WORKDIR), et pourquoi une variable exportée dans un RUN n'existe plus dans le suivant : seuls les fichiers persistent, d'où ENV pour les variables.

Le cache de COPY repose sur l'empreinte du contenu des fichiers, pas sur leur date de modification : un touch app.py n'invalide rien, un espace ajouté invalide tout ce qui suit.

L'image produite est un index OCI comme ceux de la leçon 7, avec un manifeste pour la plateforme de construction et, par défaut, un manifeste d'attestation de provenance (provenance attestation) qui décrit comment l'image a été construite.

Pièges courants

COPY . . sans .dockerignore. Le défaut le plus fréquent et le plus dangereux : secrets, historique Git, artefacts locaux dans l'image. Commencez toujours un projet par son .dockerignore.

Des dépendances non épinglées. pip install flask sans version donne un résultat différent d'une semaine à l'autre, et le cache de BuildKit masque le problème : la construction locale réutilise une vieille couche, la CI construit avec les dernières versions. Épinglez les versions (flask==3.1.3), et pour aller plus loin, utilisez un fichier de verrouillage avec empreintes (pip-tools, uv, poetry).

apt-get update seul dans son RUN. Écrit RUN apt-get update puis RUN apt-get install -y paquet, le premier RUN reste en cache des mois, et le second échoue le jour où les index en cache pointent vers des paquets retirés du miroir. Toujours dans la même instruction : RUN apt-get update && apt-get install -y --no-install-recommends paquet && rm -rf /var/lib/apt/lists/*.

Une image qui fonctionne en root mais pas en utilisateur. Le passage à USER révèle les fichiers que l'application voulait écrire : un cache, un répertoire de téléversement, un fichier PID. Repérez-les dans les journaux (Permission denied), puis donnez-leur un emplacement prévu (un répertoire créé dans l'image avec le bon propriétaire, ou un volume, leçon 9).

L'état dans le processus. En mode mémoire, chaque worker Gunicorn a sa propre liste de signalements : avec deux workers, une requête sur deux peut ne pas voir un signalement créé juste avant. Ce n'est pas un défaut de Docker, mais l'illustration d'une règle que les conteneurs rendent incontournable : l'état ne vit pas dans le processus ni dans le conteneur, il vit dans un service dédié. C'est le rôle de PostgreSQL dans les leçons suivantes.

Sécurité

  • Non-root par défaut. Un processus compromis dans un conteneur non-root ne peut ni modifier les binaires de l'image, ni utiliser les capabilities réservées à root, et une éventuelle évasion le laisse avec un UID sans droits sur l'hôte. USER doit figurer dans tout Dockerfile applicatif.
  • Le .dockerignore est une mesure de sécurité. Un .env ou une clé SSH copiés dans une couche sont récupérables par quiconque télécharge l'image (leçon 7, exercice 4), même s'ils sont supprimés ensuite.
  • Moins de contenu, moins de failles. Chaque paquet de l'image est une vulnérabilité potentielle à suivre. La variante slim de l'image Python contient déjà beaucoup moins que la variante complète ; le cours suivant va plus loin.
  • Épingler l'image de base. FROM python:3.14-slim suit les mises à jour de l'étiquette, ce qui apporte les correctifs mais rend les constructions non reproductibles. BuildKit affiche l'empreinte qu'il a résolue (FROM docker.io/library/python:3.14-slim@sha256:51dafde8...) : en production, on l'écrit dans le Dockerfile et un outil comme Renovate la met à jour par des demandes de fusion relues.
  • Vérifiez vos Dockerfiles automatiquement. Les build checks de BuildKit (docker build --check .) détectent notamment la forme shell de CMD ; l'outil Hadolint ajoute des dizaines de règles, dont les apt-get sans version ni nettoyage et un USER root final. Aucun des deux ne remplace la relecture (l'absence de USER, par exemple, passe sans alerte), mais ils ont leur place dans la CI.

En production

  • Une image, plusieurs environnements. Aucune valeur propre à un environnement (adresse de base, mots de passe, niveau de journalisation) n'est écrite dans le Dockerfile : tout passe par l'environnement au lancement (leçon 5). L'image testée en recette est celle qui part en production.
  • Des étiquettes traçables. La CI étiquette chaque image avec le commit dont elle est issue (main-84d3170 chez Lyneko), et y ajoute les annotations org.opencontainers.image.source et org.opencontainers.image.revision vues à la leçon 7.
  • Le cache en CI. Les agents de CI sont souvent éphémères : sans précaution, chaque construction repart de zéro et l'ordre des instructions ne sert à rien. BuildKit sait exporter et importer son cache depuis un registre (--cache-to, --cache-from), sujet du cours suivant.
  • Plusieurs workers, combien ? Gunicorn recommande classiquement 2 × (nombre de cœurs) + 1 workers. Dans un conteneur limité à 0,5 CPU (leçon 2), ce calcul fondé sur les cœurs de l'hôte donne un nombre absurde. Fixez le nombre de workers explicitement, en fonction des limites du conteneur.

Exercices

1. Diagnostiquer un Dockerfile. Relevez tous les défauts de ce Dockerfile, puis réécrivez-le :

FROM node:latest
COPY . /app
RUN cd /app
RUN npm install
EXPOSE 3000
CMD npm start
Solution

Défauts : latest (non reproductible) ; COPY . /app sans .dockerignore (le node_modules local est copié) et avant l'installation (aucun cache pour les dépendances) ; RUN cd /app sans effet sur l'étape suivante, donc npm install s'exécute dans / ; npm install au lieu de npm ci (qui respecte le fichier de verrouillage) ; pas d'utilisateur non-root ; CMD en forme shell, et npm start intercale de surcroît le processus npm entre le shell et l'application, qui ne recevra pas SIGTERM. Réécriture :

# syntax=docker/dockerfile:1
FROM node:24-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]

avec un .dockerignore contenant au moins node_modules, .git et .env. L'image officielle Node fournit un utilisateur node (UID 1000).

2. Prouver l'effet du cache. Avec le Dockerfile corrigé de la leçon, modifiez requirements.txt (ajoutez par exemple requests==2.32.5) et reconstruisez. Quelles étapes sont rejouées ? Pourquoi COPY app.py est-il rejoué alors que app.py n'a pas changé ?

Solution

COPY requirements.txt est invalidé (le contenu a changé), donc RUN pip install et toutes les étapes suivantes, y compris COPY app.py. Le cache d'une étape dépend de toutes les étapes précédentes : dès qu'une couche change, toutes celles construites par-dessus doivent être recalculées, même si leur propre instruction est identique.

3. Le contexte trahi (niveau 200). Créez dans le projet un fichier .env contenant DB_PASSWORD=secret, retirez la ligne .env du .dockerignore, remplacez COPY app.py . par COPY . ., puis construisez. Retrouvez le secret dans l'image sans lancer de conteneur.

Solution
$ docker save signalements:fuite -o fuite.tar
$ mkdir fuite && tar -xf fuite.tar -C fuite
$ for b in fuite/blobs/sha256/*; do tar -tzf "$b" 2>/dev/null | grep -q '\.env$' && tar -xzOf "$b" app/.env; done
DB_PASSWORD=secret

Le fichier est dans la couche de COPY . .. Remettez .env dans le .dockerignore et, dans la vraie vie, considérez le secret comme compromis s'il a été poussé dans un registre.

4. Non-root jusqu'au bout (niveau 200). L'application doit désormais écrire des pièces jointes dans /app/pieces-jointes. Modifiez le Dockerfile pour que l'utilisateur appli puisse y écrire, sans lui donner le droit de modifier app.py.

Solution

Créez le répertoire et donnez-le à l'utilisateur, avant le USER, en laissant le code appartenir à root :

RUN mkdir /app/pieces-jointes && chown appli:appli /app/pieces-jointes

app.py, copié par COPY sans --chown, appartient à root et reste en lecture seule pour l'UID 10001 : un attaquant qui prendrait le contrôle de l'application ne pourrait pas la modifier. En pratique, les pièces jointes iront dans un volume monté à cet endroit (leçon 9), ou mieux dans un stockage objet.

Récapitulatif

  • Un Dockerfile est une suite d'instructions ; RUN, COPY et ADD produisent des couches.
  • Le contexte est tout ce qui est envoyé au constructeur : le .dockerignore le limite et protège vos secrets.
  • Ce qui change rarement en haut, ce qui change souvent en bas : dépendances avant le code, pour que le cache serve.
  • Forme exec pour CMD et ENTRYPOINT, sinon le PID 1 est un shell qui ignore SIGTERM.
  • USER avec un UID numérique non privilégié ; adaptez l'application plutôt que de revenir à root.
  • docker history, docker image ls et --progress=plain sont vos outils de mesure ; docker build --check et Hadolint vos relecteurs automatiques.

Pour aller plus loin

  • La référence Dockerfile, en particulier les sections COPY (options --chown, --link, --exclude) et RUN (montages de cache et de secrets, vus au cours suivant).
  • La liste des build checks de BuildKit, qui documente chaque règle et sa justification.
  • La documentation de l'image officielle Python, qui détaille ses variantes (slim, alpine, versions de Debian).
  • Leçon suivante : persister les données, avec les volumes et les montages.

Sources