Aller au contenu
Des constructions reproductibles

Des constructions reproductibles

300 Concevoir ⏱ 1 h 10 dockerbuildkitpythongodebian

À la fin, vous saurez

  • Démontrer qu'une construction n'est pas reproductible et localiser la cause au fichier près
  • Rendre une image reproductible avec SOURCE_DATE_EPOCH et rewrite-timestamp
  • Éliminer les sources d'écart dans le contenu : journaux, .pyc, chemins de compilation
  • Figer les entrées : image de base par empreinte, dépendances verrouillées avec empreintes, instantané des paquets Debian
  • Expliquer ce que la reproductibilité apporte à la sécurité de la chaîne d'approvisionnement

Prérequis

Testé avec buildkit 0.33.1 docker 29.8.1 go 1.26.8 pip-tools 7.6.1 python 3.14.7 , vérifié le 1 octobre 2026

Pourquoi

Une construction est reproductible quand les mêmes sources, construites deux fois, donnent un résultat identique octet pour octet, donc la même empreinte. Cela semble aller de soi pour un Dockerfile, qui décrit précisément ce qu'il faut faire. Ce n'est pourtant presque jamais le cas, et la conséquence est plus grave qu'il n'y paraît :

  • On ne peut pas vérifier une image. Si votre CI produit l'image sha256:aaaa... et que vous reconstruisez les mêmes sources sur votre poste pour vérifier, vous obtenez sha256:bbbb.... Impossible de savoir si la différence vient d'une date ou d'une porte dérobée injectée dans la CI. C'est exactement ce type d'attaque, la compromission d'un système de construction, qui a touché SolarWinds en 2020.
  • On ne sait pas ce qui a changé. Deux images de la même version diffèrent : bruit ou modification réelle ? Les outils de comparaison sont inutilisables tant que tout diffère.
  • On ne peut pas reconstruire le passé. Pour corriger d'urgence une version déployée il y a six mois, il faut pouvoir reconstruire exactement cette version, avec les mêmes dépendances.

Cette leçon prend nos deux programmes, mesure qu'ils ne sont pas reproductibles, traque chaque cause jusqu'au fichier, et les corrige jusqu'à obtenir deux fois la même empreinte.

Les concepts

Deux familles d'écarts

Un écart entre deux constructions vient soit de ce qui entre dans la construction, soit de la manière dont elle se déroule :

FamilleExemplesRemède
Entrées qui changentÉtiquette d'image de base déplacée, apt-get qui installe une version plus récente, dépendance non épinglée, fichier du contexte modifiéFiger chaque entrée par une empreinte ou une date
Bruit du processusDates de création, dates des fichiers, ordre des fichiers, chemins temporaires aléatoires, journaux, cachesNormaliser : date fixe, suppression des fichiers volatils, options des outils

La première famille relève de la traçabilité (savoir exactement ce qu'on a construit) ; la seconde, du déterminisme (obtenir le même résultat à partir des mêmes entrées). Il faut les deux.

SOURCE_DATE_EPOCH

Le projet Reproducible Builds, né dans Debian, a défini une convention adoptée depuis par la plupart des outils : la variable d'environnement SOURCE_DATE_EPOCH, un nombre de secondes depuis le 1er janvier 1970, désigne la date à utiliser à la place de l'heure courante partout où un outil inscrirait une date. On lui donne en général la date du dernier commit : elle ne change que si les sources changent.

BuildKit l'utilise de deux façons :

  • passée en argument de construction (--build-arg SOURCE_DATE_EPOCH), elle fixe les dates de la configuration de l'image (created et l'historique) ;
  • combinée à l'option d'export rewrite-timestamp=true, elle réécrit aussi la date de tous les fichiers des couches plus récents qu'elle.

En pratique

Le constat

Note

Dans les sorties de cette leçon, les noms des builders (repro, repro2) et le port du registre local ont été simplifiés ; sur la machine de test, ils portaient un préfixe propre à l'expérience.

Construisons deux fois l'image scratch de signalements-export (leçon 5), sans cache, avec un builder dédié. L'export au format OCI (--output type=oci) produit une archive, et --metadata-file enregistre l'empreinte obtenue. --provenance=false écarte pour l'instant l'attestation de provenance, sur laquelle nous reviendrons.

$ for n in 1 2; do
    docker buildx build --builder repro --progress=quiet --no-cache --provenance=false \
      --output type=oci,dest=essai$n.tar --metadata-file meta$n.json .
    echo "essai $n : $(jq -r '."containerimage.digest"' meta$n.json)"
  done
