Aller au contenu
Les constructions multi-étapes

Les constructions multi-étapes

200 Pratiquer ⏱ 1 h dockerbuildkitpythonpostgresql

À la fin, vous saurez

  • Écrire un Dockerfile multi-étapes avec FROM ... AS et COPY --from
  • Séparer les outils de construction de l'image d'exécution et mesurer le gain
  • Construire une cible particulière avec --target pour les tests ou le développement
  • Expliquer comment BuildKit parallélise et ignore les étapes inutiles
  • Éviter les incompatibilités entre étape de construction et étape d'exécution

Prérequis

Testé avec buildkit 0.33.0 docker 29.8.1 psycopg 3.3.6 python 3.14.7 trivy 0.74.0 , vérifié le 1 octobre 2026

Pourquoi

La leçon précédente a dressé un constat : l'image python:3.14 complète, avec son compilateur et ses en-têtes, pèse 1,6 Go et embarque des milliers de vulnérabilités. Elle est faite pour construire. L'image python:3.14-slim est faite pour exécuter, mais elle ne sait pas construire grand-chose.

Le problème apparaît dès qu'une dépendance doit être compilée. C'est justement le cas de notre application : la documentation de psycopg, le pilote PostgreSQL de Signalements, présente la version psycopg[binary] utilisée jusqu'ici comme une solution de commodité, et recommande pour la production la version psycopg[c], compilée contre la bibliothèque libpq du système. Changeons une ligne de requirements.txt :

flask==3.1.3
gunicorn==26.2.0
psycopg[c]==3.3.6

et reconstruisons l'image du cours précédent :

$ docker build --progress=plain -t signalements:c-slim .
...
#11 4.504       couldn't run 'pg_config' --includedir: [Errno 2] No such file or directory: 'pg_config'
#11 4.504       error: [Errno 2] No such file or directory: 'pg_config'
#11 4.504   note: This error originates from a subprocess, and is likely not a problem with pip.
#11 4.506 error: metadata-generation-failed
...
#11 ERROR: process "/bin/sh -c pip install -r requirements.txt" did not complete successfully: exit code: 1

Pour compiler, il faut gcc et les en-têtes de libpq (pg_config vient du paquet libpq-dev). La réponse spontanée consiste à les installer dans l'image... et à les y laisser. Cette leçon mesure le prix de cette réponse, puis montre la bonne : la construction multi-étapes, qui sépare l'atelier où l'on fabrique de la boîte que l'on livre.

Les concepts

Plusieurs FROM dans un seul Dockerfile

Un Dockerfile peut contenir plusieurs instructions FROM. Chacune ouvre une étape (stage), avec sa propre image de départ, et peut recevoir un nom avec AS :

FROM python:3.14 AS construction
# ... on compile, on installe ...

FROM python:3.14-slim AS execution
COPY --from=construction /opt/venv /opt/venv
# ... seule cette étape devient l'image ...

Seule la dernière étape (ou celle désignée par --target) produit l'image finale. Les étapes précédentes sont des ateliers temporaires : leurs couches ne font pas partie de l'image livrée. COPY --from=<étape> copie des fichiers d'une étape vers une autre ; c'est le seul pont entre elles. Tout ce qui n'est pas copié explicitement reste dans l'atelier : compilateurs, en-têtes, caches de téléchargement, code source, fichiers intermédiaires.

Le graphe des étapes

Les étapes forment un graphe : une étape peut partir d'une autre (FROM construction AS test), ou copier depuis plusieurs. BuildKit (leçon 8 du cours précédent) analyse ce graphe avant d'exécuter quoi que ce soit, ce qui a deux conséquences importantes :

  • les étapes indépendantes s'exécutent en parallèle ;
  • les étapes dont la cible n'a pas besoin ne sont pas exécutées du tout.
    flowchart LR
  B["python:3.14"] --> C["construction<br/>venv + dépendances compilées"]
  C --> T["test<br/>pytest"]
  S["python:3.14-slim"] --> E["execution<br/>libpq5, utilisateur, code"]
  C -- "COPY --from" --> E
  E --> I(["image livrée"])
  T -. "--target test" .-> R(["résultat des tests"])
  

Ce qui doit être identique des deux côtés

