Réduire la taille des images Docker : un guide pratique pour des builds plus rapides
Rendez les images Docker plus petites et les builds plus rapides grâce aux builds multi-étapes, aux Dockerfiles optimisés pour le cache et aux contextes de build propres.
Réduire la taille des images Docker : un guide pratique pour des builds plus rapides
Réduire la taille des images Docker ne consiste pas à chercher le plus petit nombre dans docker image ls. L'objectif utile est d'obtenir un build rapide, reproductible, facile à corriger et suffisamment petit pour ne pas ralentir l'intégration continue, les déploiements ou le développement local.
Une image gonflée provient généralement de quelques habitudes courantes : copier tout le dépôt trop tôt, installer des outils de build dans l'image d'exécution, laisser les caches des gestionnaires de paquets, utiliser les tags latest et envoyer un énorme contexte de build à Docker. Corrigez ces habitudes et la plupart des équipes obtiennent des builds plus propres sans rien faire d'exotique.
Commencez par mesurer l'image actuelle :
docker image ls my-app
docker history --no-trunc my-app:latest
docker history montre quelles instructions du Dockerfile ont ajouté de la taille. Si une étape RUN ajoute une grande quantité, inspectez cette étape avant de tout modifier. Pour un examen plus approfondi, des outils comme dive peuvent montrer les fichiers ajoutés et supprimés dans chaque couche, mais les commandes intégrées suffisent pour une première passe.
Choisissez d'abord une image de base pour la compatibilité, la taille vient ensuite
Le choix de l'image de base est important, mais la plus petite image n'est pas toujours la meilleure. Alpine est petite car elle utilise musl libc et un espace utilisateur minimal. Cela fonctionne bien pour de nombreuses images Go, Node, Python et utilitaires, mais cela peut créer des frictions avec les extensions natives, les binaires précompilés, le comportement DNS ou les paquets qui attendent glibc.
Un ordre pratique est :
- Utilisez une image officielle pour votre environnement d'exécution.
- Préférez un tag versionné comme
python:3.12-slimplutôt quepython:latest. - Essayez
slimavant de passer à Alpine si votre application a des dépendances natives. - Utilisez distroless ou
scratchlorsque vous savez exactement ce dont l'environnement d'exécution a besoin.
Par exemple, un service Python avec psycopg, des bibliothèques d'images ou des paquets de cryptographie peut être plus facile à maintenir sur python:3.12-slim que sur python:3.12-alpine. L'image Alpine peut être plus petite au début, mais si vous ajoutez des compilateurs et des paquets de compatibilité pour faire fonctionner les dépendances, le résultat final peut ne pas être plus simple ou beaucoup plus petit.
Pour les services Go, scratch ou distroless peuvent être excellents car un binaire compilé statiquement peut avoir besoin de très peu de choses au moment de l'exécution. Même dans ce cas, n'oubliez pas les certificats TLS, les données de fuseau horaire et les utilisateurs non root. Une petite image qui ne peut pas faire de requêtes HTTPS n'est pas une image de production utile.
Utilisez des builds multi-étapes pour garder les outils de build hors de la production
Les builds multi-étapes sont le moyen le plus fiable de réduire la taille des images Docker pour les applications compilées et les builds frontend. L'étape de build peut être volumineuse. Elle peut contenir des compilateurs, des gestionnaires de paquets, des outils de test et des fichiers sources. L'étape finale ne doit contenir que ce dont l'application a besoin pour s'exécuter.
Un exemple Go :
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
La partie importante n'est pas l'image de base exacte. La partie importante est la frontière : le code source, le cache des modules et le compilateur restent dans builder ; l'image finale reçoit le binaire.
Une application Node a une forme similaire, mais vous devez séparer les dépendances de développement des dépendances de production :
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Pour les assets frontend servis par nginx, l'image finale n'a pas besoin de Node du tout :
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
Ce modèle supprime node_modules, l'arborescence source et les outils de build de l'image d'exécution.
Faites fonctionner le cache Docker pour vous
L'invalidation du cache Docker est simple mais impitoyable. Si une instruction change, Docker reconstruit cette couche et toutes les couches suivantes. Si vous copiez tout le projet avant d'installer les dépendances, chaque modification de code peut forcer une réinstallation des dépendances.
Ceci est lent :
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
Ceci est généralement plus rapide :
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
La couche de dépendances ne se reconstruit que lorsque requirements.txt change. Les modifications du code source ne reconstruisent que la couche COPY . . ultérieure.
La même règle s'applique à Maven, Gradle, npm, pnpm, Cargo et aux modules Go. Copiez d'abord les manifestes de dépendances, installez ou téléchargez les dépendances, puis copiez le reste du code source.
Gardez le contexte de build petit avec .dockerignore
Docker envoie le contexte de build au démon avant de commencer la construction. Si votre contexte inclut .git, node_modules local, des rapports de couverture, des vidéos, des captures d'écran, des bases de données de test et des artefacts de build, votre build est plus lent avant même que le Dockerfile ne s'exécute.
Un .dockerignore de base pourrait ressembler à ceci :
.git
.gitignore
.env
.env.*
node_modules
coverage
dist
build
target
.pytest_cache
__pycache__
*.log
.DS_Store
Soyez prudent avec les exclusions larges. Si vous ignorez dist mais que votre Dockerfile s'attend à copier un dist préconstruit, le build échouera. Si vous ignorez .env, c'est généralement bon pour les secrets, mais votre build ne devrait pas en dépendre. Le build devrait recevoir les valeurs requises via des arguments de build explicites ou une configuration CI.
Vous pouvez voir la taille du contexte au début de la sortie classique du build Docker, et la sortie de BuildKit rend également visibles les grands transferts de contexte. Si la première étape prend beaucoup de temps avant qu'aucune instruction du Dockerfile ne s'exécute, vérifiez .dockerignore.
Nettoyez les caches des gestionnaires de paquets dans la même couche
Une erreur fréquente est d'installer des paquets dans une couche et de supprimer les caches dans une couche ultérieure. Les fichiers disparaissent de la vue du système de fichiers final, mais ils peuvent toujours exister dans l'historique de l'image.
Pour les images Debian et Ubuntu :
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
Pour Alpine :
RUN apk add --no-cache ca-certificates curl
Pour Python :
RUN pip install --no-cache-dir -r requirements.txt
Pour npm :
RUN npm ci --omit=dev && npm cache clean --force
Ne copiez pas aveuglément les commandes de nettoyage. Utilisez le nettoyage qui correspond au gestionnaire de paquets et à l'image de base. Évitez également apt-get upgrade dans la plupart des Dockerfiles d'application. Cela rend les builds moins prévisibles et peut entraîner plus de modifications que prévu. Corrigez en reconstruisant régulièrement à partir d'images de base maintenues et en épinglant la famille d'images que vous attendez.
Préférez COPY sauf si vous avez besoin de ADD
COPY copie les fichiers du contexte de build. ADD a un comportement supplémentaire : il peut décompresser des archives tar locales et récupérer des URL distantes dans certains cas. Ce comportement supplémentaire peut surprendre les mainteneurs et rendre le comportement du cache plus difficile à comprendre.
Utilisez COPY pour les fichiers d'application normaux :
COPY ./src ./src
Utilisez ADD uniquement lorsque vous voulez intentionnellement son comportement d'extraction d'archive. Pour les téléchargements distants, préférez curl ou wget dans une étape RUN où vous pouvez vérifier les sommes de contrôle et échouer clairement.
Réduisez ce que vous installez, pas seulement ce que vous supprimez
Le meilleur nettoyage de cache est de ne pas installer de fichiers inutiles en premier lieu. Utilisez --no-install-recommends avec apt-get. Évitez d'installer des éditeurs, des shells, des gestionnaires de paquets et des outils de débogage dans les images de production sauf si vous avez une raison.
Cela dit, ne supprimez pas tous les outils si cela rend les opérations plus difficiles. Une image de production peut être minimale tout en restant observable. Pour certaines équipes, une image slim avec un shell est un meilleur compromis qu'une image distroless car le débogage de garde est plus simple. Pour d'autres équipes avec une journalisation, un traçage et des conteneurs de débogage éphémères solides, distroless est un bon choix.
La réponse honnête est spécifique à la charge de travail. Les images plus petites se tirent généralement plus rapidement et exposent moins de paquets à corriger, mais la maintenabilité compte toujours.
Utilisez les montages de cache BuildKit pour les téléchargements de dépendances
BuildKit peut conserver les caches de paquets en dehors de l'image finale tout en les réutilisant entre les builds. Ceci est différent du fait de laisser les caches à l'intérieur de l'image.
Pour npm :
# syntax=docker/dockerfile:1.7
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
Pour apt, les montages de cache peuvent aider dans les environnements CI où des builds répétés se produisent sur le même builder, bien que de nombreux systèmes CI utilisent des workers frais à moins que vous ne configuriez un cache persistant. Les fonctionnalités de BuildKit sont puissantes, mais gardez le Dockerfile lisible pour l'équipe qui doit le maintenir.
Vérifiez le contenu final de l'image
Après les modifications, prouvez ce qui a été amélioré :
docker build -t my-app:optimized .
docker image ls my-app
docker history my-app:optimized
Exécutez ensuite l'image de la même manière que la production l'exécute. Une image plus petite qui échoue parce qu'elle manque de certificats, de données de locale, de fichiers de fuseau horaire ou d'un répertoire inscriptible n'est pas une amélioration.
Les tests de fumée utiles incluent :
docker run --rm my-app:optimized --version
docker run --rm -p 8080:8080 my-app:optimized
curl -f http://localhost:8080/health
Si l'application écrit des fichiers, testez ce chemin. Si elle appelle des services HTTPS, testez TLS. Si elle s'exécute en tant que non-root, testez les permissions.
Un ordre d'optimisation pratique
Lorsque je révise une image Docker surdimensionnée, j'utilise cet ordre :
- Ajoutez ou corrigez
.dockerignore. - Réorganisez le Dockerfile pour que l'installation des dépendances puisse être mise en cache.
- Introduisez des builds multi-étapes.
- Passez des images de base complètes à
slim, Alpine, distroless ouscratchlà où c'est compatible. - Supprimez les caches des gestionnaires de paquets et les dépendances de développement dans la même couche où ils sont créés.
- Inspectez avec
docker historyoudiveet répétez uniquement là où les preuves le montrent.
Cet ordre évite la complexité prématurée. Il maintient également le Dockerfile compréhensible, ce qui est plus important que de gagner les derniers mégaoctets.