essai 1 : sha256:7f2b9e8235a18c34e6eb99d4ba5b61b8afcfb2112749d7283af4a7bf62d98822
essai 2 : sha256:f4691d0a921c60f611ddb916c758aad1c82f9acfbf0c245f27d8cabb3afc46c0

Deux empreintes différentes, à huit secondes d'intervalle, pour les mêmes sources. Ouvrons les deux archives (format OCI layout, leçon 7 du cours précédent) et comparons les manifestes et les configurations :

$ mkdir o1 o2 && tar -xf essai1.tar -C o1 && tar -xf essai2.tar -C o2
$ m(){ d=$(jq -r '.manifests[0].digest' $1/index.json | cut -d: -f2); jq . $1/blobs/sha256/$d; }
$ diff <(m o1 | jq '.layers[].digest') <(m o2 | jq '.layers[].digest')
1,4c1,4
< "sha256:9665e8487c632073dba48c4502151a2aecfca8c1c59a768110aafd279bff8716"
< "sha256:99a7ae6efa39172813a8baa420735ea862dc21abcc5a46f3fd88f41be6dabd6c"
< "sha256:d3195cbb8872ef8dcc9a5e20f97d8ec9e4458eb616625d4ece2731b8df167ced"
< "sha256:3424cab5fb5da534da5396c5a709c69004080df14352d131dc193296687cd962"
---
> "sha256:cdcda46f8032dec6760a294bdab97907749e1542f1decd8f9e2f4e0fb3af2833"
> "sha256:f07013c2c0d8b174b3eece896e94a1bf74c0db2b4b04985daea1e4c6f3985294"
> "sha256:cdd95f9127ef0bbc4c52fae65b9d4db9a8b1f12c12bfbdf4a2b634a5c4fe07e1"
> "sha256:92de445578d79a9527825a92533d364043a0863eb1ebcf98592d55c4578488ad"
$ c(){ d=$(m $1 | jq -r .config.digest | cut -d: -f2); jq '{created, history: [.history[].created]}' $1/blobs/sha256/$d; }
$ diff <(c o1) <(c o2)
2c2
<   "created": "2026-10-01T12:40:19.791366391Z",
---
>   "created": "2026-10-01T12:40:27.473606684Z",
...

Les quatre couches diffèrent, ainsi que les dates de la configuration. Le binaire Go est-il différent ?

$ f(){ d=$(jq -r '.manifests[0].digest' $1/index.json | cut -d: -f2)
       l=$(jq -r '.layers[3].digest' $1/blobs/sha256/$d | cut -d: -f2)
       tar -xzOf $1/blobs/sha256/$l signalements-export | sha256sum | cut -c1-16
       tar -tvzf $1/blobs/sha256/$l; }
$ f o1; f o2
0d00b8721253d58c
-rwxr-xr-x 0/0         6574242 2026-10-01 14:40 signalements-export
0d00b8721253d58c
-rwxr-xr-x 0/0         6574242 2026-10-01 14:40 signalements-export

Non : le compilateur Go est reproductible (avec -trimpath, aucun chemin de la machine n'y est inscrit), le binaire est identique octet pour octet. Seules les dates des fichiers dans l'archive de la couche, et donc l'empreinte de la couche, diffèrent.

SOURCE_DATE_EPOCH et rewrite-timestamp

Prenons pour date de référence celle du dernier commit :

$ export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
$ echo "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH ($(date -u -d @$SOURCE_DATE_EPOCH))"
SOURCE_DATE_EPOCH=1790858354 (jeu. 01 oct. 2026 12:39:14 UTC)

Premier essai, en ne passant que l'argument de construction (--build-arg SOURCE_DATE_EPOCH sans valeur reprend celle de l'environnement) :

$ for n in a b; do
    docker buildx build --builder repro --progress=quiet --no-cache --provenance=false \
      --build-arg SOURCE_DATE_EPOCH --output type=oci,dest=sr$n.tar --metadata-file sr$n.json .
    echo "sans rewrite-timestamp, essai $n : $(jq -r '."containerimage.digest"' sr$n.json)"
  done
sans rewrite-timestamp, essai a : sha256:32a6c9f337c20e59da4d30aec9afe1eea6b456d5c5b6295379f2d7197e233b9f
sans rewrite-timestamp, essai b : sha256:f8e633dd6a1ca43b7cec11acce2fb6393c8f114937d3a5854be185470ac76f27

Toujours deux empreintes. Pour voir précisément ce qui diffère, ce petit script compare deux images au format OCI layout, couche par couche, fichier par fichier (empreinte du contenu, date, droits) :

"""Compare, couche par couche, le contenu de deux images au format OCI layout."""
import hashlib, json, sys, tarfile
from pathlib import Path

