Aller au contenu
Images minimales : distroless et scratch

Images minimales : distroless et scratch

300 Concevoir ⏱ 1 h 05 dockergopythondistroless

À la fin, vous saurez

  • Construire un binaire Go statique et l'empaqueter dans une image scratch fonctionnelle
  • Fournir à une image vide ce dont un programme a besoin : certificats d'autorité, fuseaux horaires, utilisateur
  • Faire tourner une application Python sur une image distroless en respectant la compatibilité de l'interpréteur
  • Comparer images minimales et images classiques sur la taille, les paquets et les vulnérabilités
  • Déboguer un conteneur qui n'a ni shell ni outils

Prérequis

Testé avec distroless debian13 docker 29.8.1 go 1.26.8 python 3.13.5 (distroless) et 3.14.7 trivy 0.74.0 , vérifié le 1 octobre 2026

Pourquoi

Les deux premières leçons ont réduit l'image de Signalements en retirant ce qui ne servait qu'à la construction. Reste une question : que reste-t-il dans l'image d'exécution qui ne sert jamais à l'application ? Sur python:3.14-slim, la réponse est longue : un shell, apt, dpkg, les utilitaires de base, login, mount, des dizaines de bibliothèques. Aucun n'est appelé par Signalements. Tous sont présents pour un attaquant qui aurait exploité une faille de l'application, et tous apparaissent dans les rapports de vulnérabilités qu'il faut trier chaque semaine.

Les images minimales poussent la logique jusqu'au bout : ne livrer que ce que le programme exécute. À l'extrême, pour un binaire statique, l'image ne contient que le binaire lui-même : l'image scratch, littéralement vide. Entre les deux, les images distroless gardent ce qu'il faut pour exécuter un langage (bibliothèque C, certificats, interpréteur) et rien d'autre.

Ces images ont un coût, qu'il vaut mieux connaître avant d'y passer : il faut savoir ce dont le programme a réellement besoin (et une image vide le révèle brutalement), et il faut une autre méthode pour déboguer un conteneur où l'on ne peut pas ouvrir de shell. Cette leçon traite les deux, sur nos deux programmes : signalements-export en Go, et l'API Signalements en Python.

Les concepts

scratch

scratch n'est pas une image qu'on télécharge : c'est un mot réservé du Dockerfile qui signifie « partir de rien ». L'image résultante ne contient que les fichiers que vous y copiez. Aucun shell, aucune bibliothèque C, aucun /etc/passwd, aucun certificat, aucun /tmp. Elle convient aux programmes liés statiquement, qui n'ont besoin d'aucune bibliothèque partagée : c'est le cas par défaut des programmes Go compilés avec CGO_ENABLED=0, et possible pour Rust (avec la cible musl) ou C.

distroless

Les images distroless de Google partent de paquets Debian, mais n'en gardent que les fichiers d'exécution, sans gestionnaire de paquets ni shell. Elles existent en plusieurs variantes empilées :

VarianteContenuPour
staticCertificats d'autorité, données de fuseaux horaires, /etc/passwd avec les utilisateurs root, nobody et nonroot, /tmpBinaires statiques (Go, Rust)
basestatic + glibc, OpenSSLBinaires dynamiques simples
ccbase + libstdc++Programmes C++
python3, java, nodejscc + l'environnement d'exécution du langage, dans la version de DebianApplications interprétées

Chaque variante existe avec les étiquettes latest (utilisateur root), nonroot (utilisateur 65532) et debug (avec un shell BusyBox, pour le diagnostic seulement).

Ce dont un programme a vraiment besoin

Même un programme « autonome » s'appuie sur des fichiers du système, souvent sans qu'on le sache :

BesoinFichierSymptôme s'il manque
Vérifier un certificat TLS/etc/ssl/certs/ca-certificates.crtx509: certificate signed by unknown authority
Convertir une heure dans un fuseau/usr/share/zoneinfo/unknown time zone Europe/Paris
Résoudre un nom d'utilisateur/etc/passwd, /etc/groupProgramme qui affiche des UID au lieu de noms, ou qui échoue
Écrire un fichier temporaire/tmpNo usable temporary directory (leçon 9 du cours précédent)
Résoudre un nom DNS/etc/resolv.conf, /etc/hostsFournis par Docker au démarrage : rarement un problème

