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
This commit is contained in:
2026-09-11 17:11:46 +00:00
co-authored by Claude Opus 5
parent 86246dc860
commit 644da9e65e
52 changed files with 1308 additions and 1 deletions
+42
View File
@@ -0,0 +1,42 @@
# 1. The variants and how each is built
[Index](../README.md) · Next: [2. Measuring size →](02-measuring-size.md)
All nine start from the same `target/app.jar` (25 MB). The Dockerfiles are in [`docker/`](../docker),
each with a comment saying what it is for.
## The layered pattern
```dockerfile
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"]
```
`extract` without `--launcher` writes a thin `application.jar` whose manifest `Class-Path` lists
`lib/*.jar`, so the runtime stage launches with plain `java -jar` - no `JarLauncher`, no nested-jar
class loading. `list-layers` on this jar prints `dependencies`, `spring-boot-loader`,
`snapshot-dependencies`, `application`, in that order: least likely to change first.
The builder stage uses the Temurin JRE, not the JDK: `extract` needs only a JVM. Only the jlink
variant needs the JDK, for `jdeps` and `jlink`.
## Buildpacks and Jib in a restricted network
The committed output was produced in a sandbox with no route to Docker Hub or Maven Central. The
flags that made that work are in [`run-all.sh`](../scripts/run-all.sh):
- Buildpacks: `-Pcorporate-proxy,no-maven-central -Dspring-boot.build-image.pullPolicy=IF_NOT_PRESENT`
([chapter 4](04-buildpacks.md))
- Jib: `-Djib.from.image=docker://eclipse-temurin:25-jre` - take the base image from the local daemon
With ordinary internet access, `mvn spring-boot:build-image` and `mvn jib:dockerBuild` need no flags.
+58
View File
@@ -0,0 +1,58 @@
# 2. Measuring size
[← 1. The variants](01-the-variants.md) · [Index](../README.md) · Next: [3. Layered jars →](03-layered-jars.md)
"Image size" means at least three different numbers:
| Number | What it is | How it is measured here |
|---|---|---|
| on disk | uncompressed layers, what a node stores | `docker image inspect -f '{{.Size}}'` on the classic `overlay2` store |
| pushed | compressed layer blobs + config, what a registry stores and a cold node downloads | sum of `layers[].size` + `config.size` from `crane manifest` |
| rebuild delta | the blobs a push actually uploads after a change | [chapter 3](03-layered-jars.md) |
[`measure.sh`](../scripts/measure.sh) pushes every variant to a throwaway registry
(`crane registry serve --address localhost:5000` - in-memory, no Docker Hub account needed) and
reads the manifests back.
## `docker images` disagrees with itself
Docker Engine 29 can use two image stores. The same eleven tarballs, loaded into each
([`image-store-size-difference.txt`](output/image-store-size-difference.txt)):
| Image | containerd store | classic overlay2 |
|---|---|---|
| `eclipse-temurin:25-jre` | 496 MB | 352 MB |
| `gcr.io/distroless/java25-debian13:nonroot` | 305 MB | 226 MB |
| `paketobuildpacks/builder-noble-java-tiny` | 1.26 GB | 868 MB |
The containerd store's figure is larger across the board. Size comparisons from blog posts - or
between two colleagues' laptops - are only comparable if they used the same store, and the
containerd store is the default for new Docker installations. This project measures on the classic
store because its number is the unpacked size alone.
## The result
[`image-matrix.txt`](output/image-matrix.txt):
```
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. Median of three on a 2-vCPU VM - treat differences under ~0.3 s as noise.
- *packages* counts OS packages from the image filesystem without running anything in it (dpkg
`status`, distroless/Paketo `status.d/`, or `apk`), which works for images with no shell.
- Every Temurin-based variant runs as **root**: the official images set no `USER`.
A note on method: the first run of this table had distroless starting two seconds slower than
everything else. A k3s image import was running on the same two CPUs at the time. Nothing else runs
during `measure.sh` now.
+33
View File
@@ -0,0 +1,33 @@
# 3. Layered jars: the win is the second push
[← 2. Measuring size](02-measuring-size.md) · [Index](../README.md) · Next: [4. Buildpacks →](04-buildpacks.md)
`fatjar-jre` and `layered-jre` are the same size: 377 MB on disk, 144 MB pushed. The layers are the
same bytes, cut differently.
What differs is the next build. [`measure-rebuild.sh`](../scripts/measure-rebuild.sh) changes one
string constant, rebuilds every variant, and counts the layer digests that did not exist before
([`rebuild-delta.txt`](output/rebuild-delta.txt)):
```
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%
```
A fat jar re-ships all 23 MB of dependencies on every commit; a layered image ships 6 KB. Multiply
by deployments per day and nodes per cluster - every node that already has revision 1 downloads
only the changed layers.
Layers only help if their order matches change frequency. Put your own `COPY` of configuration
files *after* the dependency layers, never before, or every change re-ships everything below it.
Snapshot dependencies get their own layer because they change without a version bump.
Jib and buildpacks layer the same way without being asked.
+80
View File
@@ -0,0 +1,80 @@
# 4. Buildpacks: what the builder decides for you
[← 3. Layered jars](03-layered-jars.md) · [Index](../README.md) · Next: [5. jlink →](05-jlink.md)
`mvn spring-boot:build-image` with no configuration used `paketobuildpacks/builder-noble-java-tiny`
- the Spring Boot 4.1.1 default - and six of its 26 buildpacks took part: `ca-certificates`,
`bellsoft-liberica`, `syft`, `executable-jar`, `dist-zip`, `spring-boot`.
What you get without writing a line of Dockerfile, from the build log and
[`image-matrix.txt`](output/image-matrix.txt):
- **Java version from the jar.** `$BP_JVM_VERSION` shows `21` as its default, then
`Using Java version 25 extracted from MANIFEST.MF` - the buildpack reads `Build-Jdk-Spec`.
- **A non-root user**, `1002:1001`, and no shell (the *tiny* run image).
- **A reproducible image.** Creation date is fixed (Docker shows "46 years ago" - 1980), so the same
input gives the same digest.
- **A memory calculator** that runs before the JVM and sets `-Xmx`, metaspace, code cache and
thread stacks from the container's memory limit - see below, it can refuse to start the app.
- **An SBOM** layer from Syft.
- **Spring Cloud Bindings** on the classpath, which reads Kubernetes service bindings into Spring
properties. `BP_SPRING_CLOUD_BINDINGS_DISABLED=true` removes it.
## The size
345 MB on disk, and most of it is one layer:
```
276MB Layer: 'jre', Created by buildpack: paketo-buildpacks/[email protected]
```
Temurin's 25 JRE is 200 MB. BellSoft's JRE carries a second VM: `lib/client` is 74 MB (its own
`libjvm.so` plus two CDS archives) next to the 85 MB `lib/server`. `BP_JVM_JLINK_ENABLED=true` asks
the buildpack to jlink a smaller runtime - with the caveat in [chapter 5](05-jlink.md).
## At 512 MiB the image does not start
[`buildpacks-memory.txt`](output/buildpacks-memory.txt):
```
## 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)
```
The calculator reserves 1 MiB of stack for each of **250 threads** plus a 240 MiB code cache and
metaspace sized from the class count, *before* any heap. 512 MiB - a very common Kubernetes
`limits.memory` - is not enough, and the container exits with code 82 before Java runs. The same jar
in any of the Dockerfile-built images starts fine at 512 MiB.
Either give it more (`-m 768m``-Xmx190559K`, a quarter of the limit) or tell it the truth about
threads: `BPL_JVM_THREAD_COUNT=50` → starts at 512 MiB with `-Xmx133215K`. With virtual threads,
250 platform threads is a generous assumption.
## Behind a TLS-intercepting proxy
The Java buildpack downloads the JRE at build time, and the Spring Boot buildpack downloads Spring
Cloud Bindings from Maven Central. Inside a corporate proxy both fail. Two profiles in the
[`pom.xml`](../pom.xml) handle it:
- `corporate-proxy` - build container on the host network, `HTTPS_PROXY` passed through, and a
`ca-certificates` [binding](../bindings/ca-certificates) so the build trusts the proxy's CA
(`Added 2 additional CA certificate(s) to system truststore` in the log). With
`BP_EMBED_CERTS=false` - the default - the CA is not baked into the runtime image.
- `no-maven-central` - a `dependency-mapping` [binding](../bindings/dependency-mapping) that points
the buildpack at a local copy of the jar, keyed by the sha256 in the buildpack's `buildpack.toml`.
Both bindings are merged with `combine.children="append"`, so the profiles compose:
`-Pcorporate-proxy,no-maven-central`.
## Two traps met on the way
- **The run image is pinned by tag inside the builder.** The builder pulled as `:latest` asked for
`paketobuildpacks/ubuntu-noble-run-tiny:0.0.130`; with `pullPolicy=IF_NOT_PRESENT` and only
`:latest` present locally, the build still tried Docker Hub. Tagging the same digest as `0.0.130`
fixed it.
- **`-Dspring-boot.build-image.imageName` is ignored if the pom sets `<image><name>`** - explicit
configuration beats the user property, so the "second" build silently overwrote the first image.
The pom uses a `${buildpacks.image}` property instead.
+45
View File
@@ -0,0 +1,45 @@
# 5. jlink: 133 MB smaller, 15 metrics quieter
[← 4. Buildpacks](04-buildpacks.md) · [Index](../README.md) · Next: [6. AOT cache →](06-aot-cache.md)
[`Dockerfile.jlink-distroless`](../docker/Dockerfile.jlink-distroless) asks `jdeps` which JDK modules
the application needs and builds a runtime with only those:
```bash
jdeps --ignore-missing-deps -q --recursive --multi-release 25 --print-module-deps \
--class-path 'extracted/dependencies/lib/*' extracted/application/application.jar
```
It chose 18 modules ([`jlink-metrics.txt`](output/jlink-metrics.txt)) - including `java.desktop`,
because Spring uses `java.beans`. On `distroless/java-base` the image is **119 MB** on disk against
252 MB for distroless with the full JRE.
## It runs. It is also missing something.
The jdeps-only image starts, serves requests and passes its health check. Its startup 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: `jvm_gc_pause_seconds_*`,
`jvm_gc_memory_allocated_bytes_total`, `jvm_gc_live_data_size_bytes`, `process_cpu_usage`,
`system_cpu_usage`, `process_files_open_files` and more - 15 in total.
`com.sun.management.*` lives in the `jdk.management` module, and Micrometer touches it
reflectively, which `jdeps` cannot see. Nothing fails; the GC and CPU panels of your dashboard just
go flat after the image switch. The Dockerfile now adds it by default:
```dockerfile
ARG EXTRA_MODULES="jdk.management"
```
With it, the only difference left is `jvm_gc_concurrent_phase_time_*`, which Micrometer registers
lazily inside its GC notification listener - it appears after the first G1 concurrent cycle, which
may or may not have happened a few seconds after startup, in either image.
The general rule: diff the metric names, not just the health check, before shipping a jlink image.
Other modules commonly needed only reflectively are `jdk.crypto.cryptoki` (PKCS#11),
`jdk.localedata` (non-English locale data) and `jdk.naming.dns` (DNS lookups through JNDI).
+52
View File
@@ -0,0 +1,52 @@
# 6. The JDK 25 AOT cache
[← 5. jlink](05-jlink.md) · [Index](../README.md) · Next: [7. PID 1 and signals →](07-pid1-and-signals.md)
JDK 24 added the AOT cache (JEP 483: classes loaded and linked ahead of time), and JDK 25 made it
one step (JEP 514, `-XX:AOTCacheOutput`) and added method profiles to it (JEP 515). Spring Boot's
documented Dockerfile does a training run at image build time:
```dockerfile
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` starts the context and exits, so the training run needs no database
or network - unless your beans touch them during startup.
## What it buys and what it costs
| | `layered-jre` | `aot-cache` |
|---|---|---|
| Started in (median of 3) | 3.356 s | **1.638 s** |
| on disk | 377 MB | 440 MB (+63 MB) |
| pushed after a one-line change | 6 KB | **15.3 MB** |
The last row is the one nobody mentions. The training run happens *after* the application layer
is copied, so every code change invalidates it and the cache layer is rebuilt and re-pushed. Half
the startup time costs back most of what layering saved on pushes. Whether that trade is worth it
depends on how often pods start versus how often you deploy - scale-to-zero and aggressive
autoscaling say yes; a fleet that deploys twenty times a day and rarely restarts says no.
## A cache from a different JVM
The cache is only valid for the exact JVM build that wrote it.
[`Dockerfile.aot-cache-mismatch`](../docker/Dockerfile.aot-cache-mismatch) copies the trained
application onto the distroless image, whose JVM is Temurin 25.0.4.1 instead of 25.0.4
([`aot-cache-mismatch.txt`](output/aot-cache-mismatch.txt)):
```
[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` level, and then the application starts normally without the cache - back at
3.5 s. Nothing fails a health check. The way this happens in practice is a floating base-image tag
(`25-jre`) moving to a new JVM build between the stage that trained the cache and a later rebuild
that reused a cached training layer - or a multi-stage build that trains on one image and runs on
another, as here. Train and run on the same image, pin it by digest, and alert on `[aot]` lines at
`error`.
+29
View File
@@ -0,0 +1,29 @@
# 7. PID 1 and signals
[← 6. AOT cache](06-aot-cache.md) · [Index](../README.md) · Next: [8. Living with distroless →](08-distroless-in-practice.md)
`docker stop` - and Kubernetes, on pod deletion - sends SIGTERM to PID 1, waits (10 s for Docker,
`terminationGracePeriodSeconds` for Kubernetes), then SIGKILLs. Spring Boot's graceful shutdown only
runs if the JVM receives that SIGTERM.
[`pid1-and-signals.txt`](output/pid1-and-signals.txt):
```
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
```
- **Exec form** (`["java", "-jar", ...]`): the JVM is PID 1, SIGTERM arrives, graceful shutdown
runs, exit code 143, a quarter of a second.
- **Shell form on `eclipse-temurin:25-jre`**: `/bin/sh` is dash (`/usr/bin/dash`). dash stays
resident as PID 1 and does not forward SIGTERM. Docker waits the full 10 s and SIGKILLs: exit 137,
no graceful shutdown, in-flight requests cut off.
- **The same shell form on the Alpine image works**, because `/bin/sh` there is BusyBox ash, which
replaces itself with the last command of a `-c` string. The same Dockerfile line behaves
differently depending on the base image - which is how "it works on my image" arguments start.
- **Any shell form with more than one command** keeps the shell as PID 1 regardless.
If you need a wrapper script, end it with `exec java ...`. Distroless removes the question: with no
`/bin/sh`, a shell-form `ENTRYPOINT` cannot even start.
@@ -0,0 +1,20 @@
# 8. Living with distroless
[← 7. PID 1 and signals](07-pid1-and-signals.md) · [Index](../README.md)
`gcr.io/distroless/java25-debian13:nonroot` gives you: a JRE, glibc, CA certificates, time zone
data, and 25 OS packages in total - against 106 in `eclipse-temurin:25-jre`. No shell, no package
manager, uid 65532. It is the variant recommended in the article, and it changes a few habits.
- **`RUN` is impossible in the final stage.** Do everything - extraction, jlink, training runs - in
a builder stage with a shell, then `COPY`.
- **`ENTRYPOINT` must be exec form.**
- **`docker exec -it ... sh` does not work.** On Kubernetes use an ephemeral debug container that
shares the process namespace: `kubectl debug -it <pod> --image=busybox:1.37 --target=app`. With
Docker: `docker run -it --pid=container:<name> --network=container:<name> busybox`.
- **A `preStop` hook of `exec: ["sh", "-c", "sleep 10"]` fails**, because there is no `sh`. Use the
native `sleep` action (on by default since Kubernetes 1.30, stable in 1.34). The
[Kubernetes article](https://ankurm.com/spring-boot-4-kubernetes-probes-graceful-shutdown-cpu-limits-hpa/)
measures what that failure costs during a rolling update.
- **Tags are the variant.** `:latest` runs as root, `:nonroot` as 65532, `:debug` adds a BusyBox
shell - useful for a one-off investigation, never for production.
@@ -0,0 +1,14 @@
## sbd/docker-images:aot-cache
Starting ImagesApplication v1.0.0 using Java 25.0.4 with PID 1 (/application/application.jar started by root in /applica
Started ImagesApplication in 1.657 seconds (process running for 1.966)
exit status while running: running
## sbd/docker-images:aot-cache-mismatch
[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)
exit status while running: running
@@ -0,0 +1,21 @@
# docker history sbd/docker-images:buildpacks
69B Buildpacks Process Types
1.94kB Buildpacks Launcher Config
2.93MB Buildpacks Application Launcher
0B Application Slice: 5
14.2kB Application Slice: 4
0B Application Slice: 3
402kB Application Slice: 2
25.9MB Application Slice: 1
577kB Software Bill-of-Materials
3B Layer: 'web-application-type', Created by buildpack: paketo-buildpacks/[email protected]
77.4kB Layer: 'spring-cloud-bindings', Created by buildpack: paketo-buildpacks/[email protected]
4.01MB Layer: 'helper', Created by buildpack: paketo-buildpacks/[email protected]
11B Layer: 'classpath', Created by buildpack: paketo-buildpacks/[email protected]
276MB Layer: 'jre', Created by buildpack: paketo-buildpacks/[email protected]
214B Layer: 'java-security-properties', Created by buildpack: paketo-buildpacks/[email protected]
5.48MB Layer: 'helper', Created by buildpack: paketo-buildpacks/[email protected]
5.18MB Layer: 'helper', Created by buildpack: paketo-buildpacks/[email protected]
519B
191B
24.9MB
@@ -0,0 +1,16 @@
## 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)
## docker run -m 768m
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx190559K -XX:MaxMetaspaceSize=83872K -XX:ReservedCodeCacheSize=240M -Xss1M (Total Memory: 768M, Thread Count: 250, Loaded Class Count: 12394, Headroom: 0%)
Started ImagesApplication in 3.884 seconds (process running for 4.423)
state: running (exit 0)
## 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%)
Started ImagesApplication in 3.846 seconds (process running for 4.353)
state: running (exit 0)
@@ -0,0 +1,14 @@
# Spring Boot 4.1.1 application (webmvc + actuator + validation + prometheus), fat jar 25M
# Docker Engine 29.4.3, classic overlay2 store. 3 startup runs each, median shown.
# 'on disk' = docker image inspect .Size (uncompressed). 'pushed' = layer blobs + config in the registry (compressed).
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
@@ -0,0 +1,31 @@
# The same eleven images, loaded from the same tarballs, listed by `docker images` under the two
# Docker Engine 29.4.3 image stores. Captured once by hand (switching stores needs a daemon
# restart); the commands are in docs/02-measuring-size.md.
## containerd image store (daemon.json: {"features":{"containerd-snapshotter":true}} - the default for new installs)
REPOSITORY:TAG IMAGE ID SIZE
paketobuildpacks/ubuntu-noble-run-tiny:latest 5fb8a81f3f56 38.7MB
eclipse-temurin:25-jre 211325bca3fd 496MB
eclipse-temurin:25-jdk 00eacca34899 608MB
eclipse-temurin:25-jre-alpine 31a7faf76302 305MB
bellsoft/liberica-openjre-debian:25-cds 78b8d1ba9172 505MB
quay.io/prometheus/prometheus:v3.14.0 0230c2ba4c1c 372MB
busybox:1.37 e0b4f7eca906 6.77MB
registry.k8s.io/prometheus-adapter/prometheus-adapter:v0.12.0 b8e2bc8f8de9 131MB
paketobuildpacks/builder-noble-java-tiny:latest b54d7bce7d3c 1.26GB
gcr.io/distroless/java-base-debian13:nonroot fe682f609c79 43.1MB
gcr.io/distroless/java25-debian13:nonroot 3ad8b3cc79ab 305MB
## classic overlay2 store (daemon.json: {"features":{"containerd-snapshotter":false}})
REPOSITORY:TAG IMAGE ID SIZE
paketobuildpacks/ubuntu-noble-run-tiny:latest 3f2697c8829c 24.9MB
eclipse-temurin:25-jre a33388e97452 352MB
eclipse-temurin:25-jdk ca0436742d32 430MB
eclipse-temurin:25-jre-alpine 0988c057a1e4 226MB
bellsoft/liberica-openjre-debian:25-cds aea7393405da 356MB
quay.io/prometheus/prometheus:v3.14.0 31c1e0aacb3a 261MB
busybox:1.37 db287cb6be81 4.42MB
registry.k8s.io/prometheus-adapter/prometheus-adapter:v0.12.0 3bba96c2e4e6 89.3MB
paketobuildpacks/builder-noble-java-tiny:latest d835ec7c8f6e 868MB
gcr.io/distroless/java-base-debian13:nonroot 9bc10980a41f 26.5MB
gcr.io/distroless/java25-debian13:nonroot be8c5fbd580c 226MB
@@ -0,0 +1,30 @@
# jlink modules chosen by jdeps alone:
java.base java.compiler java.datatransfer java.desktop java.instrument java.logging java.management java.naming java.net.http java.prefs java.scripting java.security.jgss java.security.sasl java.sql java.transaction.xa java.xml jdk.jfr jdk.unsupported
# WARN lines at startup of the jdeps-only image:
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]
# Metric names: full JRE 61, jdeps-only 46, jdeps + jdk.management 58
# Present with the full JRE, missing from the jdeps-only image:
jvm_gc_concurrent_phase_time_seconds_count
jvm_gc_concurrent_phase_time_seconds_max
jvm_gc_concurrent_phase_time_seconds_sum
jvm_gc_live_data_size_bytes
jvm_gc_max_data_size_bytes
jvm_gc_memory_allocated_bytes_total
jvm_gc_memory_promoted_bytes_total
jvm_gc_pause_seconds_count
jvm_gc_pause_seconds_max
jvm_gc_pause_seconds_sum
process_cpu_time_ns_total
process_cpu_usage
process_files_max_files
process_files_open_files
system_cpu_usage
# Missing from jdeps + jdk.management: 3
jvm_gc_concurrent_phase_time_seconds_count
jvm_gc_concurrent_phase_time_seconds_max
jvm_gc_concurrent_phase_time_seconds_sum
# (jvm_gc_concurrent_phase_time_* is registered lazily, after the first G1 concurrent cycle -
# whether it appears within a few seconds of startup varies run to run, in either image.)
@@ -0,0 +1,12 @@
# docker stop sends SIGTERM to PID 1, waits 10 s, then SIGKILL. Exit code 143 = the JVM handled
# SIGTERM (128+15). 137 = it was killed (128+9).
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
layered-distroless PID 1: (image has no cat; ENTRYPOINT is exec form) docker stop: 256 ms exit code: 143 graceful-shutdown log lines: 1
# What /bin/sh is in each base image:
eclipse-temurin:25-jre -> /usr/bin/dash
eclipse-temurin:25-jre-alpine -> /bin/busybox
@@ -0,0 +1,14 @@
# One-line code change (BuildInfo.REVISION 1 -> 2), every variant rebuilt.
# 'new layers' = layer digests in revision 2 that revision 1 did not have: what a push uploads
# and what a node that already runs revision 1 downloads.
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%