Lab : Builds Container Reproductibles — Pinning, Vérification et Comparaison d’Images

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-info contient 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 :

  1. Récupère le code et configure BuildKit.
  2. Calcule SOURCE_DATE_EPOCH à partir de l’horodatage du dernier commit.
  3. Construit l’image depuis zéro (première passe) et enregistre le digest.
  4. Construit l’image depuis zéro à nouveau (deuxième passe) et enregistre le digest.
  5. Compare les deux digests. S’ils diffèrent, le job échoue et exécute diffoscope pour produire un rapport de diff détaillé.
  6. 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 digest pour 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_EPOCH dérivé de l’horodatage du commit git. N’exécutez jamais date, timestamp ou des commandes similaires dans un Dockerfile.
  • Utilisez des flags de compilateur reproductibles. Pour Go : -trimpath, -ldflags="-s -w -buildid=" et CGO_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 :