## Introduction Travailler avec des conteneurs sans comprendre l'architecture des images Docker, c'est comme piloter un avion sans instruments. Vous resterez peut-être en l'air un moment, mais tôt ou tard, vous percutez une montagne invisible. Les images ne sont pas des blocs opaques ; elles sont composées de couches, mises en cache et reconstruites selon des règles que vous pouvez contrôler. Ce guide explique comment les images Docker sont construites, stockées et utilisées en pratique. Vous apprendrez à inspecter les couches, à utiliser efficacement le cache de construction, à réduire la taille des images et à éviter les erreurs courantes qui provoquent des builds gonflés, des déploiements cassés ou des environnements non reproductibles. Nous aborderons des commandes concrètes, des exemples réels de Dockerfile et des flux de travail de débogage. À la fin, vous saurez diagnostiquer pourquoi un build a utilisé le cache ou l'a manqué, voir exactement ce qui a changé entre deux versions d'image et concevoir des images rapides à construire, légères à distribuer et sûres à exécuter en production. ## Inventaire de la version et de l'environnement Avant de modifier une image ou un conteneur en cours d'exécution, capturez l'état exact de votre environnement. Exécutez d'abord ces commandes en lecture seule : ```bash # Version de Docker et détails de l'API docker version # Informations détaillées à l'échelle du système : pilote de stockage, répertoire racine, options de sécurité docker system info # Liste des conteneurs en cours d'exécution dans un tableau propre docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" ``` Pour un conteneur spécifique, inspectez ses métadonnées complètes, y compris l'ID de l'image, les montages et les variables d'environnement : ```bash docker inspect ``` Si vous utilisez Docker Compose, vérifiez l'état du projet et la santé des services avec : ```bash docker compose ps docker compose logs --tail=50 ``` Détails clés à enregistrer : la version du moteur Docker, le pilote de stockage (overlay2, etc.), l'ID et le condensat de l'image pour chaque image déployée, et l'état de santé actuel du conteneur. Si vous dépannez un problème de données, confirmez où les fichiers sont stockés avant de toucher quoi que ce soit. Un volume nommé tel que `app_data:/var/lib/app` est géré par Docker et réutilisé lors des reconstructions de conteneurs. Un montage de type bind comme `./data:/var/lib/app` mappe directement un répertoire hôte et est utile en développement, mais il peut entraîner des problèmes de permissions ou de portabilité si le même chemin n'existe pas sur une autre machine. Effectuez un test de redémarrage pour vérifier la persistance des données : arrêtez le conteneur, recréez-le et confirmez que l'application voit toujours les fichiers attendus. Si les données disparaissent, l'application écrivait dans la couche inscriptible du conteneur au lieu d'un volume ou d'un montage. Exemple : ```bash # Arrêter et supprimer le conteneur docker rm -f mon_app # Le recréer à partir de la même image docker run -d --name mon_app -v app_data:/var/lib/app mon_image:latest # Vérifier les journaux pour les erreurs d'accès aux fichiers docker logs mon_app --tail 20 ``` ## Comment les images Docker sont construites Une image Docker est un modèle en lecture seule composé d'un empilement de couches. Chaque instruction d'un Dockerfile crée une nouvelle couche par-dessus la précédente. L'image finale est la combinaison de toutes les couches, et les conteneurs ajoutent une couche inscriptible par-dessus au moment de l'exécution. Cette conception en couches rend les images Docker efficaces : les couches peuvent être partagées entre les images et réutilisées lors des builds. ### Le système de fichiers en couches Examinons un Dockerfile typique : ```dockerfile FROM ubuntu:22.04 RUN apt-get update && apt-get install -y python3 COPY . /app RUN pip install -r /app/requirements.txt CMD ["python3", "/app/app.py"] ``` Chaque ligne crée une couche distincte : 1. `FROM ubuntu:22.04` récupère les couches de l'image de base. 2. `RUN apt-get update && apt-get install -y python3` crée une nouvelle couche avec les paquets installés. 3. `COPY . /app` copie le répertoire courant dans l'image, créant une autre couche. 4. `RUN pip install -r /app/requirements.txt` installe les dépendances Python dans une nouvelle couche. 5. `CMD` est une métadonnée qui ne crée pas de couche mais est stockée dans la configuration de l'image. Vous pouvez afficher les couches d'une image avec : ```bash docker history mon_image:latest ``` Exemple de sortie : ``` IMAGE CREATED CREATED BY SIZE COMMENT f1a2b3c4d5e6 2 hours ago CMD ["python3", "/app/app.py"] 0B buildkit.dockerfile.v0 2 hours ago RUN /bin/sh -c pip install -r /app/require... 45MB buildkit.dockerfile.v0 2 hours ago COPY . /app # buildkit 1.2MB buildkit.dockerfile.v0 2 hours ago RUN /bin/sh -c apt-get update && apt-get i... 220MB buildkit.dockerfile.v0 2 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B 2 weeks ago /bin/sh -c #(nop) ADD file:5c3a9d... in / 72.8MB ``` Notez la colonne `SIZE` : chaque couche contribue à la taille totale de l'image. Les couches volumineuses proviennent souvent de l'installation de paquets ou de la copie de fichiers. La colonne `CREATED BY` montre la commande exacte (tronquée) qui a produit la couche. ### Fonctionnement du cache de couches Docker met en cache les couches pour accélérer les reconstructions. Lorsque vous exécutez `docker build`, Docker vérifie chaque instruction par rapport au cache. Si une instruction et ses couches parentes sont inchangées, Docker réutilise la couche en cache au lieu de la réexécuter. Cependant, si une couche change, toutes les couches suivantes sont reconstruites. Cela a des implications importantes pour l'ordre des instructions dans un Dockerfile. Considérons deux Dockerfiles : **Ordre inefficace** (les changements de code d'application invalident le cache des dépendances) : ```dockerfile FROM node:16 COPY . /app RUN npm install CMD ["node", "/app/index.js"] ``` **Ordre efficace** (les dépendances sont installées en premier) : ```dockerfile FROM node:16 COPY package*.json ./ RUN npm install COPY . . CMD ["node", "index.js"] ``` Dans la première version, tout changement dans n'importe quel fichier de `.` invalide la couche `COPY`, donc `npm install` s'exécute à chaque fois. Dans la deuxième version, seuls les changements dans `package.json` ou `package-lock.json` invalident la couche `npm install` ; les changements dans d'autres fichiers ne déclenchent pas de réinstallation, ce qui rend les builds beaucoup plus rapides. ## Stratégies efficaces de cache de construction Au-delà de l'ordre, plusieurs techniques peuvent maximiser les succès de cache. ### Utiliser des builds multi-étapes Les builds multi-étapes vous permettent d'utiliser des images intermédiaires pour la compilation ou l'installation des dépendances, puis de copier uniquement les artefacts nécessaires dans l'image finale. Cela réduit considérablement la taille de l'image finale et peut également améliorer la mise en cache. Exemple pour une application Go : ```dockerfile # Étape 1 : construction FROM golang:1.21 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /app/server # Étape 2 : exécution FROM alpine:3.18 RUN apk add --no-cache ca-certificates COPY --from=builder /app/server /usr/local/bin/server ENTRYPOINT ["server"] ``` L'image finale ne contient que le binaire compilé et les dépendances d'exécution minimales, réduisant souvent la taille de plusieurs centaines de mégaoctets à quelques mégaoctets. Les caches de l'étape de construction sont toujours utilisés pendant les builds, mais l'image finale n'inclut pas la chaîne d'outils Go ni le code source. ### Épingler les versions des images de base L'utilisation de l'étiquette `latest` peut conduire à des builds non reproductibles car l'image de base peut changer entre deux builds sans aucun changement dans votre Dockerfile. Épinglez l'image de base à un condensat ou à une étiquette de version spécifique : ```dockerfile # Épingler par étiquette FROM node:16.20.2-alpine3.18 # Ou épingler par condensat pour une reproductibilité maximale FROM node@sha256:3b3c8e31a8a1b6c9e1e414f2b8f1b3cd0e0f8a2b6c9e1e414f2b8f1b3cd0e0f ``` L'utilisation d'un condensat garantit que l'image de base est exactement la même à chaque build, ce qui est crucial pour les builds de production et les audits de sécurité. ### Séparer l'installation des dépendances de la copie du code Comme indiqué ci-dessus, copiez d'abord uniquement le manifeste des dépendances, installez les dépendances, puis copiez le reste du code. Ce modèle s'applique à la plupart des langages : - Python : copier `requirements.txt`, exécuter `pip install`, puis copier le code. - Node.js : copier `package.json` et `package-lock.json`, exécuter `npm install`, puis copier le code. - Java (Maven) : copier `pom.xml`, exécuter `mvn dependency:go-offline`, puis copier le code source. - Ruby : copier `Gemfile` et `Gemfile.lock`, exécuter `bundle install`, puis copier le code. ### Éviter les contextes de construction volumineux Le contexte de construction est l'ensemble des fichiers envoyés au démon Docker lorsque vous exécutez `docker build`. Si vous avez un dépôt énorme ou si vous incluez des fichiers inutiles, le contexte de construction peut être massif, ralentissant les builds, surtout avec des démons BuildKit distants. Utilisez un fichier `.dockerignore` pour exclure les fichiers non pertinents : ``` .git *.log node_modules .DS_Store *.md docker-compose.yml ``` Vous pouvez vérifier la taille du contexte de construction en exécutant : ```bash docker build --no-cache -t test . 2>&1 | grep "Sending build context" ``` ## Inspection et gestion des images Savoir inspecter les images est essentiel pour le débogage et l'optimisation. ### Afficher l'historique et les couches d'une image `docker history` montre la commande qui a créé chaque couche et sa taille. Pour une inspection plus détaillée, utilisez `docker inspect` : ```bash docker inspect mon_image:latest ``` Cela produit un document JSON avec des métadonnées, y compris l'architecture de l'image, les condensats des couches, la configuration, etc. Pour voir uniquement les IDs des couches, analysez la sortie : ```bash docker inspect mon_image:latest | jq '.[0].RootFS.Layers' ``` Exemple : ```json [ "sha256:9f3c1a2b...", "sha256:2d9e8f7c...", "sha256:..." ] ``` ### Trouver ce qui a changé entre deux images Utilisez `docker diff` sur un conteneur pour voir les modifications apportées à son système de fichiers depuis son démarrage. Cela aide à identifier ce qu'une application écrit à l'exécution. ```bash # Démarrer un conteneur docker run -d --name test mon_image:latest # Faire quelques modifications dans le conteneur docker exec test touch /fichier_nouveau # Voir le diff docker diff test ``` Sortie : ``` C /fichier_nouveau A /var/log/monapp.log C /etc/config.ini ``` `A` signifie ajouté, `C` signifie modifié, `D` signifie supprimé. Ces informations vous aident à décider quels fichiers doivent être persistés dans des volumes ou inclus dans une nouvelle image. ### Utiliser les condensats d'image Chaque image a un condensat, qui est un hachage unique de son contenu et de ses métadonnées. Utilisez les condensats pour référencer les images sans ambiguïté : ```bash # Obtenir le condensat de l'image actuelle docker inspect --format='{{index .RepoDigests 0}}' mon_image:latest # Sortie : monrepo/mon_image@sha256:a1b2c3... # Récupérer par condensat docker pull monrepo/mon_image@sha256:a1b2c3... ``` L'utilisation de condensats dans les manifestes de déploiement garantit que vous déployez exactement l'image testée, et non une version ultérieure qui a écrasé la même étiquette. ## Optimisation de la taille et de la sécurité des images Les images volumineuses sont plus lentes à déployer et présentent une surface d'attaque plus grande. Voici des moyens pratiques de réduire la taille et d'améliorer la sécurité. ### Minimiser les couches et combiner les commandes Chaque commande `RUN` crée une nouvelle couche. Combinez les commandes liées dans un seul `RUN` avec `&&` pour réduire le nombre de couches, et nettoyez les fichiers temporaires dans la même couche : ```dockerfile RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ python3-pip \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* ``` L'utilisation de `--no-install-recommends` évite d'installer des paquets inutiles, et le nettoyage des listes de paquets économise des dizaines de mégaoctets. ### Utiliser des images de base distroless ou allégées Remplacez une image de base d'un système d'exploitation complet par une variante allégée ou une image distroless qui ne contient que le runtime et votre application. - `python:3.11-alpine` vs `python:3.11` - `node:18-alpine` vs `node:18` - `gcr.io/distroless/base-debian11` pour les binaires statiques Exemple pour un petit service Python : ```dockerfile FROM python:3.11-alpine COPY . /app WORKDIR /app RUN pip install --no-cache-dir -r requirements.txt CMD ["python", "app.py"] ``` La variante Alpine fait généralement environ 50 Mo contre 900 Mo pour l'image complète basée sur Debian. ### Supprimer les fichiers inutiles et les secrets N'intégrez jamais de secrets comme des clés API ou des mots de passe dans les images. Même si vous les supprimez dans une couche ultérieure, ils restent dans les couches précédentes et peuvent être extraits. Utilisez plutôt les secrets Docker ou les variables d'environnement à l'exécution. Vérifiez les fichiers sensibles copiés accidentellement avec des outils comme `docker scan` ou `trivy` : ```bash trivy image mon_image:latest ``` Utilisez également `.dockerignore` pour empêcher la copie de `.env`, `.git` et d'autres fichiers sensibles. ## Débogage pratique des builds Lorsqu'un build échoue ou se comporte de manière inattendue, utilisez ces commandes pour diagnostiquer. ### Activer la sortie BuildKit Par défaut, les versions récentes de Docker utilisent BuildKit, qui fournit une progression et des informations de mise en cache plus détaillées. S'il n'est pas activé, définissez `DOCKER_BUILDKIT=1`. Pour voir pourquoi une couche particulière n'a pas été mise en cache, exécutez le build avec `--progress=plain` et inspectez l'état du cache pour chaque étape : ```bash DOCKER_BUILDKIT=1 docker build --progress=plain -t mon_image . 2>&1 | grep -E "CACHED|RUN" ``` Exemple de sortie : ``` #7 [3/5] RUN npm install #7 CACHED #8 [4/5] COPY . . #8 DONE 0.0s #9 [5/5] RUN npm run build #9 DONE 12.5s ``` La ligne `CACHED` indique que la couche a été réutilisée depuis le cache. ### Tester une couche sans reconstruire toute l'image Si une commande échoue dans une étape `RUN`, vous pouvez démarrer un conteneur à partir de la dernière couche réussie et exécuter la commande manuellement pour déboguer. Trouvez l'ID de l'image de la dernière couche réussie à partir de la sortie du build ou utilisez `docker history`. ```bash # Trouver l'ID de l'image de la dernière couche réussie docker history mon_image:latest | head -3 # Démarrer un shell dans cette couche docker run -it sh ``` Reproduisez ensuite la commande en échec et inspectez l'environnement. ### Utiliser des builds multi-architectures Si vous avez besoin d'images pour différentes architectures de processeur (par exemple, amd64 et arm64), utilisez buildx : ```bash docker buildx create --name monbuilder --use docker buildx build --platform linux/amd64,linux/arm64 -t monrepo/mon_image:latest --push . ``` Cela construit et pousse une liste de manifestes contenant les deux architectures, permettant à Docker de récupérer automatiquement la bonne. ## Pièges courants et comment les éviter ### 1. Utiliser excessivement l'étiquette `latest` Utiliser `latest` peut mener à l'imprévisibilité : vous ne savez jamais exactement quelle version vous exécutez, et les retours en arrière sont impossibles. Utilisez toujours des étiquettes de version explicites ou des condensats en production. Pour le développement local, définissez également une étiquette spécifique pour éviter les surprises. Comment s'en remettre : Étiquetez les images avec une version ou un hachage de commit, par exemple `monapp:1.4.2`, et utilisez un pipeline CI/CD pour mettre à jour l'étiquette lorsqu'une nouvelle version est construite. ### 2. Placer les fichiers changeant fréquemment tôt dans le Dockerfile Si vous copiez tout le code de l'application avant d'installer les dépendances, chaque modification de code invalide le cache de la couche de dépendances, rendant les builds lents et inefficaces. Comment éviter : Séparez toujours l'installation des dépendances de la copie du code. Placez les opérations stables en premier, et mettez le `COPY` le plus fréquemment modifié près de la fin du Dockerfile. ### 3. Exécuter en tant que root dans le conteneur De nombreux Dockerfiles ne spécifient pas de `USER`, donc le conteneur s'exécute en tant que root par défaut. Cela augmente le risque de sécurité : si un attaquant compromet l'application, il dispose des privilèges root dans le conteneur, ce qui peut parfois mener à une compromission de l'hôte. Comment éviter : Créez un utilisateur non root et basculez vers lui : ```dockerfile RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser ``` Assurez-vous également que l'application n'a pas besoin de root pour se lier à des ports inférieurs à 1024 ou pour modifier des fichiers système. ### 4. Laisser des outils de débogage et des secrets dans l'image Inclure des débogueurs, du code source ou des secrets dans les images de production est un risque de sécurité et gaspille de l'espace. Comment éviter : Utilisez des builds multi-étapes pour ne conserver que les artefacts d'exécution. Utilisez `.dockerignore` pour exclure les fichiers sensibles. Ne codez jamais en dur des secrets ; utilisez des variables d'environnement ou des secrets Docker montés à l'exécution. ### 5. Ne pas nettoyer après l'installation de paquets Les gestionnaires de paquets laissent des caches et des fichiers temporaires qui gonflent les images. Par exemple, `apt-get install` laisse `/var/lib/apt/lists/*` et éventuellement des fichiers `.deb`. Comment éviter : Nettoyez dans la même couche `RUN` : ```dockerfile RUN apt-get update && apt-get install -y --no-install-recommends \ un-paquet \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* ``` Pour Python, utilisez `pip install --no-cache-dir` ; pour Node, utilisez `npm install --production` ou `npm ci --only=production`. ## Liste de contrôle opérationnelle pour la gestion des images Utilisez cette liste de contrôle avant de pousser une image vers un registre ou de la déployer en production. Attribuez un responsable (par exemple, le développeur principal ou l'ingénieur DevOps) pour chaque élément, et révisez la liste mensuellement ou à chaque version. | Élément | Commande / Vérification | Résultat attendu | Responsable | |---------|-------------------------|------------------|-------------| | L'image se construit avec succès | `docker build -t monapp:test .` | Code de sortie 0, aucune erreur dans la sortie | Développeur principal | | La taille de l'image est acceptable | `docker image inspect monapp:test --format='{{.Size}}'` | Moins de 200 Mo pour les microservices, ajuster le seuil | Ingénieur DevOps | | Aucun secret dans l'image | `trivy image monapp:test` ou `docker scan monapp:test` | Aucune vulnérabilité de haute gravité ni secret exposé | Équipe de sécurité | | Utilisateur non root spécifié | `docker inspect monapp:test --format='{{.Config.User}}'` | Non vide et différent de "root" | Développeur principal | | Condensat de l'image de base épinglé | La ligne `FROM` du Dockerfile contient `@sha256:...` | Condensat présent, pas seulement une étiquette | Ingénieur DevOps | | Le conteneur s'exécute et est sain | `docker run -d --name test monapp:test && docker ps --filter name=test` | Le conteneur démarre, le statut affiche `Up` (sain si une vérification de santé est définie) | Ingénieur QA | | Les données persistent après redémarrage | `docker rm -f test && docker run -d --name test monapp:test` et vérifier les données | Les fichiers attendus sont toujours présents | Ingénieur DevOps | Chaque élément doit être vérifié dans un environnement de préproduction avant la production. Consignez les résultats dans vos notes de version. ## Conclusion Comprendre l'architecture des images Docker transforme la conteneurisation d'une boîte noire en un processus contrôlé et prévisible. En maîtrisant les couches, la mise en cache et les stratégies de construction, vous pouvez réduire considérablement les temps de build, diminuer la taille des images, améliorer la sécurité et éviter les pièges les plus courants qui affectent les déploiements de conteneurs. Commencez par appliquer une amélioration de ce guide : réorganisez un Dockerfile pour séparer l'installation des dépendances de la copie du code, ou épinglez un condensat d'image de base. Mesurez l'impact avec `docker history` et `docker inspect`. Au fur et à mesure que vous gagnez en confiance, adoptez les builds multi-étapes, l'analyse d'images et une liste de contrôle de révision. Rappelez-vous que chaque image que vous construisez est un contrat : elle définit exactement ce qui s'exécutera en production. Une image bien conçue rend le reste de votre infrastructure plus simple et plus sûre.