Conteneurs — Application à Java

Construire des images Docker Java prêtes pour l’orchestration

Lecture
Containers
Docker
Optimisation, sécurité et bonnes pratiques pour la conteneurisation Java
Auteur
Affiliations

Université de Toulon

LIS UMR CNRS 7020

Date de publication

2026-09-30

Objectifs

Comment transformer une application Java en image Docker exploitable en production.

À l’issue, vous devez comprendre :

  • L’impact de l’ordre des instructions sur le cache de build.
  • Pourquoi séparer l’environnement de build du runtime (Multi-stage).
  • Comment rendre une image orchestrable (sécurité non-root, signaux PID 1).
Important

Ce chapitre ne vise pas à maîtriser toutes les techniques, mais à comprendre les trajectoires possibles.

Exemples pratiques

Les exemples suivants sont accessibles dans le dépôt :

✅ : ebpro/notebook-containers-intro-sample-java-helloworld

Détails
  • URL: https://github.com/ebpro/notebook-containers-intro-sample-java-helloworld
  • Branch: develop
  • Local Path: /home/jovyan/work/examples/github/ebpro/notebook-containers-intro-sample-java-helloworld
  • Latest Commit: a21605e - fix: native-image heap cap must use JAVA_TOOL_OPTIONS (real JVM var)
  • Timestamp: 2026-09-30T07:03:15Z

Clone Command:

git clone -b develop https://github.com/ebpro/notebook-containers-intro-sample-java-helloworld

Image Java simple (Maven embarqué)

La méthode la plus directe consiste à utiliser une image Maven officielle. C’est l’approche “tout-en-un” où l’on compile et exécute au même endroit.

Dockerfile.10.maven
# ============================================================
# Stage: Build & Run (Single-stage Maven)
# Purpose: Baseline development environment for Java 25
# ============================================================
FROM maven:3.9.16-eclipse-temurin-25-noble

# ------------------------------------------------------------
# Build Environment Configuration
# ------------------------------------------------------------
WORKDIR /app

ENV HOME=/home/appuser
ENV MAVEN_CONFIG=/home/appuser/.m2
ENV MAVEN_OPTS="-Dmaven.repo.local=/home/appuser/.m2"

LABEL maintainer="Emmanuel Bruno <emmanuel.bruno@univ-tln.fr>"
LABEL description="Java Hello World Application - Full Maven baseline"

# ------------------------------------------------------------
# Security: Unprivileged user creation
# ------------------------------------------------------------
RUN groupadd -r appgroup \
 && useradd -r -g appgroup -m -d /home/appuser appuser \
 && mkdir -p /home/appuser/.m2 \
 && chown -R appuser:appgroup /home/appuser \
 && chown -R appuser:appgroup /app

# ------------------------------------------------------------
# Layer 1: Dependency Caching
# ------------------------------------------------------------
# Copying only the wrapper and POM allows Docker to cache
# dependencies independently of source code changes.
COPY --chown=appuser:appgroup .mvn/ .mvn
COPY --chown=appuser:appgroup mvnw pom.xml ./

USER appuser

# Pre-resolve project dependencies to optimize build time
RUN ./mvnw dependency:resolve

# ------------------------------------------------------------
# Layer 2: Source Compilation
# ------------------------------------------------------------
COPY --chown=appuser:appgroup src ./src

# Build artifact using the 'jvm' profile (configured for thin JAR)
RUN ./mvnw clean package -DskipTests -Pjvm

# ------------------------------------------------------------
# Execution
# ------------------------------------------------------------

# Configure JVM container ergonomics for resource management
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"

# Explicitly invoke the JAR. The 'jvm' profile's Manifest
# automatically links to external dependencies in the /libs folder.
ENTRYPOINT ["java", "-jar", "/app/target/hello-world-0.1.0-SNAPSHOT.jar"]

docker image build \
  --tag javahello:mavenimage \
  --file Dockerfile.10.maven .
