Files
asmhatreandClaude Opus 5 644da9e65e Add docker-images: one Spring Boot 4 service packaged nine ways
Companion code for "Dockerizing Spring Boot 4: Layered Jars, Buildpacks,
Distroless and Image Size Benchmarks". Fat jar on JDK and JRE, layered jar
on Debian, Alpine and distroless, jlink, the JDK 25 AOT cache, Paketo
buildpacks and Jib, each measured for size on disk and pushed, rebuild
delta, startup, user and shell. Also PID 1 and signal handling, the jdeps
module gap, AOT cache mismatches and buildpacks memory calculation.
Transcripts in docs/output/, regenerated by scripts/run-all.sh.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3TETMrqVUWeFkNtz3Jbo3
2026-09-11 17:11:46 +00:00
..

Dockerizing Spring Boot 4: layered jars, buildpacks, distroless - measured

Companion project for Dockerizing Spring Boot 4: Layered Jars, Buildpacks, Distroless and Image Size Benchmarks on ankurm.com.

One ordinary Spring Boot 4 service (web, validation, Actuator, Prometheus - a 25 MB fat jar) packaged nine ways and measured the same way: size on disk, size over the wire, what a one-line code change costs to push, who the process runs as, whether there is a shell, and how long it takes to become ready. Every number in the article is in docs/output/.

Versions

Spring Boot 4.1.1
JDK in the images Temurin 25.0.4 (eclipse-temurin:25-*), Temurin 25.0.4.1 (distroless), Liberica 25.0.4 (buildpacks)
Docker Engine 29.4.3, classic overlay2 image store
Paketo builder paketobuildpacks/builder-noble-java-tiny (the Boot 4.1.1 default)
Jib 3.5.2

Base image digests the committed output was measured with:

Image Index digest
eclipse-temurin:25-jre sha256:15090d159279e5c158473eccb48cd87f57b3e3a47511a797eb5a7a7ea6f86b0f
eclipse-temurin:25-jdk sha256:dcf835e52330939b6c9f90ecab8aafcbcaa8fbf48423db44de884cf978c10144
eclipse-temurin:25-jre-alpine sha256:3137541deb3cac6626b5d9a4a2187bc0d6a34312f858bd2c67dd01e732e6b682
gcr.io/distroless/java25-debian13:nonroot sha256:fce4a1d66284e8866c46113d9bdc286c46fb8c3c3f0a098f877034349e88debe
gcr.io/distroless/java-base-debian13:nonroot sha256:40a4046b8663ac0226eae964d5844c44999e006025d44ddc786499e3d6cac1a5
paketobuildpacks/builder-noble-java-tiny:latest sha256:a836fcd7b0e4c047673e57039f243bc6d6d505fee86bce690579359839fdf7ea

Quickstart

export JAVA_HOME=/path/to/jdk-25
mvn -DskipTests package
./scripts/build-images.sh              # all nine variants, tagged sbd/docker-images:<variant>
docker run --rm -p 8080:8080 sbd/docker-images:layered-distroless
./scripts/run-all.sh                   # every measurement (~15 min, needs crane for a local registry)

The variants

Tag Built from
fatjar-jdk Dockerfile.fatjar-jdk the tutorial default
fatjar-jre Dockerfile.fatjar-jre
layered-jre Dockerfile.layered-jre jarmode=tools extract --layers
layered-alpine Dockerfile.layered-alpine musl
layered-distroless Dockerfile.layered-distroless the recommendation
jlink-distroless Dockerfile.jlink-distroless custom runtime on java-base
aot-cache Dockerfile.aot-cache JDK 25 AOT cache training run
buildpacks mvn spring-boot:build-image no Dockerfile
jib mvn jib:dockerBuild no Dockerfile, no daemon needed for jib:build

Plus three shell-form ENTRYPOINT traps (shell-form*), an AOT cache on the wrong JVM (aot-cache-mismatch) and a jlink image built from jdeps output alone (jlink-jdeps-only).

Documentation

  1. The variants and how each is built
  2. Measuring size: on disk, over the wire, and why docker images disagrees with itself
  3. Layered jars: the win is the second push, not the first
  4. Buildpacks: what the builder decides for you
  5. jlink: 133 MB smaller, 15 metrics quieter
  6. The JDK 25 AOT cache: faster start, bigger pushes, and the silent fallback
  7. PID 1 and signals: shell form, dash and BusyBox
  8. Living with distroless

Captured output

File Produced by
image-matrix.txt scripts/measure.sh
rebuild-delta.txt scripts/measure-rebuild.sh
pid1-and-signals.txt scripts/demo-signals.sh
jlink-metrics.txt scripts/demo-jlink-metrics.sh
aot-cache-mismatch.txt scripts/demo-aot-mismatch.sh
buildpacks-memory.txt scripts/demo-buildpacks-memory.sh
image-store-size-difference.txt by hand - needs a daemon restart

Findings worth the trip

  • Layering does not make the image smaller - it makes the next push roughly 4,000 times smaller: 23.4 MB for the fat jar against 6 KB for the application layer.
  • The AOT cache halves startup (3.36 s → 1.64 s) and adds 15 MB to every push, because the training run is invalidated by every code change - and on a different JVM build it fails with [error][aot] lines and the application starts anyway, at the old speed.
  • jdeps misses jdk.management. The jlink image runs, and 15 metrics - GC pauses, CPU usage - quietly disappear from /actuator/prometheus.
  • Shell-form ENTRYPOINT on the Temurin (Ubuntu, dash) images swallows SIGTERM: docker stop takes 10.2 s and ends in SIGKILL. On Alpine, BusyBox execs the command and it works - so the same Dockerfile behaves differently by base image.
  • The buildpacks image does not start under a 512 MiB memory limit: the memory calculator reserves stacks for 250 threads and a 240 MiB code cache first, and exits with code 82.
  • The buildpack's JRE layer is 276 MB against Temurin's 200 MB: BellSoft's JRE ships a 74 MB client VM (lib/client, with its own CDS archives) next to the server VM.
  • docker images reports 496 MB or 352 MB for the same eclipse-temurin:25-jre depending on the daemon's image store.