## Introduction Les builds multi-étapes de Docker permettent de compiler une application dans un conteneur et de copier uniquement les artefacts d'exécution dans une image finale beaucoup plus petite. Ce modèle réduit la taille et la surface d'attaque, mais la valeur en matière de sécurité dépend du soin apporté à la structuration de chaque étape. Un build qui laisse des identifiants dans une couche, exécute le conteneur final en tant que root, ou copie plus que le binaire compilé crée toujours un risque. Ce guide montre comment renforcer les builds multi-étapes avec des exemples concrets : sélection des images de base, utilisation des montages de secrets BuildKit, application d'un utilisateur non-root, minimisation du système de fichiers final, épinglage des dépendances et vérification du résultat. Les commandes et extraits de Dockerfile sont conçus pour être appliqués progressivement à un build existant, observer le résultat et revenir en arrière si quelque chose se casse. L'objectif est la sécurité opérationnelle : observer avant de changer, limiter le rayon d'impact, utiliser des espaces réservés au lieu de secrets, vérifier le résultat et documenter la récupération si l'état attendu n'est pas atteint. ## Inventaire des versions et de l'environnement Avant de modifier un Dockerfile, connaissez les versions et les outils impliqués. Les fonctionnalités de sécurité et la syntaxe diffèrent entre Docker Engine, BuildKit et le runtime de conteneurs. Exécutez ces commandes pour capturer l'environnement : ```bash docker version --format '{{.Server.Version}}' # Version de Docker Engine docker buildx version # Support de Buildx et BuildKit docker info --format '{{.SecurityOptions}}' # Statut de seccomp, AppArmor, SELinux ``` Exemple de sortie d'un poste de travail typique : ``` Docker Engine: 24.0.7 buildx: v0.12.0 Security Options: name=seccomp,profile=builtin name=apparmor ``` Si BuildKit n'est pas activé par défaut, définissez-le comme variable d'environnement ou dans la configuration du démon Docker. Les versions modernes de Docker (23 et ultérieures) activent BuildKit automatiquement. Pour les versions plus anciennes, exécutez les builds avec `DOCKER_BUILDKIT=1 docker build ...` ou ajoutez ceci à `/etc/docker/daemon.json` : ```json { "features": { "buildkit": true } } ``` Ensuite, redémarrez Docker et vérifiez avec `docker buildx version`. BuildKit est nécessaire pour les montages de secrets et certaines fonctionnalités de cache utilisées plus tard. Confirmez également la plateforme de déploiement cible. Les builds multi-architectures nécessitent `docker buildx build --platform linux/amd64,linux/arm64`. L'image de base doit exister pour ces plateformes ; sinon le build échoue. **Responsabilité :** L'ingénieur DevOps responsable du pipeline de build doit documenter les versions de Docker Engine et de BuildKit et les revoir mensuellement ou lors de l'adoption d'une nouvelle version LTS de Node ou de Go. ## Chemin de configuration sûr Le chemin sûr consiste à faire un petit changement à la fois et à le vérifier avant de continuer. Commencez avec un Dockerfile multi-étapes fonctionnel et améliorez-le progressivement. Un exemple typique avec Node.js : ```dockerfile # Avant le durcissement FROM node:18 AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:18-slim WORKDIR /app COPY --from=build /app/dist ./dist COPY package*.json ./ RUN npm ci --omit=dev CMD ["node", "dist/index.js"] ``` Cette image contient encore le runtime Node.js complet, s'exécute en tant que root et inclut des fichiers inutiles dans la couche finale. Le durcissement se fait par petites étapes, chacune avec une commande de vérification. **Étape 1 : Ajouter un utilisateur non-root et définir la propriété.** ```dockerfile FROM node:18-slim RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY --from=build --chown=appuser:appuser /app/dist ./dist COPY --chown=appuser:appuser package*.json ./ RUN npm ci --omit=dev && chown -R appuser:appuser /app USER appuser CMD ["node", "dist/index.js"] ``` Vérifiez le contexte utilisateur à l'intérieur du conteneur : ```bash docker run --rm myimage id # Attendu : uid=1000(appuser) gid=1000(appuser) groups=1000(appuser) ``` Si l'application doit se lier à un port inférieur à 1024, utilisez un port plus élevé en interne et mappez-le sur l'hôte, ou accordez explicitement la capacité : `docker run --cap-add=NET_BIND_SERVICE ...`. **Étape 2 : Passer à une image d'exécution distroless ou minimale.** Pour Node.js, `distroless/nodejs18-debian12` de Google ne contient que le runtime et les certificats CA. L'étape de build utilise toujours l'image complète. ```dockerfile FROM node:18 AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM gcr.io/distroless/nodejs18-debian12 WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules CMD ["dist/index.js"] ``` Remarque : avec une image distroless, il n'y a pas de shell, donc le débogage nécessite `docker run --entrypoint /nodejs/bin/node ...` et l'inspection via `docker exec` est limitée. L'image finale n'a plus `apt`, `bash` ou `curl`, ce qui réduit la surface d'attaque. Vérifiez que l'image n'a pas de gestionnaire de paquets : ```bash docker run --rm myimage which apt # Attendu : erreur, commande introuvable ``` **Étape 3 : Séparer les dépendances de build et d'exécution.** Dans l'étape de build, installez uniquement ce qui est nécessaire pour compiler. Si l'application utilise des modules natifs, l'étape de build peut avoir besoin de `python3`, `make` et `g++`, tandis que l'étape d'exécution n'en a pas besoin. ```dockerfile FROM node:18 AS build RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM gcr.io/distroless/nodejs18-debian12 WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules CMD ["dist/index.js"] ``` **Étape 4 : Utiliser des montages de secrets pour les identifiants de build.** N'intégrez jamais de secrets comme des jetons npm ou des clés SSH dans l'image. Les montages de secrets BuildKit rendent le secret disponible uniquement pour une commande `RUN` sans le persister dans une couche. ```dockerfile # syntax=docker/dockerfile:1.4 FROM node:18 AS build RUN --mount=type=secret,id=npm_token \ export NPM_TOKEN=$(cat /run/secrets/npm_token) && \ npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN && \ npm ci # reste du build ``` Passez le secret au moment du build à partir d'un fichier ou d'une variable d'environnement : ```bash docker build --secret id=npm_token,src=./npm_token.txt -t myimage . ``` Le fichier `npm_token.txt` n'est pas copié dans l'image. Après l'instruction `RUN`, le secret est démonté et disparaît. **Étape 5 : Réduire le contenu de l'image finale.** Ne copiez que l'application compilée et ses dépendances d'exécution. Excluez les fichiers de test, les cartes sources et les outils de build. Pour un binaire Go, c'est simple : ```dockerfile FROM golang:1.21 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /out/app . FROM gcr.io/distroless/static-debian12 COPY --from=build /out/app /app USER nonroot:nonroot ENTRYPOINT ["/app"] ``` L'image finale ne contient que le binaire statique et les certificats CA. Vérifiez la taille : ```bash docker images myimage # REPOSITORY TAG IMAGE ID CREATED SIZE # myimage latest a1b2c3d4e5f6 10 seconds ago 12.5MB ``` Comparée à l'image complète originale `node:18` (environ 950 Mo), la réduction est spectaculaire. **Étape 6 : Épingler les empreintes des images de base.** Les tags comme `node:18` bougent. Épinglez par empreinte pour garantir la reproductibilité et vous protéger contre un tag compromis. Trouvez l'empreinte d'une image de base : ```bash docker pull node:18-slim docker images --digests node # REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE # node 18-slim sha256:2f7f7e9b3bb4d9f1e4f2d8b3e2a8d9c3c6f4a9d0a1d5c6e9f2c1e9e5f7b2c1 6f7f8c8d9e0a 2 weeks ago 177MB ``` Utilisez l'empreinte dans la ligne `FROM` : ```dockerfile FROM node:18-slim@sha256:2f7f7e9b3bb4d9f1e4f2d8b3e2a8d9c3c6f4a9d0a1d5c6e9f2c1e9e5f7b2c1 AS build ``` **Étape 7 : Ajouter un HEALTHCHECK.** Un healthcheck aide l'orchestrateur à savoir si le conteneur fonctionne correctement. ```dockerfile HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD ["/app/healthcheck"] ``` Pour une application Node.js sans binaire séparé, utilisez `node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"`. Testez localement avec `docker run --health-cmd ...` et `docker inspect --format='{{.State.Health.Status}}' `. ## Vérification et diagnostics Après chaque étape de durcissement, vérifiez que l'image se comporte correctement et possède les propriétés de sécurité attendues. **Vérification 1 : Historique de l'image et couches.** N'exposez jamais de secrets dans les couches de l'image. Utilisez `docker history` pour rechercher du contenu suspect. ```bash docker history --no-trunc myimage ``` Recherchez les lignes contenant `ENV SECRET=...` ou `RUN echo secret > file`. Si vous avez utilisé un montage de secret, le secret ne doit apparaître dans aucune couche. Pour en être certain, analysez l'image avec un outil comme `trivy` : ```bash trivy image --severity HIGH,CRITICAL myimage ``` **Vérification 2 : Contenu du système de fichiers.** Exécutez un conteneur temporaire et listez le système de fichiers : ```bash docker run --rm --entrypoint ls myimage -la /app ``` Sortie attendue : uniquement les fichiers de l'application, pas de `.git`, pas de `node_modules` avec des dépendances de développement, pas de `Dockerfile`. **Vérification 3 : Utilisateur en cours d'exécution.** ```bash docker run --rm --entrypoint id myimage # Attendu : uid=65532(nonroot) gid=65532(nonroot) groups=65532(nonroot) ``` **Vérification 4 : Capacités et options de sécurité.** ```bash docker run --rm --cap-drop ALL --security-opt no-new-privileges myimage ``` Si l'application a besoin de capacités spécifiques, rajoutez-les une par une et documentez pourquoi. **Vérification 5 : Exposition réseau.** Assurez-vous que le conteneur écoute uniquement sur le port prévu. Avec `docker run -p 127.0.0.1:3000:3000 ...`, le service n'est pas exposé aux interfaces externes de l'hôte. **Vérification 6 : Fonctionnalités de sécurité du runtime.** Exécutez avec un système de fichiers racine en lecture seule et un utilisateur non-root : ```bash docker run --rm --read-only --tmpfs /tmp --user 1000:1000 myimage ``` Si l'application écrit dans `/tmp`, le montage `tmpfs` le permet. Pour d'autres emplacements inscriptibles comme `/var/run`, ajoutez des montages `--tmpfs` supplémentaires. ## Modes de défaillance et récupération Même avec des builds durcis, des défaillances surviennent. Modes de défaillance courants et étapes de récupération : **Défaillance : Le build échoue avec `secret not found`.** - Cause : l'ID du montage de secret n'a pas été passé avec `--secret` ou la variable d'environnement n'est pas définie. - Récupération : assurez-vous que le fichier de secret existe et est lisible. Pour les systèmes CI, utilisez le gestionnaire de secrets pour injecter le fichier. Exécutez le build avec `--progress=plain` pour voir l'erreur exacte. **Défaillance : Erreur d'exécution `exec: "node": executable file not found in $PATH` lors du passage à distroless.** - Cause : les images distroless n'incluent pas le binaire `node` dans `$PATH` ; le binaire se trouve à `/nodejs/bin/node`. - Récupération : utilisez le chemin complet dans `CMD` ou définissez `ENV PATH=/nodejs/bin:$PATH` dans le Dockerfile. **Défaillance : L'application ne peut pas se lier à un port bas.** - Cause : l'utilisateur non-root n'a pas la capacité `NET_BIND_SERVICE`. - Récupération : utilisez un port supérieur à 1024 ou ajoutez `--cap-add=NET_BIND_SERVICE` (non recommandé en production). Mieux : configurez un proxy inverse pour transférer vers un port élevé. **Défaillance : Le healthcheck échoue dans l'orchestration.** - Cause : la commande de healthcheck est absente de l'image distroless ou le point de terminaison est incorrect. - Récupération : utilisez un binaire de healthcheck compilé ou `node -e` avec le chemin complet. Testez localement avant de déployer. **Défaillance : La taille de l'image n'est pas réduite.** - Cause : la copie depuis l'étape de build inclut des fichiers inutiles ou la mauvaise image de base. - Récupération : inspectez avec `docker history --human myimage` pour voir la taille des couches. Ajustez les commandes `COPY` pour cibler uniquement les répertoires nécessaires. **Défaillance : Le conteneur plante avec `Permission denied` en écrivant sur un volume monté.** - Cause : l'utilisateur non-root n'a pas la permission d'écrire sur le répertoire initial du volume nommé. - Récupération : pré-créez le répertoire dans le Dockerfile avec `RUN mkdir -p /app/data && chown appuser:appuser /app/data`. Pour les montages bind depuis l'hôte, ajustez les permissions du répertoire hôte ou utilisez un conteneur d'initialisation. ## Pièges courants Plusieurs pièges compromettent la sécurité malgré l'utilisation de builds multi-étapes. **Piège 1 : Copier tout le contexte de build dans l'image finale.** Au lieu de `COPY --from=build /app /app`, copiez uniquement les artefacts nécessaires. Cela garde le code source, les tests et les métadonnées de build hors de l'image de production. **Piège 2 : Utiliser des tags `latest` ou mutables pour les images de base.** Les tags non épinglés conduisent à des builds non reproductibles et à des attaques potentielles de la chaîne d'approvisionnement. Épinglez toujours par empreinte ou au moins par un tag de version spécifique. **Piège 3 : Laisser des identifiants dans les variables d'environnement.** Les secrets de build doivent utiliser des montages de secrets, pas `ENV` ou `ARG`. Les valeurs `ARG` sont persistées dans l'historique de l'image et peuvent être extraites. **Piège 4 : S'exécuter en tant que root.** L'utilisateur par défaut dans la plupart des images de base est root. S'exécuter en tant que root à l'intérieur du conteneur augmente le risque d'évasion de conteneur. Passez toujours à un utilisateur non-root. **Piège 5 : Ne pas abandonner les capacités.** Par défaut, les conteneurs ont un ensemble de capacités. Tout abandonner et ne rajouter que ce qui est nécessaire réduit la surface d'attaque du noyau. Utilisez `--cap-drop ALL` et documentez les capacités requises. **Piège 6 : Absence de healthchecks et de sondes de vivacité.** Sans healthchecks, l'orchestration ne peut pas redémarrer les conteneurs en mauvaise santé. Ajoutez un healthcheck significatif qui teste l'application, pas seulement le processus. **Piège 7 : Ignorer les vulnérabilités de l'image.** Analysez régulièrement les images avec des scanners de vulnérabilités et mettez à jour les images de base. Configurez la CI pour casser le build en cas de vulnérabilités critiques. **Piège 8 : Ne pas utiliser de builds multi-étapes pour les langages statiques.** Même les binaires Go ou Rust bénéficient des builds multi-étapes pour séparer la chaîne d'outils de compilation du runtime. L'image finale à partir de `scratch` ou `distroless/static` ne contient que le binaire. **Responsabilité :** Le chef d'équipe de l'application est responsable de chaque vérification de piège lors de la revue de la pull request du Dockerfile. L'ingénieur sécurité revoit la politique d'analyse des vulnérabilités trimestriellement. ## Liste de contrôle opérationnelle Utilisez cette liste avant de promouvoir une image durcie en production. - Image de base : épinglée par empreinte ? `FROM node:18-slim@sha256:...` - Utilisateur non-root : directive `USER` présente et vérifiée avec `docker run --rm --entrypoint id` ? - Secrets : pas de `ENV` ou `ARG` avec du matériel secret ; montages de secrets utilisés pour le build. - Contenu de l'image finale : uniquement les artefacts d'exécution copiés ; pas de code source, tests ou outils de build. - Capacités : tout abandonner par défaut, ne rajouter que les nécessaires (documentées). - Healthcheck : présent et testé localement ; passe dans l'orchestrateur. - Système de fichiers en lecture seule : le conteneur s'exécute avec `--read-only` et les montages tmpfs nécessaires. - Analyse des vulnérabilités : aucune vulnérabilité élevée ou critique dans l'image finale. - Taille de l'image : raisonnable pour la technologie (par ex., <50 Mo pour un binaire Go, <200 Mo pour une application Node.js). - Reproductibilité du build : même source et Dockerfile produisent une empreinte identique (`docker build --iidfile` et comparer). - Revue du Dockerfile : approuvée par un réviseur sécurité ou DevOps. - Configuration du runtime : le manifeste d'orchestration applique les limites de ressources, le contexte de sécurité et les politiques réseau. - Plan de retour en arrière : tag ou empreinte de l'image précédente connue et documentée. **Propriétaire :** L'ingénieur plateforme est le seul responsable de la complétion de la liste pour chaque version. La liste est appliquée dans la CI comme un travail de contrôle et revue chaque semaine lors de la réunion de préparation de la version. ## Conclusion Les builds multi-étapes de Docker offrent une base solide pour la sécurité des conteneurs, mais ils ne sont pas automatiquement sécurisés. Le durcissement exige des choix délibérés : images de base minimales épinglées, utilisateurs non-root, montages de secrets, abandon des capacités et vérification rigoureuse. Chaque changement doit être appliqué progressivement avec des options d'observation et de retour en arrière. Commencez par une amélioration à faible risque, comme épingler l'empreinte de l'image de base ou ajouter un utilisateur non-root. Enregistrez la taille actuelle de l'image et le rapport de vulnérabilités, appliquez le changement et comparez. Au fil du temps, le pipeline de build devient un processus reproductible qui produit des images plus petites et plus sécurisées. La sécurité est une activité continue. Revisitez régulièrement les images de base, les résultats de vulnérabilités et les politiques de runtime. Les pratiques de ce guide forment une base qui peut être adaptée à votre pile applicative et à votre environnement de déploiement spécifiques.