docker container run --rm javahello:mavenimage
Picked up JAVA_TOOL_OPTIONS: -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0
07:04:08.564 INFO  fr.univtln.bruno.demos.docker.App - Java Vendor: Eclipse Adoptium | Version: 25.0.4.1
07:04:08.572 INFO  fr.univtln.bruno.demos.docker.App - Démarrage de l'application (Iterations: 10000)...
07:04:08.655 INFO  fr.univtln.bruno.demos.docker.App - Traitement terminé. Éléments filtrés : 8926 | Temps : 82 ms
Fin du programme. (Éléments traités: 8926)
ANALYSE DE L'IMAGE : javahello:mavenimage

   • Sécurité  : ✅ appuser

   • Surface   : ⚠️  Shell présent

   • Structure : ❌ Polluants (Maven ou /src)

   • Runtime   : ☕ Standard (JRE)

   • Java Opts : ❌ Manquante

   • Poids     : 509MB


ImportantAnalyse critique
  • Sécurité : L’image contient le code source, Maven et le JDK complet. C’est une surface d’attaque inutile.
  • Poids : L’image dépasse souvent les 800MB pour une application simple.

Image Java optimisée (multi-stage build)

Le Multi-stage build est la stratégie de référence. On utilise une image “lourde” pour compiler, puis on ne transfère que le .jar et les dépendances (lib/*.jar) vers une image JRE légère pour l’exécution.

Dockerfile.20.stage
# ============================================================
# Stage 1: Build environment
# ============================================================
# Uses the full Maven SDK to compile the source code into a thin JAR
FROM maven:3.9.16-eclipse-temurin-25-noble AS stage-build
WORKDIR /app

# Cache dependency layer: Copying only the wrapper and POM
# allows Docker to skip downloading dependencies if they haven't changed.
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline

# Copy source code and perform a clean build
COPY src ./src
# Build using the 'jvm' profile (configured to output a thin JAR)
RUN ./mvnw clean package -DskipTests -Pjvm

# ============================================================
# Stage 2: Runtime environment
# ============================================================
# Uses the JRE-only base image to minimize the security attack surface
FROM eclipse-temurin:25-jre-noble

LABEL maintainer="Emmanuel Bruno <emmanuel.bruno@univ-tln.fr>"
LABEL description="Java Hello World Application - multi-stage"

# Security: Create an unprivileged user/group to run the application
RUN groupadd -r appgroup && useradd -r -g appgroup appuser && \
    mkdir -p /app && chown -R appuser:appgroup /app

# Copy artifacts from the builder stage:
# Only the thin JAR and external dependencies are required at runtime.
COPY --from=stage-build --chown=appuser:appgroup /app/target/libs /app/libs
COPY --from=stage-build --chown=appuser:appgroup /app/target/hello-world-*.jar /app/app.jar

USER appuser

# Configure JVM ergonomics for containerized environments:
# JAVA_TOOL_OPTIONS is natively parsed by the JVM at startup,
# ensuring memory limits are respected without needing shell script injection.
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"

# Execute the application
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

docker image build \
  --tag javahello:mavenimagestage \
  --file Dockerfile.20.stage .
ANALYSE DE L'IMAGE : javahello:mavenimagestage

   • Sécurité  : ✅ appuser

   • Surface   : ⚠️  Shell présent

   • Structure : ✅ Artefact pur (Multi-stage OK)

   • Runtime   : ☕ Standard (JRE)

   • Java Opts : ❌ Manquante

   • Poids     : 298MB


Prêt pour l’orchestration (Production-Ready)

Pour être orchestrée (Kubernetes, Compose), une image doit respecter des règles d’hygiène :

  1. Non-Root : Vos Dockerfiles utilisent un appuser. Cela empêche une faille applicative de compromettre l’hôte.
  2. Signaux (Exec Form) : L’usage de ENTRYPOINT ["/script.sh"] permet à la JVM de recevoir le SIGTERM pour un arrêt propre.
  3. Mémoire : L’usage de -XX:MaxRAMPercentage permet à la JVM d’être “Container Aware”.

Affichage des images créées

docker image ls \
  --filter "reference=javahello" \
  --format "table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}\t{{.CreatedSince}}"
REPOSITORY   TAG               IMAGE ID       SIZE      CREATED
javahello    mavenimagestage   c0415d4244c7   312MB     8 seconds ago
javahello    mavenimage        f21f79c68234   533MB     About a minute ago

Accélérer les builds (Cache BuildKit)

Docker permet de monter des caches persistants pour le répertoire ~/.m2. Cela évite de retélécharger les dépendances à chaque modification du code source.

Dockerfile.30.cache
# ============================================================
# Stage 1: Build Environment
# Purpose: Leverage BuildKit caching to accelerate Maven builds
# ============================================================
FROM maven:3.9.16-eclipse-temurin-25-noble AS stage-build

WORKDIR /app

# ------------------------------------------------------------
# Layer 1: Maven Wrapper and Project Metadata
# ------------------------------------------------------------
# Copying the wrapper and POM first allows Docker to cache the
# dependency resolution layer, skipping downloads if the project
# structure hasn't changed.
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw

# ------------------------------------------------------------
# Layer 2: Dependency Caching (BuildKit)
# ------------------------------------------------------------
# Using BuildKit's cache mount persists the local M2 repository
# across builds, significantly reducing network overhead.
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw --batch-mode dependency:go-offline

# ------------------------------------------------------------
# Layer 3: Application Build
# ------------------------------------------------------------
COPY src ./src

# Compile the project using the 'jvm' profile.
# The persistent cache ensures that even if sources change,
# dependencies do not need to be re-downloaded.
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw --batch-mode clean package -DskipTests -Pjvm


# ============================================================
# Stage 2: Runtime Environment
# ============================================================
# Using a JRE-only base image minimizes the attack surface
# and keeps the final image size optimized for production.
FROM eclipse-temurin:25-jre-noble

LABEL maintainer="Emmanuel Bruno <emmanuel.bruno@univ-tln.fr>"
LABEL description="Java Hello World Application - BuildKit cache optimized"

# Security: Create an unprivileged user/group to adhere to the principle of least privilege
RUN groupadd -r appgroup && useradd -r -g appgroup appuser && \
    mkdir -p /app && chown -R appuser:appgroup /app

# ------------------------------------------------------------
# Deployment: Copy artifacts from builder stage
# ------------------------------------------------------------
# We only copy the thin JAR and external dependencies required at runtime.
COPY --from=stage-build --chown=appuser:appgroup /app/target/libs /app/libs
COPY --from=stage-build --chown=appuser:appgroup /app/target/hello-world-*.jar /app/app.jar

USER appuser

# ------------------------------------------------------------
# Runtime Execution
# ------------------------------------------------------------
# Configure JVM container ergonomics. JAVA_TOOL_OPTIONS is parsed
# natively by the JVM, avoiding the need for shell script wrappers.
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"

# Execute the application. The JAR manifest is configured to
# resolve external dependencies from the /app/libs/ directory.
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

docker image build \
  --tag javahello:dockercache \
  --file Dockerfile.30.cache .
ANALYSE DE L'IMAGE : javahello:dockercache

   • Sécurité  : ✅ appuser

   • Surface   : ⚠️  Shell présent

   • Structure : ✅ Artefact pur (Multi-stage OK)

   • Runtime   : ☕ Standard (JRE)

   • Java Opts : ❌ Manquante

   • Poids     : 298MB


Note

L’instruction --mount=type=cache,target=/root/.m2 est une fonctionnalité BuildKit qui transforme radicalement l’expérience de CI/CD.

Construction personnalisée (SDKMAN) — Avancé

L’usage de SDKMAN permet un contrôle granulaire sur les versions exactes du JDK et des outils de build, indépendamment des images Maven officielles.

Dockerfile.40.manual
# ============================================================
# Stage 1: Build Environment (Manual SDKMAN Toolchain)
# Purpose: Demonstrate manual environment provisioning and
#          fixed-UID/GID file ownership for high-security CI.
# ============================================================
FROM ubuntu:noble AS stage-build

# Toolchain versioning for reproducible builds
ARG JAVA_VERSION="25.0.3-tem"
ARG MAVEN_VERSION="3.9.16"
ARG BUILDER_UID=2000
ARG BUILDER_GID=2000

LABEL maintainer="Emmanuel Bruno <emmanuel.bruno@univ-tln.fr>"
LABEL description="Java Hello World Application - SDKMAN build"

# ------------------------------------------------------------
# OS Layer: Provision essential tools for SDKMAN
# ------------------------------------------------------------
RUN apt-get update && \
    apt-get install --yes --quiet --no-install-recommends \
      ca-certificates curl unzip zip bash && \
    rm -rf /var/lib/apt/lists/*

# ------------------------------------------------------------
# Security: Create dedicated builder user with deterministic UID/GID
# ------------------------------------------------------------
RUN groupadd -g ${BUILDER_GID} builder \
    && useradd -m -u ${BUILDER_UID} -g builder -s /bin/bash builder

USER builder
ENV HOME=/home/builder
ENV SDKMAN_DIR="$HOME/.sdkman"
ENV PATH="$SDKMAN_DIR/bin:$SDKMAN_DIR/candidates/java/current/bin:$SDKMAN_DIR/candidates/maven/current/bin:$PATH"

SHELL ["/bin/bash", "-c"]

# ------------------------------------------------------------
# Provisioning: Install Java/Maven via SDKMAN
# ------------------------------------------------------------
RUN curl -s "https://get.sdkman.io" | bash && \
    source "$SDKMAN_DIR/bin/sdkman-init.sh" && \
    sdk install java "$JAVA_VERSION" && \
    sdk install maven "$MAVEN_VERSION" && \
    rm -rf "$SDKMAN_DIR/archives/*" "$SDKMAN_DIR/tmp/*"

WORKDIR /app

# ------------------------------------------------------------
# Layer: Dependency Resolution (BuildKit Cached)
# ------------------------------------------------------------
COPY --chown=builder:builder mvnw ./
COPY --chown=builder:builder .mvn .mvn
COPY --chown=builder:builder pom.xml ./

RUN --mount=type=cache,target=/home/builder/.m2,uid=${BUILDER_UID},gid=${BUILDER_GID} \
    source "$SDKMAN_DIR/bin/sdkman-init.sh" && \
    ./mvnw --batch-mode dependency:resolve

# ------------------------------------------------------------
# Layer: Build
# ------------------------------------------------------------
COPY --chown=builder:builder src ./src

RUN --mount=type=cache,target=/home/builder/.m2,uid=${BUILDER_UID},gid=${BUILDER_GID} \
    source "$SDKMAN_DIR/bin/sdkman-init.sh" && \
    ./mvnw --batch-mode -Pjvm -DskipTests clean verify

# ============================================================
# Stage 2: Runtime Environment
# ============================================================
# Distribute using a slim JRE-only image. Note: We do not require
# the UID to match the builder; we only need a non-root user.
FROM eclipse-temurin:25-jre-noble AS stage-runtime

LABEL maintainer="Emmanuel Bruno <emmanuel.bruno@univ-tln.fr>"
LABEL description="Java Hello World Application - multi-stage runtime"

RUN groupadd -r appgroup && useradd -r -g appgroup appuser && \
    mkdir -p /app && chown -R appuser:appgroup /app

# Deploy minimal runtime artifacts from the build stage
COPY --from=stage-build --chown=appuser:appgroup /app/target/hello-world-*-SNAPSHOT.jar /app/app.jar
COPY --from=stage-build --chown=appuser:appgroup /app/target/libs /app/libs

USER appuser
ENV HOME=/home/appuser

# JVM Container Ergonomics: JAVA_TOOL_OPTIONS is natively parsed by the
# JVM, enabling container-aware resource management.
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"

ENTRYPOINT ["java", "-jar", "/app/app.jar"]

docker image build \
  --tag javahello:manual \
  --file Dockerfile.40.manual .
ANALYSE DE L'IMAGE : javahello:manual

   • Sécurité  : ✅ appuser

   • Surface   : ⚠️  Shell présent

   • Structure : ✅ Artefact pur (Multi-stage OK)

   • Runtime   : ☕ Standard (JRE)

   • Java Opts : ❌ Manquante

   • Poids     : 298MB


Exécutable natif avec GraalVM — Culture

GraalVM compile Java en binaire natif.

  • Avantage : Démarrage instantané (< 100ms) et RAM dérisoire.
  • Inconvénient : Build complexe et perte de certaines capacités dynamiques de la JVM.
Dockerfile.60.graalvm
# syntax=docker/dockerfile:1

# Layer 1 — GraalVM Native Image toolchain
# ============================================================================
# GraalVM + native-image (the "native" toolchain, installed on top of the JDK)
ARG GRAALVM_VERSION=25
FROM ghcr.io/graalvm/native-image-community:${GRAALVM_VERSION} AS builder
WORKDIR /workspace

# Maven wrapper + POM first (re-uses the /root/.m2 cache for dependency download)
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw -q -Pnative dependency:go-offline

# Cap the native-image build heap so the CI render stays within the runner
# memory budget (large pool: 24 Gi cgroup limit). JAVA_TOOL_OPTIONS is read by
# every JVM in the build (maven + native-image analysis); -Xmx8G leaves headroom
# for the native compiler + GC + runtime within the limit.
ENV JAVA_TOOL_OPTIONS=-Xmx8G
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw -Pnative -DskipTests package

# Layer 2 — minimal runtime
FROM gcr.io/distroless/base-debian12:nonroot AS runtime
WORKDIR /workspace

# Copy the compiled native executable from the builder stage
COPY --from=builder /workspace/target/*-runner /app

# The native executable is self-contained (no JVM needed at runtime).
# Run as the nonroot user (uid 65532) provided by the distroless base image.
USER nonroot:nonroot
ENTRYPOINT ["/app"]
EXPOSE 8080

# -----------------------------------------------------------------------------
# Layer 3 — optional: multi-stage variant (for the "advanced" lesson)
# -----------------------------------------------------------------------------
# In a real project you'd split build + runtime more aggressively. Here we show
# the same idea with a single GraalVM image used for both stages, to keep the
# lesson focused on the toolchain rather than image optimisation.
FROM ghcr.io/graalvm/native-image-community:${GRAALVM_VERSION} AS build-and-run
WORKDIR /workspace
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw -Pnative -DskipTests package
CMD ["./target/*-runner"]

ANALYSE DE L'IMAGE : javahello:graalvm

   • Sécurité  : ✅ nonroot:nonroot

   • Surface   : ✅ Minimaliste / Distroless

   • Structure : ✅ Artefact pur (Multi-stage OK)

   • Runtime   : 🚀 Natif (GraalVM app)

   • Java Opts : ✅ N/A (Binaire natif)

   • Poids     : 20MB


Comparaison des stratégies

Image Tag Build Cold Size Peak RAM Surface d’Attaque
mavenimage 28s 928MB 86.01 MiB Maximale
mavenimagestage 24s 406MB 71.97 MiB Moyenne
jlink-alpine 30s 163MB 55.83 MiB Faible
graalvm 57s 46MB 5.02 MiB Minimale

Conclusion

ImportantPoints clés pour la mise en production
  • Multi-stage build : Séparation build / runtime.
  • Utilisateur non-root : Sécurité de l’hôte.
  • Gestion des ressources : Paramétrage JVM pour les limites de conteneur.
  • Logs : Toujours sur stdout/stderr (laissé à la charge de l’orchestrateur).

Prochaine étape : orchestrer ces images avec Docker Compose.

Réutilisation