Aller au contenu

Lab : une chaîne d'images de confiance

Prérequis

Testé avec buildkit 0.33.1 cosign 3.1.3 docker 29.8.1 trivy 0.74.0 , vérifié le 1 octobre 2026

Ce lab rassemble le cours Construire des images de conteneurs. Vous partez de l'application Signalements telle que vous l'avez laissée à la fin du lab du cours précédent, et vous devez livrer un dépôt dont l'image passe toutes les vérifications du script fourni. Il valide le niveau 300 (Concevoir) de la compétence « Construire des images de conteneurs », complété par une revue de votre Dockerfile et de votre workflow par un pair.

Comptez deux à trois heures. Écrivez vos fichiers vous-même avant d'ouvrir les solutions.

Ce qu'il faut

  • Le dépôt du lab précédent (app.py, requirements.txt, compose.yaml...), versionné avec Git : le script lit le commit courant.
  • Le fichier de tests test_app.py de la leçon 2.
  • Un registre local : docker run -d --name registre -p 127.0.0.1:5000:5000 registry:3.
  • Cosign 3 (binaire autonome) et une paire de clés : cosign generate-key-pair, avec la phrase de passe dans COSIGN_PASSWORD. Ajoutez cosign.key et cosign.pub au .dockerignore et au .gitignore.

Les exigences

ExigenceLeçon
Le pilote PostgreSQL est la version compilée psycopg[c]2
Dockerfile multi-étapes avec une étape test qui exécute les tests2
Image finale de moins de 250 Mo, sans compilateur ni pip utilisable, utilisateur non-root1, 2, 5
Montage de cache pour pip3
Aucun secret dans les couches, l'historique ou la provenance4
Deux constructions sur deux builders neufs produisent le même manifeste6
SBOM et provenance complète joints, la provenance désignant le commit8
Aucune vulnérabilité haute ou critique corrigeable1, 2
Image signée, signature vérifiable sur l'étiquette main-<commit>9
Un workflow GitHub Actions qui reproduit cette chaîne, validé par actionlint10

Étape 1 : le Dockerfile

Solution

requirements.txt :

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

Dockerfile :

# syntax=docker/dockerfile:1

# ---- Étape « construction » : compilateur, en-têtes, pip -------------------
FROM python:3.14 AS construction
ENV 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 .
# Avec SOURCE_DATE_EPOCH, pip compile des .pyc déterministes (validés par empreinte, pas par date)
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

