In the cloud-native era of 2025, containerizing a Java application is no longer just about writing a Dockerfile that “works.” With rising cloud infrastructure costs and the increasing adoption of Kubernetes and Serverless platforms (like AWS Fargate or Google Cloud Run), the efficiency of your container images has a direct impact on your bottom line and system reliability.
For mid-to-senior Java developers, the goal is threefold: minimize image size, optimize startup latency, and maximize security.
A bloated 600MB image slows down CI/CD pipelines, increases scaling time during traffic spikes, and expands the attack surface. In this comprehensive guide, we will move beyond the basics. We will explore advanced techniques including Spring Boot layer extraction, multi-stage builds, Google Jib, Distroless images, custom runtimes with jlink, and Class Data Sharing (AppCDS).
Prerequisites and Environment #
To follow this guide, ensure you have the following environment set up. We are targeting modern standards for 2025.
- Java Development Kit (JDK): Version 21 (LTS) or 24. We will use JDK 21 in examples.
- Build Tool: Maven 3.9+ or Gradle 8.5+.
- Docker: Docker Desktop or Rancher Desktop (Engine v26+).
- IDE: IntelliJ IDEA or VS Code.
- Framework: Spring Boot 3.3+ (though concepts apply to generic Java apps).
1. The Anatomy of a Java Container Image #
Before writing code, we must understand the “Layering Architecture” of Docker. Docker images are built from layers. When you rebuild an image, Docker caches layers that haven’t changed.
The traditional “Fat JAR” approach is inefficient because it bundles your application code (which changes frequently) with your dependencies (which change rarely) into a single binary.
The Problem: The “Fat JAR” Anti-Pattern #
Consider this common, yet suboptimal Dockerfile:
# ❌ The "Naive" Approach - DO NOT USE IN PRODUCTION
FROM eclipse-temurin:21-jdk
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]Why is this bad?
If you change one line of code in your UserService.java, the entire 80MB+ JAR file is rebuilt. Docker sees the COPY instruction has changed, invalidating that layer. You are re-uploading 80MB to your registry every time you commit code, wasting bandwidth and storage.
2. Strategy A: Layered Builds with Spring Boot #
Spring Boot 3 includes native support for layering. This splits your application into four parts based on how frequently they change:
- Dependencies: Third-party libraries (Change rarely).
- Spring Boot Loader: The internal class loader (Change very rarely).
- Snapshot Dependencies: Internal libraries under development (Change often).
- Application: Your actual classes and resources (Change most often).
Visualizing the Layering Strategy #
The following diagram illustrates how splitting layers improves the build cache hit rate.
Implementation Steps #
First, ensure your pom.xml enables layering:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
</plugins>
</build>Next, use a Multi-Stage Dockerfile to extract the layers and copy them separately.
# syntax=docker/dockerfile:1
# --- Stage 1: Build and Extract ---
FROM eclipse-temurin:21-jdk-jammy as builder
WORKDIR /builder
COPY . .
# Skip tests for build speed in this example, but run them in CI
RUN ./mvnw clean package -DskipTests
# Extract the layers using the jarmode capability
ARG JAR_FILE=target/*.jar
RUN java -Djarmode=layertools -jar ${JAR_FILE} extract
# --- Stage 2: Runtime Image ---
FROM eclipse-temurin:21-jre-jammy
WORKDIR /application
# Copy layers in order of least frequent changes to most frequent
COPY --from=builder /builder/dependencies/ ./
COPY --from=builder /builder/spring-boot-loader/ ./
COPY --from=builder /builder/snapshot-dependencies/ ./
COPY --from=builder /builder/application/ ./
# Use the JarLauncher provided by Spring Boot
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]Key Takeaway: By copying dependencies first and application last, Docker will cache the heavy dependency layers. Subsequent builds where you only change business logic will be near-instantaneous.
3. Strategy B: Choosing the Right Base Image #
The Operating System (OS) layer significantly dictates your image size and security posture. In 2025, we primarily debate between three options: Debian-based (Jammy/Bookworm), Alpine Linux, and Distroless.
Comparison of Base Images #
The table below compares these popular options for a standard Java application.
| Feature | Eclipse Temurin (Ubuntu/Debian) | Alpine Linux (Musl) | Google Distroless |
|---|---|---|---|
| Size (Compressed) | ~80-100 MB | ~40-50 MB | ~50-60 MB |
| Compatibility | High (Standard glibc) | Medium (Uses musl libc) | High (glibc based) |
| Security | Standard (contains shell, package mgr) | Good (minimal surface) | Best (No shell, no package mgr) |
| Debugging | Easy (apt-get, bash) |
Moderate (apk, sh) |
Hard (Requires debug variant) |
| Recommendation | Development / General Purpose | Microservices (if tested) | Production Security |
The Case for “Distroless” #
“Distroless” images contain only your application and its runtime dependencies. They do not contain package managers, shells, or any other programs you would expect to find in a standard Linux distribution.
Why use Distroless?
- Security: If a hacker exploits a vulnerability in your app (e.g., Log4Shell), they cannot spawn a shell because there is no
/bin/bashor/bin/shinside the container. - Compliance: Scanners report fewer CVEs because there are fewer OS packages installed.
Distroless Dockerfile Example #
# Stage 1: Build (Same as above)
FROM eclipse-temurin:21-jdk-jammy as builder
WORKDIR /build
COPY . .
RUN ./mvnw clean package -DskipTests
RUN java -Djarmode=layertools -jar target/*.jar extract
# Stage 2: Distroless Runtime
# We use the 'java21-debian12' tag
FROM gcr.io/distroless/java21-debian12
WORKDIR /app
COPY --from=builder /build/dependencies/ ./
COPY --from=builder /build/spring-boot-loader/ ./
COPY --from=builder /build/snapshot-dependencies/ ./
COPY --from=builder /build/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]4. Strategy C: Extreme Optimization with jlink
#
If you are running hundreds of microservices, even a 60MB base image might be too heavy. The standard JRE includes modules your app likely never uses (like AWT, Swing, or SQL managers if you use reactive drivers).
Java 9 introduced the Java Platform Module System (JPMS). We can use the jlink tool to create a custom, minimal Java Runtime Environment containing only the modules required by our application.
Step-by-Step jlink Implementation
#
1. Determine Dependencies:
First, use jdeps to figure out which Java modules your application needs.
jdeps --ignore-missing-deps --print-module-deps --multi-release 21 target/my-app.jarOutput might look like: java.base,java.logging,java.net.http,java.sql
2. Create the Custom Runtime Dockerfile:
This is an advanced technique involving a multi-stage build where one stage builds the JVM itself.
# --- Stage 1: Build App ---
FROM eclipse-temurin:21-jdk-jammy as app-builder
WORKDIR /src
COPY . .
RUN ./mvnw clean package -DskipTests
RUN java -Djarmode=layertools -jar target/*.jar extract
# --- Stage 2: Build Custom JRE ---
FROM eclipse-temurin:21-jdk-jammy as jre-builder
# Create a custom JRE using jlink
# We explicitly add modules found via jdeps, plus extras usually needed by Spring
RUN $JAVA_HOME/bin/jlink \
--add-modules java.base,java.logging,java.naming,java.desktop,java.management,java.security.jgss,java.instrument,java.sql \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /optimized-jdk
# --- Stage 3: Final Runtime ---
# We start from a tiny OS base (Debian slim)
FROM debian:bookworm-slim
ENV JAVA_HOME=/opt/java-runtime
ENV PATH="${JAVA_HOME}/bin:${PATH}"
# Copy our custom, stripped-down Java runtime
COPY --from=jre-builder /optimized-jdk $JAVA_HOME
WORKDIR /app
COPY --from=app-builder /src/dependencies/ ./
COPY --from=app-builder /src/spring-boot-loader/ ./
COPY --from=app-builder /src/snapshot-dependencies/ ./
COPY --from=app-builder /src/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]Result: You can often achieve a total image size (OS + Java + App) of under 40-50MB, roughly comparable to Go applications, while maintaining the full power of the Java ecosystem.
5. Startup Performance: AppCDS (Application Class Data Sharing) #
In serverless environments (like AWS Lambda Java runtime or Knative), cold start time is critical. The JVM spends significant time loading and verifying classes at startup.
AppCDS allows you to archive the class metadata of loaded classes and dump it into a file. When the JVM restarts, it memory-maps this file, skipping the parsing and verification steps.
Implementing AppCDS in Docker #
This requires a “Training Run” during the build process.
# ... (Assuming previous layers are set up) ...
# 1. Perform a Training Run to generate the archive
# We run the app with -XX:ArchiveClassesAtExit
# We use Spring Boot's 'application' layer to ensure we capture actual usage
# The '&' and 'pid=$!' allows us to start it, wait a bit, and kill it gracefully
RUN java -XX:ArchiveClassesAtExit=app-cds.jsa \
-Dspring.context.exit=onRefresh \
org.springframework.boot.loader.launch.JarLauncher
# 2. Configure the Entrypoint to use the archive
ENTRYPOINT ["java", "-XX:SharedArchiveFile=app-cds.jsa", "org.springframework.boot.loader.launch.JarLauncher"]Note: In complex Spring Boot apps, simply starting and stopping (onRefresh) covers the framework initialization classes, which usually offer the biggest ROI for startup time improvement (often 20-30% faster).
6. Alternative: Leaving Dockerfiles Behind with Google Jib #
Sometimes the best Dockerfile is no Dockerfile at all. Google Jib is a Maven/Gradle plugin that builds optimized Docker and OCI images for your Java applications without a Docker daemon and without deep knowledge of Docker best practices.
It automatically handles:
- Layering (dependencies vs resources vs classes).
- Reproducible builds.
- Pushing to registries.
Maven Configuration #
Add this to your pom.xml:
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.4.1</version>
<configuration>
<from>
<image>eclipse-temurin:21-jre-jammy</image>
</from>
<to>
<image>my-registry.com/my-app:1.0.0</image>
</to>
<container>
<jvmFlags>
<jvmFlag>-Xms512m</jvmFlag>
<jvmFlag>-Xdebug</jvmFlag>
</jvmFlags>
<!-- Good security practice: non-root user -->
<user>1000:1000</user>
</container>
</configuration>
</plugin>To build and push:
mvn jib:buildJib is excellent for CI pipelines where you might not want to install the Docker Daemon or run “Docker in Docker” (DinD), which has security implications.
7. Production Best Practices & Common Pitfalls #
Even with an optimized image, configuration mistakes can cause issues in production Kubernetes clusters.
A. Memory Limits and Container Awareness #
Historically, the JVM was unaware of container limits, causing it to grab all host memory and get OOM-Killed (Out of Memory) by Kubernetes.
Since Java 10 (and refined in Java 21), the JVM is container-aware. However, you should strictly set RAM percentages rather than absolute heap sizes (-Xmx).
Best Practice:
Use -XX:MaxRAMPercentage. This sets the heap size as a percentage of the container’s limit (defined in K8s YAML).
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]If the container gets 2GB RAM, the JVM heap will take 1.5GB, leaving 500MB for non-heap usage (Metaspace, threads, off-heap buffers).
B. The PID 1 Zombie Reaper Problem #
In Linux, Process ID 1 (PID 1) has special responsibilities, specifically reaping zombie processes. A standard java command does not handle this.
If you use ENTRYPOINT ["java", ...] directly, and your app spawns shell commands or sub-processes, you might encounter zombie processes leaking memory.
Solution:
- Use Tini (built into Docker with
--init). - Or ensure your base image handles it (most modern images like Temurin do okay).
- Use the
execform in Dockerfile to ensure signals (likeSIGTERM) are passed to Java for graceful shutdown.
# Correct: Signals pass to Java
ENTRYPOINT ["java", "-jar", "app.jar"]
# Incorrect: Shell swallows signals, Java doesn't shut down gracefully
ENTRYPOINT java -jar app.jarC. Running as Non-Root #
Running containers as root is a major security risk. If a container breakout occurs, the attacker gains root access to the host node.
# Create a group and user
RUN groupadd -r javauser && useradd -r -g javauser javauser
# Set ownership
RUN chown -R javauser:javauser /app
# Switch user
USER javauser
ENTRYPOINT ["java", ...]Conclusion #
Building efficient Java container images in 2025 is a blend of art and science. It requires moving away from the convenience of Fat JARs toward the precision of layered architectures, custom runtimes, and security-first base images.
Recap of the optimal path:
- Standard: Use Spring Boot Layering + Eclipse Temurin JRE.
- Secure: Use Google Distroless images to remove the OS attack surface.
- Ultra-Light: Use
jlinkto create custom runtime images under 50MB. - Fast: Implement AppCDS to slash startup times.
By implementing these strategies, you reduce your cloud storage costs, speed up your CI/CD pipelines, and ensure your applications scale rapidly to meet user demand.
Further Reading #
Did you find this deep dive helpful? Share it with your team or subscribe to our feed for more advanced Java architecture guides.