Une image scratch ne fournit aucun de ces fichiers ; c'est à vous de les y mettre. Une image distroless les fournit tous.

En pratique

signalements-export dans scratch, version naïve

signalements-export interroge l'API Signalements et produit un CSV ; il affiche sur la sortie d'erreur la date de l'export dans un fuseau horaire choisi (-fuseau, par défaut Europe/Paris). Première tentative, la plus courte possible :

# syntax=docker/dockerfile:1
FROM golang:1.26 AS construction
WORKDIR /src
COPY go.mod *.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /signalements-export .

FROM scratch
COPY --from=construction /signalements-export /signalements-export
ENTRYPOINT ["/signalements-export"]
  • CGO_ENABLED=0 produit un binaire statique, sans dépendance à la bibliothèque C (leçon 1).
  • -trimpath retire du binaire les chemins de la machine de construction (/src/...), utile pour la reproductibilité (leçon 6).
  • -ldflags="-s -w" retire la table des symboles et les informations de débogage : le binaire est plus petit, mais les traces de pile restent lisibles (Go garde ses propres tables pour cela).
$ docker build -q -t export:naif -f Dockerfile.naif .
$ docker image ls --tree export:naif | grep linux/amd64
└─ linux/amd64       e962663baae4       8.81MB         2.63MB
$ docker run --rm export:naif -version
signalements-export dev (linux/amd64, go1.26.8)

8,8 Mo, 2,6 Mo compressés : c'est presque uniquement le binaire. Mais dès qu'on l'utilise :

$ docker run --rm export:naif -url https://pypi.org/simple
erreur : fuseau horaire : unknown time zone Europe/Paris
$ docker run --rm export:naif -fuseau UTC -url https://pypi.org/simple
export du 01/10/2026 12:32 UTC
erreur : Get "https://pypi.org/simple/signalements": tls: failed to verify certificate: x509: certificate signed by unknown authority

Deux échecs, deux fichiers manquants. Le fuseau Europe/Paris est introuvable : Go cherche les données de fuseaux dans /usr/share/zoneinfo, absent. Avec UTC, qui ne demande aucune donnée, on va plus loin, jusqu'à la connexion HTTPS : Go cherche les certificats des autorités de confiance dans /etc/ssl/certs/ (entre autres emplacements), et n'en trouve aucun, donc ne fait confiance à personne. Et le programme tourne en root :

$ docker image inspect export:naif --format 'User={{json .Config.User}}'
User=""

La version complète

# syntax=docker/dockerfile:1
FROM golang:1.26 AS construction
WORKDIR /src
COPY go.mod *.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -tags timetzdata -o /signalements-export . \
 && echo "export:x:65532:65532:signalements-export:/nonexistent:/sbin/nologin" > /passwd \
 && echo "export:x:65532:" > /group

FROM scratch
COPY --from=construction /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=construction /passwd /etc/passwd
COPY --from=construction /group /etc/group
COPY --from=construction /signalements-export /signalements-export
USER 65532:65532
ENTRYPOINT ["/signalements-export"]

