24 - Optimisation des images
Ce que tu vas apprendre
- Réduire la taille de tes images Docker (multi-stage, Alpine, .dockerignore)
- Activer et exploiter BuildKit
- Utiliser le cache de build (--cache-from, registry cache)
- Analyser tes images layer par layer avec dive
- Des comparaisons avant/apres sur des cas réels
Prerequisites
Avoir suivi l'article sur la sécurité. Savoir écrire un Dockerfile multi-stage. Connaitre les bases des layers Docker.
Mon premier Dockerfile Node.js produisait une image de 1.2 Go. Un npm install avec toutes les devDependencies, un COPY de tout le repo (incluant .git, node_modules local, les tests), le tout sur une image node:20 complète. Aujourd'hui la meme app fait 85 Mo. Voici comment j'y suis arrive.
Le .dockerignore : la première optimisation
Avant d'optimiser le Dockerfile, empeche les fichiers inutiles d'entrer dans le contexte de build :
node_modules
.git
.gitignore
.env
.env.*
*.md
tests
coverage
.vscode
.idea
dist
Dockerfile
docker-compose*.yml
Sans .dockerignore, Docker envoie tout le répertoire au daemon de build. Sur un projet avec 500 Mo de node_modules locaux et un dossier .git de 200 Mo, ca fait 700 Mo envoyes pour rien. Le build est lent avant meme de commencer.
Multi-stage : séparer build et runtime
Le principe qu'on a deja vu dans l'article sur le frontend, applique a un backend :
dockerfile# AVANT : image unique, 1.2 Go
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
dockerfile# APRES : multi-stage, 85 Mo
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/package-lock.json ./package-lock.json
RUN npm ci --omit=dev
USER node
CMD ["node", "dist/server.js"]
Le gain vient de trois choses : Alpine au lieu de Debian (130 Mo vs 1.1 Go pour la base), pas de devDependencies en runtime, et seuls les fichiers compiles sont copies.
L'ordre des instructions compte
Docker cache chaque layer. Si une instruction n'a pas change, Docker réutilisé le cache. Mais des qu'une instruction change, toutes celles qui suivent sont recalculees.
dockerfile# MAUVAIS : chaque changement de code invalide le npm ci
COPY . .
RUN npm ci
# BON : le npm ci n'est relance que si package.json change
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Cette seule modification fait passer un rebuild de 45 secondes a 3 secondes quand tu ne changes que du code source. C'est le gain le plus visible au quotidien.
BuildKit
BuildKit est le builder moderne de Docker. Il est actif par défaut depuis Docker 23, mais sur les versions plus anciennes il faut l'activer :
bash# Variable d'environnement
DOCKER_BUILDKIT=1 docker build .
# Ou dans la config Docker daemon.json
{
"features": {
"buildkit": true
}
}
Ce que BuildKit apporte :
- Le build parallèle des stages independants
- Un meilleur cache
- Les secrets de build (qu'on a vus dans l'article sécurité)
- Le mount cache pour les gestionnaires de paquets
dockerfile# Mount cache pour npm : evite de retelecharger les packages
RUN --mount=type=cache,target=/root/.npm \
npm ci
Le --mount=type=cache créé un cache persistant entre les builds. Le dossier /root/.npm (le cache npm) n'est pas re-telecharge a chaque build. Sur un projet avec beaucoup de dépendances, ca divise le temps de build par deux ou trois.
Cache distant (registry cache)
Pour partager le cache entre les machines (CI, coequipiers) :
bash# Pousser le cache vers un registry
docker buildx build \
--cache-to type=registry,ref=registry.example.com/myapp:cache \
--cache-from type=registry,ref=registry.example.com/myapp:cache \
-t myapp:latest .
En CI, chaque build profite du cache du build précédent. Sans ca, la CI repart de zero a chaque commit. Sur paltemps.fr, le cache registry a divise le temps de CI par quatre.
bash# Alternative : cache local pour le dev
docker buildx build \
--cache-to type=local,dest=/tmp/docker-cache \
--cache-from type=local,src=/tmp/docker-cache \
-t myapp:latest .
Builds parallèles avec buildx
Si ton Dockerfile a des stages independants, BuildKit les build en parallèle :
dockerfileFROM node:20-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
FROM node:20-alpine AS backend-builder
WORKDIR /backend
COPY backend/package.json backend/package-lock.json ./
RUN npm ci
COPY backend/ .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=backend-builder /backend/dist ./dist
COPY --from=frontend-builder /frontend/dist ./public
RUN npm ci --omit=dev
CMD ["node", "dist/server.js"]
Les stages frontend-builder et backend-builder se construisent en parallèle automatiquement. Pas besoin de configurer quoi que ce soit, BuildKit détecté les dépendances.
Analyser avec dive
dive est un outil qui te montre le contenu de chaque layer de ton image :
bash# Installer dive
brew install dive # macOS
# ou
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive mon-image:latest
# Analyser une image
dive mon-image:latest
dive te montre :
- La taille de chaque layer
- Les fichiers ajoutes, modifies ou supprimes dans chaque layer
- Le pourcentage d'espace gaspille
- Un score d'efficacite global
C'est comme ca que j'ai découvert que mon image contenait 200 Mo de fichiers TypeScript source alors que je n'avais besoin que du JavaScript compile. Un COPY . . trop genereux.
Comparaisons avant/apres
Quelques gains réels que j'ai mesures :
| Optimisation | Avant | Apres | Gain |
|---|---|---|---|
| node:20 vers node:20-alpine | 1.1 Go | 130 Mo | -88% |
| .dockerignore (node_modules, .git) | 45s de context upload | 2s | -95% |
| Copier package.json avant le code | 45s rebuild | 3s rebuild | -93% |
| Multi-stage (retirer devDeps) | 350 Mo | 85 Mo | -76% |
| Mount cache npm | 30s npm ci | 5s npm ci | -83% |
| Registry cache en CI | 4min build | 1min build | -75% |
Le plus gros gain est toujours le passage a Alpine. Le deuxieme est l'ordre des COPY pour le cache. Ces deux changements prennent 5 minutes et transforment l'experience.
Layer squashing
Tu peux fusionner toutes les layers en une seule avec --squash. En pratique, je ne le fais jamais. Le cache par layer est trop precieux. Le seul cas ou ca a du sens : des images de base custom que tu ne rebuilds presque jamais.
bashdocker build --squash -t mon-image:squashed .
Résumé
- Le
.dockerignoreempeche les fichiers inutiles d'entrer dans le build. - Copie
package.jsonavant le code source pour exploiter le cache des layers. - Alpine divise la taille de l'image de base par 8.
- Le mount cache BuildKit accéléré
npm cientre les builds. - Le registry cache partage le cache entre CI et développeurs.
- dive analyse les layers et trouve l'espace gaspille.
Article précédent : Docker 23 - Sécurité Article suivant : Docker 25 - Multi-platform