# Dockerizing Spring Boot 4: layered jars, buildpacks, distroless - measured Companion project for [**Dockerizing Spring Boot 4: Layered Jars, Buildpacks, Distroless and Image Size Benchmarks**](https://ankurm.com/dockerizing-spring-boot-4-layered-jars-buildpacks-distroless/) 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/`](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 ```bash export JAVA_HOME=/path/to/jdk-25 mvn -DskipTests package ./scripts/build-images.sh # all nine variants, tagged sbd/docker-images: 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`](docker/Dockerfile.fatjar-jdk) | the tutorial default | | `fatjar-jre` | [`Dockerfile.fatjar-jre`](docker/Dockerfile.fatjar-jre) | | | `layered-jre` | [`Dockerfile.layered-jre`](docker/Dockerfile.layered-jre) | `jarmode=tools extract --layers` | | `layered-alpine` | [`Dockerfile.layered-alpine`](docker/Dockerfile.layered-alpine) | musl | | `layered-distroless` | [`Dockerfile.layered-distroless`](docker/Dockerfile.layered-distroless) | **the recommendation** | | `jlink-distroless` | [`Dockerfile.jlink-distroless`](docker/Dockerfile.jlink-distroless) | custom runtime on `java-base` | | `aot-cache` | [`Dockerfile.aot-cache`](docker/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](docs/01-the-variants.md) 2. [Measuring size: on disk, over the wire, and why `docker images` disagrees with itself](docs/02-measuring-size.md) 3. [Layered jars: the win is the second push, not the first](docs/03-layered-jars.md) 4. [Buildpacks: what the builder decides for you](docs/04-buildpacks.md) 5. [jlink: 133 MB smaller, 15 metrics quieter](docs/05-jlink.md) 6. [The JDK 25 AOT cache: faster start, bigger pushes, and the silent fallback](docs/06-aot-cache.md) 7. [PID 1 and signals: shell form, dash and BusyBox](docs/07-pid1-and-signals.md) 8. [Living with distroless](docs/08-distroless-in-practice.md) ## Captured output | File | Produced by | |---|---| | [`image-matrix.txt`](docs/output/image-matrix.txt) | `scripts/measure.sh` | | [`rebuild-delta.txt`](docs/output/rebuild-delta.txt) | `scripts/measure-rebuild.sh` | | [`pid1-and-signals.txt`](docs/output/pid1-and-signals.txt) | `scripts/demo-signals.sh` | | [`jlink-metrics.txt`](docs/output/jlink-metrics.txt) | `scripts/demo-jlink-metrics.sh` | | [`aot-cache-mismatch.txt`](docs/output/aot-cache-mismatch.txt) | `scripts/demo-aot-mismatch.sh` | | [`buildpacks-memory.txt`](docs/output/buildpacks-memory.txt) | `scripts/demo-buildpacks-memory.sh` | | [`image-store-size-difference.txt`](docs/output/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.