# ---- É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 \
 && rm -f /var/log/apt/*.log /var/log/dpkg.log /var/cache/ldconfig/aux-cache
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"]

.dockerignore :

# Tout ce qui n'a rien à faire dans l'image
.git
.venv
__pycache__/
*.pyc
.env
Dockerfile*
.dockerignore
secrets/
compose*.yaml
verifier.sh
cosign.key
cosign.pub
verifier-chaine.sh
.github

Étape 2 : le workflow

Écrivez .github/workflows/image.yml et validez-le avec actionlint.

Solution
name: Image Signalements

on:
  push:
    branches: [main]
  pull_request:

# Par défaut, le jeton du workflow ne peut que lire le dépôt.
permissions:
  contents: read

env:
  IMAGE: rg.fr-par.scw.cloud/lyneko-apps/signalements

jobs:
  image:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      id-token: write # jeton OIDC pour la signature sans clé
    steps:
      - name: Récupérer les sources
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Date de référence (constructions reproductibles)
        run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"

      - name: Préparer Buildx
        uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

      - name: Se connecter au registre Scaleway
        if: github.event_name == 'push'
        uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: rg.fr-par.scw.cloud
          username: nologin
          password: ${{ secrets.SCW_SECRET_KEY }}

      - name: Étiquettes et annotations
        id: meta
        uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=sha,prefix=main-,format=short,enable={{is_default_branch}}
            type=ref,event=pr
          # Date du commit plutôt que l'heure de la construction : l'image reste reproductible.
          labels: |
            org.opencontainers.image.created={{commit_date 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'}}
          annotations: |
            org.opencontainers.image.created={{commit_date 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'}}

      - name: Tests (étape « test » du Dockerfile)
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          target: test
          cache-from: type=registry,ref=${{ env.IMAGE }}:cache

      - name: Construire, attester et publier
        id: construction
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          platforms: linux/amd64
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          annotations: ${{ steps.meta.outputs.annotations }}
          build-args: SOURCE_DATE_EPOCH=${{ env.SOURCE_DATE_EPOCH }}
          outputs: type=image,push=${{ github.event_name == 'push' }},rewrite-timestamp=true
          sbom: true
          provenance: mode=max
          cache-from: type=registry,ref=${{ env.IMAGE }}:cache
          cache-to: ${{ github.event_name == 'push' && format('type=registry,ref={0}:cache,mode=max', env.IMAGE) || '' }}

      - name: Analyse de vulnérabilités (bloquante)
        if: github.event_name == 'push'
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: ${{ env.IMAGE }}@${{ steps.construction.outputs.digest }}
          severity: CRITICAL,HIGH
          ignore-unfixed: true
          exit-code: "1"
          version: v0.74.0

      - name: Installer Cosign
        if: github.event_name == 'push'
        uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
        with:
          cosign-release: v3.1.3

      - name: Signer l'image (sans clé, identité du workflow)
        if: github.event_name == 'push'
        env:
          EMPREINTE: ${{ steps.construction.outputs.digest }}
        run: cosign sign --yes "${IMAGE}@${EMPREINTE}"

Les empreintes des actions sont celles du 1er octobre 2026 ; mettez-les à jour. Le registre rg.fr-par.scw.cloud/lyneko-apps est celui de Lyneko : remplacez-le par le vôtre.

Étape 3 : vérifier

Ce script rejoue localement la chaîne du workflow, sur deux builders neufs, contre le registre local, puis contrôle chaque exigence. Placez-le à la racine du dépôt, commitez votre travail, et lancez-le.

#!/usr/bin/env bash
# Vérifie la chaîne d'images de confiance du lab « Construire des images de conteneurs ».
# Usage : ./verifier-chaine.sh   (dans le dépôt Git du projet, avec cosign.key et cosign.pub,
#         un registre local sur REGISTRE, et COSIGN_PASSWORD exporté)
set -uo pipefail
REGISTRE=${REGISTRE:-127.0.0.1:5000}
IMAGE=$REGISTRE/lyneko-apps/signalements
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
TAG=main-$(git rev-parse --short=7 HEAD)
TRIVY="aquasec/trivy:0.74.0"
ok=0; ko=0
verifier() { local d=$1; shift; if "$@" >/dev/null 2>&1; then echo "  OK  $d"; ok=$((ok+1)); else echo "  KO  $d"; ko=$((ko+1)); fi; }

printf '[registry."%s"]\n  http = true\n' "$REGISTRE" > .buildkitd-lab.toml
construire() {  # construire <builder> <fichier de métadonnées> <sortie>
  docker buildx create --name "$1" --driver docker-container --driver-opt network=host \
    --buildkitd-config .buildkitd-lab.toml >/dev/null
  docker buildx build --builder "$1" --progress=quiet --platform linux/amd64 \
    --build-arg SOURCE_DATE_EPOCH --output "$3" \
    --sbom=true --provenance=mode=max --metadata-file "$2" . >/dev/null
  docker buildx rm "$1" >/dev/null
}
manifeste() { docker buildx imagetools inspect "$IMAGE@$(jq -r '."containerimage.digest"' "$1")" --raw \
  | jq -r '.manifests[] | select(.platform.os=="linux") | .digest'; }

echo "Tests et constructions (deux builders neufs)..."
verifier "les tests passent (cible test)" docker buildx build --progress=quiet --target test -o type=cacheonly .
# Le premier agent publie l'étiquette ; le second ne publie que par empreinte, pour comparer
# sans déplacer l'étiquette (son index diffère : la provenance change à chaque construction).
construire lab-agent1 .meta1.json "type=image,name=$IMAGE:$TAG,push=true,rewrite-timestamp=true" \
  || { echo "échec de la construction"; exit 1; }
construire lab-agent2 .meta2.json "type=image,name=$IMAGE,push-by-digest=true,push=true,rewrite-timestamp=true" \
  || { echo "échec de la construction"; exit 1; }
EMPREINTE=$(jq -r '."containerimage.digest"' .meta1.json)
docker pull -q "$IMAGE@$EMPREINTE" >/dev/null

echo "Image"
verifier "moins de 250 Mo sur le disque" bash -c "[ \$(docker image inspect $IMAGE@$EMPREINTE --format '{{.Size}}') -lt 250000000 ]"
verifier "utilisateur non-root" bash -c "u=\$(docker image inspect $IMAGE@$EMPREINTE --format '{{.Config.User}}'); [ -n \"\$u\" ] && [ \"\${u%%:*}\" != 0 ] && [ \"\${u%%:*}\" != root ]"
verifier "pas de compilateur dans l'image" bash -c "! docker run --rm --entrypoint sh $IMAGE@$EMPREINTE -c 'command -v gcc cc'"
verifier "pas de pip utilisable dans l'image" bash -c "! docker run --rm --entrypoint python $IMAGE@$EMPREINTE -m pip --version"

echo "Reproductibilité"
verifier "deux builders neufs produisent le même manifeste" bash -c "[ \"$(manifeste .meta1.json)\" = \"$(manifeste .meta2.json)\" ]"

echo "Attestations"
verifier "SBOM SPDX joint à l'image" bash -c "docker buildx imagetools inspect $IMAGE@$EMPREINTE --format '{{json .SBOM}}' | jq -e '.SPDX.packages | length > 50'"
verifier "provenance avec le bon commit" bash -c "docker buildx imagetools inspect $IMAGE@$EMPREINTE --format '{{json .Provenance}}' | jq -e --arg c $(git rev-parse HEAD) '.SLSA.runDetails.metadata.buildkit_metadata.vcs.revision == \$c'"

echo "Sécurité"
verifier "aucune vulnérabilité haute ou critique corrigeable" docker run --rm --network host -v trivy-cache:/root/.cache $TRIVY image --quiet --insecure --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 "$IMAGE@$EMPREINTE"
cosign signing-config create --out .signing-config-lab.json >/dev/null 2>&1
cosign sign --yes --key cosign.key --signing-config .signing-config-lab.json --allow-http-registry "$IMAGE@$EMPREINTE" >/dev/null 2>&1
verifier "signature vérifiée sur l'étiquette $TAG" cosign verify --key cosign.pub --insecure-ignore-tlog=true --allow-http-registry "$IMAGE:$TAG"

rm -f .buildkitd-lab.toml .meta1.json .meta2.json .signing-config-lab.json
echo
echo "Résultat : $ok vérification(s) réussie(s), $ko échouée(s)."
[ "$ko" -eq 0 ]
$ export COSIGN_PASSWORD='...' REGISTRE=127.0.0.1:5000
$ ./verifier-chaine.sh

Sur le projet de référence :

Tests et constructions (deux builders neufs)...
  OK  les tests passent (cible test)
lab-agent1 removed
lab-agent2 removed
Image
  OK  moins de 250 Mo sur le disque
  OK  utilisateur non-root
  OK  pas de compilateur dans l'image
  OK  pas de pip utilisable dans l'image
Reproductibilité
  OK  deux builders neufs produisent le même manifeste
Attestations
  OK  SBOM SPDX joint à l'image
  OK  provenance avec le bon commit
Sécurité
  OK  aucune vulnérabilité haute ou critique corrigeable
  OK  signature vérifiée sur l'étiquette main-3457dcb

Résultat : 10 vérification(s) réussie(s), 0 échouée(s).

Une première version de ce script faisait construire et pousser l'étiquette par les deux builders. La vérification de la signature échouait alors : la seconde construction avait déplacé l'étiquette vers son propre index, différent du premier puisque l'attestation de provenance change à chaque construction, alors que c'était le premier index qui avait été signé. D'où la version ci-dessus, où le second builder publie seulement par empreinte (push-by-digest=true). C'est aussi une règle de CI : une étiquette ne doit être publiée qu'une fois, par la construction qui sera signée.

Pour aller plus loin

  • Ajoutez la variante linux/arm64 (leçon 7) en construisant les roues de psycopg pour ARM sur un nœud natif, et vérifiez que la reproductibilité tient pour chaque plateforme.
  • Remplacez la paire de clés par une signature sans clé dans le workflow, et écrivez la commande cosign verify correspondante, avec l'identité exacte du workflow.
  • Produisez un SBOM CycloneDX signé avec cosign attest, et vérifiez-le dans le script.

Plan du cours