Une image Docker de 1,2 GB pour exécuter un petit service écrit en Go peut sembler une simple perte d’espace de stockage. Cependant, le véritable problème survient lorsque Kubernetes doit créer de nouveaux pods sous pression. Chaque nœud ne disposant pas de l’image en cache doit la télécharger avant de lancer le conteneur. Séparer la phase de compilation de l’environnement d’exécution à l’aide d’un build multi-étages peut considérablement réduire cette taille, bien que la réduction concrète de 1,2 GB à 8 MB dépende de l’application. Il ne faut pas interpréter cette réduction comme une règle universelle, mais comme une optimisation potentielle.
Les clés pour des images Docker ultra-légères en 30 secondes
- Les builds multi-étages permettent de compiler une application dans une image complète, puis de ne copier que le résultat nécessaire pour la production.
- Docker recommande cette technique pour réduire la taille de l’image et la surface d’attaque.
scratchest une image totalement vide, idéale pour des binaires statiques.- Une image officielle
golang:1.22pèse environ 285 MB compressés sous Linux, avant d’ajouter le code spécifique et d’autres couches. - Moins de bytes peut signifier des déploiements et une mise à l’échelle plus rapides, surtout lorsque le nœud ne possède pas encore les couches en cache.
L’exemple est simple. Une application en Go peut être construite avec un Dockerfile comme celui-ci :
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
Ça fonctionne.
Le problème est que l’image utilisée pour compiler devient aussi celle utilisée pour exécuter le service.
Cela signifie transporter en production des outils qui ont peut-être été nécessaires lors de la construction (docker build), mais qui ne servent plus une fois que le binaire fonctionne.
La documentation officielle de Docker décrit précisément ce problème : dans une construction traditionnelle, toutes les instructions s’exécutent dans le même environnement, ce qui entraîne que les couches pour télécharger les dépendances, compiler et empaqueter peuvent finir dans l’image finale. Docker recommande donc l’utilisation des builds multi-étages pour séparer ces deux phases.
La compilation n’a pas besoin de voyager en production
La différence clé consiste à traiter la compilation et l’exécution comme deux étapes distinctes.
Voici un Dockerfile pour une simple application en Go, utilisant cette approche :
FROM golang:1.22 AS build
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM scratch
COPY --from=build /app/server /server
CMD ["/server"]
La première étape peut être aussi lourde que nécessaire.
Elle inclut Go, les bibliothèques, outils de compilation, et autres dépendances nécessaires à la création du binaire.
Une fois cette étape terminée, on ne copie que le binaire /app/server dans la seconde image.
Le compilateur reste derrière.
Le gestionnaire de paquets reste derrière.
Les outils de build utilisés durant la processus restent également derrière.
Docker recommande dans sa documentation quasiment ce modèle pour Go : une première étape basée sur golang, suivie d’une étape minimale basée sur scratch, à laquelle seul l’exécutable compilé est copié.
scratch possède une caractéristique unique : il ne contient presque rien, puisqu’il s’agit d’une image de base vide.
Il n’inclut pas une distribution Linux classique, ni shell, gestionnaire de paquets ou outils classiques de l’espace utilisateur.
Pour un binaire statique qui ne nécessite aucune dépendance supplémentaire, cette base peut être extraordinairement petite. Docker recommande justement scratch pour des binaires totalement statiques.
Cela peut entraîner des réductions considérables en taille.
Mais il convient de nuancer une idée répandue sur les réseaux sociaux : passer de 1,2 GB à 8 MB est réalisable pour une application spécifique, mais cela ne signifie pas que golang:1.22 occupe lui seul 1,2 GB.
Docker Hub indique que pour golang:1.22.12-bookworm, la taille compressée sous linux/amd64 est d’environ 284,79 MB. La variante Alpine tourne autour de 69,91 MB. En revanche, les couches additionnelles d’une application, dépendances, artefacts, caches, etc., peuvent augmenter considérablement la taille finale.
La comparaison correcte serait donc :
| Conception | Ce qui va en production | Taille potentielle |
|---|---|---|
| Une seule étape | Runtime + compilateur + outils + application | Cents de MB ou plus |
| Build multi-étages + image minimale | Runtime minimal + application | De dizaines de MB |
Build multi-étages + scratch |
Principalement le binaire et fichiers explicitement copiés | Peu ou pas de MB (pouvant descendre à quelques MB) |
Les valeurs exactes dépendent du programme compilé, de l’architecture, des symboles, bibliothèques et autres fichiers additionnels.
L’importance de la taille quand Kubernetes doit réagir rapidement
Une image volumineuse n’entraîne pas forcément un déploiement lent dans tous les cas.
Docker et les runtimes de conteneurs fonctionnent par couches et caches. Si un nœud possède déjà les couches nécessaires, il n’a pas besoin de les télécharger à nouveau.
La situation change lorsqu’un nœud nouveau apparaît.
Cela peut se produire lors d’un autoscaling, après le remplacement d’une machine, le déplacement de charges entre zones ou lorsque Kubernetes planifie un pod sur un hôte qui ne possède pas encore l’image.
Avant de lancer le conteneur, il faut télécharger les couches nécessaires depuis le registre.
C’est pourquoi la taille de l’image influence le cold start du conteneur, même si ce n’est qu’un des éléments.
Une image de 1,2 GB contre une autre de 8 MB représente une différence brute de 150x. La durée réelle dépend du débit, de la latence du registre, des couches déjà stockées localement, de la vitesse du disque et du parallélisme disponible.
Mathématiquement, transférer 1,2 GB sur une connexion effective de 100 Mbit/s nécessite environ 96 secondes, en ignorant protocoles, compression et autres goulets d’étranglement. D’un autre côté, télécharger 8 MB dans ces conditions ne prendrait que moins d’une seconde.
En pratique, ces chiffres seront différents, mais ils expliquent pourquoi l’optimisation de la taille peut être cruciale lors d’un pic de trafic.
Le Horizontal Pod Autoscaler de Kubernetes peut augmenter rapidement le nombre de réplicas. Si ces réplicas mettent plusieurs dizaines de secondes à télécharger l’image, la capacité supplémentaire intervient tardivement, souvent après que le pic soit passé.
La même différence concerne un déploiement sur plusieurs dizaines ou centaines de nœuds.
Il ne s’agit pas seulement d’espace dans un registre.
Il s’agit aussi de trafic réseau.
De temps de déploiement.
Et de récupération après défaillance.
Moins de contenu réduit aussi la surface d’attaque, mais scratch comporte des coûts
Un autre avantage important : tout logiciel qui n’est pas inclus dans une image ne constitue pas une surface d’attaque.
Si le conteneur final n’a pas besoin d’un compilateur, il n’y a pas de raison de l’installer.
Il en va de même pour curl, wget, un gestionnaire de paquets ou toute utilité système.
Docker indique explicitement que l’utilisation de builds multi-étages pour séparer l’environnement de compilation du runtime peut réduire à la fois la taille de l’image et sa surface d’attaque.
Cependant, faire de scratch une recommandation universelle serait une erreur.
Une image vide n’inclut pas non plus les éléments dont certaines applications pourraient avoir besoin.
Il peut être nécessaire d’ajouter des certificats d’autorités de certification (CA), des configurations de fuseaux horaires ou certains fichiers système. Une application utilisant CGO pourrait également dépendre de bibliothèques partagées, rendant une simple copie du binaire insuffisante.
De plus, les outils de diagnostic disparaissent dans une image scratch.
Il n’est généralement pas possible d’exécuter :
docker exec -it mon-conteneur /bin/sh
car /bin/sh n’existe probablement pas dans une image scratch.
D’un point de vue sécurité, cela peut être un avantage. D’un point de vue opérationnel, cela oblige à recourir à d’autres techniques de débogage, utilisant des conteneurs éphémères ou des images préparées spécifiquement pour le diagnostic.
C’est pourquoi une image minimale ne doit pas toujours être totalement vide.
Selon l’application, il peut être plus judicieux d’utiliser une base allégée contenant les dépendances essentielles, tout en conservant un build multi-étages pour éliminer tout le toolchain de compilation.
Il est également conseillé de revoir le fichier .dockerignore. Docker recommande d’exclure du contexte des éléments comme .git, artefacts locaux ou répertoires de dépendances qui ne doivent pas être transférés lors du build.
Une question clé pour la conception d’un Dockerfile :
Ce fichier ou paquet est-il nécessaire pour construire ou pour exécuter l’application ?
Si c’est uniquement pour la construction, il ne devrait pas apparaître dans l’image de production.
Une image plus petite ne rend pas une application nécessairement plus rapide, plus sûre ou plus efficace. Un service de 20 Mo peut avoir un démarrage de deux minutes, tandis qu’un autre de plusieurs centaines de mégaoctets peut s’exécuter immédiatement.
Toutefois, supprimer des centaines de mégaoctets jamais utilisés est une optimisation qui agit dans plusieurs domaines : réduction du transfert, du stockage, des délais de distribution et de l’état logiciel dans le conteneur.
Pour beaucoup de programmes compilés, la démarche commence simplement par un second FROM.
Questions fréquentes
Qu’est-ce qu’un build multi-étages en Docker ?
C’est un Dockerfile qui utilise plusieurs instructions FROM pour séparer différentes phases de construction. Cela permet de compiler l’application lors d’une étape et de ne copier que les fichiers nécessaires dans l’image finale.
Une image Docker peut-elle réellement passer de 1,2 GB à 8 MB ?
Oui, cela est possible pour certains programmes, en particulier avec des binaires statiques de petite taille. Mais cette réduction n’est pas automatique ni garantie. La taille finale dépend de l’application, de ses dépendances et de tous les fichiers utilisés au runtime.
Est-il recommandé d’utiliser FROM scratch en production ?
Cela peut être judicieux pour des exécutables entièrement statiques qui ne nécessitent pas de composants système supplémentaires. Cependant, d’autres applications exigent des certificats, des bibliothèques, des fuseaux horaires ou des outils, ce qui oblige à utiliser une image de base plus complète.
Une image Docker plus petite accélère-t-elle le scalage dans Kubernetes ?
Cela peut contribuer lorsque les nœuds doivent télécharger l’image avant de lancer de nouveaux pods. Si les couches sont déjà en cache, l’impact est moindre. La taille influence donc la rapidité du cold start, mais la bonne architecture et la bande passante jouent également un rôle crucial.
C++ sur macOS : comment compiler du code natif et quel langage choisir