Dockerizing Spring Boot 4: Layered Jars, Buildpacks, Distroless and Image Size Benchmarks
One Spring Boot 4 service packaged nine ways (fat jar, layered on Temurin, Alpine and distroless, jlink, the JDK 25 AOT cache, buildpacks and Jib) and measured the same way: size on disk and over the wire, what a one-line change costs to push, startup, who the process runs as, and the failure each variant is most likely to hand you.
Every “best Dockerfile for Spring Boot” argument is about the number docker images prints. It is the least useful number in the discussion. What a container image actually costs you is the bytes every deployment pushes and every node pulls, the seconds before a new pod takes traffic, who the process runs as, and what breaks quietly when you trim it — and those are the numbers nobody benchmarks.
So this article does. One ordinary Spring Boot 4 service — web, validation, Actuator, Prometheus, a 25 MB fat jar — packaged nine ways: a fat jar on the JDK and on the JRE, Spring Boot’s layered extraction on Temurin, Alpine and distroless, a jlink custom runtime, a JDK 25 AOT cache, Cloud Native Buildpacks, and Jib. Each one measured the same way, and each one with the failure it is most likely to hand you.
Part
For you if
Covers
1 — Beginner
your Dockerfile is FROM jdk + COPY app.jar
the three meanings of “image size”, the layered Dockerfile, why layering does not shrink the image
2 — Intermediate
you are choosing between Dockerfile, buildpacks and Jib
the nine-variant benchmark, what a one-line change costs to push, distroless, buildpacks’ hidden decisions
3 — Advanced
you are squeezing startup time and size
the AOT cache trade-off, jlink’s missing metrics, PID 1 and signals, a memory limit buildpacks will not start under
Versions this was verified against. Spring Boot 4.1.1 (GA, published to Maven Central on 20 August 2026), Spring Framework 7.0.9, Docker Engine 29.4.3 on the classic overlay2 store, base images eclipse-temurin:25-jre/-jdk/-jre-alpine (Temurin 25.0.4), gcr.io/distroless/java25-debian13:nonroot (Temurin 25.0.4.1), the Paketo builder-noble-java-tiny that Boot 4.1.1 uses by default (BellSoft Liberica 25.0.4), and Jib 3.5.2. Every base image is pinned by digest in the companion README. Startup figures are the median of three runs on a 2-vCPU VM — treat differences under 0.3 s as noise.
Companion code: spring-boot-demo, directory docker-images/. Nine Dockerfiles or build configurations, the measurement scripts, and every table below under docs/output/, regenerated by scripts/run-all.sh.
Part 1 — What “image size” means, and the layered Dockerfile
The Dockerfile most projects start with
FROM eclipse-temurin:25-jdk
COPY target/app.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
It works, and it is 456 MB. Swapping -jdk for -jre takes it to 377 MB. Most size advice stops around here, one step early, because it is measuring the wrong thing.
Three numbers called “size”
Number
What it is
Who pays it
on disk
uncompressed layers
node disk; what docker images shows
pushed
compressed layer blobs + config
the registry, and a node pulling the image cold
rebuild delta
the blobs that changed since the last build
every deploy: CI uploads it, every warm node downloads it
The companion project measures the second and third by pushing every variant to a throwaway local registry (crane registry serve — in-memory, no account) and reading the manifests back.
Even the first number is ambiguous. Docker Engine 29 made the containerd image store the default for new installations, and it reports sizes differently. The same eclipse-temurin:25-jre tarball, loaded into each store: 496 MB under containerd, 352 MB under the classic store. The distroless Java 25 image: 305 MB against 226 MB. Before comparing your numbers with a blog post — or with a colleague’s laptop — check docker info for which store you are both on. Everything here is measured on the classic store.
Layered extraction
A Spring Boot fat jar contains your few kilobytes of classes and 25 MB of dependencies that change once a month. Spring Boot can split them into layers with its tools jar mode:
Four COPY lines, four layers, ordered from least to most likely to change — list-layers on this jar prints dependencies, spring-boot-loader, snapshot-dependencies, application. Without --launcher, extract writes a thin application.jar whose manifest Class-Path points at lib/, so the runtime is a plain java -jar: no JarLauncher, no nested-jar class loading. The builder stage only needs a JVM, not a JDK.
And here is the part that surprises people: the layered image is exactly as big as the fat-jar image.fatjar-jre and layered-jre are both 377 MB on disk and 144 MB pushed. Same bytes, cut differently. The win arrives on the second build.
ready ms is docker run to the first 200 from /actuator/health/readiness; Started is Spring Boot’s own figure; packages counts OS packages from the image’s filesystem without running anything in it, which works even for images with no shell. What the table says, in order of how much it should change your mind:
Every Temurin-based image runs as root. The official images set no USER, and neither does Jib by default. Distroless :nonroot runs as 65532 and buildpacks as 1002.
Distroless and Alpine are the same size — 252 MB and 251 MB — but distroless has 25 packages and no shell where Alpine has 44 and BusyBox.
Nothing but the AOT cache moves startup. 3.25–3.94 s for everything else is noise on this machine. Part 3 covers what the 1.64 s costs.
Buildpacks is not small: 345 MB, below.
A benchmark that ran next to something else is not a benchmark. The first pass of this table had distroless starting in 5.9 s, two seconds slower than everything else — a tidy, publishable finding. A k3s image import had been running on the same two CPUs at the time. Rerun with nothing else active, distroless is the fastest of the ordinary images. The regeneration script now says so in its first comment.
What a one-line change costs
The number that matters for a team deploying many times a day. The rebuild script changes one string constant, rebuilds all nine, and counts layer digests that did not exist in the previous build:
variant new layers bytes to push image total share
fatjar-jdk 1 of 7 23422K 178M 13.1%
fatjar-jre 1 of 7 23422K 144M 16.2%
layered-jre 1 of 11 6K 144M 0.0%
layered-alpine 1 of 10 6K 98M 0.0%
layered-distroless 1 of 39 6K 97M 0.0%
jlink-distroless 1 of 39 6K 76M 0.0%
aot-cache 2 of 12 15323K 160M 9.6%
buildpacks 2 of 20 64K 125M 0.1%
jib 1 of 10 2K 144M 0.0%
23.4 MB against 6 KB: roughly four thousand times less for every push, and for every node that already runs the previous version. Buildpacks and Jib layer the same way without being asked. The one outlier with a layered layout is the AOT cache, and that is Part 3.
Buildpacks: what the builder decides for you
mvn spring-boot:build-image with no configuration used paketobuildpacks/builder-noble-java-tiny, and six of its 26 buildpacks took part. What you get without writing a Dockerfile: a non-root user, no shell, a reproducible image (creation date fixed at 1980), an SBOM, the Java version read from the jar (the log shows $BP_JVM_VERSION defaulting to 21, then Using Java version 25 extracted from MANIFEST.MF), and a memory calculator that sets -Xmx and friends from the container limit.
The size comes from one layer:
276MB Layer: 'jre', Created by buildpack: paketo-buildpacks/[email protected]
Temurin’s Java 25 JRE is 200 MB. BellSoft’s carries a second VM: lib/client is 74 MB — its own libjvm.so and CDS archives — next to the 85 MB server VM. Part 3 has the other thing the buildpack decides for you, which matters more.
Behind a corporate proxy, buildpacks fail at build time, not at pull time. The Java buildpack downloads its JRE during the build, and the Spring Boot buildpack downloads Spring Cloud Bindings from Maven Central. Both need the proxy and its CA inside the build container. The companion pom.xml has a corporate-proxy profile (host network, HTTPS_PROXY, and a ca-certificates binding) and a no-maven-central profile (a dependency-mapping binding keyed by the sha256 in the buildpack’s buildpack.toml). Chapter 4 has both, and two traps met on the way.
Distroless in practice
The recommendation at the end of this article is the layered distroless image, so it is worth knowing what it takes away. RUN is impossible in the final stage — extraction, jlink and training runs happen in a builder stage with a shell. ENTRYPOINT must be exec form. docker exec ... sh does not work; use an ephemeral container that shares the process namespace (kubectl debug -it <pod> --image=busybox:1.37 --target=app). And a Kubernetes preStop hook of exec: ["sh", "-c", "sleep 10"] fails, because there is no sh — the Kubernetes article measures what that costs during a rolling update.
Part 3 — Squeezing, and what squeezing breaks
The JDK 25 AOT cache: half the startup, most of the layering gone
JDK 24 added an ahead-of-time cache of loaded and linked classes (JEP 483); JDK 25 made it a single step with -XX:AOTCacheOutput (JEP 514) and added method profiles to it (JEP 515). Spring Boot’s documented Dockerfile does a training run while building the image:
spring.context.exit=onRefresh refreshes the context and exits, so the training run needs no database — unless your beans open connections during startup. The result, from the two tables above:
layered-jre
aot-cache
Started in
3.356 s
1.638 s
on disk
377 MB
440 MB
pushed after a one-line change
6 KB
15.3 MB
The last row is the one missing from every write-up of this feature. The training run comes after the application layer, so every code change invalidates it, and the cache layer is rebuilt and re-pushed with every deploy. You get half the startup time back and give up most of what layering saved. Worth it where pods start far more often than you deploy — scale-to-zero, aggressive autoscaling. Not obviously worth it for a fleet that deploys twenty times a day and rarely restarts. (The startup-time article measured the same cache outside a container.)
Then there is the version coupling. The cache is valid only for the exact JVM build that wrote it. The trained application, copied onto the distroless image — Temurin 25.0.4.1 instead of 25.0.4:
[0.007s][warning][aot] The AOT cache was created by a different version or build of HotSpot
[0.007s][error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details.
[0.008s][error ][aot] Loading static archive failed.
[0.008s][error ][aot] Unable to map shared spaces
Starting ImagesApplication v1.0.0 using Java 25.0.4.1 with PID 1 (/application/application.jar started by nonroot in /ap
Started ImagesApplication in 3.564 seconds (process running for 4.05)
Four lines at error, and then nothing is wrong. The JVM discards the cache and starts at the old speed; no health check notices. In practice this happens when the training stage and the runtime stage use different images, or when a floating tag like 25-jre moves to a new JVM build and a later build reuses a cached training layer. Train and run on the same image, pin it by digest, and alert on [aot] lines at error.
jlink: 133 MB smaller, 15 metrics quieter
A custom runtime built from the modules jdeps says the application needs, on distroless/java-base:
It picks 18 modules — java.desktop among them, because Spring uses java.beans — and the image is 119 MB against 252 MB. It starts, serves requests and passes its health check. Its log has two warnings:
i.m.c.i.binder.jvm.JvmGcMetrics : GC notifications will not be available because com.sun.management.GarbageCollectionNotificationInfo is not present
i.m.c.i.binder.jvm.JvmGcMetrics : GC notifications will not be available because no GarbageCollectorMXBean of the JVM provides any. GCs=[G1 Young Generation, G1 Concurrent GC, G1 Old Generation]
and /actuator/prometheus exports 46 metric names instead of 61. Gone, among others:
com.sun.management lives in the jdk.management module and Micrometer reaches it reflectively, which jdeps cannot see. Nothing fails. The GC and CPU panels of your dashboards go flat the day the smaller image ships. Adding jdk.management brings back all but jvm_gc_concurrent_phase_time, which Micrometer only registers once the first G1 concurrent cycle has happened. Before shipping any jlink image, diff the metric names, not just the health check.
PID 1: the same Dockerfile line, two behaviours
docker stop and Kubernetes send SIGTERM to PID 1 and SIGKILL after a grace period. Spring Boot’s graceful shutdown only happens if the JVM gets the SIGTERM. Four ways to write the ENTRYPOINT:
Shell form — ENTRYPOINT java -jar /app/app.jar — on the Temurin image leaves /bin/sh as PID 1. That is dash on Ubuntu, and dash does not forward the signal: ten seconds of waiting, SIGKILL, exit 137, in-flight requests cut. The identical line on the Alpine image works, because BusyBox’s sh replaces itself with the last command of a -c string. So a Dockerfile can pass review on one base image and misbehave after a base-image change. Any shell form with two commands keeps the shell regardless. Use exec form, or end a wrapper script with exec java ....
Buildpacks will not start at 512 MiB
The buildpack’s memory calculator runs before the JVM, and it budgets fixed regions first:
## docker run -m 512m
unable to calculate memory configuration
fixed memory regions require 595872K which is greater than 512M available for allocation: -XX:MaxDirectMemorySize=10M, -XX:MaxMetaspaceSize=83872K, -XX:ReservedCodeCacheSize=240M, -Xss1M * 250 threads
ERROR: failed to launch: exec.d: failed to execute exec.d file at path '/layers/paketo-buildpacks_bellsoft-liberica/helper/exec.d/memory-calculator': exit status 1
state: exited (exit 82)
One MiB of stack for each of 250 threads, a 240 MiB code cache, and metaspace sized from the class count — before any heap. 512 MiB is one of the most common Kubernetes memory limits there is, and every Dockerfile-built image in the table starts under it. Tell the calculator the truth about threads and it fits:
With virtual threads, 250 platform threads is a generous assumption anyway.
The long tail
The run image is pinned by tag inside the builder, so pullPolicy=IF_NOT_PRESENT with only :latest local still goes to Docker Hub: chapter 4
-Dspring-boot.build-image.imageName is silently ignored when the pom sets <image><name>: chapter 4
Other modules that only reflection needs — jdk.crypto.cryptoki, jdk.localedata, jdk.naming.dns: chapter 5
How the package counts were taken from images with no shell: chapter 2
What to actually use. The layered distroless Dockerfile from Part 1: 97 MB pushed, 6 KB per change, non-root, no shell, the full JRE so nothing goes missing, and nothing in it you did not write. Choose buildpacks instead if you would rather not own a Dockerfile and are happy to set BPL_JVM_THREAD_COUNT; choose Jib if your CI has no Docker daemon, and set a non-root user. Add the AOT cache only if startup time is a measured problem and you have pinned the base image. And jlink is for when image size itself is the constraint — after you have diffed the metrics.
If your current Dockerfile is the fat jar on -jdk, the one change worth making this week is the layered extraction. It is four lines, and it turns a 23 MB push into a 6 KB one.
Further reading
Companion project — nine variants, the measurement scripts, every transcript
No Comments yet!