DevOps, CI/CD et conteneurs

Les bases de Docker : images, conteneurs et Dockerfiles

Débutant7 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Docker empaquette une application avec tout ce dont elle a besoin dans une image, puis exécute cette image sous forme de processus isolé : un conteneur. Ce guide présente les briques de base — images, couches, registres, conteneurs, volumes — puis montre comment écrire un Dockerfile qui se construit vite et produit une image légère et sûre, avec des exemples concrets pour un service Spring Boot.

Ce que vous allez apprendre

  • Distinguer une image, une couche, un conteneur et un registre
  • Lancer, inspecter, arrêter et supprimer des conteneurs avec les commandes essentielles de la CLI
  • Écrire un Dockerfile multi-stage qui produit une image légère et reproductible
  • Utiliser .dockerignore, l'ordre des couches et les cache mounts pour garder des builds rapides
  • Éviter les erreurs les plus courantes : exécution en root, tag latest, secrets dans les images

« Ça marche sur ma machine » était une blague ; Docker en a fait une stratégie de déploiement en intégrant la machine à l'artefact. Une image embarque l'application, son runtime, ses bibliothèques et sa configuration. Exécutez la même image sur un portable, un runner de CI et un cluster de production, et vous obtenez le même comportement. Ce guide explique le modèle qui tient cette promesse, puis passe à la pratique : les commandes de tous les jours et la façon d'écrire un Dockerfile dont vous n'aurez pas honte plus tard.

Images, couches, conteneurs et registres

Quatre mots portent l'essentiel du vocabulaire de Docker.

Une image est un modèle en lecture seule : un instantané de système de fichiers accompagné de métadonnées comme la commande par défaut, les variables d'environnement et les ports exposés. Les images sont construites à partir d'un Dockerfile et identifiées par un nom et un tag, par exemple eclipse-temurin:21-jre.

Une image se compose de couches (layers). Chaque instruction d'un Dockerfile qui modifie le système de fichiers — COPY, RUN, ADD — produit une nouvelle couche empilée sur les précédentes. Les couches sont adressées par leur contenu et partagées : si dix images commencent toutes par FROM eclipse-temurin:21-jre, les couches de base ne sont stockées et téléchargées qu'une fois. C'est aussi ce qui fait fonctionner le cache de build, comme nous le verrons.

Un conteneur est une instance d'une image, en cours d'exécution ou arrêtée. Docker ajoute une fine couche inscriptible au-dessus des couches en lecture seule de l'image, crée une vue isolée de la table des processus, du réseau et du système de fichiers grâce aux namespaces Linux, et applique des limites de ressources avec les cgroups. Le conteneur n'est qu'un processus du noyau hôte : aucun système d'exploitation invité ne démarre.

Un registre stocke et distribue les images. Docker Hub est le registre public par défaut ; les organisations font tourner des registres privés (Harbor, GitLab, Amazon ECR, GitHub Container Registry). docker pull et docker push font circuler les images entre un hôte et un registre.

$ docker pull eclipse-temurin:21-jre
$ docker image ls
REPOSITORY         TAG      IMAGE ID       CREATED       SIZE
eclipse-temurin    21-jre   3a1f0b2c9d7e   2 weeks ago   268MB

Les commandes du quotidien

Une douzaine de commandes suffit pour aller loin.

# Lancer un conteneur depuis une image, en interactif, et le supprimer à la sortie
docker run --rm -it ubuntu:24.04 bash

# Lancer un serveur web en arrière-plan, en mappant le port 8080 de l'hôte sur le port 80 du conteneur
docker run -d --name web -p 8080:80 nginx:1.27

# Voir ce qui tourne (ajouter -a pour inclure les conteneurs arrêtés)
docker ps

# Logs, shell dans le conteneur, consommation de ressources
docker logs -f web
docker exec -it web sh
docker stats web

# Arrêter, démarrer, supprimer
docker stop web
docker start web
docker rm -f web

# Inspecter n'importe quoi — images, conteneurs, réseaux — en JSON
docker inspect web | jq '.[0].NetworkSettings.Ports'

# Récupérer de l'espace disque : conteneurs arrêtés, images orphelines, réseaux inutilisés
docker system prune

Deux options méritent d'être assimilées tôt. --rm supprime le conteneur à sa sortie, ce qui évite que votre machine se remplisse de conteneurs morts issus de vos essais. -v (ou --mount) attache un volume ou un répertoire de l'hôte pour que les données survivent au conteneur :

docker run -d --name db -v pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=dev postgres:16

Sans le volume, les fichiers de la base vivent dans la couche inscriptible du conteneur et disparaissent avec docker rm.

Écrire un Dockerfile

