Lab : une chaîne d'images de confiance
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.pyde 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 dansCOSIGN_PASSWORD. Ajoutezcosign.keyetcosign.pubau.dockerignoreet au.gitignore.
Les exigences
| Exigence | Leçon |
|---|---|
Le pilote PostgreSQL est la version compilée psycopg[c] | 2 |
Dockerfile multi-étapes avec une étape test qui exécute les tests | 2 |
| Image finale de moins de 250 Mo, sans compilateur ni pip utilisable, utilisateur non-root | 1, 2, 5 |
| Montage de cache pour pip | 3 |
| Aucun secret dans les couches, l'historique ou la provenance | 4 |
| Deux constructions sur deux builders neufs produisent le même manifeste | 6 |
| SBOM et provenance complète joints, la provenance désignant le commit | 8 |
| Aucune vulnérabilité haute ou critique corrigeable | 1, 2 |
Image signée, signature vérifiable sur l'étiquette main-<commit> | 9 |
Un workflow GitHub Actions qui reproduit cette chaîne, validé par actionlint | 10 |
Étape 1 : le Dockerfile
Solution
requirements.txt :
flask==3.1.3
gunicorn==26.2.0
psycopg[c]==3.3.6Dockerfile :
# 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 verifycorrespondante, avec l'identité exacte du workflow. - Produisez un SBOM CycloneDX signé avec
cosign attest, et vérifiez-le dans le script.