Comprendre l'embonpoint de vos conteneurs et préparer l'environnement
Chaque mégaoctet inutile qui traîne dans votre registre d'images est une vulnérabilité potentielle en attente d'exploitation et un coût de transfert réseau inutile qui ralentit vos déploiements en production. Lorsque vous débutez en ingénierie DevOps, la tentation est grande d'écrire des fichiers de configuration simples qui compilent et exécutent vos applications au même endroit, mais cette habitude génère des conteneurs obèses contenant des compilateurs, des gestionnaires de paquets et des codes sources complets qui n'ont rien à faire sur un serveur de production.
Pourquoi nos images Docker grossissent-elles sans contrôle ?
Le problème principal réside dans l'accumulation des couches de build. Pour compiler une application écrite dans un langage moderne, vous avez besoin d'un kit de développement complet contenant des outils d'analyse de code, des bibliothèques de test et des utilitaires de compilation. Si vous utilisez une seule image de base pour tout faire, tous ces outils de développement se retrouvent embarqués dans votre image finale, augmentant radicalement la surface d'attaque de votre infrastructure et allongeant le temps nécessaire pour démarrer de nouvelles instances de votre application lors d'un pic de charge.
Préparation de notre plan d'action technique
Pour illustrer ce guide pratique de production, nous allons utiliser une application écrite en langage Go. Le choix de ce langage est idéal car il permet de générer un binaire autonome après compilation, ce qui nous permettra de réduire la taille de notre image finale de manière spectaculaire en appliquant le principe du multi-stage building. Avant de commencer, assurez-vous d'avoir installé l'environnement d'exécution Docker sur votre machine locale.
docker --version
Résultat:
Docker version 26.1.3, build 26.1.3-1
La méthode naïve : l'approche à étape unique
Pour bien apprécier la puissance de l'optimisation par étapes, nous devons d'abord étudier le comportement et les limites de la configuration classique d'un Dockerfile à étape unique. Cette approche, bien que fonctionnelle dans un environnement local de développement, pose de graves problèmes de sécurité et d'efficacité lorsqu'elle est poussée sur vos clusters Kubernetes de production.
Le Dockerfile classique sans optimisation
Voici l'implémentation typique qu'un développeur junior met en place pour faire fonctionner rapidement son application. Ce fichier récupère l'image officielle de développement, y copie l'intégralité du code source, télécharge les modules externes, puis compile et exécute le binaire directement au sein du même conteneur.
FROM golang:1.22.3-alpine3.19
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o api-service .
EXPOSE 8080
CMD ["./api-service"]
Analysons les instructions clés de cette configuration naïve. L'instruction FROM golang:1.22.3-alpine3.19 utilise une image de base contenant l'intégralité du compilateur Go et des outils associés. La commande RUN go build -o api-service. compile notre code source pour générer un fichier exécutable nommé api-service. Malheureusement, après cette compilation, le compilateur Go et les fichiers sources restent présents dans le système de fichiers du conteneur, ce qui alourdit considérablement l'image finale.
Mesurons l'impact de cette configuration sur l'espace disque en construisant notre image à l'aide de l'outil de build de Docker.
docker build -t api-service:naive .
docker images api-service:naive
Résultat:
REPOSITORY TAG IMAGE ID CREATED SIZE
api-service naive a1b2c3d4e5f6 10 seconds ago 842MB
Une image de près de 850 mégaoctets pour exécuter une simple API est inacceptable en production. C'est l'équivalent d'acheter un camion de déménagement entier simplement pour transporter une petite enveloppe scellée.
Le Multi-Stage Build : diviser pour régner
Pour résoudre ce problème de poids, nous allons utiliser le multi-stage building. Cette fonctionnalité majeure de Docker vous permet d'utiliser plusieurs instructions FROM temporaires au sein d'un unique Dockerfile, vous permettant ainsi de passer d'un environnement de compilation riche à un environnement d'exécution minimaliste.
Le principe fondamental de la séparation des responsabilités
Pensez au multi-stage build comme à un chantier de construction. Pour bâtir une maison, vous avez besoin d'échafaudages, de grues et d'outils lourds. Une fois la maison terminée, vous ne laissez pas les échafaudages à l'intérieur du salon : vous les démontez et vous ne livrez que la structure propre au client. Le multi-stage build applique exactement ce principe en vous permettant d'isoler la phase de construction de la phase de livraison.
Le schéma ci-dessus illustre parfaitement le flux de notre processus de construction. Dans l'étape du constructeur, nous réunissons le code source et le compilateur pour générer notre binaire. Une fois cette étape franchie, nous créons un tout nouveau conteneur léger et nous y copions uniquement le binaire compilé, laissant de côté toutes les dépendances lourdes du SDK.
Une première implémentation multi-étape fonctionnelle
Mettons en pratique ce concept en réécrivant notre configuration. Nous allons diviser notre processus en deux étapes distinctes : une étape nommée builder chargée de compiler le code, et une étape de production utilisant une image Alpine légère pour faire tourner l'application.
# Étape 1 : Compilation
FROM golang:1.22.3-alpine3.19 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o api-service .
# Étape 2 : Exécution
FROM alpine:3.19.1
WORKDIR /app
COPY --from=builder /app/api-service .
EXPOSE 8080
CMD ["./api-service"]
Dans ce fichier, l'alias AS builder permet de nommer notre première étape de build. Dans la seconde étape, l'instruction COPY --from=builder /app/api-service. indique à Docker d'extraire uniquement le fichier binaire de l'étape précédente pour l'injecter dans la nouvelle image de base Alpine. Le reste du compilateur Go est définitivement abandonné.
docker build -t api-service:multistage .
docker images api-service:multistage
Résultat:
REPOSITORY TAG IMAGE ID CREATED SIZE
api-service multistage b2c3d4e5f6a1 5 seconds ago 23.4MB
Le résultat parle d'eux-mêmes : nous sommes passés d'une image de 842 mégaoctets à une image de seulement 23,4 mégaoctets, tout en conservant exactement les mêmes fonctionnalités applicatives.
Sécuriser et optimiser pour la production
Bien que notre image de 23 mégaoctets soit déjà une grande victoire, un ingénieur DevOps chevronné sait que la sécurité en production exige d'aller encore plus loin. L'image Alpine utilisée contient encore un gestionnaire de paquets et un terminal de commande shell qui pourraient être exploités par un attaquant ayant réussi à s'introduire dans votre conteneur.
Le choix de l'image de base minimale et les utilisateurs non-root
Pour atteindre le niveau de sécurité maximal, nous allons utiliser une image de base vide nommée scratch. Cette image spéciale ne contient aucun fichier, aucun outil d'administration, aucun interpréteur de commandes et aucun utilisateur par défaut. C'est l'incarnation même du principe du privilège minimal.
Le danger des droits root par défaut
Par défaut, les conteneurs Docker exécutent vos applications avec les privilèges de l'utilisateur root. Si un attaquant parvient à exploiter une faille de sécurité dans votre code, il obtiendra instantanément les droits d'administration sur l'hôte physique. Il est impératif de configurer un utilisateur non-privilégié.
Le Dockerfile de production ultime et sécurisé
Voici l'implémentation de niveau production que vous devez déployer sur vos environnements réels. Elle intègre la création d'un utilisateur système non-privilégié, l'installation sécurisée des certificats de sécurité pour les appels HTTPS extérieurs et l'utilisation du cache de montage pour accélérer vos builds répétés.
# Étape 1 : Builder optimisé
FROM golang:1.22.3-alpine3.19 AS builder
# Installation des utilitaires de sécurité et création d'un utilisateur non-privilégié
RUN apk --no-cache add ca-certificates && \
adduser -D -g '' -u 10001 appuser
WORKDIR /app
# Utilisation des caches de montage pour optimiser le téléchargement des dépendances
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
# Compilation statique optimisée en retirant les tables de symboles de débogage
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-o api-service .
# Étape 2 : Runner ultra-sécurisé sans système d'exploitation
FROM scratch
# Importation sécurisée de la configuration utilisateur et des certificats SSL
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
WORKDIR /app
# Récupération du binaire compilé statiquement
COPY --from=builder /app/api-service .
# Activation de l'utilisateur non-privilégié
USER appuser
EXPOSE 8080
ENTRYPOINT ["./api-service"]
Ce script de production regorge d'astuces avancées. L'argument de compilation -ldflags="-s -w" indique au compilateur Go de retirer toutes les informations de débogage de l'exécutable, réduisant sa taille de près de 30% supplémentaires. L'instruction USER appuser garantit que l'application s'exécute avec des privilèges extrêmement restreints, protégeant ainsi le serveur physique hôte de toute tentative de compromission.
Construisons cette version de production pour observer le résultat final de nos optimisations.
docker build -t api-service:prod .
docker images api-service:prod
Résultat:
REPOSITORY TAG IMAGE ID CREATED SIZE
api-service prod c3d4e5f6a1b2 3 seconds ago 12.8MB
Nous avons réussi l'exploit de passer d'une image naïve de 842 mégaoctets à une image durcie et sécurisée de seulement 12,8 mégaoctets, prête à encaisser de fortes charges de trafic en production.
Vers des déploiements plus agiles et résilients
En adoptant le multi-stage building, vous transformez radicalement la qualité de votre infrastructure de conteneurs. Ce changement de méthode n'est pas qu'une simple optimisation de l'espace disque ; il s'agit d'une fondation incontournable pour sécuriser vos chaînes de déploiement et réduire l'empreinte carbone de vos infrastructures cloud.
Ce qu'il faut retenir pour vos prochains déploiements
- Séparez toujours l'environnement de développement et de compilation de l'environnement final de production.
- Exploitez les images minimalistes comme scratch ou les images distroless pour éliminer toute surface d'attaque logicielle inutile.
- Configurez systématiquement un utilisateur non-root au sein de vos fichiers de configuration pour interdire les privilèges d'administration en cas d'intrusion.
- Utilisez les caches de montage pour accélérer vos pipelines d'intégration continue tout en conservant des builds propres.
Prenez le temps dès aujourd'hui d'analyser vos registres de conteneurs et d'appliquer ces bonnes pratiques sur vos projets en cours. Vos équipes de sécurité et vos administrateurs système vous remercieront pour la rapidité et la robustesse de ces nouvelles images hautement optimisées.
Espace commentaire
Écrire un commentaire
Rejoignez la discussion
Vous devez être connecté pour poster un message.
19 commentaires
Totalement. C'est même la norme pour builder en
arm64etamd64simultanément.En prod, on utilise
docker buildxpour le multi-architecture. Ça marche toujours avec le multi-stage ?Bien sûr.
distrolessest un bon compromis si tu as besoin de bibliothèques C dynamiques quescratchne peut pas gérer.Est-ce qu'on peut utiliser
distrolessau lieu descratch?La méthode naïve faisait 800 Mo, c'est abusé. Merci pour ce guide, ça va sauver de la bande passante sur notre CI/CD.
Il faut faire un
chownsur tes dossiers de données dans leDockerfileou gérer les permissions au niveau de tondocker-compose.ymlavec unuser: "10001".Le
USER appuserme bloque l'accès aux logs ou à certains fichiers sur le volume monté. Des conseils ?Oui, le principe est identique. Tu compiles dans une image
rust:alpine, et tu copies le binaire final dansscratch. Rien ne change.Quelqu'un a déjà testé ça avec du Rust ? Est-ce que la logique reste la même ?
J'ai testé les
-ldflags="-s -w", ça gagne vraiment du poids, c'est impressionnant.Non, ça nécessite BuildKit. Assure-toi d'avoir
DOCKER_BUILDKIT=1activé dans tes variables d'environnement.Est-ce que le
--mount=type=cachefonctionne sur les vieilles versions de Docker ?Il faut copier les certificats depuis l'étape
buildervers ton image finale comme montré dans l'article :J'ai un souci avec les certificats SSL dans
scratch. Mes appels API HTTPS échouent. Comment on gère ça proprement ?Parce que
scratchest vide. Pas de shell, pas de gestionnaire de paquets, pas de vecteurs d'attaque. C'est la surface d'attaque minimale.Question bête : pourquoi utiliser
scratchplutôt qu'une imagealpinetrès légère ?Ah bien vu, j'avais oublié le
CGO_ENABLED=0. Ça tourne nickel maintenant, merci !C'est probablement parce que ton binaire n'est pas lié statiquement. Si tu utilises
scratch, il faut impérativementCGO_ENABLED=0.Vérifie bien ta commande de build :
Super article. J'ai essayé le multi-stage sur un projet Go, mais je me tape une erreur
standard_init_linux.go:211: exec user process caused "no such file or directory"au démarrage du conteneur. Une idée ?