Docker les bases: image, container et layer

1. Vue d’ensemble

Nombreux sont les débutants en Docker qui confondent image et container, ou qui ne comprennent pas pourquoi docker build est si rapide lors de la deuxième exécution. Cet article explique trois concepts fondamentaux: image, container et layer — accompagnés d’un exemple réel de construction d’image pour une application Spring Boot. Après la lecture, vous serez capable d’écrire un Dockerfile sensé, de savoir comment exploiter le cache des layers pour construire plus rapidement, et d’éviter quelques erreurs communes lors de la conteneurisation d’une application Java.

2. Qu’est-ce qu’une image

Une image est un snapshot en lecture seule qui contient tout ce qu’il faut pour exécuter une application: le système d’exploitation de base, le runtime (la JVM par exemple), les bibliothèques, les variables d’environnement et votre code. Une image n’exécute pas d’elle-même — elle ressemble plutôt à un « modèle » à partir duquel créer des containers.

Vous définissez une image via un Dockerfile:

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

Explication ligne par ligne:

  • FROM: sélectionne l’image de base (JRE 21 sur Temurin). Tout ce qui est construit par la suite repose sur cette base.
  • WORKDIR: définit le répertoire de travail dans le container; les commandes suivantes s’exécutent relativement à ce répertoire.
  • COPY: copie le fichier jar préalablement construit (issu de mvn package) dans l’image.
  • ENTRYPOINT: la commande exécutée au démarrage du container.

Construisez l’image avec la commande:

docker build -t order-service:1.0 .

Le résultat:

[+] Building 3.2s (8/8) FINISHED
 => [1/3] FROM eclipse-temurin:21-jre
 => [2/3] WORKDIR /app
 => [3/3] COPY target/order-service.jar app.jar
 => exporting to image
 => => naming to docker.io/library/order-service:1.0

L’image est maintenant sauvegardée localement; vous pouvez voir la liste avec docker images.

Remarque: Une image est simplement un fichier statique. Exécuter docker build plusieurs fois avec le même Dockerfile et le même code ne créera pas une application en cours d’exécution — pour cela, vous devez créer un container à partir de l’image.

3. Qu’est-ce qu’un container

Un container est une instance en exécution (ou arrêtée) créée à partir d’une image. Si une image est une « classe », un container est un « objet » — vous pouvez créer plusieurs containers à partir de la même image; chaque container est indépendant en termes de processus, réseau et couche d’écriture du système de fichiers.

Créer et lancer un container:

docker run -d --name order-svc -p 8080:8080 order-service:1.0
  • -d: exécute en mode détaché (arrière-plan).
  • --name: donne un nom au container pour le gérer facilement, plutôt que d’utiliser un ID aléatoire.
  • -p 8080:8080: mappe le port 8080 de l’hôte vers le port 8080 du container.

Vérifier les containers en cours d’exécution:

docker ps
CONTAINER ID   IMAGE                  STATUS         PORTS                    NAMES
3f2a9c1b8d4e   order-service:1.0      Up 5 seconds   0.0.0.0:8080->8080/tcp   order-svc

Vous pouvez lancer un second container à partir de la même image, en changeant simplement le nom et le port:

docker run -d --name order-svc-2 -p 8081:8080 order-service:1.0

Ces deux containers sont totalement indépendants: si l’un se bloque ou si vous écrivez des fichiers dans son système de fichiers, l’autre n’en sera pas affecté. L’image d’origine (order-service:1.0) reste inchangée — c’est la nature en lecture seule de l’image.

Arrêtez et supprimez le container quand il n’est plus utile:

docker stop order-svc && docker rm order-svc

3.1. Comment un container diffère-t-il d’une machine virtuelle (VM)

Un container partage le noyau avec l’hôte et ne virtualise pas le système d’exploitation complet comme une VM. Par conséquent, un container démarre en quelques secondes (comme le montre le log de construction ci-dessus) plutôt qu’en dizaines de secondes, et consomme beaucoup moins de ressources.

4. Qu’est-ce qu’une layer et pourquoi c’est important

Chaque ligne de commande dans un Dockerfile (FROM, COPY, RUN, ADD…) crée une layer distincte. Ces layers s’empilent (union filesystem), et Docker met en cache chaque layer — si une layer n’a pas changé depuis la dernière construction, Docker réutilise le cache au lieu de la reconstruire.

Considérez ce Dockerfile non optimisé:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
ENTRYPOINT ["java", "-jar", "target/order-service.jar"]

Le problème: COPY . . copie tout le code source, y compris si vous modifiez une seule ligne de commentaire. Comme la layer COPY change, Docker est forcé de reconstruire la layer RUN mvn package qui suit — ce qui prend plusieurs minutes à chaque fois, même si la logique du code est inchangée.

La façon d’optimiser est de séparer d’abord la partie dépendances (qui change rarement), puis la partie code (qui change souvent):

FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Explication:

  • COPY pom.xml . puis RUN mvn dependency:go-offline: cette layer change uniquement quand vous ajoutez/supprimez une dépendance dans pom.xml. La plupart du temps, cette layer est mise en cache — en passant l’étape de téléchargement des bibliothèques (qui prend généralement le plus de temps).
  • COPY src ./src placée après: seule cette layer et celles qui la suivent sont reconstruites quand vous modifiez le code; la layer de dépendances garde le cache.
  • Multi-stage build (AS build puis FROM ... jre): l’étape finale ne récupère que le fichier jar construit, sans inclure le JDK ni le cache Maven — l’image de production est considérablement plus petite.