def couches(racine):
    racine = Path(racine)
    index = json.loads((racine / "index.json").read_text())
    manifeste = json.loads((racine / "blobs/sha256" / index["manifests"][0]["digest"].split(":")[1]).read_text())
    return [racine / "blobs/sha256" / c["digest"].split(":")[1] for c in manifeste["layers"]]

def contenu(couche):
    resultat = {}
    with tarfile.open(couche) as tar:
        for m in tar:
            donnees = tar.extractfile(m).read() if m.isfile() else m.linkname.encode()
            resultat[m.name] = (hashlib.sha256(donnees).hexdigest()[:12], m.mtime, m.mode)
    return resultat

for n, (a, b) in enumerate(zip(couches(sys.argv[1]), couches(sys.argv[2])), start=1):
    if a.name == b.name:
        continue
    ca, cb = contenu(a), contenu(b)
    differents = sorted(k for k in ca.keys() | cb.keys() if ca.get(k) != cb.get(k))
    print(f"couche {n} : {len(differents)} fichier(s) différent(s)")
    for k in differents[:8]:
        print(f"  {k}\n    essai 1 : {ca.get(k)}\n    essai 2 : {cb.get(k)}")
$ mkdir ra rb && tar -xf sra.tar -C ra && tar -xf srb.tar -C rb
$ python3 comparer.py ra rb
couche 1 : 3 fichier(s) différent(s)
  etc
    essai 1 : ('e3b0c44298fc', 1790859532, 493)
    essai 2 : ('e3b0c44298fc', 1790859540, 493)
  etc/ssl
    essai 1 : ('e3b0c44298fc', 1790859532, 493)
...

