Dockerize Spring Boot: From 800MB ‘Giant’ to 150MB and the Ultimate Trick to Fix OOM

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

The struggles of “naive” Java app Dockerization

Java developers have likely winced more than once seeing a Spring Boot JAR file weighing several hundred MBs. Worse, when stuffed into a basic Docker image, the size can skyrocket to over a gigabyte. When I first started, I would just take the openjdk:17 image and COPY the JAR file in. The result? Bloated images, slow builds, and containers that frequently suffered from “sudden death” (OOM) in production without a clear reason.

I once managed a cluster running over 30 microservices on AWS. Failing to optimize the Dockerfile led to significant increases in ECR storage and RAM costs. After applying Multi-stage Builds and fine-tuning the JVM, I managed to cut resource consumption by 40%. Specifically, RAM per instance dropped from 512MB to around 300MB while the app remained smooth. This article is a checklist derived from those “expensive” lessons.

Why is your image so “fat”?

The biggest trap is bringing the entire build environment into the runtime. To run a JAR file, you only need the JRE. But to build it, you drag along the JDK, Maven, source code, and tons of junk libraries in the .m2 directory. This is not only wasteful but also opens up an unnecessary attack surface.

Multi-stage Builds solve this by splitting the process into two rounds:

  • Round 1 (Build): Use a fully-equipped image to compile and package the app.
  • Round 2 (Run): Extract only the resulting JAR file into a lightweight JRE image for execution.

The result is a final image that is extremely compact, saving bandwidth during push/pull operations and significantly speeding up deployment.

Hands-on: A “best practice” Dockerfile for Spring Boot projects

Below is the Dockerfile template I usually use for Maven projects. If you’re using Gradle, simply change the build commands accordingly.

# Stage 1: Build stage
FROM maven:3.8.4-openjdk-17-slim AS build
WORKDIR /app

# Optimize layer caching: Download dependencies first
COPY pom.xml .
RUN mvn dependency:go-offline -B

# Copy source code and build
COPY src ./src
RUN mvn package -DskipTests

# Stage 2: Runtime stage
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

# Create a non-root user to avoid security risks
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring

# Copy the jar file from the build stage
COPY --from=build /app/target/*.jar app.jar

# Optimize JVM configuration for containers
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport -Djava.security.egd=file:/dev/./urandom"

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Quick explanation of the techniques used:

  1. Layer Caching (Lines 6-7): Separating pom.xml allows Docker to cache downloaded libraries. If you only modify code without adding new dependencies, this step is skipped, making builds 5-10 times faster.
  2. Alpine Image (Line 13): Using alpine keeps the image lightweight, around 120MB compared to the 600MB of a traditional Ubuntu-based image.
  3. Non-root User (Lines 17-18): By default, Docker runs as root. If the app has a security vulnerability, a hacker could gain control of the container. The spring user limits the scope of potential damage.
  4. JVM Tuning (Line 24): This is the “key” to curing OOM Killer issues in production.

Don’t let the JVM “disobey” Docker’s commands

A classic mistake is hardcoding RAM limits like -Xmx1g. This is extremely risky in a container. If the container is allocated 1GB of RAM (--memory=1g) and you configure the JVM to use exactly 1GB, the OS will definitely kill the app. This is because the JVM also needs memory for Metaspace, Stack, and Native Memory.

The “golden” parameters I recommend:

  • -XX:+UseContainerSupport: Helps the JVM accurately recognize resource limits from Docker’s cgroups.
  • -XX:MaxRAMPercentage=75.0: Instead of hardcoding, tell the JVM to use a maximum of 75% of the allocated RAM. The remaining 25% is reserved for the OS and overhead. This is the safest figure based on numerous real-world tests.
  • -Djava.security.egd=file:/dev/./urandom: Speeds up Spring Boot startup on Linux by accelerating random number generation for security tokens.

Real numbers don’t lie

After applying this solution, my system changed dramatically:

  • Image Size: Dropped spectacularly from 650MB to 155MB.
  • Build Time: Subsequent builds took only 40 seconds instead of over 4 minutes, thanks to efficient caching.
  • Stability: Sudden container restarts at 2 AM have almost completely disappeared.

Saving a few hundred MBs of RAM might seem small, but at a scale of 100 containers, you save thousands of dollars in infrastructure costs annually. That is the difference between a developer and a true engineer.

The Bottom Line

Dockerizing Spring Boot is more than just writing a few commands to get it running. It’s a combination of size optimization and smart JVM configuration. Don’t forget to use docker stats to monitor your containers’ health regularly. If possible, set up Prometheus for a more comprehensive view of your app’s performance. Good luck with your optimization!

Share: