Pratique : Images Docker

Créer et optimiser des images Docker

Practice
Containers
Docker
Images
Exercices pratiques pour créer et optimiser des images Docker avec des applications Java simples.
Auteur
Affiliations

Université de Toulon

LIS UMR CNRS 7020

Date de publication

2026-02-01

Environnement et reproductibilite

Jupyter Kernel: bash

🖥️ Env Ubuntu 24.04.3 LTS / x86_64 • 🐳 Docker Client 29.1.5 / Server 29.1.5 • 🌿 Git Branch @ 7b914bd

Ce support a été généré par Quarto : les cellules sont exécutées par le noyau indiqué lors du rendu.

Certaines parties ont été rédigées avec l’assistance d’un modèle de langage ; le contenu a été relu et validé par l’auteur.

ImportantCorrections

✅ Updated existing repository: ebpro/notebook-containers-intro-exercices-images

Détails
git clone -b main https://github.com/ebpro/notebook-containers-intro-exercices-images

EXERCICE 1 — Image Java simple

Créer une image Docker contenant une application Java simple construite avec Maven Wrapper.

Structure du projet

Créez l’arborescence suivante :


hello-java/
├── src/
│   └── main/
│       └── java/
│           └── app/
│               └── App1.java
├── pom.xml

Etudier le code de l’application Java App1.java :

src/main/java/app/App1.java
package app;

import org.slf4j.LoggerFactory;
import org.slf4j.Logger;

public class App1 {
    private static final Logger logger = LoggerFactory.getLogger(App1.class);

    public static void main(String[] args) {
        logger.info("Hello from Java in Docker!");
    }
}

Etudier le fichier pom.xml, regarder :

  • les coordonnées du projet (groupId, artifactId, version) : identifier ton application et sa version.
  • la section properties est très importante, car elle regroupe les versions de Java et des bibliothèques ; le projet utilise Java 21.
    • la propriété main.class définit la classe principale de l’application (avec la méthode main) qui sera définit dans le manifeste du JAR exécutable.
  • La partie dependencies définit les bibliothèques externes dont le projet a besoin, comme JUnit pour les tests ou les outils de logging pour afficher des messages.
  • build/plugins, définissent comment Maven va compiler le code et lancer les tests automatiquement.
  • Enfin, la section profiles (notamment prod). Un profil Maven est une partie optionnelle de la configuration activé manuellement (ici avec -Pprod). Ce profile sert à créer une version prête à être distribuée, avec un JAR exécutable (dans target) et les dépendances copiées dans le dossier (libe).
pom.xml
<?xml version="1.0" encoding="UTF-8"?>

<!--
  POM Maven pédagogique
  =====================
  Objectif :
  - Compiler une application Java 21
  - Exécuter des tests
  - Produire un JAR exécutable
  - Copier les dépendances dans target/libs
  - Être facilement utilisable avec Docker
-->

<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <!-- Version du modèle Maven (toujours 4.0.0) -->
    <modelVersion>4.0.0</modelVersion>

    <!-- Coordonnées du projet -->
    <groupId>fr.univtln.bruno.demos.docker</groupId>
    <artifactId>hello-java</artifactId>
    <version>0.1.0-SNAPSHOT</version>

    <name>Hello Java</name>
    <description>
        Application Java "Hello World" utilisée pour démontrer Maven et Docker
    </description>

    <!-- ===================================================== -->
    <!-- Propriétés                                           -->
    <!-- ===================================================== -->
    <!--
      Bonne pratique :
      - Centraliser toutes les versions
      - Faciliter les mises à jour
      - Éviter les valeurs "en dur"
    -->
    <properties>

        <!-- Encodage -->
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

        <!-- Version de Java -->
        <java.version>21</java.version>

        <!-- Classe principale de l'application -->
        <main.class>app.App1</main.class>

        <!-- Dépendances -->
        <junit.version>5.11.4</junit.version>
        <slf4j.version>2.0.16</slf4j.version>
        <logback.version>1.5.16</logback.version>

        <!-- Plugins Maven -->
        <maven.compiler.version>3.13.0</maven.compiler.version>
        <maven.surefire.version>3.5.2</maven.surefire.version>
        <maven.jar.version>3.4.2</maven.jar.version>
        <maven.dependency.version>3.6.1</maven.dependency.version>
        <versions.maven.version>2.21.0</versions.maven.version>

    </properties>

    <!-- ===================================================== -->
    <!-- Dépendances                                          -->
    <!-- ===================================================== -->
    <dependencies>

        <!-- ======================= -->
        <!-- Dépendances de test     -->
        <!-- ======================= -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>

        <!-- ======================= -->
        <!-- Logging (runtime)       -->
        <!-- ======================= -->

        <!-- API de logging (abstraction) -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>${slf4j.version}</version>
        </dependency>

        <!-- Implémentation concrète -->
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>${logback.version}</version>
        </dependency>

    </dependencies>

    <!-- ===================================================== -->
    <!-- Build                                                 -->
    <!-- ===================================================== -->
    <build>

        <plugins>

            <!-- ======================= -->
            <!-- Compilation Java        -->
            <!-- ======================= -->
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>${maven.compiler.version}</version>
                <configuration>
                    <!-- Utilisation du mode release (bonne pratique) -->
                    <release>${java.version}</release>

                    <!-- Conserve les noms des paramètres (utile pour frameworks) -->
                    <parameters>true</parameters>
                </configuration>
            </plugin>

            <!-- ======================= -->
            <!-- Exécution des tests     -->
            <!-- ======================= -->
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>${maven.surefire.version}</version>
            </plugin>

        <!-- ======================= -->
        <!-- Gestion des versions    -->
        <!-- ======================= -->
        <plugin>
            <!-- Plugin de maintenance (NON exécuté automatiquement) -->
            <groupId>org.codehaus.mojo</groupId>
            <artifactId>versions-maven-plugin</artifactId>
            <version>${versions.maven.version}</version>

            <configuration>
                <rulesUri>
                    https://bruno.univ-tln.fr/rules.xml
                </rulesUri>
            </configuration>
        </plugin>

        </plugins>

    </build>

    <!-- ===================================================== -->
    <!-- Profils Maven                                        -->
    <!-- ===================================================== -->
    <!--
      Un profil = un mode de build
      Ici :
      - default : développement
      - prod    : production / Docker
    -->
    <profiles>

        <!-- ================================================= -->
        <!-- Profil production                                 -->
        <!-- ================================================= -->
        <profile>
            <id>prod</id>

            <build>
                <plugins>

                    <!-- ========================================= -->
                    <!-- Copier les dépendances dans target/libs    -->
                    <!-- ========================================= -->
                    <!--
                      Résultat :
                      target/
                        ├── hello-java-XXX.jar
                        └── libs/
                            ├── logback.jar
                            └── slf4j.jar
                    -->
                    <plugin>
                        <artifactId>maven-dependency-plugin</artifactId>
                        <version>${maven.dependency.version}</version>
                        <executions>
                            <execution>
                                <id>copy-dependencies</id>
                                <phase>package</phase>
                                <goals>
                                    <goal>copy-dependencies</goal>
                                </goals>
                                <configuration>
                                    <outputDirectory>
                                        ${project.build.directory}/libs
                                    </outputDirectory>
                                    <includeScope>runtime</includeScope>
                                </configuration>
                            </execution>
                        </executions>
                    </plugin>

                    <!-- ========================================= -->
                    <!-- Création du JAR exécutable                 -->
                    <!-- ========================================= -->
                    <!--
                      Le MANIFEST.MF contiendra :
                      - Main-Class
                      - Class-Path: libs/...
                    -->
                    <plugin>
                        <artifactId>maven-jar-plugin</artifactId>
                        <version>${maven.jar.version}</version>
                        <configuration>
                            <archive>
                                <manifest>
                                    <mainClass>${main.class}</mainClass>
                                    <addClasspath>true</addClasspath>
                                    <classpathPrefix>libs/</classpathPrefix>
                                </manifest>

                                <!-- Infos utiles pour le debug -->
                                <manifestEntries>
                                    <Built-By>${user.name}</Built-By>
                                    <Build-Jdk>${java.version}</Build-Jdk>
                                    <Build-Time>${maven.build.timestamp}</Build-Time>
                                </manifestEntries>
                            </archive>
                        </configuration>
                    </plugin>

                </plugins>
            </build>
        </profile>

    </profiles>

</project>
Avertissement

Les options -ntp --quiet permettent de réduire la verbosité de Maven dans les logs sur ce support. L’option --batch-mode réponnd automatiquement “oui” aux questions posées par Maven.

supprimer ces options lors de l’exécution.

Générer le Maven Wrapper avec la commande :

mvn wrapper:wrapper

Tester que l’application fonctionne localement :

./mvnw --quiet --batch-mode package -Pprod
java -jar target/hello-world-*.jar app.App1
Error: Unable to access jarfile target/hello-world-*.jar
: 1
ImportantA FAIRE
  1. Ecrire un Dockerfile pour construire l’image Docker. Vous utiliserez l’image de base eclipse-temurin:21-jdk-alpine. Vous devrez copier les fichiers, construire l’application avec Maven Wrapper, et définir la commande de démarrage.
  2. Construire l’image Docker
  3. Lancer un conteneur pour exécuter l’application Java.
Dockerfile
# Définir l'image de base
FROM eclipse-temurin:21-jdk

# Definir le répertoire de travail
WORKDIR /app

# Copier les fichiers du projet dans le conteneur
COPY . .

# Construire l'application avec Maven Wrapper
RUN ./mvnw package -Pprod

# Définir la commande de démarrage

# Il est plus propre d'utiliser la syntaxe json comme nous le ferons plus tard
# mais pour faire simple ici nous utilisons la syntaxe shell qui support les les variables d'environnement et le globbing (*)

CMD java -jar target/hello-java-*.jar
docker build --quiet -t hello-java:0.0.1 .
sha256:b875dd92269f7d3fc4ebde14bb04547a80750f9b6445cc8586d97954b487001e
docker run --rm hello-java:0.0.1
07:46:39.464 [main] INFO app.App1 -- Hello from Java in Docker!

Exercice 2 — ARG, ENV (adapté au projet hello-java)

Ajouter au projet la classe app.App2 qui lit un fichier pour connaitre le commit. Ce fichier sera créé à la construction de l’image avec un build-arg. L’application lit aussi la variable d’environnement LOG_LEVEL (pour contrôler le niveau de logs au runtime).

src/main/java/app/App2.java
package app;

import org.slf4j.LoggerFactory;
import org.slf4j.Logger;
import java.nio.file.Files;
import java.nio.file.Path;

public class App2 {
    private static final Logger logger = LoggerFactory.getLogger(App2.class);

    public static void main(String[] args) {

        try {
            String gitCommit = Files.readString(Path.of("/app/GIT_COMMIT")).trim();
            logger.info("Git commit (build-time) : " + gitCommit);
        } catch (Exception e) {
            logger.error("Failed to read GIT_COMMIT file", e);
        }
        String logLevel = System.getenv("LOG_LEVEL");

        logger.info("Log level (run-time)    : " + logLevel);
    }
}
ImportantA FAIRE

Créer un Dockerfile.arg_env qui accepte un build-arg et crée le fichier /app/GIT_COMMIT avec cette valeur.

Depuis le dossier hello-java/, construisez l’image en passant la valeur du commit au build :

docker build --quiet \
    -f Dockerfile.arg_env \
    --build-arg GIT_COMMIT=abc123 \
    -t hello-java:env \
    .
sha256:03f9ec828e4d6415f0fc2a5540809e9d7c39eaec9afe9edb762c87a15f642338
  1. Lancez le conteneur pour vérifier que le commit est bien passé au build :
docker run --rm hello-java:env
07:47:12.023 [main] INFO app.App2 -- Git commit (build-time) : abc123
07:47:12.025 [main] INFO app.App2 -- Log level (run-time)    : INFO
  1. Lancez le conteneur en surchargeant avec une variable d’environnement le niveau de logs (runtime) :
docker run --rm --env LOG_LEVEL=DEBUG hello-java:env
07:47:13.011 [main] INFO app.App2 -- Git commit (build-time) : abc123
07:47:13.013 [main] INFO app.App2 -- Log level (run-time)    : DEBUG
  1. Que peut-on faire si on ajoute dans le Dockerfile une instruction ENV COMMIT=${GIT_COMMIT} ?
Dockerfile.arg_env
FROM eclipse-temurin:21-jdk

WORKDIR /app

# Définir une variable d’argument pour le commit Git
ARG GIT_COMMIT="unknown"

# Pour rendre la variable d’argument disponible en tant que variable d’environnement
# Il est alors possible de la surcharger au runtime avec --env
# ENV GIT_COMMIT=${GIT_COMMIT}

# inscrire la valeur dans un fichier dans l'image qui pourra être lue par l'application
RUN printf '%s' "${GIT_COMMIT}" > /app/GIT_COMMIT

# Ajouter un label avec le commit Git dans les métadonnées de l’image
LABEL git.commit="${GIT_COMMIT}"

# Configuration dynamique du conteneur
ENV LOG_LEVEL=INFO

COPY . .

RUN ./mvnw package -Pprod

CMD java -cp target/hello-java-*.jar app.App2

EXERCICE 3 — Multi-stage build + ENTRYPOINT/CMD

Compléter l’application Java :

src/main/java/app/App3.java
package app;

import java.util.Optional;

import org.slf4j.LoggerFactory;
import org.slf4j.Logger;

public class App3 {
    private static final Logger logger = LoggerFactory.getLogger(App3.class);

    public static void main(String[] args) {
        String name = Optional.ofNullable(System.getenv("NAME")).orElse("World");
        logger.info("Hello " + name + "!");
    }
}
ImportantA FAIRE

Créer une image optimisée en multi-stage et utiliser ENTRYPOINT + CMD.

  1. Ecrire un Dockerfile Dockerfile.multistage qui utilise une construction en deux étapes :

    • une étape de build utilisant l’image eclipse-temurin:21-jdk pour compiler l’application Java
    • une étape runtime utilisant l’image eclipse-temurin:21-jre pour exécuter cette nouvelle classe de l’application Java
  2. Utiliser ENTRYPOINT pour définir la commande principale et CMD pour passer un argument optionnel (le nom à saluer).

Tester votre solution avec les commandes suivantes :

Construire l’image Docker

docker build --quiet \
  --file Dockerfile.multistage \
  -t hello-java:0.0.1-multi \
  .
sha256:01b25219280cfaa7a0a95b493509b7bcdeb1754ff6da5233e8b7c60d9a0533b9
  1. Lancer le conteneur sans argument (doit afficher “Hello World!”)
docker run --rm hello-java:0.0.1-multi
07:48:06.685 [main] INFO app.App3 -- Hello World!
  1. Lancer le conteneur avec un argument (doit afficher “Hello Pierre!”)
docker run --rm -e NAME=Pierre hello-java:0.0.1-multi
07:48:07.499 [main] INFO app.App3 -- Hello Pierre!
  1. comparer la taille de l’image optimisée
docker image ls --filter "reference=hello-java*"
                                                            i Info →   U  In Use

IMAGE                    ID             DISK USAGE   CONTENT SIZE   EXTRA

hello-java:0.0.1         b875dd92269f        776MB          255MB        

hello-java:0.0.1-multi   01b25219280c        404MB          101MB        

hello-java:env           03f9ec828e4d        776MB          255MB        
ImportantExtensions (Bonus)
  1. (Bonus 1) Modifier l’application Java pour ajouter une variable d’environnement GREETING personnalisable pour le message de salutation.
  2. (Bonus 2) Optimiser le Dockerfile pour le pas télécharger inutilement des dépendances Maven à chaque build quand seule le code source change.
  3. (Bonus 3) Utiliser --mount=type=cache associé à RUN pour cacher le répertoire .m2 lors du build Maven.
  4. (Bonus 4) Utiliser des images de base plus légères (ex: eclipse-temurin:21-jre-alpine).
Dockerfile.multistage
# =========================
# Étape 1 : build Maven
# =========================

# Image de base avec JDK (nécessaire pour compiler)
FROM eclipse-temurin:21-jdk AS build

# Répertoire de travail dans le conteneur
WORKDIR /app

# Copier uniquement les fichiers Maven (pour optimiser le cache Docker)
COPY pom.xml mvnw ./
COPY .mvn .mvn

# Télécharger les dépendances dans le cache Maven
# (cache persistant entre builds grâce à BuildKit)
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw -q dependency:go-offline

# Copier le code source (après les dépendances pour optimiser le cache)
COPY src src

# Compiler le projet et générer :
# - le JAR de l'application
# - le dossier libs/ contenant les dépendances
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw package -Pprod -DskipTests


# =========================
# Étape 2 : image runtime
# =========================

# Image plus légère avec uniquement le JRE
FROM eclipse-temurin:21-jre

WORKDIR /app

# Copier le JAR principal depuis l'étape build
COPY --from=build /app/target/hello-java-*.jar app.jar

# Copier les dépendances dans un dossier libs/
COPY --from=build /app/target/libs libs

# Script d'entrée
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh

# Best practice: run as non-root user
RUN useradd -m appuser
USER appuser

# Variable d'environnement exemple
ENV NAME=World

# Lancer l'application avec un classpath composé :
# - du JAR principal
# - de toutes les libs dans le dossier libs/
ENV MAIN_CLASS=app.App3

ENTRYPOINT ["/app/entrypoint.sh"]

Réutilisation