Le contenu est identique (même empreinte, celle d'un répertoire vide), mais la date du répertoire etc, créé par la copie des certificats au moment de la construction, diffère de 8 secondes. La date de la configuration, elle, est maintenant fixée :

$ ... | jq -c '{created, h: [.history[].created][0:2]}'
{"created":"2026-10-01T12:39:14Z","h":["2026-10-01T12:39:14Z","2026-10-01T12:39:14Z"]}

Ajoutons l'option d'export rewrite-timestamp=true :

$ for n in 3 4; do
    docker buildx build --builder repro --progress=quiet --no-cache --provenance=false \
      --build-arg SOURCE_DATE_EPOCH --output type=oci,dest=essai$n.tar,rewrite-timestamp=true \
      --metadata-file meta$n.json .
    echo "essai $n : $(jq -r '."containerimage.digest"' meta$n.json)"
  done
essai 3 : sha256:c93d4e8ff2e11482b0d26565e84526c65c61595f7792c99b4ef3d2b4c247fe16
essai 4 : sha256:c93d4e8ff2e11482b0d26565e84526c65c61595f7792c99b4ef3d2b4c247fe16

La même empreinte. Et sur un autre builder, neuf, qui simule une autre machine :

$ docker buildx create --name repro2 --driver docker-container
$ docker buildx build --builder repro2 --progress=quiet --no-cache --provenance=false \
    --build-arg SOURCE_DATE_EPOCH --output type=oci,dest=essai5.tar,rewrite-timestamp=true \
    --metadata-file meta5.json .
$ echo "autre builder : $(jq -r '."containerimage.digest"' meta5.json)"
autre builder : sha256:c93d4e8ff2e11482b0d26565e84526c65c61595f7792c99b4ef3d2b4c247fe16

Identique. N'importe qui, avec les mêmes sources et la même date de référence, peut maintenant vérifier que l'image publiée est bien issue de ces sources.

Le cas difficile : l'image Python

Appliquons la même recette au Dockerfile multi-étapes de Signalements (leçon 2, avec psycopg[c] compilé), en gardant SOURCE_DATE_EPOCH exporté :

$ for n in 1 2; do
    docker buildx build --builder repro --progress=quiet --no-cache --provenance=false \
      --build-arg SOURCE_DATE_EPOCH --output type=oci,dest=app$n.tar,rewrite-timestamp=true \
      --metadata-file meta$n.json .
    echo "essai $n : $(jq -r '."containerimage.digest"' meta$n.json)"
  done
essai 1 : sha256:1963d3ccebcb1905bb7697e1293b35bd866420f8275ffe113c0d25986ff2f268
essai 2 : sha256:40bffda8b1312a96a37d747cf1a462130a73879697804e86800b899be2f8dc98
$ mkdir o1 o2 && tar -xf app1.tar -C o1 && tar -xf app2.tar -C o2
$ python3 comparer.py o1 o2
couche 5 : 4 fichier(s) différent(s)
  var/cache/ldconfig/aux-cache
    essai 1 : ('a5a8f62e4389', 1790858354, 384)
    essai 2 : ('3e79908f5584', 1790858354, 384)
  var/log/apt/history.log
    essai 1 : ('0c24680d3e46', 1790858354, 420)
    essai 2 : ('37c2de9dc6e8', 1790858354, 420)
  var/log/apt/term.log
    essai 1 : ('4c8084325648', 1790858354, 416)
    essai 2 : ('49ab27747498', 1790858354, 416)
  var/log/dpkg.log
    essai 1 : ('d9abe6972f71', 1790858354, 420)
    essai 2 : ('6cc19811032d', 1790858354, 420)
couche 6 : 288 fichier(s) différent(s)
  opt/venv/lib/python3.14/site-packages/blinker/__pycache__/__init__.cpython-314.pyc
    essai 1 : ('1316aaa5843f', 1790858354, 420)
    essai 2 : ('13be4155fac6', 1790858354, 420)
  opt/venv/lib/python3.14/site-packages/blinker/__pycache__/_utilities.cpython-314.pyc
...

(Les répertoires o1 et o2 de l'image Go ont été vidés auparavant ; le script nomme toujours « essai 1 » et « essai 2 » les deux images qu'il compare.) Cette fois, les dates sont toutes réécrites à 1790858354 : ce sont les contenus qui diffèrent. Deux causes :

  1. Couche 5, l'étape apt-get : les journaux d'apt et de dpkg contiennent l'heure de chaque opération, et le cache de ldconfig change à chaque exécution. Ces fichiers ne servent à rien dans une image.
  2. Couche 6, l'environnement virtuel : 288 fichiers .pyc. pip compile les modules Python en bytecode à l'installation, et chaque .pyc contient dans son en-tête la date de modification du fichier source correspondant, celle de l'installation.

Corrigeons les deux :

FROM python:3.14 AS construction
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_ROOT_USER_ACTION=ignore
RUN python -m venv --without-pip /opt/venv
COPY requirements.txt .
# Avec SOURCE_DATE_EPOCH, pip compile des .pyc déterministes (validés par empreinte, pas par date)
ARG SOURCE_DATE_EPOCH
RUN --mount=type=cache,target=/root/.cache/pip \
    pip --python /opt/venv/bin/python install -r requirements.txt
...
FROM python:3.14-slim AS execution
...
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 \
 && rm -f /var/log/apt/*.log /var/log/dpkg.log /var/cache/ldconfig/aux-cache
  • Déclarer ARG SOURCE_DATE_EPOCH dans l'étape la rend visible aux RUN qui suivent. Quand cette variable est définie, le module py_compile de Python produit des .pyc validés par empreinte du source (mode CHECKED_HASH) au lieu de la date : ils deviennent déterministes.
  • Les journaux d'apt et de dpkg et le cache de ldconfig sont supprimés dans la même instruction.
$ for n in 3 4; do ... done
essai 3 : sha256:115b5d0b022335a8c97195ec6107ff1aa63980f2bc7556c42fcbd5854530ef3f
essai 4 : sha256:c0e8fcdd9f4641b3336b9c23aeb494a76affad4c33f77cb12de4fe78553f20d1
$ mkdir o3 o4 && tar -xf app3.tar -C o3 && tar -xf app4.tar -C o4
$ python3 comparer.py o3 o4
couche 6 : 3 fichier(s) différent(s)
  opt/venv/lib/python3.14/site-packages/psycopg_c-3.3.6.dist-info/RECORD
    essai 1 : ('55e9a639a117', 1790858354, 420)
    essai 2 : ('c9d40a7e6387', 1790858354, 420)
  opt/venv/lib/python3.14/site-packages/psycopg_c/_psycopg.cpython-314-x86_64-linux-gnu.so
    essai 1 : ('a4449bdf0622', 1790858354, 493)
    essai 2 : ('74450cb21e67', 1790858354, 493)
  opt/venv/lib/python3.14/site-packages/psycopg_c/pq.cpython-314-x86_64-linux-gnu.so
    essai 1 : ('925c949f4ffc', 1790858354, 493)
    essai 2 : ('4375eb4a2bcf', 1790858354, 493)

Il ne reste que les deux modules C de psycopg, compilés pendant l'installation (et le fichier RECORD qui liste leurs empreintes). Que contiennent-ils de variable ?

$ python3 extraire-chemins.py o3 o4
o3 [b'/tmp/pip-install-juaq1pne/psycopg-c_43eace29ee044ddba38b81b662e50']
o4 [b'/tmp/pip-install-2c472dah/psycopg-c_77ee42c221ec45729e6b7e2d6a8a1']

(Le script extraire-chemins.py, une quinzaine de lignes sur le modèle de comparer.py, lit le module pq de psycopg dans la sixième couche de chaque image et en extrait les chaînes qui commencent par /tmp/.) pip compile chaque paquet dans un répertoire temporaire au nom aléatoire, et le compilateur C inscrit ce chemin dans les informations de débogage du module. Comme on ne connaît pas ce chemin à l'avance, on ne peut pas le remplacer par une constante ; on peut en revanche ne pas produire d'informations de débogage, inutiles dans une image d'exécution :

ARG SOURCE_DATE_EPOCH
# Sans informations de débogage, le chemin temporaire de compilation n'est plus inscrit dans les modules C
ENV CFLAGS="-g0"
RUN --mount=type=cache,target=/root/.cache/pip \
    pip --python /opt/venv/bin/python install -r requirements.txt
$ for n in 5 6; do ... done
essai 5 : sha256:70fd4fd895d8c71cdfc75d6e05e391ccde6a06672fdfeff4fb86c23d7a464cb8
essai 6 : sha256:70fd4fd895d8c71cdfc75d6e05e391ccde6a06672fdfeff4fb86c23d7a464cb8

L'image Python, avec son apt-get, son environnement virtuel et son module C compilé, est maintenant reproductible.

Figer les entrées

Les essais précédents ont été faits à quelques minutes d'intervalle. Dans six mois, la même commande ne donnera pas la même image, parce que les entrées auront changé. Il faut les figer une à une.

L'image de base, par empreinte. FROM python:3.14-slim suit l'étiquette, qui avance à chaque correctif. On écrit l'empreinte résolue (BuildKit l'affiche à chaque construction, leçon 8 du cours précédent), en gardant l'étiquette pour la lisibilité :

FROM python:3.14-slim@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d AS execution

Les dépendances Python, avec leurs empreintes. flask==3.1.3 fige une version, mais ni ses dépendances (werkzeug, jinja2...), ni le contenu exact du fichier téléchargé. L'outil pip-compile (projet pip-tools) produit un fichier verrouillé complet, avec les empreintes de chaque archive publiée :

$ cat requirements.in
flask==3.1.3
gunicorn==26.2.0
psycopg[binary]==3.3.6
$ docker run --rm -v "$PWD":/w -w /w python:3.14-slim sh -c \
    'pip install -q --root-user-action ignore pip-tools; pip-compile --quiet --generate-hashes --strip-extras -o requirements.txt requirements.in'
$ head -20 requirements.txt
#
# This file is autogenerated by pip-compile with Python 3.14
# by the following command:
#
#    pip-compile --generate-hashes --no-index --output-file=requirements.txt --strip-extras requirements.in
#
blinker==1.9.0 \
    --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
    --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
    # via flask
click==8.5.0 \
    --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
    --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
    # via flask
flask==3.1.3 \
    --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
    --hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
    # via -r requirements.in
gunicorn==26.2.0 \
    --hash=sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447 \
$ grep -cE "^[a-z]" requirements.txt
10
$ grep -c "sha256:" requirements.txt
171

Dix paquets (trois directs, sept transitifs), 171 empreintes : une par archive publiée de chaque version (roue par plateforme et archive source). requirements.in est ce que l'on édite ; requirements.txt se régénère et se versionne. Dès qu'un fichier contient des empreintes, pip passe en mode vérification : il refuse toute archive dont l'empreinte ne figure pas dans la liste. Falsifions les deux empreintes de Flask pour simuler une archive remplacée sur le dépôt :

$ docker run --rm -v "$PWD":/w:ro python:3.14-slim pip install --root-user-action ignore --no-cache-dir \
    --require-hashes -r /w/requirements-altere.txt
...
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
    flask==3.1.3 from https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl (from -r /w/requirements-altere.txt (line 15)):
        Expected sha256 0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9ec
        Expected     or f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670d
             Got        f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c

L'installation est refusée. Un paquet sans empreinte, dans ce mode, est refusé lui aussi, et pip affiche l'empreinte qu'il aurait fallu :

$ cat sans-hash.txt
flask==3.1.3
gunicorn==26.2.0
$ docker run --rm -v "$PWD":/w:ro python:3.14-slim pip install --root-user-action ignore --no-cache-dir \
    --require-hashes -r /w/sans-hash.txt
...
ERROR: Hashes are required in --require-hashes mode, but they are missing from some requirements. ...
    flask==3.1.3 --hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c

Go fait de même nativement : go.sum contient l'empreinte de chaque module, et go build refuse un module qui ne correspond pas.

Les paquets Debian, par instantané. apt-get install libpq5 installe la dernière version publiée le jour de la construction. Pour reconstruire à l'identique dans six mois, il faut le même état du dépôt Debian. L'archive snapshot.debian.org conserve chaque état des dépôts officiels depuis 2005, adressable par date. Les images officielles Debian indiquent d'ailleurs, en commentaire, l'instantané dont elles sont issues :

$ docker run --rm python:3.14-slim cat /etc/apt/sources.list.d/debian.sources
Types: deb
# http://snapshot.debian.org/archive/debian/20260918T000000Z
URIs: http://deb.debian.org/debian
Suites: trixie trixie-updates
Components: main
Signed-By: /usr/share/keyrings/debian-archive-keyring.pgp
...

Il suffit de faire pointer apt vers l'instantané choisi :

# syntax=docker/dockerfile:1
FROM python:3.14-slim
ARG INSTANTANE=20261001T000000Z
RUN sed -i -e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${INSTANTANE}|" \
           -e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${INSTANTANE}|" \
           /etc/apt/sources.list.d/debian.sources \
 && apt-get -o Acquire::Check-Valid-Until=false update \
 && apt-get install -y --no-install-recommends libpq5 \
 && dpkg-query -W libpq5 libssl3t64
$ docker build --progress=plain --no-cache -o type=cacheonly .
...
#7 0.626 Get:1 http://snapshot.debian.org/archive/debian/20261001T000000Z trixie InRelease [140 kB]
...
#7 5.129 Get:10 http://snapshot.debian.org/archive/debian/20261001T000000Z trixie/main amd64 libpq5 amd64 17.11-0+deb13u1 [237 kB]
...

Acquire::Check-Valid-Until=false est nécessaire parce que les métadonnées d'un dépôt ont une date d'expiration (pour empêcher un attaquant de servir un dépôt périmé) ; un instantané ancien est par nature « expiré ». Les signatures restent vérifiées.

Warning

Un instantané fige aussi les vulnérabilités du jour. La reproductibilité ne remplace pas les mises à jour : on fait avancer la date de l'instantané volontairement, comme une dépendance, à chaque cycle de mise à jour.

Sous le capot

Ce que fait rewrite-timestamp. Au moment de l'export, BuildKit parcourt les couches et remplace la date de chaque fichier plus récent que SOURCE_DATE_EPOCH par cette date, puis recalcule l'archive et son empreinte. Les fichiers plus anciens (ceux des images de base, par exemple) gardent leur date d'origine : la réécriture ne touche que ce que votre construction a produit.

Pourquoi les .pyc deviennent déterministes. Depuis Python 3.7 (PEP 552), un fichier .pyc peut être validé soit par la date et la taille du source (le mode par défaut), soit par une empreinte du source. Quand SOURCE_DATE_EPOCH est défini, py_compile choisit automatiquement le mode par empreinte, qui ne contient plus aucune date. pip utilise py_compile pour compiler les paquets installés.

Pourquoi le compilateur Go est reproductible sans effort. Go a fait de la reproductibilité un objectif explicite : à partir des mêmes sources, de la même version de Go et des mêmes options, go build produit le même binaire, quelle que soit la machine. La principale source de variation, les chemins de la machine, disparaît avec -trimpath ; restent à fixer la version de Go et les options de construction, ce que fait un Dockerfile épinglé. C'est ce qui a permis au projet Go de vérifier, depuis Go 1.21, que ses propres binaires publiés correspondent à leurs sources.

L'attestation de provenance n'est pas reproductible, et c'est normal. Poussons deux fois l'image reproductible avec une attestation de provenance :

$ for n in 1 2; do
    docker buildx build --progress=quiet --no-cache --build-arg SOURCE_DATE_EPOCH --provenance=mode=min \
      --output type=image,name=127.0.0.1:5000/export:essai$n,push=true,unpack=false,rewrite-timestamp=true .
    echo "essai $n : index $(docker buildx imagetools inspect 127.0.0.1:5000/export:essai$n --format '{{.Manifest.Digest}}' | cut -c1-19)"
    docker buildx imagetools inspect 127.0.0.1:5000/export:essai$n --raw \
      | jq -r '.manifests[] | "  \(.annotations["vnd.docker.reference.type"] // "image") \(.digest[0:19])"'
  done
essai 1 : index sha256:e56b6f4e5bd6
  image sha256:c93d4e8ff2e1
  attestation-manifest sha256:714e1fbab8af
essai 2 : index sha256:059fa185a361
  image sha256:c93d4e8ff2e1
  attestation-manifest sha256:58816629f7de

Le manifeste de l'image est identique (c93d4e8ff2e1, le même qu'avec le builder dédié), mais l'attestation diffère : elle enregistre l'heure et l'identifiant de chaque construction, ce qui est son rôle. L'index, qui référence les deux, diffère donc aussi. Pour comparer deux constructions, comparez l'empreinte du manifeste de la plateforme, pas celle de l'index.

Pièges courants

exporter option "rewrite-timestamp" conflicts with "unpack". Avec le builder par défaut et le magasin d'images containerd, l'exporteur d'images décompresse l'image dans le magasin de Docker, ce qui est incompatible avec la réécriture des dates :

$ docker buildx build ... --output type=image,name=127.0.0.1:5000/export:essai1,push=true,rewrite-timestamp=true .
...
ERROR: failed to build: failed to solve: exporter option "rewrite-timestamp" conflicts with "unpack"

Ajoutez unpack=false (l'image n'est alors pas utilisable localement sans docker pull), ou utilisez un builder docker-container.

Oublier de déclarer ARG SOURCE_DATE_EPOCH dans l'étape. L'argument fixe la configuration de l'image même sans déclaration, mais les commandes RUN ne voient la variable que si l'étape la déclare. Sans elle, les .pyc restent non déterministes.

SOURCE_DATE_EPOCH qui change à chaque construction. Si la CI la calcule avec date +%s, on perd tout l'intérêt. Elle doit venir des sources (date du dernier commit), pour être identique quel que soit le moment de la construction.

Une empreinte falsifiée qui passe. En essayant de démontrer le mode vérification de pip, nous avions d'abord falsifié une seule des deux empreintes de Flask : l'installation a réussi. pip a constaté que la roue ne correspondait plus, s'est rabattu sur l'archive source, dont l'empreinte était encore valide, et a construit Flask à partir des sources. Le mode vérification garantit que ce qui est installé correspond à l'une des empreintes listées ; si l'une d'elles est compromise dans votre fichier de verrouillage lui-même, la protection tombe. Le fichier de verrouillage est un document de sécurité, à relire en revue de code.

Le cache qui donne une fausse impression de reproductibilité. Deux constructions avec cache donnent évidemment la même image : la seconde réutilise les couches de la première. Testez toujours avec --no-cache, et idéalement sur un builder neuf.

Sécurité

  • La reproductibilité rend la CI vérifiable. Avec des constructions reproductibles, un tiers (une autre équipe, un auditeur, un second système de construction indépendant) peut reconstruire l'image et comparer l'empreinte. Une divergence révèle une compromission de la chaîne de construction. Le référentiel SLSA (leçon 8) cite cette vérification indépendante comme l'un des moyens les plus solides de faire confiance à une construction.
  • Les empreintes protègent contre la substitution. Une image de base épinglée par empreinte, des dépendances avec empreintes et des modules Go vérifiés par go.sum empêchent qu'un dépôt compromis, un miroir malveillant ou une étiquette déplacée injecte du code dans votre image sans que la construction échoue.
  • Figer n'est pas geler. Des entrées figées sans processus de mise à jour accumulent les vulnérabilités. La combinaison saine : tout figer, et faire avancer les empreintes automatiquement par des demandes de fusion relues et testées.

En production

  • En CI, calculez SOURCE_DATE_EPOCH à partir du commit (git log -1 --format=%ct) et passez-le à la construction ; l'action docker/build-push-action le prend en charge comme variable d'environnement. La leçon 10 l'intègre.
  • Automatisez les mises à jour. Renovate (ou Dependabot) sait mettre à jour les empreintes d'images dans les FROM, régénérer les fichiers verrouillés de pip-tools et faire avancer une date d'instantané, en ouvrant une demande de fusion par mise à jour : la CI la teste, une personne la relit.
  • Vérifiez la reproductibilité régulièrement. Une tâche périodique qui reconstruit la dernière version publiée sur un builder neuf et compare l'empreinte du manifeste détecte aussi bien une régression de reproductibilité qu'une anomalie de la chaîne.
  • Visez la reproductibilité là où elle compte. Les images publiées pour des clients, ou destinées à des environnements réglementés, la justifient pleinement ; pour un outil interne, des entrées épinglées et une construction traçable suffisent souvent.

Exercices

1. Reproduire chez soi. Reprenez le Dockerfile scratch de signalements-export (leçon 5). Construisez-le deux fois sans cache, avec SOURCE_DATE_EPOCH fixé à la date du dernier commit de votre dépôt et rewrite-timestamp=true, et vérifiez que les empreintes sont identiques. Obtenez-vous la même empreinte que dans la leçon ? Pourquoi ?

Solution

Les deux constructions doivent donner la même empreinte entre elles. Elles ne donneront probablement pas celle de la leçon : votre SOURCE_DATE_EPOCH est différent (autre commit), et l'image golang:1.26 a pu évoluer (nouvelle version de Go, donc nouveau binaire, ou nouveaux certificats d'autorité). Avec la même date, la même empreinte de golang:1.26 épinglée dans le Dockerfile et les mêmes sources, vous obtiendriez exactement sha256:c93d4e8ff2e1....

2. Trouver la cause (niveau 300). Ajoutez à l'étape d'exécution de Signalements l'instruction RUN date > /app/construit-le.txt. Montrez avec le script de comparaison que l'image n'est plus reproductible, puis proposez deux façons de garder une date de construction dans l'image sans perdre la reproductibilité.

Solution

Le script signale app/construit-le.txt dans la dernière couche, avec deux contenus différents. Deux solutions : écrire SOURCE_DATE_EPOCH au lieu de l'heure courante (ARG SOURCE_DATE_EPOCH puis RUN date -u -d @$SOURCE_DATE_EPOCH > /app/construit-le.txt), ce qui, vérifié, redonne deux empreintes identiques et un fichier contenant Thu Oct 1 12:39:14 UTC 2026 ; ou ne pas mettre la date dans le système de fichiers, et la porter dans une étiquette OCI que l'on renseigne soi-même à partir de la même date : --label org.opencontainers.image.created=$(date -u -d @$SOURCE_DATE_EPOCH +%FT%TZ).

3. Verrouiller les dépendances. Générez avec pip-compile --generate-hashes le fichier verrouillé des dépendances de Signalements, modifiez le Dockerfile pour installer avec --require-hashes, puis ajoutez une dépendance dans requirements.in sans régénérer le fichier verrouillé. Que se passe-t-il ?

Solution

Rien ne change : la construction utilise requirements.txt, qui ne contient pas la nouvelle dépendance. C'est le comportement voulu : seul le fichier verrouillé fait foi. Il faut relancer pip-compile pour l'y ajouter, et la différence apparaîtra dans la demande de fusion (nouvelles lignes, nouvelles empreintes, éventuellement nouvelles dépendances transitives), où elle sera relue. Une vérification en CI (pip-compile puis git diff --exit-code) détecte un fichier verrouillé qui n'est pas à jour.

4. Reconstruire le passé (niveau 400). Six mois après sa publication, une faille impose de reconstruire en urgence la version 2.3 de Signalements avec un seul paquet Debian mis à jour. Listez tout ce qu'il faut avoir conservé pour y parvenir, et la procédure.

Solution

Il faut : le commit exact des sources (donc SOURCE_DATE_EPOCH) ; le Dockerfile avec les images de base épinglées par empreinte (et ces images encore disponibles, d'où l'intérêt de les recopier dans votre propre registre) ; le fichier de dépendances verrouillé avec empreintes (et les archives encore disponibles, ou un miroir interne) ; la date d'instantané Debian utilisée. Procédure : partir du commit de la 2.3 sur une branche de correctif, reconstruire sans changement et vérifier que l'empreinte obtenue est celle de l'image publiée (preuve que l'environnement est bien reconstitué), puis appliquer le seul changement voulu (avancer l'instantané Debian, ou installer explicitement la version corrigée du paquet), reconstruire, et comparer les deux images avec le script de comparaison : seule la couche concernée doit différer.

Récapitulatif

  • Par défaut, deux constructions identiques donnent deux images différentes : dates de la configuration et des fichiers, journaux, .pyc, chemins temporaires inscrits dans les binaires.
  • SOURCE_DATE_EPOCH (date du dernier commit) fixe les dates de la configuration ; rewrite-timestamp=true réécrit celles des fichiers. Les deux sont nécessaires.
  • Traquez les écarts au fichier près en comparant les couches ; supprimez les fichiers volatils, déclarez ARG SOURCE_DATE_EPOCH pour des .pyc déterministes, compilez sans informations de débogage (-g0) pour les modules C, -trimpath pour Go.
  • Figez les entrées : image de base par empreinte, dépendances verrouillées avec empreintes (pip-compile --generate-hashes, go.sum), paquets Debian par instantané.
  • Comparez l'empreinte du manifeste de la plateforme : l'attestation de provenance, elle, change à chaque construction.
  • La reproductibilité rend la chaîne de construction vérifiable ; elle ne dispense pas des mises à jour, qu'on automatise.

Pour aller plus loin

  • Le site reproducible-builds.org, en particulier la spécification de SOURCE_DATE_EPOCH et la liste des outils qui la respectent.
  • La page de BuildKit sur la reproductibilité, qui détaille les options d'export et leurs limites.
  • L'article du blog Go Perfectly Reproducible, Verified Go Toolchains (2023), sur la manière dont Go vérifie ses propres binaires.
  • Leçon suivante : construire une même image pour plusieurs architectures processeur.
Voir ma constellation →

Sources