Consultez les layers d’une image avec:

docker history order-service:1.0
IMAGE          CREATED        CREATED BY                      SIZE
a1b2c3d4e5f6   3 minutes ago  ENTRYPOINT ["java" "-jar"...]   0B
7f8e9d0c1b2a   3 minutes ago  COPY /app/target/order-ser…     45.2MB
...

Chaque ligne correspond à une layer, avec sa taille. La layer COPY jar occupe la plus grande partie de l’espace — c’est logique puisqu’elle contient le code réel de l’application.

4.1. Comment fonctionne le cache de layers lors d’une reconstruction

Supposons que vous modifiez une seule classe Java puis que vous reconstruisez:

docker build -t order-service:1.1 .
 => [build 1/5] FROM eclipse-temurin:21-jdk
 => CACHED [build 2/5] COPY pom.xml .
 => CACHED [build 3/5] RUN mvn dependency:go-offline
 => [build 4/5] COPY src ./src
 => [build 5/5] RUN mvn package -DskipTests
 => [stage-1 2/2] COPY --from=build /app/target/order-service.jar app.jar

Les deux lignes CACHED montrent que Docker saute le téléchargement des dépendances — la construction est notablement plus rapide qu’avec l’approche COPY . . initiale.

Remarque: Docker compare le cache en fonction du contenu du fichier et de l’ordre des commandes, pas du timestamp. Si vous modifiez l’ordre d’une COPY ou d’un RUN, toutes les layers à partir de ce point seront invalidées, même si le contenu est réellement identique à la fois précédente.

5. Vérification par test automatisé

Lors de la conteneurisation d’un service, vous voulez généralement vous assurer que l’image construite s’exécute correctement. Utilisez la bibliothèque Testcontainers pour écrire des tests qui confirment que le container démarre et retourne les bonnes réponses.

Ajoutez la dépendance:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.1</version>
    <scope>test</scope>
</dependency>

Écrivez le test:

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;

import static org.assertj.core.api.Assertions.assertThat;

class OrderServiceContainerTest {

    @Test
    void containerShouldRespondOnHealthEndpoint() {
        try (GenericContainer<?> container =
                new GenericContainer<>("order-service:1.0")
                        .withExposedPorts(8080)
                        .waitingFor(Wait.forHttp("/actuator/health"))) {

            container.start();

            assertThat(container.isRunning()).isTrue();
            assertThat(container.getMappedPort(8080)).isPositive();
        }
    }
}
  • GenericContainer crée un container temporaire à partir de l’image order-service:1.0, se nettoie automatiquement quand le bloc try se termine.
  • waitingFor(Wait.forHttp(...)): le test attend que l’endpoint health retourne un code 2xx avant de considérer le container comme prêt, évitant les conditions de course.
  • getMappedPort(8080): Docker mappe un port aléatoire sur l’hôte; le test récupère le vrai port s’il doit faire d’autres appels.

Exécuter ce test confirme que l’image que vous avez construite à la section 2 non seulement se compile avec succès, mais s’exécute réellement correctement — une étape de vérification que beaucoup de gens oublient.

6. Erreurs courantes

  • Confondre image et container lors du nettoyage: docker rm supprime un container, mais l’image reste et consomme toujours de l’espace disque. Pour supprimer une image, utilisez docker rmi.
  • COPY toute la source avant d’installer les dépendances: comme l’explique la section 4, cela nuit à l’avantage du cache des layers et ralentit inutilement la construction.
  • Utiliser une image de base trop lourde (par exemple ubuntu complète) pour un runtime JRE: l’image de production devient énorme, augmentant le temps de pull/déploiement. Préférez une image JRE minimaliste comme eclipse-temurin:21-jre ou une variante -alpine si compatible.
  • Construire une nouvelle image sans modifier le tag: réutiliser le tag latest rend le rollback difficile en cas d’erreur en production, car vous ne savez pas exactement quelle image s’exécute.

Quand vous n’avez pas besoin de multi-stage build: si vous construisez le jar à l’avance en CI (par exemple GitHub Actions) puis ne faites que docker build, vous n’avez pas besoin du stage build dans votre Dockerfile — contentez-vous de COPY le jar déjà prêt, comme dans l’exemple de la section 2. Le multi-stage n’est nécessaire que si vous voulez que Docker compile le code source lui-même.

7. Conclusion

Une image est un modèle statique, un container est une instance en cours d’exécution créée à partir de cette image, et une layer est un ensemble de couches empilées qui aide Docker à mettre en cache intelligemment pour accélérer les constructions. Maîtriser l’ordre des layers dans votre Dockerfile — placer les éléments peu changeants (dépendances) avant les éléments qui changent fréquemment (code source) — est le moyen le plus simple d’optimiser le temps de construction. À partir de ces bases, vous pouvez progresser vers des sujets tels que Docker Compose pour orchestrer plusieurs services, ou optimiser la taille de l’image avec des bases distroless.

dockerdevopscontainerimagespring-boot