Présentation
Si vous construisez le même Dockerfile deux fois et obtenez des images différentes, vous ne pouvez pas vérifier l’intégrité du build. Un build non reproductible signifie que vous n’avez aucun moyen de confirmer que l’artefact en production a bien été produit à partir du code source que vous avez audité. Les attaquants peuvent exploiter cette ambiguïté pour injecter du code malveillant durant le processus de build sans être détectés.
Ce lab vous guide à travers les sources de non-reproductibilité dans les builds de containers, démontre les techniques pour éliminer chacune d’entre elles, et montre comment vérifier automatiquement la reproductibilité dans les pipelines CI/CD. À la fin, vous disposerez d’un Dockerfile entièrement reproductible et d’un workflow GitHub Actions qui le prouve à chaque commit.
Prérequis
- Docker avec BuildKit — Docker Desktop 23.0+ a BuildKit activé par défaut. Vérifiez avec
docker buildx version. - diffoscope — Installez avec
pip install diffoscope. Cet outil effectue une comparaison récursive et approfondie de fichiers et d’archives. - crane — Installez depuis go-containerregistry. Utilisé pour inspecter et manipuler les images de containers et les registres.
- Cosign — Installez depuis Sigstore. Utilisé pour la signature et la vérification d’images de containers.
- Un dépôt de test avec un Dockerfile (nous en créerons un lors de l’étape de configuration).
- Go 1.22+ installé localement (optionnel, pour les tests locaux en dehors de Docker).
Configuration de l’environnement
Créez un dépôt de test avec une application Go simple. Cela nous donne un projet réaliste et minimal pour travailler tout au long du lab.
Étape 1 : Initialiser le projet
mkdir repro-build-lab && cd repro-build-lab
git init
go mod init github.com/example/repro-build-lab
Étape 2 : Créer l’application Go
Créez cmd/app/main.go :
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from repro-build-lab v1\n")
})
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "ok\n")
})
fmt.Printf("Listening on :%s\n", port)
http.ListenAndServe(":"+port, nil)
}
Étape 3 : Créer le Dockerfile intentionnellement non reproductible
Ce Dockerfile contient toutes les erreurs courantes qui mènent à des builds non reproductibles :
# Intentionally non-reproducible Dockerfile
FROM golang:latest
WORKDIR /src
# Floating package versions
RUN apt-get update && apt-get install -y curl
# Embeds current timestamp into the image
RUN echo "Built at $(date)" > /build-info
COPY . .
RUN go build -o /app ./cmd/app
EXPOSE 8080
CMD ["/app"]
Remarquez les problèmes :
FROM golang:latest— l’image de base change sans prévenir.apt-get install -y curl— aucun verrouillage de version, donc la version installée est flottante.echo "Built at $(date)"— injecte un horodatage qui diffère à chaque build.- Pas de
.dockerignore— des fichiers locaux comme.git/s’infiltrent dans le contexte de build, modifiant les hachages des couches.
Commitez le projet initial :
git add -A
git commit -m "Initial non-reproducible project"
Exercice 1 : Démontrer la non-reproductibilité
Avant de corriger quoi que ce soit, prouvons que le Dockerfile actuel produit des images différentes à chaque build.
Étape 1 : Construire l’image deux fois
# First build
docker build --no-cache -t myapp:build1 .
# Wait a moment so the timestamp differs
sleep 2
# Second build
docker build --no-cache -t myapp:build2 .
Le flag --no-cache force Docker à exécuter chaque couche depuis zéro, ce qui est essentiel pour cette comparaison. Dans un environnement CI/CD réel, les builds s’exécutent souvent sur des runners neufs sans cache.
Étape 2 : Comparer les digests des images
docker inspect --format='{{.Id}}' myapp:build1
# sha256:a1b2c3d4e5f6... (example)
docker inspect --format='{{.Id}}' myapp:build2
# sha256:f6e5d4c3b2a1... (different!)
Les digests sont différents même si rien dans le code source n’a changé. Cela signifie que vous ne pouvez pas vérifier qu’une image donnée a été produite à partir d’un commit spécifique.
Étape 3 : Utiliser diffoscope pour identifier les différences
# Export both images as tarballs
docker save myapp:build1 -o build1.tar
docker save myapp:build2 -o build2.tar
# Run diffoscope
diffoscope build1.tar build2.tar --html-dir diff-report
Ouvrez diff-report/index.html dans un navigateur. Le rapport révèle exactement ce qui diffère entre les deux builds :
- Horodatages — le fichier
/build-infocontient des dates différentes. - Métadonnées des paquets apt — les listes de paquets et les fichiers de cache contiennent des horodatages et peuvent télécharger des micro-versions différentes.
- Binaire Go — le binaire compilé contient des chemins de build embarqués et des identifiants de build.
- Ordre et métadonnées des couches — Docker intègre des horodatages de création dans les métadonnées des couches.
Chacun de ces éléments est une source de non-reproductibilité que nous éliminerons dans les exercices suivants.
Exercice 2 : Épingler l’image de base par digest
La plus grande source de dérive est l’image de base. golang:latest est une cible mouvante — elle peut changer entre les builds, entre les exécutions CI, ou même entre les régions si un registre est éventuellement cohérent.
Étape 1 : Trouver le digest actuel
crane digest golang:1.22
# sha256:d0902bacefdde1cf45079803dc16feeb58f3aa9df52052cc00deb2c3e5de367b
Étape 2 : Épingler l’image de base
Mettez à jour la ligne FROM dans le Dockerfile :
FROM golang:1.22@sha256:d0902bacefdde1cf45079803dc16feeb58f3aa9df52052cc00deb2c3e5de367b
Le format est image:tag@sha256:digest. Docker effectuera le pull par digest, en ignorant le tag. Le tag est conservé pour la lisibilité humaine.
Étape 3 : Reconstruire et comparer
docker build --no-cache -t myapp:pinned1 .
sleep 2
docker build --no-cache -t myapp:pinned2 .
docker inspect --format='{{.Id}}' myapp:pinned1
docker inspect --format='{{.Id}}' myapp:pinned2
Les digests sont encore différents — d’autres sources de non-reproductibilité subsistent. Mais si vous comparez les couches, la couche de l’image de base est désormais identique entre les builds. Vous avez éliminé la plus grande source de dérive.
Pourquoi c’est important
Sans épinglage par digest, un tag compromis ou détourné peut silencieusement remplacer votre image de base par une image malveillante. L’épinglage par digest est une garantie cryptographique : vous obtenez exactement les octets attendus, ou le build échoue.
Exercice 3 : Épingler les versions des paquets
Les versions flottantes des paquets introduisent du non-déterminisme dans la couche de dépendances. Chaque fois que apt-get update s’exécute, il récupère l’index actuel du dépôt, qui peut lister des versions de paquets différentes.
Option A : Épingler les versions des paquets Debian
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl=7.88.1-10+deb12u8 && \
rm -rf /var/lib/apt/lists/*
Pour trouver la version actuellement disponible dans votre image de base :
docker run --rm golang:1.22 apt-cache policy curl
Option B : Utiliser Alpine avec des paquets épinglés
Les paquets Alpine ont des chaînes de version plus simples et des images plus petites :
FROM golang:1.22-alpine@sha256:<alpine-digest>
RUN apk add --no-cache curl=8.5.0-r0
Option C : Build multi-étapes (recommandé)
La meilleure approche est d’éviter complètement l’installation de paquets dans l’image finale. Utilisez un build multi-étapes où l’étape de build dispose des outils et l’étape d’exécution est minimale :
# Build stage — tools are only needed here
FROM golang:1.22@sha256:d0902bacefdde1cf45079803dc16feeb58f3aa9df52052cc00deb2c3e5de367b AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app ./cmd/app
# Runtime stage — no apt-get, no floating packages
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
CMD ["/app"]
Avec cette approche, l’image d’exécution n’a aucun appel au gestionnaire de paquets, ce qui élimine toute une classe de non-reproductibilité.
Reconstruire et comparer
docker build --no-cache -t myapp:pinpkg1 .
sleep 2
docker build --no-cache -t myapp:pinpkg2 .
docker inspect --format='{{.Id}}' myapp:pinpkg1
docker inspect --format='{{.Id}}' myapp:pinpkg2
Les couches de paquets sont désormais identiques entre les builds. Les différences restantes proviennent des horodatages et du binaire Go lui-même.
Exercice 4 : Supprimer les horodatages et le contenu non déterministe
Les horodatages sont la source de non-reproductibilité la plus évidente. Toute commande qui capture l’heure actuelle produit un résultat différent à chaque build.
Étape 1 : Supprimer les horodatages explicites
Supprimez la ligne qui écrit l’heure de build :
# REMOVE this line:
# RUN echo "Built at $(date)" > /build-info
Si vous avez besoin de métadonnées de build, passez-les en tant que label avec une valeur fixe dérivée de la source :
ARG BUILD_COMMIT
LABEL org.opencontainers.image.revision=${BUILD_COMMIT}
Étape 2 : Définir SOURCE_DATE_EPOCH
SOURCE_DATE_EPOCH est une variable d’environnement standardisée qui indique aux outils de build d’utiliser un horodatage fixe au lieu de l’heure actuelle. De nombreux outils la respectent, notamment tar, gzip, zip et le compilateur Go.
ARG SOURCE_DATE_EPOCH
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
Construisez avec l’horodatage du dernier commit git :
docker build \
--build-arg SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) \
--no-cache \
-t myapp:repro .
Cela garantit que les builds à partir du même commit utilisent toujours le même horodatage, quelle que soit le moment réel du build.
Étape 3 : Utiliser la sortie OCI de BuildKit
BuildKit peut produire des images au format OCI avec une création de couches plus déterministe :
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) \
--output type=oci,dest=myapp.tar \
--no-cache \
.
Le format de sortie OCI évite certaines métadonnées non déterministes que le format d’image Docker par défaut inclut.
Exercice 5 : Builds Go reproductibles
Go intègre par défaut plusieurs informations non déterministes dans les binaires compilés : les chemins de fichiers locaux, un identifiant de build unique et des symboles de débogage qui référencent l’environnement de build.
Étape 1 : Utiliser les flags de build reproductible
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w -buildid=" \
-o /app ./cmd/app
Voici ce que fait chaque flag :
| Flag | Objectif |
|---|---|
CGO_ENABLED=0 |
Désactive cgo, produisant un binaire lié statiquement. Évite la dépendance aux bibliothèques C système qui peuvent différer entre les builds. |
-trimpath |
Supprime tous les chemins du système de fichiers local du binaire compilé. Sans cela, le binaire contient des chemins comme /src/cmd/app/main.go de l’environnement de build. |
-ldflags="-s -w" |
Supprime la table des symboles (-s) et les informations de débogage DWARF (-w). Celles-ci contiennent des données spécifiques à l’environnement de build. |
-ldflags="-buildid=" |
Définit l’identifiant de build à vide. Go génère normalement un identifiant de build unique qui change entre les builds même avec un source identique. |
Étape 2 : Vérifier la reproductibilité du binaire
# Build twice
docker build --no-cache -t myapp:go1 .
docker build --no-cache -t myapp:go2 .
# Extract and hash the binary from each image
docker create --name tmp1 myapp:go1
docker cp tmp1:/app ./app1
docker rm tmp1
docker create --name tmp2 myapp:go2
docker cp tmp2:/app ./app2
docker rm tmp2
sha256sum app1 app2
Les hachages SHA-256 de app1 et app2 devraient être identiques. Le binaire Go est désormais reproductible bit à bit.
Exercice 6 : Le Dockerfile entièrement reproductible
Combinons maintenant toutes les techniques en un seul Dockerfile entièrement reproductible.
Le Dockerfile complet
# syntax=docker/dockerfile:1
# ---- Build Stage ----
FROM golang:1.22@sha256:d0902bacefdde1cf45079803dc16feeb58f3aa9df52052cc00deb2c3e5de367b AS builder
ARG SOURCE_DATE_EPOCH
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
WORKDIR /src
# Cache dependency downloads
COPY go.mod go.sum ./
RUN go mod download && go mod verify
# Copy source and build
COPY . .
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w -buildid=" \
-o /app ./cmd/app
# ---- Runtime Stage ----
FROM gcr.io/distroless/static-debian12:nonroot@sha256:6ec5aa99dc335b19f6c2bcb8e09cf92404e56f0db4e2f58cf92c4536e1548415
ARG SOURCE_DATE_EPOCH
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
COPY --from=builder /app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
Le fichier .dockerignore complet
.git
.github
.gitignore
*.md
README*
LICENSE
docker-compose*.yml
Makefile
.env
.env.*
*.tar
*.log
tmp/
build/
diff-report/
Le .dockerignore est crucial. Sans lui, le répertoire .git/ s’infiltre dans le contexte de build. Comme .git/ contient des horodatages, des fichiers de verrouillage et d’autres métadonnées changeantes, il rend chaque contexte de build unique même lorsque le source est identique.
Construire et vérifier
SOURCE_EPOCH=$(git log -1 --format=%ct)
# Build twice
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${SOURCE_EPOCH} \
--output type=oci,dest=build1.tar \
--no-cache .
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${SOURCE_EPOCH} \
--output type=oci,dest=build2.tar \
--no-cache .
# Compare
sha256sum build1.tar build2.tar
Avec toutes les techniques de reproductibilité appliquées, les hachages SHA-256 des deux archives OCI devraient correspondre ou être extrêmement proches. Toute différence restante se trouvera dans les métadonnées de configuration de l’image et peut être résolue avec le flag --source-date-epoch de BuildKit (disponible dans BuildKit 0.13+) :
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${SOURCE_EPOCH} \
--source-date-epoch ${SOURCE_EPOCH} \
--output type=oci,dest=build-final.tar \
--no-cache .
Exercice 7 : Vérifier la reproductibilité en CI/CD
La reproductibilité n’a de valeur que si vous la vérifiez en continu. Un build reproductible aujourd’hui peut devenir non reproductible demain si quelqu’un ajoute une dépendance flottante ou un horodatage. La solution est de construire deux fois à chaque exécution CI et d’affirmer que les résultats sont identiques.
Workflow GitHub Actions
Créez .github/workflows/reproducible-build.yml :
name: Verify Reproducible Build
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
verify-reproducibility:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute SOURCE_DATE_EPOCH
id: epoch
run: echo "value=$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT"
- name: Build image (first pass)
run: |
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${{ steps.epoch.outputs.value }} \
--output type=oci,dest=build-pass1.tar \
--no-cache \
.
- name: Record first digest
id: digest1
run: echo "sha=$(sha256sum build-pass1.tar | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- name: Build image (second pass)
run: |
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${{ steps.epoch.outputs.value }} \
--output type=oci,dest=build-pass2.tar \
--no-cache \
.
- name: Record second digest
id: digest2
run: echo "sha=$(sha256sum build-pass2.tar | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- name: Compare digests
run: |
echo "Build 1: ${{ steps.digest1.outputs.sha }}"
echo "Build 2: ${{ steps.digest2.outputs.sha }}"
if [ "${{ steps.digest1.outputs.sha }}" != "${{ steps.digest2.outputs.sha }}" ]; then
echo "::error::Builds are NOT reproducible! Digests differ."
echo "Running diffoscope to identify differences..."
pip install diffoscope
diffoscope build-pass1.tar build-pass2.tar --text diff-output.txt || true
cat diff-output.txt
exit 1
fi
echo "Builds are reproducible. Digests match."
- name: Upload diff report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: reproducibility-diff
path: diff-output.txt
- name: Sign the verified image
if: github.ref == 'refs/heads/main'
env:
COSIGN_EXPERIMENTAL: "true"
run: |
# Load the OCI image into Docker
docker load -i build-pass1.tar
# In production, push to a registry and sign with Cosign:
# cosign sign --yes $REGISTRY/$IMAGE@$DIGEST
echo "Image verified as reproducible and ready for signing."
Ce workflow effectue les actions suivantes à chaque push et pull request :
- Récupère le code et configure BuildKit.
- Calcule
SOURCE_DATE_EPOCHà partir de l’horodatage du dernier commit. - Construit l’image depuis zéro (première passe) et enregistre le digest.
- Construit l’image depuis zéro à nouveau (deuxième passe) et enregistre le digest.
- Compare les deux digests. S’ils diffèrent, le job échoue et exécute
diffoscopepour produire un rapport de diff détaillé. - En cas de succès sur la branche main, l’image vérifiée est prête pour la signature avec Cosign.
C’est la garantie la plus forte que vous puissiez avoir : chaque exécution CI prouve que votre build est reproductible. Si un développeur introduit du non-déterminisme, le build échoue immédiatement.
Exercice 8 : Comparer les images entre versions
Les builds reproductibles vous donnent également la capacité de comparer les versions entre elles et de vérifier que seuls les changements attendus sont présents. C’est essentiel pour l’audit des releases : vous voulez confirmer qu’un changement de version n’a modifié que le binaire de l’application, pas l’image de base ou les paquets système.
Étape 1 : Construire la version 1
SOURCE_EPOCH=$(git log -1 --format=%ct)
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${SOURCE_EPOCH} \
--output type=oci,dest=image-v1.tar \
--no-cache .
Étape 2 : Effectuer une modification du code
Modifiez cmd/app/main.go pour changer la chaîne de version :
fmt.Fprintf(w, "Hello from repro-build-lab v2\n")
Commitez le changement :
git add cmd/app/main.go
git commit -m "Bump to v2"
Étape 3 : Construire la version 2
SOURCE_EPOCH=$(git log -1 --format=%ct)
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=${SOURCE_EPOCH} \
--output type=oci,dest=image-v2.tar \
--no-cache .
Étape 4 : Comparer avec diffoscope
diffoscope image-v1.tar image-v2.tar --html-dir version-diff-report
Ouvrez le rapport. Vous devriez voir que les seules différences sont :
- Le binaire de l’application Go — car nous avons modifié le code source.
- La valeur de
SOURCE_DATE_EPOCH— car l’horodatage du commit a changé.
Les couches de l’image de base, le runtime distroless et toutes les autres couches devraient être complètement identiques.
Étape 5 : Comparer les couches avec crane
# Load images and push to a local registry for crane inspection
docker run -d -p 5000:5000 --name registry registry:2
# Load and push v1
docker load -i image-v1.tar
docker tag myapp:latest localhost:5000/myapp:v1
docker push localhost:5000/myapp:v1
# Load and push v2
docker load -i image-v2.tar
docker tag myapp:latest localhost:5000/myapp:v2
docker push localhost:5000/myapp:v2
# List layers for each version
crane manifest localhost:5000/myapp:v1 | jq '.layers[].digest'
crane manifest localhost:5000/myapp:v2 | jq '.layers[].digest'
Comparez les digests des couches. Vous verrez que toutes les couches sont identiques sauf celle contenant le binaire Go. C’est exactement ce que vous voulez : un changement de version ne devrait modifier que la couche applicative, rien d’autre.
Si vous constatez des changements de couches inattendus (par exemple, la couche de l’image de base diffère), cela signifie que quelque chose a cassé la reproductibilité et nécessite une investigation. Cette comparaison couche par couche est une technique d’audit puissante qui ne fonctionne que lorsque vos builds sont reproductibles.
Nettoyage
# Remove test images
docker rmi myapp:build1 myapp:build2 myapp:pinned1 myapp:pinned2 \
myapp:pinpkg1 myapp:pinpkg2 myapp:go1 myapp:go2 myapp:repro 2>/dev/null
# Remove OCI tarballs
rm -f build1.tar build2.tar build-pass1.tar build-pass2.tar \
image-v1.tar image-v2.tar myapp.tar
# Remove extracted binaries
rm -f app1 app2
# Remove diff reports
rm -rf diff-report version-diff-report
# Stop and remove the local registry
docker stop registry && docker rm registry 2>/dev/null
# Remove the test project (optional)
cd .. && rm -rf repro-build-lab
Points clés à retenir
- Épinglez les images de base par digest, pas par tag. Les tags sont des pointeurs mutables. Les digests sont des garanties cryptographiques. Utilisez
crane digestpour trouver le digest actuel et mettez-le à jour délibérément via une PR, pas silencieusement pendant un build. - Épinglez toutes les versions de paquets ou évitez les gestionnaires de paquets dans l’image d’exécution. Les builds multi-étapes avec des images d’exécution distroless ou scratch éliminent toute une catégorie de non-reproductibilité.
- Éliminez toutes les sources d’horodatages. Utilisez
SOURCE_DATE_EPOCHdérivé de l’horodatage du commit git. N’exécutez jamaisdate,timestampou des commandes similaires dans un Dockerfile. - Utilisez des flags de compilateur reproductibles. Pour Go :
-trimpath,-ldflags="-s -w -buildid="etCGO_ENABLED=0. D’autres langages disposent d’options similaires. - Vérifiez la reproductibilité en CI/CD en construisant deux fois et en comparant. C’est le seul moyen de garantir que votre build reste reproductible au fur et à mesure de l’évolution du projet. Si les digests divergent, faites échouer le build.
- Utilisez diffoscope pour auditer les changements entre versions. Les builds reproductibles permettent des diffs d’images significatifs. Vous pouvez vérifier qu’une release ne contient que les changements prévus — rien de plus.
Prochaines étapes
Maintenant que vous pouvez produire des images de containers reproductibles, explorez comment construire une chaîne complète d’intégrité et de provenance autour d’elles :
- Intégrité des builds et builds reproductibles — approfondissement de la théorie derrière les builds reproductibles, les exigences de niveau de build SLSA, et comment la reproductibilité s’intègre dans un cadre plus large d’intégrité des builds.
- Provenance des artefacts et attestations : de SLSA à in-toto — apprenez comment générer et vérifier les attestations de provenance pour vos builds reproductibles, créant une chaîne auditable de la source au déploiement.