Réduire la taille d'une image Docker influe directement sur la vitesse de déploiement et la sécurité. Une image de plusieurs gigaoctets allonge les temps de build, augmente les coûts de registry, ralentit les déploiements et élargit la surface d'attaque en embarquant des paquets inutiles. La bonne nouvelle : dans la plupart des projets, on peut réduire la taille de l'image de 70 à 80 % avec quelques techniques fondamentales. Dans ce guide, je couvre les multi-stage builds, les images basées sur Alpine, le .dockerignore et l'optimisation des couches avec des exemples concrets.
Pourquoi la taille de l'image compte-t-elle ?
Vous payez le prix d'une image trop lourde à chaque étape. Les grandes images mettent plus de temps à être poussées vers le registry, vous font attendre plus longtemps dans le pipeline CI/CD et gonflent le trafic lors du pull en production. Dans les environnements à autoscaling, chaque nouveau pod ou conteneur télécharge l'image de zéro ; l'écart entre 50 Mo et 1,2 Go se transforme en minutes sur des centaines de conteneurs.
- Vitesse : une image plus petite signifie des pulls, des pushs et des cold starts plus rapides.
- Sécurité : moins de paquets signifie moins de CVE et une surface d'attaque réduite.
- Coût : le stockage du registry et le trafic réseau sont directement proportionnels à la taille.
Choisir la bonne image de base
Le gain le plus facile vient du choix de l'image de base. La plupart des images officielles de langage reposent sur un Debian complet et incluent des outils de compilation, de la documentation et toutes les extensions du langage. Dans la plupart des cas, vous n'avez besoin que du runtime.
- alpine : une distribution minuscule basée sur musl libc, d'environ 5 Mo. Des variantes comme
node:20-alpineoupython:3.12-alpinene pèsent qu'une fraction de la version complète. - slim : des variantes comme
python:3.12-slimsont basées sur Debian mais dépouillées des paquets inutiles. Un bon compromis si vous voulez éviter le risque de compatibilité musl d'Alpine. - distroless : les images
gcr.io/distrolessde Google ne contiennent que votre application et le runtime ; il n'y a même pas de shell. L'une des options les plus petites et les plus sûres pour la production.
Une mise en garde : comme Alpine utilise musl libc, elle peut poser des problèmes de compilation avec les extensions natives qui dépendent de glibc (notamment les paquets à extensions C en Python, comme numpy ou pandas). Dans ces cas, slim est plus fluide.
Séparer build et runtime avec les multi-stage builds
La technique la plus puissante pour réduire les images est le multi-stage build. L'idée est simple : vous compilez l'application dans une étape « builder » avec tous les outils de build, puis vous copiez uniquement le résultat produit dans une étape finale propre et légère. Compilateurs, dépendances de dev et fichiers intermédiaires n'entrent jamais dans l'image finale.
Un exemple typique pour une application Node.js :
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
Ici, npm ci --omit=dev ne laisse que les dépendances de production dans l'image finale, tandis que COPY --from=builder ne déplace que le dossier dist compilé. Tout ce qui est spécifique au build reste dans la première étape et est jeté.
Pour les langages compilés, le gain est encore plus important. L'exemple Go montre la puissance de produire un binaire statique et de le placer sur une image scratch :
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]
Avec cette approche, un environnement de build de plusieurs centaines de mégaoctets se réduit à un unique binaire de quelques mégaoctets.
Optimiser le nombre et le contenu des couches
Chaque commande RUN, COPY et ADD crée une nouvelle couche, et les données ajoutées dans une couche restent dans l'historique de l'image même si une couche ultérieure les supprime. C'est pourquoi le nettoyage doit se faire dans le même RUN :
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Ici, --no-install-recommends ignore les paquets recommandés mais inutiles, tandis que rm -rf /var/lib/apt/lists/* vide le cache apt dans la même couche. Si vous les répartissez sur des lignes séparées, le cache reste dans l'image.
Un autre point clé pour le cache des couches est l'ordre des commandes : copiez d'abord ce qui change rarement (les manifestes de dépendances) et ensuite ce qui change souvent (le code source). Placer COPY package*.json ./ avant COPY . . garantit que npm ci ne se relance pas à chaque modification du code.
Exclure l'inutile avec .dockerignore
Chaque fichier envoyé au contexte de build peut fuir dans l'image et ralentir la construction. Le fichier .dockerignore fonctionne comme .gitignore et représente souvent l'un des gains les plus rapides :
.git
node_modules
dist
*.log
.env
.env.*
coverage
Dockerfile
docker-compose.yml
README.md
Exclure en particulier node_modules et .git empêche à la fois un contexte de build trop lourd et la copie accidentelle de gros répertoires. Exclure les fichiers .env est aussi essentiel pour la sécurité.
Mesurer et analyser la taille
Pendant l'optimisation, mesurez pour voir ce qui fonctionne réellement. La commande docker images donne une comparaison rapide :
docker images myapp --format "{{.Repository}}:{{.Tag}} {{.Size}}"
Pour une analyse plus poussée, l'outil dive est inestimable ; il affiche la taille de chaque couche et les fichiers ajoutés, ce qui vous permet de repérer les couches qui nuisent à l'« efficacité ». Garder BuildKit activé (DOCKER_BUILDKIT=1, ou par défaut sur Docker moderne) permet aussi des builds parallèles et plus efficaces. Pour exploiter le cache de build plus intelligemment, vous pouvez conserver un cache de dépendances persistant avec RUN --mount=type=cache.
Questions fréquentes
Alpine est-il toujours le meilleur choix ?
Non. Alpine est très petit, mais comme il utilise musl libc, vous pouvez rencontrer des problèmes de compilation avec certains paquets natifs et, rarement, des différences de comportement DNS. Si vous avez un projet Python/Node lourd en dépendances natives, la variante slim est généralement plus fluide tout en restant bien plus petite que l'image complète.
Les multi-stage builds ralentissent-ils le CI/CD ?
En pratique, non. L'étape builder est mise en cache et ne se relance que lorsque les dépendances ou le source changent. Grâce à la petite image résultante, les temps de push et de pull diminuent ; au total, le pipeline est généralement plus rapide.
J'ai réduit l'image mais elle reste grande, que puis-je faire d'autre ?
Trouvez les plus grandes couches avec dive. Le coupable est généralement un cache apt/npm non vidé, des dépendances de dev inutiles, des node_modules qui fuient dans le contexte de build, ou de gros fichiers statiques copiés. Passer l'étape finale à distroless ou scratch apporte un gain supplémentaire.
Vos images consomment-elles encore des gigaoctets ? Si vous avez besoin d'aide pour migrer votre Dockerfile vers le multi-stage et accélérer votre pipeline CI/CD, contactez-moi.