Files
spring-boot-demo/docker-images/README.md
T
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

100 lines
5.9 KiB
Markdown

# 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:<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`](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.