Images minimales : distroless et scratch
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 :
| Variante | Contenu | Pour |
|---|---|---|
static | Certificats d'autorité, données de fuseaux horaires, /etc/passwd avec les utilisateurs root, nobody et nonroot, /tmp | Binaires statiques (Go, Rust) |
base | static + glibc, OpenSSL | Binaires dynamiques simples |
cc | base + libstdc++ | Programmes C++ |
python3, java, nodejs | cc + l'environnement d'exécution du langage, dans la version de Debian | Applications 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 :
| Besoin | Fichier | Symptôme s'il manque |
|---|---|---|
| Vérifier un certificat TLS | /etc/ssl/certs/ca-certificates.crt | x509: 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/group | Programme qui affiche des UID au lieu de noms, ou qui échoue |
| Écrire un fichier temporaire | /tmp | No usable temporary directory (leçon 9 du cours précédent) |
| Résoudre un nom DNS | /etc/resolv.conf, /etc/hosts | Fournis 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=0produit un binaire statique, sans dépendance à la bibliothèque C (leçon 1).-trimpathretire 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 paquettime/tzdatade Go (mesuré ici : 409 600 octets de plus pour le binaire). L'alternative est de copier/usr/share/zoneinfo(2 Mo dans l'imagegolang:1.26) ; l'étiquette a l'avantage de figer la version des données avec celle du binaire. - Un utilisateur :
USER 65532:65532suffirait au noyau, qui ne manipule que des nombres, mais un/etc/passwdminimal donne un nom à l'UID pour les outils et les programmes qui le demandent. L'UID 65532 est la convention des images distroless pournonroot.
$ 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
--pythonil 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 :
ENTRYPOINTetCMDsont obligatoirement en forme exec. Gunicorn est lancé comme module de l'interpréteur de l'environnement virtuel (python3 -m gunicorn) : le scriptgunicornfonctionnerait aussi (sa première ligne désigne l'interpréteur de l'environnement), mais l'appel par module est explicite. - Pas de
USER: l'étiquettenonrootde 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é
| Image | Disque / compressée | Paquets | Vulnérabilités | dont hautes | dont hautes corrigeables | Shell |
|---|---|---|---|---|---|---|
export sur alpine:3.22 | 21,6 Mo / 6,43 Mo | 18 | 0 | 0 | 0 | oui |
export sur distroless/static | 15,4 Mo / 3,49 Mo | 8 | 0 | 0 | 0 | non |
export sur scratch | 9,71 Mo / 2,86 Mo | 2 | 0 | 0 | 0 | non |
signalements sur python:3.14-slim | 217 Mo / 51,8 Mo | 116 | 209 | 55 | 11 | oui |
signalements sur distroless/python3 | 129 Mo / 30,7 Mo | 48 | 167 | 24 | 2 | non |
signalements sur chainguard/python | 169 Mo / 42,1 Mo | 60 | 6 | 4 | 4 | non |
(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 parpython -m venv. La technique de la leçon 2 (--without-pipetpip --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
nonrootde distroless et l'utilisateur par défaut des images Chainguard évitent d'oublier leUSER. - N'utilisez pas les variantes
debugen 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=v1On 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
scratchest vide : idéal pour un binaire statique (Go avecCGO_ENABLED=0), à condition d'y ajouter certificats, fuseaux horaires et utilisateur quand le programme en a besoin.signalements-exporty 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/tzdataet decrypto/x509de 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.
Sources
- GoogleContainerTools, distroless : README et variantes
- Docker, Create a base image (scratch)
- Go, package time/tzdata (étiquette timetzdata)
- Go, package crypto/x509 : emplacements des certificats sous Linux
- Go, cmd/go : informations de construction embarquées (debug/buildinfo)
- Chainguard Academy, Python images
- Kubernetes, Debug Running Pods (conteneurs éphémères)