Trois ajouts :

  • Les certificats sont copiés depuis l'image de construction, qui les tient à jour par le paquet Debian ca-certificates.
  • Les fuseaux horaires sont embarqués dans le binaire avec l'étiquette de construction timetzdata, qui inclut le paquet time/tzdata de Go (mesuré ici : 409 600 octets de plus pour le binaire). L'alternative est de copier /usr/share/zoneinfo (2 Mo dans l'image golang:1.26) ; l'étiquette a l'avantage de figer la version des données avec celle du binaire.
  • Un utilisateur : USER 65532:65532 suffirait au noyau, qui ne manipule que des nombres, mais un /etc/passwd minimal donne un nom à l'UID pour les outils et les programmes qui le demandent. L'UID 65532 est la convention des images distroless pour nonroot.
$ docker build -q -t export:scratch .
$ docker image ls --tree export:scratch | grep linux/amd64
└─ linux/amd64         e7421db2e63d       9.71MB         2.86MB
$ docker run --rm export:scratch -url https://pypi.org/simple
export du 01/10/2026 14:33 CEST
erreur : statut HTTP 406
$ docker history export:scratch --format '{{.Size}}\t{{.CreatedBy}}'
0B	ENTRYPOINT ["/signalements-export"]
0B	USER 65532:65532
6.58MB	COPY /signalements-export /signalements-expo…
12.3kB	COPY /group /etc/group # buildkit
12.3kB	COPY /passwd /etc/passwd # buildkit
242kB	COPY /etc/ssl/certs/ca-certificates.crt /etc…

L'heure est en CEST, l'heure d'été de Paris. La connexion TLS aboutit : l'erreur 406 vient de PyPI, qui refuse simplement notre requête, ce qui prouve que le certificat a été vérifié. Le tout pour 9,7 Mo, dont 6,6 Mo de binaire. Et l'outil fonctionne contre la vraie API, lancée dans un réseau Docker (cours précédent, leçon 10) :

$ docker run --rm --network signalements export:scratch -url http://app:8000
export du 01/10/2026 14:34 CEST
id,lieu,description
1,Rue des Lilas,Lampadaire éteint
2,Place de la Mairie,"Banc cassé, dangereux"

La même chose sur distroless/static

Si l'on ne veut pas gérer soi-même certificats, fuseaux et utilisateur, distroless/static les fournit :

# syntax=docker/dockerfile:1
FROM golang:1.26 AS construction
WORKDIR /src
COPY go.mod *.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /signalements-export .

FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=construction /signalements-export /signalements-export
ENTRYPOINT ["/signalements-export"]
$ docker image ls --tree export:distroless | grep linux/amd64
└─ linux/amd64            e9efd99fe9f9       15.4MB         3.49MB
$ docker run --rm export:distroless -url https://pypi.org/simple
export du 01/10/2026 14:33 CEST
erreur : statut HTTP 406
$ docker image inspect export:distroless --format 'User={{.Config.User}}'
User=65532

Sans étiquette timetzdata ni copie de certificats, tout fonctionne : l'image fournit /usr/share/zoneinfo, les certificats, et l'utilisateur nonroot. Sa variante debug (avec un shell BusyBox, à n'utiliser que pour le diagnostic) permet de voir les utilisateurs prévus :

$ docker run --rm --entrypoint /busybox/sh gcr.io/distroless/static-debian13:debug-nonroot -c 'cat /etc/passwd; id'
root:x:0:0:root:/root:/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/sbin/nologin
nonroot:x:65532:65532:nonroot:/home/nonroot:/sbin/nologin
uid=65532(nonroot) gid=65532(nonroot) groups=65532(nonroot)

Ces 5,7 Mo de plus que la version scratch sont essentiellement l'image de base distroless/static elle-même, dont les données de fuseaux horaires complètes sont la plus grosse part. En échange, l'image de base est maintenue par d'autres, et ses certificats sont mis à jour à chaque reconstruction de distroless.

L'API Python sur distroless

Pour Python, la contrainte vient de l'interpréteur : l'image gcr.io/distroless/python3-debian13 embarque le Python de Debian 13, en version 3.13 (leçon 1), installé dans /usr/bin. L'environnement virtuel doit donc être construit avec ce même interpréteur, dans une image Debian 13 :

$ docker run --rm --entrypoint /usr/bin/python3 gcr.io/distroless/python3-debian13:nonroot -c "import sys; print(sys.version, sys.executable)"
3.13.5 (main, Aug 10 2026, 12:06:59) [GCC 14.2.0] /usr/bin/python3
$ docker run --rm debian:13-slim sh -c 'apt-get update -qq >/dev/null && apt-cache policy python3.13 | sed -n 1,3p'
python3.13:
  Installed: (none)
  Candidate: 3.13.5-2+deb13u5

Même version, même chemin. Le Dockerfile :

# syntax=docker/dockerfile:1

# Construction avec le Python de Debian 13, le même que celui de l'image distroless
FROM debian:13-slim AS construction
RUN apt-get update \
 && apt-get install -y --no-install-recommends python3 python3-venv python3-pip \
 && rm -rf /var/lib/apt/lists/*
RUN python3 -m venv --without-pip /opt/venv
COPY requirements.txt .
RUN pip3 --python /opt/venv/bin/python3 install --no-cache-dir -r requirements.txt

FROM gcr.io/distroless/python3-debian13:nonroot
COPY --from=construction /opt/venv /opt/venv
WORKDIR /app
COPY app.py .
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
ENTRYPOINT ["/opt/venv/bin/python3", "-m", "gunicorn"]
CMD ["--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "app:app"]

Quelques détails :

  • Le pip de Debian refuse d'installer des paquets dans le Python du système (PEP 668), mais avec --python il se relance dans l'environnement virtuel, où ce garde-fou ne s'applique pas : aucune option supplémentaire n'est nécessaire.
  • L'image distroless n'a pas de shell : ENTRYPOINT et CMD sont obligatoirement en forme exec. Gunicorn est lancé comme module de l'interpréteur de l'environnement virtuel (python3 -m gunicorn) : le script gunicorn fonctionnerait aussi (sa première ligne désigne l'interpréteur de l'environnement), mais l'appel par module est explicite.
  • Pas de USER : l'étiquette nonroot de l'image le fixe déjà à 65532.
$ docker build -q -t signalements:distroless -f Dockerfile.distroless .
$ docker image ls --tree signalements:distroless | grep linux/amd64
└─ linux/amd64                  e3a388f18a63        129MB         30.7MB
$ docker run -d --name app-dl --network signalements \
    -e DATABASE_URL=postgresql://postgres:essai@db:5432/postgres signalements:distroless
$ docker logs app-dl 2>&1 | tail -3
[2026-10-01 12:34:48 +0000] [1] [INFO] Using worker: sync
[2026-10-01 12:34:48 +0000] [7] [INFO] Booting worker with pid: 7
[2026-10-01 12:34:48 +0000] [8] [INFO] Booting worker with pid: 8
$ docker run --rm --network signalements export:scratch -url http://app-dl:8000 2>/dev/null
id,lieu,description
1,Rue des Lilas,Lampadaire éteint
2,Place de la Mairie,"Banc cassé, dangereux"

L'API fonctionne, et l'outil Go en scratch l'interroge : deux images minimales qui coopèrent.

Et si l'on tient à Python 3.14 ?

Les images Python de Chainguard suivent la dernière version de Python. Leur variante -dev (avec shell et pip) sert à construire, la variante normale à exécuter :

# syntax=docker/dockerfile:1
FROM cgr.dev/chainguard/python:latest-dev AS construction
WORKDIR /app
RUN python -m venv /app/venv
COPY requirements.txt .
RUN /app/venv/bin/pip install --no-cache-dir -r requirements.txt

FROM cgr.dev/chainguard/python:latest
WORKDIR /app
COPY --from=construction /app/venv /app/venv
COPY app.py .
ENV PATH="/app/venv/bin:$PATH" PYTHONUNBUFFERED=1
ENTRYPOINT ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "app:app"]
$ docker image ls --tree signalements:chainguard | grep linux/amd64
└─ linux/amd64                  3e84c730ff9a        169MB         42.1MB
$ docker run --rm --entrypoint python signalements:chainguard --version
Python 3.14.7

Elle fonctionne aussi, en 3.14. Rappel de la leçon 1 : la version gratuite n'existe qu'en étiquette latest, on ne peut donc pas épingler une version mineure de Python sans abonnement.

Le bilan, mesuré

ImageDisque / compresséePaquetsVulnérabilitésdont hautesdont hautes corrigeablesShell
export sur alpine:3.2221,6 Mo / 6,43 Mo18000oui
export sur distroless/static15,4 Mo / 3,49 Mo8000non
export sur scratch9,71 Mo / 2,86 Mo2000non
signalements sur python:3.14-slim217 Mo / 51,8 Mo1162095511oui
signalements sur distroless/python3129 Mo / 30,7 Mo48167242non
signalements sur chainguard/python169 Mo / 42,1 Mo60644non

(Mesures Trivy 0.74.0 du 1er octobre 2026 ; la ligne python:3.14-slim est l'image de référence du cours précédent, en une seule étape.)

Lecture :

  • Pour le binaire Go, toutes les variantes sont propres ; la différence porte sur la surface (18 paquets et un shell, contre 2 « paquets » et rien) et la taille.
  • Pour Python, distroless divise par plus de deux les vulnérabilités hautes et supprime le shell. Les 2 corrigeables restantes sont dans OpenSSL (libssl3t64), corrigé dans Debian depuis la dernière reconstruction de l'image distroless : l'image minimale ne dispense pas de surveiller la fraîcheur de la base.
  • Les 4 vulnérabilités hautes de la variante Chainguard (et les 2 autres) sont... celles de pip (urllib3, setuptools, msgpack), installé dans l'environnement virtuel par python -m venv. La technique de la leçon 2 (--without-pip et pip --python) les ferait disparaître : une image de base durcie ne protège pas de ce que l'on y ajoute.

Sous le capot

Ce que Trivy trouve dans une image vide. L'image scratch contient deux « paquets » selon Trivy :

$ docker save export:scratch -o img.tar
$ docker run --rm -v "$PWD":/t:ro -v trivy-cache:/root/.cache aquasec/trivy:0.74.0 \
    image --quiet --input /t/img.tar --list-all-pkgs --format json \
  | jq -r '.Results[] | "\(.Target) \(.Type) \([.Packages[]?.Name] | join(","))"'
signalements-export gobinary lyneko.fr/signalements-export,stdlib

Un binaire Go embarque ses informations de construction : chemin du module, version de Go (donc de la bibliothèque standard), versions de toutes les dépendances. Trivy les lit dans le binaire lui-même, sans gestionnaire de paquets ; -s -w ne les retire pas. C'est grâce à elles qu'une vulnérabilité de la bibliothèque standard de Go est détectée dans un binaire scratch (comme dans gosu, leçon 12 du cours précédent). go version -m <binaire> les affiche.

Pourquoi distroless n'a pas besoin de dpkg pour être analysé. Les images distroless ne contiennent pas dpkg, mais elles contiennent, dans /var/lib/dpkg/status.d/, un fichier de statut par paquet dont elles ont extrait les fichiers. Les analyseurs les lisent pour savoir quels paquets Debian, dans quelle version, composent l'image. Sans ces fichiers, une image minimale serait une boîte noire pour les outils de sécurité.

Comment se lance un programme sans shell. Avec la forme exec, Docker (via runc) appelle directement execve() sur le chemin indiqué : aucun shell n'est nécessaire. Avec la forme shell, il faudrait /bin/sh, absent : l'image ne démarre pas. Pour la même raison, une variable comme $PORT écrite dans un CMD en forme exec n'est pas remplacée (c'est le shell qui fait cette substitution), et docker exec ne peut lancer que les binaires présents dans l'image.

Pièges courants

exec: "sh": executable file not found in $PATH. Vous tentez d'ouvrir un shell dans une image qui n'en a pas :

$ docker exec app-dl sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH

Ce n'est pas un défaut : c'est le but. Déboguez autrement (ci-dessous).

x509: certificate signed by unknown authority dans une image scratch. Les certificats d'autorité manquent ; copiez ca-certificates.crt depuis l'étape de construction, et mettez à jour cette étape régulièrement, sans quoi les certificats vieillissent (une autorité retirée reste acceptée, une nouvelle n'est pas reconnue). Si l'application doit faire confiance à l'autorité interne d'un client, ajoutez son certificat au même fichier pendant la construction.

unknown time zone. Les données de fuseaux manquent ; en Go, -tags timetzdata ou l'import de time/tzdata les embarque, sinon copiez /usr/share/zoneinfo. Les autres langages ont leurs propres mécanismes (Python s'appuie sur /usr/share/zoneinfo ou sur le paquet tzdata de PyPI).

Construire pour distroless avec le mauvais Python. Un environnement virtuel construit avec python:3.14 ne fonctionne pas dans distroless/python3-debian13, qui n'a que la 3.13 : les liens de l'environnement pointent vers /usr/local/bin/python, absent de distroless. Construisez avec le Python de la même distribution (ici debian:13-slim), et refaites la vérification à chaque montée de version de distroless.

L'entrée de l'image distroless Python. Son ENTRYPOINT d'origine est /usr/bin/python3.13. Si vous ne le remplacez pas, docker run image app.py exécute python3.13 app.py, et votre CMD devient un argument de l'interpréteur. Fixez explicitement ENTRYPOINT et CMD, comme dans la leçon.

Un binaire « statique » qui ne l'est pas. Un programme Go qui utilise net ou os/user avec cgo activé (le défaut dans golang:1.26, qui a un compilateur C) est lié dynamiquement à glibc et échoue dans scratch avec le no such file or directory trompeur de la leçon 1. CGO_ENABLED=0, et vérifiez avec file.

Sécurité

  • Moins de contenu, moins de moyens pour un attaquant. Sans shell, sans curl, sans gestionnaire de paquets, l'exploitation d'une faille de l'application ne donne pas d'environnement confortable : pas de téléchargement d'outils, pas de script, pas d'installation. Ce n'est pas une protection absolue (un attaquant qui exécute du code Python a l'interpréteur Python), mais cela élimine une grande partie des scénarios automatisés.
  • Moins de bruit dans les analyses. Une image minimale a peu de vulnérabilités à trier, ce qui rend visibles celles qui comptent. Une équipe qui doit traiter 200 alertes par image finit par ne plus les lire.
  • Non-root dès la base. Les étiquettes nonroot de distroless et l'utilisateur par défaut des images Chainguard évitent d'oublier le USER.
  • N'utilisez pas les variantes debug en production. Elles remettent un shell, et avec lui ce que l'image minimale avait retiré. Réservez-les à un diagnostic ponctuel, hors production, ou préférez la méthode ci-dessous.

En production

Déboguer une image sans shell. La méthode du cours précédent (leçon 6) s'applique telle quelle : un conteneur d'outillage qui partage les namespaces de la cible.

$ docker run --rm --pid container:app-dl --network container:app-dl --cap-add SYS_PTRACE busybox:1.37 \
    sh -c 'ps -o pid,user,args | head -4; ls /proc/1/root/app; wget -qO- http://127.0.0.1:8000/sante'
PID   USER     COMMAND
    1 65532    /opt/venv/bin/python3 -m gunicorn --bind 0.0.0.0:8000 --workers 2 --no-control-socket app:app
    7 65532    /opt/venv/bin/python3 -m gunicorn --bind 0.0.0.0:8000 --workers 2 --no-control-socket app:app
    8 65532    /opt/venv/bin/python3 -m gunicorn --bind 0.0.0.0:8000 --workers 2 --no-control-socket app:app
/proc/1/root/app:
app.py
{"etat":"ok"}

On voit les processus de l'application (UID 65532), son système de fichiers par /proc/1/root, et l'on interroge son port local. Sur Kubernetes, c'est exactement ce que fait kubectl debug avec un conteneur éphémère.

Arbitrer. Une image minimale demande une équipe à l'aise avec ce mode de diagnostic, et une politique de mise à jour de la base comme pour toute image. Pour les binaires statiques, scratch ou distroless/static sont un choix évident. Pour les langages interprétés, distroless impose la version du langage de la distribution, Chainguard impose latest en version gratuite, et une image slim bien construite (leçon 2) reste une option raisonnable. Choisissez par service, documentez, mesurez.

Les certificats internes. Dans les environnements de clients publics, les services internes sont souvent signés par une autorité de certification privée. Prévoyez dans l'étape de construction l'ajout de ce certificat au paquet de certificats copié dans l'image, plutôt que de désactiver la vérification TLS dans l'application.

Exercices

1. Trouver ce qui manque. Partez du Dockerfile naïf (scratch, sans rien d'autre que le binaire). Lancez signalements-export -fuseau America/Montreal -url https://example.org. Corrigez une erreur à la fois, en notant le message de chacune, jusqu'à obtenir une réponse HTTP.

Solution

D'abord erreur : fuseau horaire : unknown time zone America/Montreal : ajoutez -tags timetzdata (ou copiez /usr/share/zoneinfo). Ensuite x509: certificate signed by unknown authority : copiez /etc/ssl/certs/ca-certificates.crt. Enfin :

$ docker run --rm export:scratch -fuseau America/Montreal -url https://example.org
export du 01/10/2026 08:38 EDT
erreur : statut HTTP 404

L'heure de Montréal s'affiche, et la réponse 404 d'example.org (qui ne sert évidemment pas l'API) prouve que la connexion TLS a abouti. Ajoutez pour finir USER 65532:65532 et vérifiez avec docker image inspect que l'image ne tourne plus en root.

2. Lire les informations embarquées. Extrayez le binaire de l'image scratch (avec docker create puis docker cp) et affichez ses informations de construction avec go version -m, depuis un conteneur golang:1.26. Quelles options de construction y figurent ?

Solution
$ id=$(docker create export:scratch) && docker cp $id:/signalements-export . && docker rm $id
$ docker run --rm -v "$PWD":/x golang:1.26 go version -m /x/signalements-export
/x/signalements-export: go1.26.8
	path	lyneko.fr/signalements-export
	mod	lyneko.fr/signalements-export	(devel)
	build	-buildmode=exe
	build	-compiler=gc
	build	-tags=timetzdata
	build	-trimpath=true
	build	CGO_ENABLED=0
	build	GOARCH=amd64
	build	GOOS=linux
	build	GOAMD64=v1

On y lit la version de Go, le chemin du module, et les paramètres de construction : l'étiquette timetzdata, -trimpath, CGO_ENABLED=0, l'architecture. Les options -ldflags n'y figurent pas : Go les omet quand -trimpath est actif, parce qu'elles pourraient contenir des chemins de la machine de construction. Ce sont ces informations que lisent Trivy et les générateurs de SBOM (leçon 8).

3. Signalements sans pip sur Chainguard (niveau 300). Modifiez le Dockerfile Chainguard pour que l'environnement virtuel ne contienne pas pip, puis vérifiez avec Trivy que les 4 vulnérabilités corrigeables ont disparu.

Solution

Dans l'étape de construction, créez l'environnement avec python -m venv --without-pip /app/venv et installez avec le pip de l'image -dev : pip --python /app/venv/bin/python install --no-cache-dir -r requirements.txt (la technique de la leçon 2). L'image d'exécution cgr.dev/chainguard/python:latest ne contient pas pip elle-même. Mesuré : l'image passe de 169 à 150 Mo, et Trivy n'y trouve plus aucune vulnérabilité.

4. Choisir (niveau 300). Pour chacun de ces programmes, proposez une image d'exécution minimale et listez ce qu'il faut y ajouter : (a) un service Go qui appelle une API HTTPS interne signée par l'autorité de certification privée d'une collectivité ; (b) un script Python de traitement de fichiers qui utilise zoneinfo et écrit dans /tmp ; (c) un binaire Rust lié dynamiquement à glibc et OpenSSL.

Solution

(a) scratch (ou distroless/static), binaire CGO_ENABLED=0, paquet de certificats augmenté du certificat de l'autorité privée (ajouté dans l'étape de construction avec update-ca-certificates), utilisateur non-root. (b) distroless/python3-debian13:nonroot si Python 3.13 convient : elle fournit zoneinfo et /tmp ; sinon une image slim sans pip. (c) gcr.io/distroless/base-debian13:nonroot, qui fournit glibc et OpenSSL ; ou une compilation statique avec la cible musl pour viser scratch. Dans tous les cas, vérifiez avec ldd (ou file) ce que le binaire attend réellement.

Récapitulatif

  • scratch est vide : idéal pour un binaire statique (Go avec CGO_ENABLED=0), à condition d'y ajouter certificats, fuseaux horaires et utilisateur quand le programme en a besoin. signalements-export y tient en 9,7 Mo.
  • distroless fournit ces fichiers et, selon la variante, glibc et un environnement de langage, sans shell ni gestionnaire de paquets. Son Python est celui de Debian (3.13) : construisez avec le même.
  • Résultat pour Signalements : 129 Mo et 24 vulnérabilités hautes sur distroless, contre 217 Mo et 55 sur l'image slim de départ.
  • Une base minimale ne protège pas de ce que l'on y ajoute (pip dans l'environnement virtuel) ni de son propre vieillissement (OpenSSL à mettre à jour).
  • On débogue une image sans shell avec un conteneur d'outillage qui partage ses namespaces, comme kubectl debug.

Pour aller plus loin

  • Le dépôt distroless : le README détaille chaque variante, et les exemples par langage montrent les constructions recommandées.
  • La documentation du paquet time/tzdata et de crypto/x509 de Go, pour comprendre où le langage cherche ces données sur chaque système.
  • Leçon suivante : rendre les constructions reproductibles, pour que deux constructions des mêmes sources produisent exactement la même image.
Voir ma constellation →

Sources