Docker pour les devs - 24 - Optimisation des images

Réduire la taille des images, exploiter le cache BuildKit, analyser les layers avec dive et des comparaisons avant/apres.

  1. 01 Docker pour les devs - 00 - Pourquoi Docker change tout
  2. 02 Docker pour les devs - 01 - Containers vs VMs
  3. 03 Docker pour les devs - 02 - L'architecture de Docker
  4. 04 Docker pour les devs - 03 - Docker Desktop, Engine et alternatives
  5. 05 Docker pour les devs - 04 - Écrire un Dockerfile
  6. 06 Docker pour les devs - 05 - Layers et cache
  7. 07 Docker pour les devs - 06 - Le .dockerignore
  8. 08 Docker pour les devs - 07 - Multi-stage builds
  9. 09 Docker pour les devs - 08 - Choisir son image de base
  10. 10 Docker pour les devs - 09 - Docker Compose, les bases
  11. 11 Docker pour les devs - 10 - Docker Compose avance
  12. 12 Docker pour les devs - 11 - Networking Docker, les bases
  13. 13 Docker pour les devs - 12 - Networking Docker avance
  14. 14 Docker pour les devs - 13 - Volumes et persistance
  15. 15 Docker pour les devs - 14 - Variables d'environnement et secrets
  16. 16 Docker pour les devs - 15 - Permissions et utilisateurs
  17. 17 Docker pour les devs - 16 - Docker et monorepo
  18. 18 Docker pour les devs - 17 - Dev vs Prod
  19. 19 Docker pour les devs - 18 - ENTRYPOINT, CMD et scripts d'initialisation
  20. 20 Docker pour les devs - 19 - Debugger ses conteneurs
  21. 21 Docker pour les devs - 20 - Bases de donnees dans Docker
  22. 22 Docker pour les devs - 21 - Sauvegardes et restauration
  23. 23 Docker pour les devs - 22 - Conteneuriser un frontend
  24. 24 Docker pour les devs - 23 - Sécurité des conteneurs
  25. 25 Docker pour les devs - 24 - Optimisation des images
  26. 26 Docker pour les devs - 25 - Builds multi-platform
  27. 27 Docker pour les devs - 26 - Limiter les ressources de tes conteneurs
  28. 28 Docker pour les devs - 27 - Gerer les logs comme un adulte
  29. 29 Docker pour les devs - 28 - Healthchecks et restart policies
  30. 30 Docker pour les devs - 29 - Nettoyer Docker avant qu'il mange ton disque
  31. 31 Docker pour les devs - 30 - Registries et stratégie de tags
  32. 32 Docker pour les devs - 31 - Docker en CI/CD
  33. 33 Docker pour les devs - 32 - Au-dela de Compose
  34. 34 Docker pour les devs - 33 - Glossaire Docker de A a Z

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 .dockerignore empeche les fichiers inutiles d'entrer dans le build.
  • Copie package.json avant 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 ci entre 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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.