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
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Build every image variant from target/app.jar. Tag suffix is $1 (default: none).
# ./scripts/build-images.sh -> sbd/docker-images:<variant>
# ./scripts/build-images.sh -r2 -> sbd/docker-images:<variant>-r2
# Buildpacks and Jib need extra flags in a network-restricted environment; set
# BUILDPACKS_ARGS / JIB_ARGS (scripts/run-all.sh shows the sandbox values).
set -euo pipefail
source "$(dirname "$0")/env.sh"
SUFFIX="${1:-}"
cd "$MODULE_DIR"
for v in "${VARIANTS[@]}"; do
case "$v" in
buildpacks)
mvn -q -o -DskipTests ${BUILDPACKS_ARGS:-} -Dbuildpacks.image="sbd/docker-images:buildpacks$SUFFIX" \
spring-boot:build-image > /dev/null ;;
jib)
mvn -q -o -DskipTests ${JIB_ARGS:-} -Djib.to.image="sbd/docker-images:jib$SUFFIX" jib:dockerBuild > /dev/null ;;
*)
docker build -q -f "docker/Dockerfile.$v" -t "sbd/docker-images:$v$SUFFIX" . > /dev/null ;;
esac
echo "built sbd/docker-images:$v$SUFFIX"
done
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# The AOT cache trained in image aot-cache, run by its own JVM and by a different JVM build.
# -> docs/output/aot-cache-mismatch.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
cd "$MODULE_DIR"
docker build -q -f docker/Dockerfile.aot-cache-mismatch -t sbd/docker-images:aot-cache-mismatch . > /dev/null
{
for t in aot-cache aot-cache-mismatch; do
name="aot-$$"; docker rm -f "$name" > /dev/null 2>&1
docker run -d --name "$name" "sbd/docker-images:$t" > /dev/null
for _ in $(seq 1 200); do docker logs "$name" 2>&1 | grep -q 'Started ImagesApplication' && break; sleep 0.1; done
echo "## sbd/docker-images:$t"
docker logs "$name" 2>&1 | grep -E '\[aot\]|Starting ImagesApplication|Started ImagesApplication' \
| sed -E 's/^[0-9T:.-]+Z +INFO 1 --- \[docker-images\] \[ +main\] com.ankurm.images.ImagesApplication +: //' | cut -c1-120
echo "exit status while running: $(docker inspect -f '{{.State.Status}}' "$name")"
docker rm -f "$name" > /dev/null
echo
done
} | tee "$OUT/aot-cache-mismatch.txt"
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Where the buildpacks image's size goes, and why its JRE is bigger than Temurin's.
# -> docs/output/buildpacks-layers.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
W=$(mktemp -d)
{
echo "# docker history sbd/docker-images:buildpacks"
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' sbd/docker-images:buildpacks | cut -c1-110
echo
cid=$(docker create sbd/docker-images:buildpacks); docker export "$cid" | tar -x -C "$W" layers/paketo-buildpacks_bellsoft-liberica/jre; docker rm "$cid" > /dev/null
cid=$(docker create eclipse-temurin:25-jre); docker export "$cid" | tar -x -C "$W" opt/java/openjdk; docker rm "$cid" > /dev/null
L="$W/layers/paketo-buildpacks_bellsoft-liberica/jre"; T="$W/opt/java/openjdk"
echo "# The two JREs, in MB (du -sm)"
printf '%-44s %6s\n' "BellSoft Liberica JRE (buildpacks) total" "$(du -sm "$L" | cut -f1)"
printf '%-44s %6s\n' " lib/server" "$(du -sm "$L/lib/server" | cut -f1)"
printf '%-44s %6s\n' " lib/client" "$(du -sm "$L/lib/client" | cut -f1)"
printf '%-44s %6s\n' "Temurin JRE (eclipse-temurin:25-jre) total" "$(du -sm "$T" | cut -f1)"
printf '%-44s %6s\n' " lib/server" "$(du -sm "$T/lib/server" | cut -f1)"
printf '%-44s %6s\n' " lib/client" "$( [ -d "$T/lib/client" ] && du -sm "$T/lib/client" | cut -f1 || echo absent)"
} | tee "$OUT/buildpacks-layers.txt"
rm -rf "$W"
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# The buildpacks image under common container memory limits. The Paketo memory calculator runs
# before the JVM and refuses to launch if its fixed regions do not fit.
# -> docs/output/buildpacks-memory.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
run() { # label, docker args...
local label="$1"; shift
local name="bpm-$$"; docker rm -f "$name" > /dev/null 2>&1
docker run -d --name "$name" "$@" sbd/docker-images:buildpacks > /dev/null
for _ in $(seq 1 100); do
docker logs "$name" 2>&1 | grep -qE 'Started ImagesApplication|failed to launch' && break; sleep 0.1
done
echo "## $label"
docker logs "$name" 2>&1 | grep -E 'Calculated JVM Memory|unable to calculate|fixed memory regions|failed to launch|Started ImagesApplication' \
| sed -E 's/^[0-9T:.-]+Z +INFO 1 --- \[docker-images\] \[ +main\] com.ankurm.images.ImagesApplication +: //; s/\x1b\[[0-9;]*m//g' | cut -c1-230
echo "state: $(docker inspect -f '{{.State.Status}} (exit {{.State.ExitCode}})' "$name")"
echo
docker rm -f "$name" > /dev/null
}
{
run "docker run -m 512m" -m 512m
run "docker run -m 768m" -m 768m
run "docker run -m 512m -e BPL_JVM_THREAD_COUNT=50" -m 512m -e BPL_JVM_THREAD_COUNT=50
} | tee "$OUT/buildpacks-memory.txt"
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# The jlink image built from jdeps' module list alone runs fine - and exports fewer metrics.
# Diffs /actuator/prometheus metric names against the full-JRE image. -> docs/output/jlink-metrics.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
names() {
local name="jl-$$-$2"
docker rm -f "$name" > /dev/null 2>&1
docker run -d --name "$name" -p "$2:8080" "$1" > /dev/null
for _ in $(seq 1 300); do curl -s -o /dev/null "localhost:$2/actuator/health" && break; sleep 0.1; done
curl -s -o /dev/null "localhost:$2/api/items"
curl -s "localhost:$2/actuator/prometheus" | grep -v '^#' | sed 's/[{ ].*//' | sort -u
docker logs "$name" 2>&1 | grep -E ' WARN ' | sed 's/^.* WARN [0-9]* --- \[[^]]*\] \[ *[^]]*\] //' >&2
docker rm -f "$name" > /dev/null
}
{
echo "# jlink modules chosen by jdeps alone:"
docker run --rm --entrypoint /opt/jre/bin/java sbd/docker-images:jlink-jdeps-only --list-modules | sed 's/@.*//' | tr '\n' ' '; echo
echo
echo "# WARN lines at startup of the jdeps-only image:"
names sbd/docker-images:jlink-jdeps-only 18101 2>&1 >/tmp/jl-a.txt | sed 's/^/ /'
names sbd/docker-images:layered-distroless 18102 2>/dev/null > /tmp/jl-b.txt
names sbd/docker-images:jlink-distroless 18103 2>/dev/null > /tmp/jl-c.txt
echo
echo "# Metric names: full JRE $(wc -l < /tmp/jl-b.txt), jdeps-only $(wc -l < /tmp/jl-a.txt), jdeps + jdk.management $(wc -l < /tmp/jl-c.txt)"
echo "# Present with the full JRE, missing from the jdeps-only image:"
comm -23 /tmp/jl-b.txt /tmp/jl-a.txt | sed 's/^/ /'
echo "# Missing from jdeps + jdk.management: $(comm -23 /tmp/jl-b.txt /tmp/jl-c.txt | wc -l)"
comm -23 /tmp/jl-b.txt /tmp/jl-c.txt | sed 's/^/ /'
echo "# (jvm_gc_concurrent_phase_time_* is registered lazily, after the first G1 concurrent cycle -"
echo "# whether it appears within a few seconds of startup varies run to run, in either image.)"
} | tee "$OUT/jlink-metrics.txt"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Who is PID 1, does SIGTERM reach the JVM, and how long does `docker stop` take?
# -> docs/output/pid1-and-signals.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
probe() {
local tag="$1" name="sig-$$"
docker rm -f "$name" > /dev/null 2>&1
docker run -d --name "$name" "sbd/docker-images:$tag" > /dev/null
for _ in $(seq 1 200); do docker logs "$name" 2>&1 | grep -q 'Started ImagesApplication' && break; sleep 0.1; done
local pid1
# NUL-separated argv; translate in the pipe (a shell variable cannot hold NUL bytes)
pid1=$(docker exec "$name" cat /proc/1/cmdline 2>/dev/null | tr '\0' ' ')
case "$pid1" in ""|*"exec failed"*) pid1="(image has no cat; ENTRYPOINT is exec form)" ;; esac
local t0 t1; t0=$(date +%s%N); docker stop "$name" > /dev/null; t1=$(date +%s%N)
local code; code=$(docker inspect -f '{{.State.ExitCode}}' "$name")
local graceful; graceful=$(docker logs "$name" 2>&1 | grep -c 'Commencing graceful shutdown' || true)
printf '%-22s PID 1: %-58s docker stop: %5d ms exit code: %-4s graceful-shutdown log lines: %s\n' \
"$tag" "${pid1:0:58}" "$(( (t1 - t0) / 1000000 ))" "$code" "$graceful"
docker rm -f "$name" > /dev/null
}
{
echo "# docker stop sends SIGTERM to PID 1, waits 10 s, then SIGKILL. Exit code 143 = the JVM handled"
echo "# SIGTERM (128+15). 137 = it was killed (128+9)."
echo
for t in layered-jre shell-form shell-form-alpine shell-form-wrapper layered-distroless; do probe "$t"; done
echo
echo "# What /bin/sh is in each base image:"
echo "eclipse-temurin:25-jre -> $(docker run --rm --entrypoint readlink eclipse-temurin:25-jre -f /bin/sh)"
echo "eclipse-temurin:25-jre-alpine -> $(docker run --rm --entrypoint readlink eclipse-temurin:25-jre-alpine -f /bin/sh)"
} | tee "$OUT/pid1-and-signals.txt"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT="$MODULE_DIR/docs/output"
REGISTRY="${REGISTRY:-localhost:5000}" # any registry you can push to; see docs/02-measuring-size.md
REPO="$REGISTRY/sbd-docker-images"
mkdir -p "$OUT"
# Every variant the article measures, in the order the table lists them.
VARIANTS=(fatjar-jdk fatjar-jre layered-jre layered-alpine layered-distroless jlink-distroless aot-cache buildpacks jib)
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# For -Pno-maven-central only: download the one Maven Central artifact the Spring Boot buildpack
# fetches during a build, and check it against the sha256 the buildpack expects (which is also
# the name of the mapping file next to it).
set -euo pipefail
D="$(cd "$(dirname "$0")/.." && pwd)/bindings/dependency-mapping"
curl -sfL -o "$D/spring-cloud-bindings-2.0.4.jar" \
https://repo1.maven.org/maven2/org/springframework/cloud/spring-cloud-bindings/2.0.4/spring-cloud-bindings-2.0.4.jar
echo "32e47c2139d6379836910f0d1cc253a019ac282f3aeea12237069f00046279ad $D/spring-cloud-bindings-2.0.4.jar" | sha256sum -c
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# "A developer changed one line": bump BuildInfo.REVISION, rebuild every variant, and count the
# layers - and bytes - that differ from the previous build. That is what CI pushes and what every
# node pulls on the next rollout. -> docs/output/rebuild-delta.txt
# Run scripts/measure.sh first: it pushes the revision-1 images this compares against.
set -euo pipefail
source "$(dirname "$0")/env.sh"
cd "$MODULE_DIR"
FILE=src/main/java/com/ankurm/images/BuildInfo.java
trap 'sed -i "s/REVISION = \"2\"/REVISION = \"1\"/" "$FILE"; mvn -q -o -DskipTests package' EXIT
sed -i 's/REVISION = "1"/REVISION = "2"/' "$FILE"
mvn -q -o -DskipTests package
./scripts/build-images.sh -r2 > /dev/null
layers() { crane manifest "$1" | python3 -c 'import json,sys
for l in json.load(sys.stdin)["layers"]: print(l["digest"], l["size"])'; }
{
echo "# One-line code change (BuildInfo.REVISION 1 -> 2), every variant rebuilt."
echo "# 'new layers' = layer digests in revision 2 that revision 1 did not have: what a push uploads"
echo "# and what a node that already runs revision 1 downloads."
echo
printf '%-20s %12s %14s %14s %s\n' variant "new layers" "bytes to push" "image total" "share"
for v in "${VARIANTS[@]}"; do
docker tag "sbd/docker-images:$v-r2" "$REPO:$v-r2" && docker push -q "$REPO:$v-r2" > /dev/null
layers "$REPO:$v" | sort > /tmp/r1; layers "$REPO:$v-r2" | sort > /tmp/r2
new=$(comm -13 <(cut -d' ' -f1 /tmp/r1) <(cut -d' ' -f1 /tmp/r2))
count=$(echo "$new" | grep -c . || true)
bytes=0; for d in $new; do bytes=$(( bytes + $(grep "^$d " /tmp/r2 | cut -d' ' -f2) )); done
total=$(awk '{s+=$2} END {print s}' /tmp/r2)
printf '%-20s %6s of %-3s %13sK %13sM %5.1f%%\n' "$v" "$count" "$(wc -l < /tmp/r2)" "$(( bytes / 1000 ))" "$(( total / 1000000 ))" \
"$(python3 -c "print(100*$bytes/$total)")"
done
} | tee "$OUT/rebuild-delta.txt"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Measure every variant: size on disk, size over the wire, layers, user, shell, packages,
# startup to readiness, and memory at idle. -> docs/output/image-matrix.txt
# Needs a registry at $REGISTRY (crane registry serve --address localhost:5000 is enough).
set -uo pipefail
source "$(dirname "$0")/env.sh"
RUNS="${RUNS:-3}"
PORT=18090
compressed() { # image in registry -> "bytes layers"
crane manifest "$1" | python3 -c 'import json,sys
m=json.load(sys.stdin); L=m["layers"]
print(sum(l["size"] for l in L)+m["config"]["size"], len(L))'
}
packages() { # count OS packages from the image filesystem, without running anything in it
local cid; cid=$(docker create "$1" /x 2>/dev/null)
docker export "$cid" > /tmp/img-fs.tar; docker rm "$cid" > /dev/null
local n=""
if tar -tf /tmp/img-fs.tar var/lib/dpkg/status > /dev/null 2>&1; then
n=$(tar -xOf /tmp/img-fs.tar var/lib/dpkg/status | grep -c '^Package:')
n="$n (dpkg)"
elif [ "$(tar -tf /tmp/img-fs.tar | grep -c '^var/lib/dpkg/status.d/.')" -gt 0 ]; then
# distroless and Paketo "tiny" keep one status file per package (plus .md5sums) in status.d/
n=$(tar -tf /tmp/img-fs.tar | grep -E '^var/lib/dpkg/status.d/[^/]+$' | grep -vc '\.md5sums$')
n="$n (status.d)"
elif tar -tf /tmp/img-fs.tar lib/apk/db/installed > /dev/null 2>&1; then
n=$(tar -xOf /tmp/img-fs.tar lib/apk/db/installed | grep -c '^P:')
n="$n (apk)"
else n="?"; fi
rm -f /tmp/img-fs.tar; echo "$n"
}
startup() { # image -> "ms_to_ready startedIn rss"
local name="measure-$$" t0 t1
docker rm -f "$name" > /dev/null 2>&1
t0=$(date +%s%N)
docker run -d --name "$name" -p "$PORT:8080" "$1" > /dev/null
for _ in $(seq 1 600); do
if [ "$(curl -s -o /dev/null -w '%{http_code}' localhost:$PORT/actuator/health/readiness)" = 200 ]; then break; fi
sleep 0.05
done
t1=$(date +%s%N)
for _ in 1 2 3 4 5; do curl -s -o /dev/null localhost:$PORT/api/items; done
sleep 2
local rss started
rss=$(docker stats --no-stream --format '{{.MemUsage}}' "$name" | awk '{print $1}')
started=$(docker logs "$name" 2>&1 | grep -o 'Started ImagesApplication in [0-9.]* seconds' | awk '{print $4}')
docker rm -f "$name" > /dev/null
echo "$(( (t1 - t0) / 1000000 )) ${started:-?} $rss"
}
{
echo "# Spring Boot 4.1.1 application (webmvc + actuator + validation + prometheus), fat jar $(du -h "$MODULE_DIR/target/app.jar" | cut -f1)"
echo "# Docker Engine $(docker version --format '{{.Server.Version}}'), classic overlay2 store. $RUNS startup runs each, median shown."
echo "# 'on disk' = docker image inspect .Size (uncompressed). 'pushed' = layer blobs + config in the registry (compressed)."
echo
printf '%-20s %9s %9s %7s %-9s %-6s %-16s %9s %9s %9s %s\n' variant "on disk" pushed layers user shell packages "ready ms" "Started" "RSS" "JVM"
for v in "${VARIANTS[@]}"; do
img="sbd/docker-images:$v"
docker tag "$img" "$REPO:$v" && docker push -q "$REPO:$v" > /dev/null
disk=$(docker image inspect -f '{{.Size}}' "$img")
read -r wire layers < <(compressed "$REPO:$v")
user=$(docker image inspect -f '{{.Config.User}}' "$img"); user="${user:-root}"
if docker run --rm --entrypoint /bin/sh "$img" -c true > /dev/null 2>&1; then shell=yes; else shell=no; fi
pkgs=$(packages "$img")
samples=()
for _ in $(seq 1 "$RUNS"); do samples+=("$(startup "$img")"); done
median=$(printf '%s\n' "${samples[@]}" | sort -n | sed -n "$(( (RUNS + 1) / 2 ))p")
read -r ready started rss <<< "$median"
name="measure-$$"; docker run -d --name "$name" -p "$PORT:8080" "$img" > /dev/null
for _ in $(seq 1 600); do curl -s -o /dev/null localhost:$PORT/api/runtime && break; sleep 0.05; done
jvm=$(curl -s localhost:$PORT/api/runtime | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["java.vendor"].split()[0], r["java.version"])')
docker rm -f "$name" > /dev/null
printf '%-20s %8sM %8sM %7s %-9s %-6s %-16s %9s %9s %9s %s\n' "$v" \
"$(( disk / 1000000 ))" "$(( wire / 1000000 ))" "$layers" "$user" "$shell" "$pkgs" "$ready" "${started}s" "$rss" "$jvm"
done
} | tee "$OUT/image-matrix.txt"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Regenerate every transcript under docs/output/ (about 15 minutes; startup is measured three
# times per image). Stop anything else CPU-hungry first - a parallel job skewed one startup figure
# by two seconds while this was being written.
#
# Needs: Docker, crane (github.com/google/go-containerregistry) for the size-over-the-wire numbers,
# and the base images listed in docs/01-the-variants.md. In a network-restricted build environment
# set BUILDPACKS_ARGS and JIB_ARGS - the values used for the committed output are the defaults below.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
export BUILDPACKS_ARGS="${BUILDPACKS_ARGS--Pcorporate-proxy,no-maven-central -Dspring-boot.build-image.pullPolicy=IF_NOT_PRESENT}"
export JIB_ARGS="${JIB_ARGS--Djib.from.image=docker://eclipse-temurin:25-jre}"
crane registry serve --address "$REGISTRY" > /tmp/registry.log 2>&1 &
REG_PID=$!
trap 'kill $REG_PID 2>/dev/null' EXIT
sleep 1
mvn -q -DskipTests package
./scripts/build-images.sh
docker build -q -f docker/Dockerfile.jlink-distroless --build-arg EXTRA_MODULES= -t sbd/docker-images:jlink-jdeps-only . > /dev/null
for v in shell-form shell-form-alpine shell-form-wrapper; do
docker build -q -f "docker/Dockerfile.$v" -t "sbd/docker-images:$v" . > /dev/null
done
./scripts/measure.sh
./scripts/measure-rebuild.sh
./scripts/demo-signals.sh
./scripts/demo-jlink-metrics.sh
./scripts/demo-aot-mismatch.sh
./scripts/demo-buildpacks-memory.sh
./scripts/demo-buildpacks-layers.sh
echo "Regenerated: $(ls docs/output | wc -l) files in docs/output/"