Skip to main content

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.
PartFor you ifCovers
1 — Beginneryour Dockerfile is FROM jdk + COPY app.jarthe three meanings of “image size”, the layered Dockerfile, why layering does not shrink the image
2 — Intermediateyou are choosing between Dockerfile, buildpacks and Jibthe nine-variant benchmark, what a one-line change costs to push, distroless, buildpacks’ hidden decisions
3 — Advancedyou are squeezing startup time and sizethe 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”

NumberWhat it isWho pays it
on diskuncompressed layersnode disk; what docker images shows
pushedcompressed layer blobs + configthe registry, and a node pulling the image cold
rebuild deltathe blobs that changed since the last buildevery 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:
FROM eclipse-temurin:25-jre AS builder
WORKDIR /builder
COPY target/app.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

FROM gcr.io/distroless/java25-debian13:nonroot
WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]
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.
fatjar-jre base image + JRE app.jar (deps + code) changes every build → 23,422 KB pushed layered-jre base image + JRE dependencies (unchanged) loader, snapshots application → 6 KB pushed Same total size: 377 MB on disk, 144 MB pushed, for both. After a one-line code change the fat-jar image re-pushes the whole jar because dependencies and code share a layer; the layered image re-pushes only the application layer. Every node already running the previous version downloads the same difference.

Part 2 — Nine images, measured

The benchmark

variant                on disk    pushed  layers user      shell  packages          ready ms   Started       RSS  JVM
fatjar-jdk                456M      178M       7 root      yes    115 (dpkg)            5362    3.932s  169.5MiB  Eclipse 25.0.4
fatjar-jre                377M      144M       7 root      yes    106 (dpkg)            5091    3.944s  182.1MiB  Eclipse 25.0.4
layered-jre               377M      144M      11 root      yes    106 (dpkg)            4272    3.356s  169.5MiB  Eclipse 25.0.4
layered-alpine            251M       98M      10 root      yes    44 (apk)              4634    3.775s  145.9MiB  Eclipse 25.0.4
layered-distroless        252M       97M      39 65532     no     25 (status.d)         4118    3.254s  164.3MiB  Eclipse 25.0.4.1
jlink-distroless          119M       76M      39 65532     no     24 (status.d)         4386     3.39s  165.7MiB  Eclipse 25.0.4
aot-cache                 440M      160M      12 root      yes    106 (dpkg)            2412    1.638s  174.6MiB  Eclipse 25.0.4
buildpacks                345M      125M      20 1002:1001 no     10 (status.d)         4726    3.615s  183.6MiB  BellSoft 25.0.4
jib                       377M      144M      10 root      yes    106 (dpkg)            4323    3.457s  162.5MiB  Eclipse 25.0.4
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:
RUN java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar application.jar
ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-jar", "application.jar"]
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-jreaot-cache
Started in3.356 s1.638 s
on disk377 MB440 MB
pushed after a one-line change6 KB15.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:
RUN MODULES="$(jdeps --ignore-missing-deps -q --recursive --multi-release 25 --print-module-deps \
        --class-path 'extracted/dependencies/lib/*' extracted/application/application.jar)" \
 && jlink --add-modules "$MODULES" --strip-debug --no-man-pages --no-header-files \
          --compress=zip-6 --output /builder/jre
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:
    jvm_gc_memory_allocated_bytes_total
    jvm_gc_pause_seconds_count
    jvm_gc_pause_seconds_max
    jvm_gc_pause_seconds_sum
    process_cpu_usage
    system_cpu_usage
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:
layered-jre            PID 1: java -jar application.jar                                  docker stop:   244 ms  exit code: 143  graceful-shutdown log lines: 1
shell-form             PID 1: /bin/sh -c java -jar /app/app.jar                          docker stop: 10160 ms  exit code: 137  graceful-shutdown log lines: 0
shell-form-alpine      PID 1: java -jar /app/app.jar                                     docker stop:   219 ms  exit code: 143  graceful-shutdown log lines: 1
shell-form-wrapper     PID 1: /bin/sh -c echo "starting revision $(date +%s)" && java -j docker stop: 10176 ms  exit code: 137  graceful-shutdown log lines: 0
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:
## docker run -m 512m -e BPL_JVM_THREAD_COUNT=50
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx133215K -XX:MaxMetaspaceSize=83872K -XX:ReservedCodeCacheSize=240M -Xss1M (Total Memory: 512M, Thread Count: 50, Loaded Class Count: 12394, Headroom: 0%)
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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.