Ce que l'on copie d'une étape à l'autre doit fonctionner dans l'environnement d'arrivée. Pour du code compilé ou un environnement Python, cela impose :

  • la même bibliothèque C (glibc ou musl, leçon 1) ;
  • la même version de l'interpréteur et le même chemin (un environnement virtuel contient des liens vers /usr/local/bin/python) ;
  • les bibliothèques partagées nécessaires à l'exécution, installées dans l'étape finale (ici libpq5, la bibliothèque d'exécution, sans ses en-têtes de développement).

python:3.14 et python:3.14-slim partent de la même Debian 13 et installent Python au même endroit, dans la même version (3.14.7) : elles forment une paire sûre.

En pratique

Version 1 : tout dans une seule étape

Installons ce qui manque, dans l'image slim elle-même :

# 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 apt-get update \
 && apt-get install -y --no-install-recommends build-essential libpq-dev \
 && rm -rf /var/lib/apt/lists/*

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"]
$ /usr/bin/time -f "durée : %e s" docker build -q --no-cache -t signalements:une-etape -f Dockerfile.une-etape .
sha256:dcb41c8c3571eef780a065b34b25a3a8246ed0bf0b956325d701ac3059e8a8b6
durée : 74.81 s
$ docker image ls --tree signalements:une-etape | grep linux/amd64
└─ linux/amd64                 2d20c9811973        677MB          165MB
$ docker history signalements:une-etape --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
25.5MB	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 …
352MB	RUN /bin/sh -c apt-get update  && apt-get in…

Ça fonctionne, mais 352 Mo de compilateur et d'en-têtes sont désormais livrés en production, pour servir une fois, pendant la construction. L'image a triplé (677 Mo contre 217 Mo pour la version psycopg[binary]), et un attaquant qui prendrait pied dans le conteneur y trouverait de quoi compiler ses outils. Supprimer ces paquets dans une instruction RUN ultérieure n'y changerait rien : les fichiers resteraient dans la couche qui les a ajoutés (leçon 7 du cours précédent).

Version 2 : construire dans une étape, exécuter dans une autre

# syntax=docker/dockerfile:1

# Étape 1 : construire l'environnement Python, avec compilateur et en-têtes
FROM python:3.14 AS construction
ENV PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install -r requirements.txt

# Étape 2 : l'image d'exécution, sans compilateur
FROM python:3.14-slim AS execution
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"
RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 \
 && rm -rf /var/lib/apt/lists/*
RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin appli
COPY --from=construction /opt/venv /opt/venv
WORKDIR /app
COPY app.py .
USER 10001
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "app:app"]

Les choix :

  • L'étape construction part de python:3.14, qui contient déjà gcc et libpq-dev : pas besoin d'apt-get.
  • Les dépendances s'installent dans un environnement virtuel (/opt/venv), un répertoire autonome, facile à copier d'un bloc. Mettre son bin en tête du PATH équivaut à l'« activer ».
  • L'étape execution n'installe que libpq5, la bibliothèque dont le module compilé a besoin à l'exécution, puis copie l'environnement virtuel.
$ /usr/bin/time -f "durée : %e s" docker build --progress=plain --no-cache -t signalements:multi .
...
#8 [execution 1/6] FROM docker.io/library/python:3.14-slim@sha256:51dafde8...
#9 [construction 1/4] FROM docker.io/library/python:3.14@sha256:be8ccd08...
#10 [construction 2/4] RUN python -m venv /opt/venv
#10 DONE 2.4s
#11 [execution 2/6] RUN apt-get update  && apt-get install -y --no-install-recommends libpq5  && rm -rf /var/lib/apt/lists/*
#12 [construction 3/4] COPY requirements.txt .
#12 DONE 0.0s
#13 [construction 4/4] RUN pip install -r requirements.txt
#11 [execution 2/6] RUN apt-get update  && apt-get install -y --no-install-recommends libpq5  && rm -rf /var/lib/apt/lists/*
#11 DONE 7.0s
#14 [execution 3/6] RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin appli
#14 DONE 0.2s
#13 [construction 4/4] RUN pip install -r requirements.txt
#13 DONE 37.3s
#15 [execution 4/6] COPY --from=construction /opt/venv /opt/venv
...
durée : 43.79 s

Lisez l'entrelacement des numéros : pendant que l'étape construction compile psycopg (étape #13, 37 secondes), l'étape execution installe libpq5 et crée l'utilisateur (#11 et #14) en même temps. BuildKit n'attend la fin de la compilation que pour la copie (#15), qui en dépend. D'où une construction plus rapide que la version en une étape (44 secondes contre 75), alors qu'elle fait davantage.

$ docker image ls --tree signalements:multi | grep linux/amd64
└─ linux/amd64             e288c7ab17b8        233MB         54.9MB
$ docker history signalements:multi --format '{{.Size}}\t{{.CreatedBy}}' | head -10
0B	CMD ["gunicorn" "--bind" "0.0.0.0:8000" "--w…
0B	EXPOSE [8000/tcp]
0B	USER 10001
12.3kB	COPY app.py . # buildkit
8.19kB	WORKDIR /app
39.6MB	COPY /opt/venv /opt/venv # buildkit
41kB	RUN /bin/sh -c useradd --system --uid 10001 …
3.84MB	RUN /bin/sh -c apt-get update  && apt-get in…
0B	ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFER…
0B	CMD ["python3"]

233 Mo au lieu de 677. L'historique ne garde aucune trace du compilateur : seules les couches de l'étape finale y figurent. L'application fonctionne, avec l'implémentation C de psycopg :

$ docker run -d --name app --network signalements -p 127.0.0.1:8000:8000 \
    -e DATABASE_URL=postgresql://postgres:essai@db:5432/postgres signalements:multi
$ curl -s -X POST localhost:8000/signalements -H 'Content-Type: application/json' \
    -d '{"lieu":"Rue du Port","description":"Pavé descellé"}'
{"description":"Pavé descellé","id":1,"lieu":"Rue du Port"}
$ docker exec app python -c "import psycopg; print(psycopg.pq.__impl__, psycopg.pq.version())"
c 170011
$ docker exec app sh -c 'which gcc pg_config; ls /opt/venv/lib/python3.14/site-packages | grep -E "^pip"'
pip
pip-26.2.1.dist-info

c confirme l'implémentation compilée, liée à la libpq 17.11 de Debian. gcc et pg_config sont absents. Mais pip est toujours là, dans l'environnement virtuel (python -m venv l'y installe par défaut) et dans l'image de base. La leçon 1 a montré que ses dépendances embarquées portent une partie des vulnérabilités. Allons plus loin.

Version 3 : sans pip, avec une étape de test

# syntax=docker/dockerfile:1

# ---- Étape « construction » : compilateur, en-têtes, pip -------------------
FROM python:3.14 AS construction
ENV PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_ROOT_USER_ACTION=ignore
# Un environnement virtuel sans pip : c'est le pip de l'image qui l'alimente.
RUN python -m venv --without-pip /opt/venv
COPY requirements.txt .
RUN pip --python /opt/venv/bin/python install -r requirements.txt

# ---- Étape « test » : la construction, plus les outils de test --------------
FROM construction AS test
RUN pip --python /opt/venv/bin/python install pytest==9.1.1
WORKDIR /app
COPY app.py test_app.py ./
RUN /opt/venv/bin/python -m pytest -q

# ---- Étape « execution » : l'image livrée -----------------------------------
FROM python:3.14-slim AS execution
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"
RUN apt-get update \
 && apt-get upgrade -y \
 && apt-get install -y --no-install-recommends libpq5 \
 && rm -rf /var/lib/apt/lists/* \
 && python -m pip uninstall --yes --root-user-action ignore pip \
 && useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin appli
COPY --from=construction /opt/venv /opt/venv
WORKDIR /app
COPY app.py .
USER 10001
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "app:app"]

Trois nouveautés :

  1. Un environnement virtuel sans pip. python -m venv --without-pip crée l'environnement vide, et l'option --python de pip (disponible depuis pip 22.3) fait installer les paquets dans cet environnement par le pip de l'image de construction. L'environnement copié dans l'image finale ne contient que les dépendances de l'application.
  2. Le pip de l'image slim désinstallé. Il appartient à l'image de base : ses fichiers restent dans une couche inférieure (aucun octet gagné), mais ils ne sont plus visibles dans le système de fichiers du conteneur. Personne ne pourra plus s'en servir à l'exécution, et les analyseurs, qui examinent le système de fichiers final, ne le signalent plus.
  3. apt-get upgrade dans l'étape d'exécution, pour récupérer les correctifs publiés depuis la construction de l'image de base (nous y revenons dans les pièges).

Et une étape test, qui part de construction et exécute deux tests de l'API en mode mémoire avec le client de test de Flask (test_app.py, une quinzaine de lignes).

Ce que BuildKit construit, et ce qu'il ignore

Construisons l'image par défaut (la dernière étape) :

$ /usr/bin/time -f "durée : %e s" docker build --progress=plain --no-cache -t signalements:2.0 .
...
#9 [construction 1/4] FROM docker.io/library/python:3.14@sha256:be8ccd08...
#10 [construction 2/4] RUN python -m venv --without-pip /opt/venv
#11 [execution 2/5] RUN apt-get update  && apt-get upgrade -y  && apt-get install ...
#12 [construction 3/4] COPY requirements.txt .
#13 [construction 4/4] RUN pip --python /opt/venv/bin/python install -r requirements.txt
#14 [execution 3/5] COPY --from=construction /opt/venv /opt/venv
#15 [execution 4/5] WORKDIR /app
#16 [execution 5/5] COPY app.py .
...
durée : 34.55 s

Aucune ligne [test ...] : l'étape de test n'est pas un ancêtre de l'étape execution, BuildKit ne l'a pas exécutée. Pour lancer les tests, on désigne la cible :

$ docker build --progress=plain --target test -t signalements:test .
...
#8 [construction 2/4] RUN python -m venv --without-pip /opt/venv
#8 CACHED
#9 [construction 3/4] COPY requirements.txt .
#9 CACHED
#10 [construction 4/4] RUN pip --python /opt/venv/bin/python install -r requirements.txt
#10 CACHED
#11 [test 1/4] RUN pip --python /opt/venv/bin/python install pytest==9.1.1
#12 [test 2/4] WORKDIR /app
#13 [test 3/4] COPY app.py test_app.py ./
#14 [test 4/4] RUN /opt/venv/bin/python -m pytest -q
#14 0.509 2 passed in 0.06s

Les étapes de construction sont reprises du cache de la construction précédente : on teste exactement les dépendances qui seront livrées. Et si un test échoue, la construction échoue :

$ docker build --progress=plain --target test .
...
#14 0.470 FAILED test_app.py::test_accueil - AssertionError: assert 'signalements' == '...
#14 0.470 1 failed, 1 passed in 0.07s
#14 ERROR: process "/bin/sh -c /opt/venv/bin/python -m pytest -q" did not complete successfully: exit code: 1

C'est un moyen simple de garantir, dans n'importe quelle chaîne CI, qu'aucune image n'est publiée si ses tests ne passent pas : on construit --target test, puis la cible finale, qui réutilise le cache commun.

L'ancien constructeur de Docker, lui, exécutait toutes les étapes dans l'ordre du fichier, qu'elles servent ou non :

$ DOCKER_BUILDKIT=0 docker build -t signalements:ancien .
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
...
Step 10/19 : RUN /opt/venv/bin/python -m pytest -q
...
Step 11/19 : FROM python:3.14-slim AS execution
...

Si un tutoriel ancien affirme que les étapes inutilisées « ralentissent la construction », il parle de ce constructeur.

Le bilan, mesuré

VarianteSur le disqueCompresséePaquetsVulnérabilitésdont hautesdont hautes corrigeablesConstruction sans cache
Une étape, compilateur inclus677 Mo165 Mo1801 639104574,8 s
Multi-étapes, version 2233 Mo54,9 Mo145230551143,8 s
Multi-étapes, version 3229 Mo54,7 Mo10718444034,6 s

(Paquets et vulnérabilités mesurés avec Trivy 0.74.0, comme à la leçon 1 ; les durées dépendent de la machine et du réseau.)

Deux surprises méritent explication :

  • La version 2 a plus de vulnérabilités corrigeables (11) que la version en une étape (5). En installant libpq-dev, la version en une étape a tiré des mises à jour d'OpenSSL publiées depuis la construction de l'image de base : elle a été « corrigée » par accident. La version 2, qui n'installe que libpq5, a gardé l'OpenSSL d'origine, plus les dépendances vulnérables de pip. La version 3 corrige les deux causes, volontairement : apt-get upgrade et l'absence de pip.
  • Le nombre de paquets passe de 145 à 107 en retirant pip : les 38 paquets de différence sont les bibliothèques embarquées par pip (dans l'environnement virtuel et dans l'image de base), que Trivy compte comme paquets Python.

Sous le capot

Ce que devient une étape intermédiaire. Ses couches existent dans le cache de BuildKit, pas dans l'image exportée. L'image finale ne référence que les couches de l'étape exportée : celles de son image de base, puis celles produites par ses instructions. Un COPY --from crée une nouvelle couche contenant uniquement les fichiers copiés, comme un COPY depuis le contexte.

Comment BuildKit décide quoi exécuter. Il traduit le Dockerfile en un graphe d'opérations (le LLB), puis part de la cible demandée et remonte ses dépendances : les instructions de l'étape, l'étape dont elle part (FROM étape), les étapes dont elle copie (COPY --from). Ce qui n'est pas atteint par ce parcours n'est pas exécuté. Les branches indépendantes du graphe sont ordonnancées en parallèle, dans la limite des ressources de la machine.

COPY --from accepte aussi une image. Le nom après --from peut être une étape, ou n'importe quelle image :

FROM alpine:3.22
COPY --from=busybox:1.37 /bin/busybox /usr/local/bin/busybox-glibc
COPY --from=signalements:2.0 /app/app.py /tmp/app.py
$ docker build -q -t copie -f Dockerfile.copie .
$ docker run --rm copie sh -c 'ls -l /usr/local/bin/busybox-glibc /tmp/app.py'
-rw-rw-r--    1 root     root          2541 Oct  1 11:40 /tmp/app.py
-rwxr-xr-x    1 root     root       1017416 Sep 26  2024 /usr/local/bin/busybox-glibc

C'est la façon propre de récupérer un binaire statique publié sous forme d'image (un init comme tini, un client de base de données) sans installer de gestionnaire de paquets. Épinglez alors l'image source par empreinte (leçon 6), comme une image de base.

Pourquoi l'environnement virtuel survit au voyage. Un environnement virtuel est un répertoire : un fichier pyvenv.cfg qui désigne l'interpréteur de base (home = /usr/local/bin), des liens symboliques bin/python vers cet interpréteur, et les paquets dans lib/python3.14/site-packages. Tant que l'étape d'arrivée a le même interpréteur au même chemin, et les mêmes bibliothèques partagées, l'environnement fonctionne tel quel.

Pièges courants

Copier un environnement vers une base incompatible. Remplaçons l'étape d'exécution par python:3.14-alpine, sans rien changer d'autre :

$ docker run --rm --entrypoint sh signalements:mauvais -c 'python -c "import psycopg" 2>&1 | tail -3'
    __impl__ = module.__impl__
               ^^^^^^^^^^^^^^^
AttributeError: module 'psycopg_c.pq' has no attribute '__impl__'

Le message ne dit rien de la vraie cause, que révèle ldd sur le module compilé :

$ docker run --rm --entrypoint sh signalements:mauvais -c 'ldd /opt/venv/lib/python3.14/site-packages/psycopg_c/pq.cpython-314-x86_64-linux-gnu.so 2>&1 | head -5'
	/lib/ld-musl-x86_64.so.1 (0x7397d5f3c000)
Error loading shared library libpq.so.5: No such file or directory (needed by /opt/venv/lib/python3.14/site-packages/psycopg_c/pq.cpython-314-x86_64-linux-gnu.so)
	libc.so.6 => /lib/ld-musl-x86_64.so.1 (0x7397d5f3c000)
Error relocating /opt/venv/lib/python3.14/site-packages/psycopg_c/pq.cpython-314-x86_64-linux-gnu.so: PQfmod: symbol not found
Error relocating /opt/venv/lib/python3.14/site-packages/psycopg_c/pq.cpython-314-x86_64-linux-gnu.so: PyUnicode_FromFormat: symbol not found

Le suffixe du fichier, linux-gnu, le disait déjà : il a été compilé pour glibc. Sur musl, la bibliothèque libpq manque et les symboles ne se résolvent pas ; psycopg, qui essaie plusieurs implémentations l'une après l'autre, masque l'erreur de chargement. Règle : les deux étapes partagent la même famille de base. Pour une image finale Alpine, construisez dans python:3.14-alpine (avec apk add build-base libpq-dev).

Oublier une bibliothèque d'exécution. Sans libpq5 dans l'étape finale, l'erreur est de la même famille : libpq.so.5: cannot open shared object file. Pour trouver ce qu'il faut, lancez ldd sur les modules compilés de l'environnement, dans l'image finale.

Une étape de base qui ne correspond pas en version. python:3.14 et python:3.14-slim sont mises à jour séparément ; pendant quelques heures après une nouvelle version de Python, l'une peut avoir la 3.14.8 et l'autre encore la 3.14.7. Les modules compilés restent compatibles dans une même version mineure (3.14), mais pour une garantie stricte, épinglez les deux images par empreinte et mettez-les à jour ensemble (leçon 6).

Les fichiers copiés appartiennent à root. COPY --from copie avec le propriétaire d'origine (root ici). C'est souhaitable pour le code et les dépendances (l'application ne peut pas les modifier) ; pour un répertoire où l'application doit écrire, utilisez COPY --chown=10001:10001.

apt-get upgrade dans un Dockerfile. Longtemps déconseillé, parce qu'il rend la construction dépendante du jour où elle a lieu. C'est vrai : deux constructions à une semaine d'écart n'auront pas les mêmes paquets. Mais l'alternative est pire : livrer des correctifs de sécurité en retard, en attendant la prochaine reconstruction de l'image officielle. Le compromis raisonnable : upgrade dans l'étape finale, reconstruction régulière, et reproductibilité assurée par l'épinglage de l'image résultante (leçon 6).

Sécurité

  • Pas d'outils de construction en production. Un compilateur, des en-têtes, un gestionnaire de paquets de langage dans l'image livrée sont des outils offerts à un attaquant, et des vulnérabilités à suivre. Le multi-étapes les laisse dans l'atelier.
  • Les secrets restent... dans les couches de l'étape. Une idée reçue tenace veut qu'un secret utilisé dans une étape intermédiaire soit « sans danger » puisque l'étape n'est pas livrée. Un fichier copié ne fuit pas dans l'image finale, c'est vrai ; mais il reste dans le cache de construction, dans les journaux de la CI, et dans l'image de l'étape si quelqu'un la construit avec --target. Et une valeur passée par ARG ou ENV dans une étape intermédiaire se retrouve, elle, dans l'attestation de provenance complète de l'image finale, qui enregistre la définition de toutes les étapes (vérifié : un argument déclaré seulement dans une étape intermédiaire apparaît trois fois dans la provenance mode=max). La leçon 4 montre la bonne méthode : les montages de secrets.
  • Désinstaller n'est pas supprimer. Le pip de l'image de base, désinstallé dans l'étape finale, n'est plus utilisable dans le conteneur, mais ses octets restent dans la couche de l'image de base, téléchargeable par qui a accès à l'image. Pour un fichier sensible, cela ne suffit pas : il ne doit jamais entrer dans une couche.
  • Les tests dans la construction. Une étape test garantit que l'image livrée a passé ses tests avec ses dépendances exactes ; c'est une protection contre la publication d'une image cassée, pas un substitut aux tests d'intégration.

En production

  • Une cible par usage. Un même Dockerfile peut décrire test, execution, et une cible dev (avec outils de débogage et rechargement à chaud) utilisée par le compose.override.yaml du cours précédent (leçon 11), grâce à l'attribut build.target de Compose. Le code de construction n'est écrit qu'une fois.
  • En CI, construisez d'abord --target test, puis la cible finale : la seconde réutilise le cache de la première. La leçon 10 assemble cette chaîne, et la leçon 3 montre comment partager ce cache entre agents de CI.
  • Pour les langages compilés, le gain est encore plus net : l'étape de construction contient la chaîne de compilation complète (plusieurs centaines de mégaoctets), l'étape finale un seul binaire. La leçon 5 le fait avec signalements-export, jusqu'à une image scratch de quelques mégaoctets.
  • Documentez l'étape finale comme le contrat de l'image : c'est elle que l'on audite, que l'on analyse et que l'on signe.

Exercices

1. Lire un Dockerfile multi-étapes. Dans le Dockerfile final de la leçon, quelles étapes sont exécutées par docker build ., par docker build --target construction . et par docker build --target test . ? Vérifiez avec --progress=plain.

Solution

docker build . : construction et execution (en parallèle jusqu'au COPY --from), pas test. --target construction : seulement construction. --target test : construction puis test. Dans la sortie de --progress=plain, chaque ligne d'étape est préfixée par le nom de l'étape ([construction 2/4], [test 4/4]...), ce qui permet de le vérifier directement.

2. Trouver les bibliothèques d'exécution. Sur l'image finale, listez les bibliothèques partagées dont dépendent tous les modules compilés (.so) de l'environnement virtuel, et vérifiez qu'aucune ne manque, en particulier libpq.

Solution
$ docker run --rm --entrypoint sh signalements:2.0 -c \
    'find /opt/venv -name "*.so" -exec ldd {} \; | awk "/=>/ {print \$1, \$3}" | sort -u' | grep -E "libpq|not"
libpq.so.5 /lib/x86_64-linux-gnu/libpq.so.5

awk ne garde que le nom de chaque bibliothèque et le fichier qui la fournit (les adresses de chargement varient d'un appel à l'autre et empêcheraient sort -u de dédoublonner). Une ligne not found signalerait une bibliothèque à installer dans l'étape d'exécution ; ici il n'y en a aucune, et libpq.so.5 est bien fournie par le paquet libpq5, installé dans l'étape finale.

3. Une cible de développement (niveau 300). Ajoutez au Dockerfile une étape dev (à vous de choisir l'étape dont elle part) qui réinstalle de quoi déboguer (un shell interactif agréable n'est pas nécessaire, mais pip et pytest le sont), et lance le serveur de développement de Flask. Modifiez le compose.override.yaml du cours précédent pour l'utiliser.

Solution

L'étape execution n'a plus pip ; l'étape dev doit donc repartir de l'environnement de construction, ou le reconstruire. Une solution :

FROM construction AS dev
RUN pip --python /opt/venv/bin/python install pytest==9.1.1
ENV PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1
WORKDIR /app
COPY app.py test_app.py ./
CMD ["flask", "--app", "app", "run", "--host", "0.0.0.0", "--port", "8000", "--debug"]

et, dans compose.override.yaml :

services:
  app:
    build:
      context: .
      target: dev

L'étape dev part de construction, qui a le compilateur : c'est voulu en développement. Cette cible ne doit jamais être poussée dans le registre de production.

4. Mesurer (niveau 200). Reproduisez le tableau du bilan sur votre machine pour la version en une étape et la version 3. Les chiffres de vulnérabilités sont-ils identiques à ceux de la leçon ? Pourquoi ?

Solution

Les tailles seront très proches ; les nombres de vulnérabilités, presque sûrement différents. Ils dépendent du jour de la mesure (nouvelles vulnérabilités publiées, nouveaux correctifs, nouvelle version de l'image de base, mise à jour de la base de données de Trivy). C'est pourquoi une politique de sécurité ne repose jamais sur une analyse ponctuelle, mais sur une analyse répétée à chaque construction et régulièrement sur les images déployées.

Récapitulatif

  • Un Dockerfile peut avoir plusieurs étapes (FROM ... AS nom) ; seule l'étape finale (ou --target) devient l'image, et COPY --from est le seul pont entre étapes.
  • On construit dans une image complète, on exécute dans une image minimale : compilateurs, en-têtes et gestionnaires de paquets restent dans l'atelier.
  • Pour Signalements avec psycopg[c] : 677 Mo et 104 vulnérabilités hautes en une étape, 229 Mo et aucune vulnérabilité haute corrigeable en multi-étapes, construit plus vite.
  • BuildKit parallélise les étapes indépendantes et ignore celles dont la cible n'a pas besoin ; une étape test ne coûte que si on la demande.
  • Les deux étapes doivent partager la même bibliothèque C, le même interpréteur au même chemin, et l'étape finale doit avoir les bibliothèques partagées d'exécution.

Pour aller plus loin

  • La page Multi-stage builds de la documentation Docker, notamment pour les étapes qui partent d'autres étapes et les arguments de construction partagés.
  • La documentation d'installation de psycopg, qui explique les trois implémentations (binary, c, Python pur) et leurs usages.
  • Leçon suivante : le cache de BuildKit en profondeur, pour que ces constructions soient aussi rapides en CI que sur votre poste.
Voir ma constellation →

Sources