Un Dockerfile est un script d'instructions qui produisent une image. Voici un exemple minimal pour une application Spring Boot dont le build a déjà produit target/app.jar :

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Construisez-le puis lancez-le :

docker build -t myorg/app:1.0.0 .
docker run --rm -p 8080:8080 myorg/app:1.0.0

Le . final désigne le contexte de build : le répertoire envoyé au démon Docker. Tout ce que référence COPY doit s'y trouver, et tout ce qu'il contient est transféré — d'où l'importance de .dockerignore.

Les instructions que vous utiliserez :

InstructionRôle
FROMImage de base. Tout Dockerfile commence ici (ou par FROM scratch pour partir de rien).
WORKDIRDéfinit le répertoire de travail des instructions suivantes ; le crée s'il n'existe pas.
COPYCopie des fichiers du contexte de build dans l'image. Préférez-la à ADD, qui sait aussi télécharger des URL et décompresser des archives.
RUNExécute une commande au moment du build et enregistre le résultat dans une couche.
ENVDéfinit une variable d'environnement pour le build et l'exécution.
ARGVariable de build, passée avec --build-arg ; absente à l'exécution.
EXPOSEDocumente le port sur lequel écoute le processus. Elle ne publie rien.
USERChange d'utilisateur pour les instructions suivantes et pour le conteneur en cours d'exécution.
ENTRYPOINT / CMDLa commande à exécuter. Utilisez la forme tableau JSON pour qu'aucun shell ne s'interpose et que les signaux atteignent le processus.
HEALTHCHECKUne commande que Docker exécute périodiquement pour indiquer l'état de santé du conteneur.

Les builds multi-stage

Le Dockerfile ci-dessus suppose que le jar a été construit hors de Docker, ce qui rend le build dépendant du Maven et du JDK de l'hôte. Un build multi-stage compile dans un premier stage et ne copie que l'artefact dans un stage d'exécution allégé :

# ---- stage de build ----
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
# Copier d'abord les fichiers qui changent le moins, pour que le téléchargement des dépendances soit mis en cache
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -q dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -q package -DskipTests

# ---- stage d'exécution ----
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 10001 app
WORKDIR /app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

Le stage de build, avec Maven et le JDK complet, est jeté ; l'image finale ne contient que le JRE et le jar. La taille passe d'environ 700 Mo à moins de 300 Mo, et ni le code source ni l'outillage de build ne partent en production. Le même schéma s'applique à Node (build avec node:22, exécution avec node:22-slim ou fichiers statiques servis par nginx), à Go (build, puis FROM scratch) et à tout le reste.

Des builds rapides : cache et ordre des instructions

Docker met chaque couche en cache et la réutilise tant que l'instruction et ses entrées n'ont pas changé. Le cache est invalidé à la première instruction dont les entrées ont changé, et toutes les instructions suivantes sont reconstruites. Deux conséquences :

  • Ordonnez les instructions de la moins volatile à la plus volatile. Copiez pom.xml ou package.json et installez les dépendances avant de copier le code source. Modifier un fichier source ne reconstruit alors que l'étape de compilation, pas le téléchargement des dépendances.
  • Utilisez .dockerignore. Un COPY . . est invalidé dès qu'un fichier du contexte change — y compris .git, les sorties de build et les fichiers d'échange de l'éditeur. Excluez-les :
.git
target
node_modules
*.log
.idea
.vscode
Dockerfile
docker-compose*.yml

Le --mount=type=cache de BuildKit (vu plus haut) conserve les caches du gestionnaire de paquets d'un build à l'autre sans les figer dans une couche. Regroupez avec && les commandes RUN qui vont ensemble, pour que leurs fichiers intermédiaires ne deviennent jamais une couche, et faites le ménage dans la même instruction :

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*

L'essentiel en sécurité et en hygiène

Ne tournez pas en root. Par défaut, le processus d'un conteneur s'exécute avec l'UID 0. Une évasion de conteneur ou un bug applicatif offre alors à un attaquant les droits root sur tout le système de fichiers de l'hôte visible par le conteneur. Créez un utilisateur et basculez dessus avec USER, comme dans l'exemple multi-stage.

Figez les images de base. FROM node ou FROM node:latest, c'est une image différente chaque semaine. Utilisez un tag précis (node:22.9-slim) et, pour des builds reproductibles, un digest : FROM node:22.9-slim@sha256:…. Reconstruisez délibérément pour récupérer les correctifs de sécurité.

Ne mettez jamais de secrets dans une image. Un COPY .env . ou un ENV API_KEY=... reste pour toujours dans une couche, visible avec docker history, et part vers chaque registre où l'image est poussée. Transmettez les secrets à l'exécution via des variables d'environnement ou des fichiers montés, et utilisez le --mount=type=secret de BuildKit si une étape de build en a réellement besoin.

