Images Docker#
Si un conteneur est un processus en cours d’exécution, une image Docker en est le plan de construction — le modèle immuable à partir duquel on instancie autant de conteneurs qu’on veut. Comprendre la structure d’une image est essentiel pour écrire de bons Dockerfiles, optimiser les temps de build et réduire les tailles des images en production.
Anatomie d’une image#
Pensez à une image Docker comme à un millefeuille : une succession de couches fines, chacune ne contenant que les différences par rapport à la couche inférieure. Ces couches sont stockées sous forme de tarballs compressés, chacune identifiée par un hash SHA256 de son contenu — son digest.
Le manifest#
Chaque image possède un manifest : un fichier JSON qui répertorie ses couches dans l’ordre et référence la configuration de l’image.
# Exemple de manifest OCI (simplifié) — tel que Docker le stocke sur le registre
manifest_exemple = {
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": 7682,
"digest": "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 29125632,
"digest": "sha256:2408cc74d12b6cd092bb8b516ba7d5e290f485d3eb9672efc00f0583730179e8"
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 45678901,
"digest": "sha256:a1f58c7e2b3d4a5f6c7e8b9d0a1f2c3e4d5a6f7c8e9b0a1f2c3d4e5f6a7b8c9"
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 3456789,
"digest": "sha256:b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3"
}
]
}
print("Manifest de l'image (format OCI v2) :")
print("-" * 50)
print(f" Nombre de couches : {len(manifest_exemple['layers'])}")
total_bytes = sum(l["size"] for l in manifest_exemple["layers"])
print(f" Taille totale compressée : {total_bytes / 1024 / 1024:.1f} MB")
print()
for i, layer in enumerate(manifest_exemple["layers"]):
digest_court = layer["digest"][:19] + "..."
taille_mb = layer["size"] / 1024 / 1024
print(f" Couche {i} : {digest_court} ({taille_mb:.1f} MB)")
Manifest de l'image (format OCI v2) :
--------------------------------------------------
Nombre de couches : 3
Taille totale compressée : 74.6 MB
Couche 0 : sha256:2408cc74d12b... (27.8 MB)
Couche 1 : sha256:a1f58c7e2b3d... (43.6 MB)
Couche 2 : sha256:b2c3d4e5f6a7... (3.3 MB)
La configuration JSON#
En plus du manifest, chaque image possède une configuration JSON qui décrit tout ce qui doit se passer au lancement du conteneur.
config_exemple = {
"architecture": "amd64",
"os": "linux",
"config": {
"Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"LANG=C.UTF-8",
"PYTHONUNBUFFERED=1"],
"Cmd": ["python3", "app/main.py"],
"WorkingDir": "/app",
"ExposedPorts": {"8000/tcp": {}},
"User": "appuser",
},
"history": [
{"created_by": "FROM ubuntu:22.04", "empty_layer": False},
{"created_by": "RUN apt-get install python3", "empty_layer": False},
{"created_by": "WORKDIR /app", "empty_layer": True},
{"created_by": "COPY requirements.txt .", "empty_layer": False},
{"created_by": "RUN pip install -r requirements.txt", "empty_layer": False},
{"created_by": "COPY . .", "empty_layer": False},
{"created_by": "EXPOSE 8000", "empty_layer": True},
{"created_by": "CMD [\"python3\", \"app/main.py\"]", "empty_layer": True},
]
}
print("Configuration de l'image :")
print(f" Plateforme : {config_exemple['os']}/{config_exemple['architecture']}")
print(f" Répertoire de travail : {config_exemple['config']['WorkingDir']}")
print(f" Commande par défaut : {config_exemple['config']['Cmd']}")
print(f" Utilisateur : {config_exemple['config']['User']}")
print(f" Ports exposés : {list(config_exemple['config']['ExposedPorts'].keys())}")
print()
print("Historique des couches :")
couches_reelles = [h for h in config_exemple["history"] if not h["empty_layer"]]
couches_vides = [h for h in config_exemple["history"] if h["empty_layer"]]
print(f" Couches avec données : {len(couches_reelles)}")
print(f" Instructions sans couche (metadata) : {len(couches_vides)}")
for h in config_exemple["history"]:
signe = " +" if not h["empty_layer"] else " ·"
print(f"{signe} {h['created_by']}")
Configuration de l'image :
Plateforme : linux/amd64
Répertoire de travail : /app
Commande par défaut : ['python3', 'app/main.py']
Utilisateur : appuser
Ports exposés : ['8000/tcp']
Historique des couches :
Couches avec données : 5
Instructions sans couche (metadata) : 3
+ FROM ubuntu:22.04
+ RUN apt-get install python3
· WORKDIR /app
+ COPY requirements.txt .
+ RUN pip install -r requirements.txt
+ COPY . .
· EXPOSE 8000
· CMD ["python3", "app/main.py"]
Le Dockerfile instruction par instruction#
Un Dockerfile est un fichier texte qui décrit, étape par étape, comment construire une image. Chaque instruction (sauf quelques exceptions) crée une nouvelle couche dans l’image finale.
CMD vs ENTRYPOINT : la distinction cruciale#
C’est l’une des sources de confusion les plus fréquentes chez les débutants. Voici la règle simple :
ENTRYPOINTdéfinit l”exécutable du conteneur. Il est difficile à remplacer (il faut--entrypoint).CMDdéfinit les arguments par défaut. Il est facile à remplacer (on les passe directement aprèsdocker run <image>).
Règle pratique pour CMD et ENTRYPOINT
Utilisez la combinaison ENTRYPOINT + CMD pour les images « outils » où l’exécutable est fixe mais les arguments varient. Par exemple, une image backup-tool avec ENTRYPOINT ["backup"] et CMD ["--help"] : par défaut elle affiche l’aide, mais docker run backup-tool --target /data --s3 bucket/path passe des arguments réels.
Pour les services (serveur web, base de données), CMD seul suffit souvent.
Le cache de build#
Docker est intelligent : il ne reexécute pas les instructions dont le résultat n’a pas changé depuis le dernier build. Ce mécanisme de cache peut réduire un build de plusieurs minutes à quelques secondes.
La règle du cache est simple mais importante : dès qu’une couche est invalidée, toutes les couches suivantes le sont aussi.
La règle d’or est : placez ce qui change le moins souvent en premier, ce qui change souvent en dernier. Les dépendances (requirements.txt, package.json, go.mod) changent beaucoup moins fréquemment que votre code source — copiez-les séparément avant de copier le reste.
Images de base : choisir la bonne fondation#
Le choix de l’image de base est l’une des décisions les plus impactantes sur la taille finale et la surface d’attaque de votre image.
scratch est l’image vide absolue — zéro octet. Utile uniquement pour des binaires compilés statiquement (Go, Rust) qui n’ont besoin d’aucune bibliothèque système.
Alpine Linux est basé sur musl libc (au lieu de la glibc standard) et BusyBox. Très léger (7 MB) mais peut causer des incompatibilités avec certaines bibliothèques C. Souvent utilisé pour Python mais avec quelques pièges (notamment pour NumPy qui doit être recompilé).
distroless (Google) contient uniquement les bibliothèques d’exécution nécessaires, sans shell, sans gestionnaire de paquets. Excellente sécurité (pas de shell = moins de surface d’attaque) mais difficile à déboguer.
debian:slim et ubuntu offrent un bon compromis : glibc standard, outils de débogage disponibles, taille raisonnable.
Exemple complet : Dockerfile pour une app Python#
Voici un Dockerfile pédagogique pour une application Python Flask/FastAPI, commenté ligne par ligne.
Dockerfile illustratif
Le Dockerfile ci-dessous est un exemple de référence. Il sera optimisé (multi-stage, cache mount) au chapitre 5.
# ── Dockerfile pour une app FastAPI ──────────────────────────────────────────
# Image de base : python slim = python + debian minimal (sans outils de dev)
FROM python:3.12-slim
# Métadonnées (OCI labels)
LABEL org.opencontainers.image.author="Lôc Cosnier"
LABEL org.opencontainers.image.description="API FastAPI exemple"
# Variables d'environnement
# PYTHONUNBUFFERED : affiche les logs immédiatement (pas de buffering stdout)
# PYTHONDONTWRITEBYTECODE : ne génère pas de fichiers .pyc (inutiles en conteneur)
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
# Répertoire de travail dans le conteneur
WORKDIR /app
# ÉTAPE CRITIQUE POUR LE CACHE :
# On copie uniquement requirements.txt d'abord,
# puis on installe les dépendances.
# Tant que requirements.txt ne change pas, pip install est servi depuis le cache.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Maintenant on copie le code source (change souvent → couche invalidée souvent)
COPY src/ ./src/
# Utilisateur non-root pour la sécurité
# (UID 1000 = premier utilisateur système standard sur Linux)
RUN adduser --disabled-password --gecos "" --uid 1000 appuser
USER appuser
# Documenter le port (n'ouvre pas vraiment le port — juste de la documentation)
EXPOSE 8000
# Vérification de santé : Docker testera cette commande périodiquement
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
# Commande par défaut
CMD ["python3", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Parsing et analyse d’un manifest (simulation)#
import hashlib
import json
import struct
def analyser_manifest(manifest: dict) -> None:
"""Analyse un manifest Docker et affiche des statistiques."""
layers = manifest.get("layers", [])
config = manifest.get("config", {})
total_bytes = sum(l.get("size", 0) for l in layers)
total_mb = total_bytes / 1024 / 1024
print("Analyse du manifest Docker")
print("=" * 55)
print(f" Schéma version : {manifest.get('schemaVersion', '?')}")
print(f" Nombre de couches : {len(layers)}")
print(f" Taille totale compressée : {total_mb:.1f} MB")
print(f" Config digest : {config.get('digest', 'N/A')[:30]}...")
print()
print(f" {'#':<4} {'Taille (MB)':<14} {'Digest (abrégé)'}")
print(" " + "-" * 48)
for i, layer in enumerate(layers):
taille_mb = layer.get("size", 0) / 1024 / 1024
digest_court = layer.get("digest", "N/A")[:25] + "..."
barre = "█" * int(taille_mb / 3)
print(f" {i:<4} {taille_mb:>8.1f} MB {digest_court} {barre}")
# Calcul de la taille décompressée estimée (facteur ~3)
decompresse_estime = total_mb * 3
print()
print(f" Taille décompressée estimée (×3) : ~{decompresse_estime:.0f} MB")
# Manifest simulé d'une image python:3.12-slim
manifest_python_slim = {
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": 8512,
"digest": "sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
},
"layers": [
{"size": int(29.1 * 1024 * 1024),
"digest": "sha256:2408cc74d12b6cd092bb8b516ba7d5e290f485d3eb9672efc00f0583730179e8"},
{"size": int(15.4 * 1024 * 1024),
"digest": "sha256:a1f58c7e2b3d4a5f6c7e8b9d0a1f2c3e4d5a6f7c8e9b0a1f2c3d4e5f6a7b8c9"},
{"size": int(42.7 * 1024 * 1024),
"digest": "sha256:b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4"},
{"size": int(7.3 * 1024 * 1024),
"digest": "sha256:c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5"},
]
}
analyser_manifest(manifest_python_slim)
Analyse du manifest Docker
=======================================================
Schéma version : 2
Nombre de couches : 4
Taille totale compressée : 94.5 MB
Config digest : sha256:a1b2c3d4e5f6a7b8c9d0e1f...
# Taille (MB) Digest (abrégé)
------------------------------------------------
0 29.1 MB sha256:2408cc74d12b6cd092... █████████
1 15.4 MB sha256:a1f58c7e2b3d4a5f6c... █████
2 42.7 MB sha256:b3c4d5e6f7a8b9c0d1... ██████████████
3 7.3 MB sha256:c4d5e6f7a8b9c0d1e2... ██
Taille décompressée estimée (×3) : ~283 MB
Résumé#
Une image Docker est une pile de couches immuables (OverlayFS), chacune identifiée par un digest SHA256.
Le manifest liste les couches ; la configuration JSON décrit la commande, les variables d’environnement, le répertoire de travail.
Chaque instruction Dockerfile qui modifie le filesystem (FROM, RUN, COPY, ADD) crée une nouvelle couche. Les autres (ENV, EXPOSE, CMD…) ajoutent uniquement des métadonnées.
CMD= arguments par défaut (surchargeable) ;ENTRYPOINT= exécutable fixe. La combinaison des deux est le pattern le plus flexible.Le cache de build est invalidé dès qu’une couche change. Pour en profiter au maximum : dépendances d’abord, code source ensuite.
Les tags sont mutables ; les digests sont immuables. En production, référencez vos images par digest.
Choisissez l’image de base selon vos contraintes :
alpinepour la légèreté,slimpour la compatibilité,distrolesspour la sécurité maximale.
Le prochain chapitre passe à la pratique : comment lancer, inspecter, déboguer et gérer des conteneurs en conditions réelles.