Votre Dockerfile est probablement trop volumineux pour la production

Vous venez de terminer votre application. Elle compile, fonctionne en local et vous êtes prêt à la livrer. Vous écrivez un Dockerfile, construisez une image, la poussez vers un registre et la déployez sur un serveur. Le déploiement fonctionne, mais quelque chose cloche. L'image fait 1,2 Go. Son téléchargement sur le serveur prend trois minutes. Les coûts de stockage grimpent. Et quelque part au fond de votre esprit, vous vous demandez : ai-je vraiment besoin d'un système d'exploitation complet avec un gestionnaire de paquets et un shell juste pour exécuter mon binaire Go ?

C'est le moment où la plupart des équipes réalisent que construire une image et construire une image adaptée à la livraison sont deux choses différentes. Le Dockerfile que vous écrivez pour les tests locaux n'est pas le même que celui que vous devriez utiliser en production. Si votre image est volumineuse, lente à construire ou pleine d'outils inutiles, l'ensemble de votre pipeline en souffre. Chaque téléchargement prend plus de temps. Chaque construction gaspille du temps. Chaque scanner de vulnérabilités trouve plus de problèmes qu'il ne le devrait.

La bonne nouvelle, c'est que corriger cela ne nécessite pas de réécrire complètement votre infrastructure. Cela nécessite de comprendre trois choses : comment les Dockerfiles fonctionnent réellement, comment garder les images petites, et comment rendre les constructions reproductibles.

Comment un Dockerfile devient une image

Un Dockerfile est un fichier texte contenant des instructions. Chaque instruction crée une couche. Lorsque vous reconstruisez l'image, Docker vérifie quelles couches n'ont pas changé et les réutilise depuis le cache. C'est pourquoi la deuxième construction est plus rapide que la première : les couches inchangées ne sont pas reconstruites.

Le problème est que la plupart des gens écrivent des Dockerfiles comme s'ils écrivaient un script d'installation. Ils installent tout, copient tout, et ne réalisent que plus tard que l'image finale contient des compilateurs, des outils de débogage et des utilitaires système qui n'ont rien à voir avec l'exécution de l'application. Chaque outil supplémentaire dans l'image est un poids supplémentaire lors du téléchargement, une surface d'attaque supplémentaire pour les vulnérabilités, et une complexité supplémentaire lorsque vous essayez de comprendre ce qui se trouve réellement dans l'image.

La solution ne consiste pas à écrire moins de code. Il s'agit de séparer l'environnement de construction de l'environnement d'exécution.

Les constructions multi-étapes : le modèle le plus important

Les constructions multi-étapes vous permettent de définir plusieurs étapes dans un seul Dockerfile. La première étape contient tout ce qui est nécessaire pour compiler l'application : le SDK complet, les compilateurs, les outils de construction et les dépendances. La deuxième étape contient uniquement ce qui est nécessaire pour exécuter l'artefact compilé : le binaire, les bibliothèques d'exécution, et rien d'autre.

Voici à quoi cela ressemble pour une application Go :

Le diagramme ci-dessous illustre comment les deux étapes fonctionnent ensemble :

flowchart TD A["Dockerfile"] --> B["Étape 1 : Construction"] B --> C["FROM golang:1.22-alpine"] C --> D["COPY go.mod go.sum"] D --> E["RUN go mod download"] E --> F["COPY code source"] F --> G["RUN go build -o myapp"] G --> H["Binaire : myapp"] A --> I["Étape 2 : Exécution"] I --> J["FROM alpine:3.19"] J --> K["COPY --from=builder /app/myapp /myapp"] K --> L["CMD [\"/myapp\"]"] L --> M["Image finale petite (~20-30 Mo)"] H -.-> K
# Étape 1 : Construction
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp .

# Étape 2 : Exécution
FROM alpine:3.19
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]

La première étape utilise le SDK Go complet. La deuxième étape utilise une image Alpine minimale qui ne contient que le strict nécessaire. Le SDK Go, le code source et le cache de construction ne se retrouvent jamais dans l'image finale. Le résultat est une image qui fait souvent 20 à 30 mégaoctets au lieu de plus d'un gigaoctet.

Le même modèle s'applique à tout langage compilé. Pour les langages interprétés comme Python ou Node.js, vous pouvez utiliser des constructions multi-étapes pour installer les dépendances dans une étape et ne copier que les paquets installés dans l'étape finale, en laissant de côté le cache du gestionnaire de paquets et les outils de construction.

La sécurité commence par ce que vous laissez de côté

Chaque outil dans votre image est une vulnérabilité potentielle. Un shell comme Bash ou un gestionnaire de paquets comme apt peut sembler inoffensif, mais si un attaquant accède à votre conteneur, ces outils deviennent des armes. Ils permettent à l'attaquant d'installer de nouveaux logiciels, de télécharger des charges utiles ou de se déplacer vers d'autres systèmes.