Gardez l'image légère. Moins de paquets, c'est moins de vulnérabilités à corriger et des pulls plus rapides. Privilégiez les bases -slim, -alpine ou distroless, et scannez les images avec docker scout ou Trivy dans la CI.

Gérez les signaux. Utilisez la forme tableau JSON d'ENTRYPOINT pour que votre processus soit le PID 1 et reçoive SIGTERM à l'arrêt du conteneur. Une forme shell comme ENTRYPOINT java -jar app.jar intercale sh devant votre processus : c'est le shell qui reçoit le signal et l'avale, et votre application est tuée sans arrêt propre à l'expiration du délai de grâce de dix secondes.

Les erreurs courantes

  • Traiter les conteneurs comme des animaux de compagnie. Se connecter à un conteneur pour y modifier des fichiers fonctionne… jusqu'à ce qu'il soit recréé. Modifiez le Dockerfile et reconstruisez.
  • Publier des ports avec EXPOSE seul. EXPOSE n'est que de la documentation. C'est -p host:container (ou une entrée ports dans Compose) qui publie réellement.
  • Croire que localhost désigne l'hôte. Dans un conteneur, localhost, c'est le conteneur lui-même. Joignez les autres conteneurs via un réseau défini par l'utilisateur et leurs noms de service, et l'hôte via host.docker.internal sous Docker Desktop.
  • Écrire des données dans le système de fichiers du conteneur. Elles disparaissent au docker rm. Utilisez des volumes.
  • Ignorer la règle « un processus, un conteneur ». Entasser une appli, une base de données et un démon cron dans une seule image sabote l'orchestration, la journalisation et la montée en charge. Compose et Kubernetes existent pour les applications multi-conteneurs.
  • Ignorer le code de sortie. docker ps -a affiche les conteneurs terminés avec leur code ; docker logs explique pourquoi. 137 signifie un arrêt par l'OOM killer ou par un SIGKILL : augmentez la limite mémoire ou corrigez la fuite.

Points clés à retenir

  • Une image est un modèle en couches, en lecture seule ; un conteneur est un processus lancé à partir d'elle, avec une couche inscriptible par-dessus ; un registre stocke les images.
  • Apprenez run, ps, logs, exec, stop, rm, build, push et system prune. Ajoutez --rm à vos essais et -v à tout ce qui stocke des données.
  • Écrivez des Dockerfiles multi-stage : build dans une image complète, exécution dans une image allégée, et seul l'artefact passe de l'une à l'autre.
  • Ordonnez les instructions du plus stable au plus volatil, entretenez .dockerignore et utilisez les cache mounts pour qu'une modification du code source ne reconstruise que l'étape de compilation.
  • Exécutez avec un utilisateur non root, figez les images de base, gardez les secrets hors des couches, utilisez la forme JSON d'ENTRYPOINT et gardez l'image légère.

Sources et lectures complémentaires

Questions fréquentes

Un conteneur est-il une machine virtuelle allégée ?
Non. Une machine virtuelle fait tourner un système d'exploitation invité complet au-dessus d'un hyperviseur. Un conteneur est un processus ordinaire du noyau hôte, isolé par les namespaces Linux et limité par les cgroups, qui voit son propre système de fichiers issu de l'image. C'est pour cela qu'un conteneur démarre en quelques millisecondes et partage le noyau de l'hôte — et qu'il ne peut pas exécuter un noyau différent de celui de l'hôte.
Quelle est la différence entre CMD et ENTRYPOINT ?
ENTRYPOINT définit l'exécutable qui tourne systématiquement ; CMD fournit des arguments par défaut que l'utilisateur peut remplacer sur la ligne de commande de docker run. Mettez la partie fixe dans ENTRYPOINT (la commande java et ses options) et dans CMD les arguments que l'on s'attend à voir changer. Si seul CMD est défini, la commande entière peut être remplacée.
Pourquoi mon image est-elle si volumineuse ?
Le plus souvent parce que les outils de build, le code source et les caches du gestionnaire de paquets ont fini dans l'image finale. Un build multi-stage compile dans un premier stage et ne copie que l'artefact produit dans une image d'exécution allégée. Vérifiez aussi que .dockerignore exclut .git, node_modules, target et les dossiers de l'IDE, et que vous videz les caches du gestionnaire de paquets dans la même instruction RUN que l'installation.
Faut-il utiliser le tag latest ?
Pas pour ce que vous déployez. latest n'est qu'un tag mutable qui pointe vers la dernière image poussée : deux builds lancés à des jours différents peuvent donc produire des images différentes. Figez vos images de base sur une version précise (idéalement un digest) et taguez vos propres images avec la version du build ou le hash du commit.