Le principe est simple : n'incluez rien qui ne soit pas nécessaire à l'exécution de l'application. Si vous avez besoin de déboguer un conteneur en cours d'exécution occasionnellement, ne conservez pas les outils de débogage dans l'image de production. Créez plutôt une image de débogage séparée ou utilisez des conteneurs éphémères qui montent les outils nécessaires au moment de l'exécution.

Pour les images de production, envisagez d'utiliser des images de base "distroless". Ces images ne contiennent que l'environnement d'exécution et les bibliothèques dont votre application a besoin. Pas de shell, pas de gestionnaire de paquets, pas d'utilitaires. Google maintient des images distroless pour plusieurs langages, dont Go, Python, Java et Node.js. Elles sont petites, minimales et réduisent considérablement la surface d'attaque.

Si distroless vous semble trop restrictif, Alpine est une alternative raisonnable. Elle est petite et inclut un shell, mais elle utilise musl libc au lieu de glibc, ce qui peut entraîner des problèmes de compatibilité avec certaines applications. Testez votre application sur Alpine avant de vous y engager.

La reproductibilité n'est pas optionnelle

Une image qui ne peut pas être reconstruite à l'identique dans six mois est un passif. La cause la plus courante de constructions non reproductibles est l'utilisation du tag latest pour les images de base. latest change à chaque fois que le mainteneur publie une nouvelle version. La construction d'aujourd'hui pourrait utiliser Go 1.22, mais celle du mois prochain pourrait utiliser Go 1.23, et vous ne le saurez pas avant que quelque chose ne se casse.

Épinglez toujours votre image de base à un tag de version spécifique ou, mieux encore, à un hash de digest. Un hash de digest est la somme de contrôle cryptographique du contenu de l'image. Il ne change jamais. Si vous utilisez golang:1.22-alpine@sha256:abc123..., vous êtes assuré d'obtenir exactement la même image de base à chaque fois, quel que soit le moment ou le lieu de la construction.

FROM golang:1.22-alpine@sha256:abc123def456 AS builder

Cela s'applique à chaque image que vous téléchargez, y compris les images intermédiaires utilisées dans les constructions multi-étapes. Si vous ne trouvez pas le digest pour une version spécifique, utilisez le tag le plus spécifique disponible, comme golang:1.22.0-alpine3.19 au lieu de golang:1.22-alpine.

L'ordre des couches affecte la vitesse de construction

Docker met en cache les couches en fonction de l'ordre des instructions. Si vous modifiez un fichier qui est copié tôt dans le Dockerfile, toutes les couches suivantes sont invalidées et reconstruites. Si vous modifiez un fichier qui est copié tard, seules les couches après ce point sont reconstruites.

La règle pratique est la suivante : placez les instructions qui changent rarement en haut, et celles qui changent fréquemment en bas.

  • Installez d'abord les dépendances système.
  • Copiez les manifests de dépendances (comme go.mod et go.sum) et exécutez l'étape de téléchargement des dépendances.
  • Copiez le code source en dernier.

Ainsi, si vous modifiez uniquement votre code source, l'étape d'installation des dépendances est réutilisée depuis le cache. La construction est plus rapide car Docker ne télécharge pas à nouveau les paquets qui n'ont pas changé.

Une liste de contrôle pratique pour votre Dockerfile

Avant de pousser votre prochaine image en production, passez en revue cette liste de contrôle :

  • L'image finale contient-elle uniquement ce qui est nécessaire pour exécuter l'application ?
  • Les outils de construction, les SDK et le code source sont-ils exclus de l'étape finale ?
  • L'image de base est-elle épinglée à une version ou un digest spécifique, et non à latest ?
  • L'image de base est-elle minimale (distroless ou Alpine) plutôt qu'une image OS complète ?
  • Les instructions qui changent rarement sont-elles placées avant celles qui changent fréquemment ?
  • Y a-t-il absence de shell ou de gestionnaire de paquets dans l'image de production, sauf en cas d'absolue nécessité ?

Ce qui compte le plus

Un Dockerfile n'est pas seulement un script de construction. C'est un contrat entre votre processus de construction et votre environnement de production. Un bon Dockerfile produit une image petite, sécurisée et reproductible. Un mauvais Dockerfile produit une image lente à télécharger, difficile à déboguer et impossible à reconstruire en toute confiance.

La prochaine fois que vous écrirez un Dockerfile, commencez par avoir l'image finale en tête. Demandez-vous : de quoi cette application a-t-elle réellement besoin pour fonctionner ? Ensuite, laissez tout le reste de côté. Votre pipeline sera plus rapide, vos déploiements seront plus fluides, et votre équipe de sécurité aura un souci de moins.