From b5b9cafba7904436fe7919787731e681b13610ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:11:48 +0000 Subject: [PATCH 1/4] Add Dockerfiles: standard JVM, Java 25 AOT, and GraalVM native - Dockerfile: Maven wrapper build on eclipse-temurin:25-jdk, layered Spring Boot jar on eclipse-temurin:25-jre, non-root user - Dockerfile.aot: same jar with Spring AOT (spring-boot:process-aot) and a Java 25 AOT cache created by a training run on the runtime image (-XX:AOTCacheOutput / -XX:AOTCache); starts in ~1.4 s instead of ~5.4 s - Dockerfile.native: GraalVM native-image-community:25 builder, native executable on debian:bookworm-slim - .dockerignore, docs/docker.md (native measurements to follow) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2 --- .dockerignore | 9 ++++++ Dockerfile | 35 ++++++++++++++++++++++ Dockerfile.aot | 43 +++++++++++++++++++++++++++ Dockerfile.native | 28 +++++++++++++++++ docs/docker.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 191 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Dockerfile.aot create mode 100644 Dockerfile.native create mode 100644 docs/docker.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2806ade --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# Build output and local tooling; the image builds from sources +target/ +.git/ +.github/ +.idea/ +*.iml +.vscode/ +postman-tests/ +docs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd2b56b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 +# +# Standard JVM image: build with the Maven wrapper, run the Spring Boot jar on a JRE. +# +# docker build -t resourceapi . +# docker run --rm -p 8080:8080 resourceapi +# +# Tests are not run here (CI runs them); see docs/docker.md. + +FROM eclipse-temurin:25-jdk AS build +WORKDIR /workspace + +# Dependencies first, so they stay cached while only the sources change +COPY .mvn/ .mvn/ +COPY mvnw pom.xml ./ +RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -q dependency:go-offline + +COPY src/ src/ +# Build the jar and split it into layers (dependencies change less often than the application) +RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -q -DskipTests package \ + && cp target/resourceapi-*.jar application.jar \ + && java -Djarmode=tools -jar application.jar extract --layers --destination extracted + +FROM eclipse-temurin:25-jre +WORKDIR /app +RUN useradd --system --uid 1001 --no-create-home spring + +COPY --from=build /workspace/extracted/dependencies/ ./ +COPY --from=build /workspace/extracted/spring-boot-loader/ ./ +COPY --from=build /workspace/extracted/snapshot-dependencies/ ./ +COPY --from=build /workspace/extracted/application/ ./ + +USER spring +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "application.jar"] diff --git a/Dockerfile.aot b/Dockerfile.aot new file mode 100644 index 0000000..e9b8bdc --- /dev/null +++ b/Dockerfile.aot @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1 +# +# JVM image with ahead-of-time optimizations for faster startup: +# - Spring AOT: the bean wiring is generated at build time (spring.aot.enabled) +# - Java 25 AOT cache (JEP 483/514/515): a training run while building the image records the +# loaded and linked classes and method profiles; containers start from that cache +# +# docker build -f Dockerfile.aot -t resourceapi:aot . +# docker run --rm -p 8080:8080 resourceapi:aot +# +# Like a native image, Spring AOT fixes at build time which beans exist; see docs/native-image.md. + +FROM eclipse-temurin:25-jdk AS build +WORKDIR /workspace + +COPY .mvn/ .mvn/ +COPY mvnw pom.xml ./ +RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -q dependency:go-offline + +COPY src/ src/ +# Run Spring's AOT processing after compiling; "package" then builds a regular jar that includes its output +RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -q -DskipTests compile spring-boot:process-aot package \ + && cp target/resourceapi-*.jar application.jar \ + && java -Djarmode=tools -jar application.jar extract --layers --destination extracted + +# The AOT cache only works with the same JVM and class path it was created with, +# so it is created on the runtime image, from the files the container runs +FROM eclipse-temurin:25-jre +WORKDIR /app +RUN useradd --system --uid 1001 --no-create-home spring + +COPY --from=build /workspace/extracted/dependencies/ ./ +COPY --from=build /workspace/extracted/spring-boot-loader/ ./ +COPY --from=build /workspace/extracted/snapshot-dependencies/ ./ +COPY --from=build /workspace/extracted/application/ ./ + +# Training run: start the application until its context is ready, then exit and write the cache +RUN java -XX:AOTCacheOutput=application.aot -Dspring.aot.enabled=true \ + -Dspring.context.exit=onRefresh -jar application.jar + +USER spring +EXPOSE 8080 +ENTRYPOINT ["java", "-XX:AOTCache=application.aot", "-Dspring.aot.enabled=true", "-jar", "application.jar"] diff --git a/Dockerfile.native b/Dockerfile.native new file mode 100644 index 0000000..1ad18ba --- /dev/null +++ b/Dockerfile.native @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 +# +# GraalVM native image: the application compiled to a native executable, no JVM in the image. +# +# docker build -f Dockerfile.native -t resourceapi:native . +# docker run --rm -p 8080:8080 resourceapi:native +# +# The build needs about 10 GB of memory for Docker (see docs/native-image.md#build-memory) and +# produces an executable for the build platform's architecture (e.g. linux/amd64). + +FROM ghcr.io/graalvm/native-image-community:25 AS build +WORKDIR /workspace + +COPY .mvn/ .mvn/ +COPY mvnw pom.xml ./ +RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -q dependency:go-offline + +COPY src/ src/ +RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -q -Pnative -DskipTests native:compile + +# Small glibc-based image (the executable links against glibc and zlib) +FROM debian:bookworm-slim +RUN useradd --system --uid 1001 --no-create-home spring +COPY --from=build /workspace/target/resourceapi /app/resourceapi + +USER spring +EXPOSE 8080 +ENTRYPOINT ["/app/resourceapi"] diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..0e35e9c --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,76 @@ +# Docker images + +Three ways to package the application, from the simplest to the fastest-starting. All are multi-stage +builds: the application is compiled inside Docker, and the final image contains only what is needed to +run it, running as a non-root user on port 8080. Tests are not run during the image build (CI runs them). + +| | `Dockerfile` | `Dockerfile.aot` | `Dockerfile.native` | +|---|---|---|---| +| What runs | Spring Boot jar on a JRE | Spring Boot jar on a JRE, with Spring AOT and a Java 25 AOT cache | Native executable, no JVM | +| Base images | `eclipse-temurin:25-jdk` → `eclipse-temurin:25-jre` | same | `ghcr.io/graalvm/native-image-community:25` → `debian:bookworm-slim` | +| Image size | ~600 MB | ~750 MB | (being measured) | +| Startup | ~5.4 s | ~1.4 s | (being measured) | +| Memory after start | ~300 MB | ~320 MB | (being measured) | +| Build | ~1 min after dependencies are cached | ~1 min | (being measured), needs ~10 GB memory | +| Configuration at runtime | everything | values only (see below) | values only (see below) | + +Measured on a 4-core / 16 GB machine; startup is Spring Boot's own "Started ... in" time. + +## Standard: `Dockerfile` + +```sh +docker build -t resourceapi . +docker run --rm -p 8080:8080 resourceapi +``` + +The Maven wrapper builds the jar on a JDK image; dependencies are downloaded in their own layer (and into a +BuildKit cache), so changing only the sources doesn't download them again. The jar is split into Spring +Boot's layers (dependencies, loader, application) and run with `java -jar` on a JRE image. + +## Faster JVM startup: `Dockerfile.aot` + +```sh +docker build -f Dockerfile.aot -t resourceapi:aot . +docker run --rm -p 8080:8080 resourceapi:aot +``` + +Still the JVM and the same jar, plus two ahead-of-time optimizations: + +- **Spring AOT** (`spring-boot:process-aot`): the bean wiring is generated as code at build time and used at + runtime with `-Dspring.aot.enabled=true`, so Spring doesn't have to work it out on every start. +- **Java 25 AOT cache** ([JEP 483](https://openjdk.org/jeps/483), [514](https://openjdk.org/jeps/514), + [515](https://openjdk.org/jeps/515)): during the image build the application is started once (a training + run that stops as soon as the context is ready, `-Dspring.context.exit=onRefresh`) with + `-XX:AOTCacheOutput`. The JVM records the classes it loaded and linked and their method profiles in + `application.aot`; containers start with `-XX:AOTCache=application.aot` and skip most of that work. + Most of the speed-up comes from the cache (Spring AOT alone starts in about 4.3 s). + +The cache only fits the exact JVM and class path it was created with, which is why it is created on the +runtime image itself. If it can't be used, the JVM ignores it and starts normally (just slower). A few +warnings like `class ... cannot be archived` during the build are expected: those classes come from the +Java-version-specific part of a multi-release jar and are loaded normally. + +## Native executable: `Dockerfile.native` + +```sh +docker build -f Dockerfile.native -t resourceapi:native . +docker run --rm -p 8080:8080 resourceapi:native +``` + +Builds the GraalVM native executable (see the [native image guide](native-image.md)) in GraalVM's +`native-image-community` image and runs it on `debian:bookworm-slim`. Give Docker about 10 GB of memory for +the build. The executable is built for the architecture of the machine (or `--platform`) that builds it. + +## Configuration + +All three images are configured like the application itself, e.g. with environment variables: + +```sh +docker run --rm -p 9090:9090 -e SERVER_PORT=9090 resourceapi +docker run --rm -p 8080:8080 -e SPRING_HTTP_CLIENTS_READ_TIMEOUT=10s resourceapi:aot +``` + +With `Dockerfile.aot` and `Dockerfile.native`, Spring AOT decides at build time which beans exist, so +settings that switch beans on or off (for example `springdoc.api-docs.enabled` or the active profile) only +take effect when the image is built; see +[configuration fixed at build time](native-image.md#configuration-fixed-at-build-time). From 965791b2ea492ef7925ef63084778dfb5402d42b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:17:45 +0000 Subject: [PATCH 2/4] docs/docker.md: native image measurements; link Docker docs from README Native image: ~400 MB, starts in ~0.27 s, ~90 MB after start, ~6 min build peaking at ~10.6 GB. All three images verified: API, X-Trace-Id, Swagger UI, async sync (202), non-root user. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2 --- README.md | 8 ++++++++ docs/docker.md | 11 ++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fd09b55..7efb8ef 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,14 @@ Java 25+ (Spring Boot 4) ## Build `./mvnw clean install` +## Docker +Three Dockerfiles: `Dockerfile` (standard JVM image), `Dockerfile.aot` (JVM with Spring AOT and a Java 25 AOT +cache, starts ~4x faster) and `Dockerfile.native` (GraalVM native executable). See [Docker images](docs/docker.md). + +``` +docker build -t resourceapi . && docker run --rm -p 8080:8080 resourceapi +``` + ## Native image (GraalVM) The JVM build (`./mvnw clean install`) is the main build and the only one CI runs. A native executable is an optional alternative and is built manually. The application can be compiled ahead of time into a native executable with diff --git a/docs/docker.md b/docs/docker.md index 0e35e9c..d84974c 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -8,13 +8,14 @@ run it, running as a non-root user on port 8080. Tests are not run during the im |---|---|---|---| | What runs | Spring Boot jar on a JRE | Spring Boot jar on a JRE, with Spring AOT and a Java 25 AOT cache | Native executable, no JVM | | Base images | `eclipse-temurin:25-jdk` → `eclipse-temurin:25-jre` | same | `ghcr.io/graalvm/native-image-community:25` → `debian:bookworm-slim` | -| Image size | ~600 MB | ~750 MB | (being measured) | -| Startup | ~5.4 s | ~1.4 s | (being measured) | -| Memory after start | ~300 MB | ~320 MB | (being measured) | -| Build | ~1 min after dependencies are cached | ~1 min | (being measured), needs ~10 GB memory | +| Image size | ~600 MB | ~750 MB (incl. the 125 MB AOT cache) | ~400 MB | +| Startup | ~5.4 s | ~1.4 s | ~0.27 s | +| Memory after start | ~300 MB | ~320 MB | ~90 MB | +| Build | ~1 min after dependencies are cached | ~1 min | ~6 min, needs ~10 GB memory | | Configuration at runtime | everything | values only (see below) | values only (see below) | -Measured on a 4-core / 16 GB machine; startup is Spring Boot's own "Started ... in" time. +Measured on a 4-core / 16 GB machine: startup is Spring Boot's own "Started ... in" time (median of three), +memory is `docker stats` right after startup, image size as reported by `docker images`. ## Standard: `Dockerfile` From 4e9ade2782cd9a78910674d94e138f5089526fb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:25:47 +0000 Subject: [PATCH 3/4] docs/docker.md: flags the AOT image depends on and their pitfalls spring.aot.enabled must be a JVM system property (an environment variable is ignored and Spring silently starts without AOT); keep the runtime flags when overriding the entrypoint; heap limits and another GC keep using the AOT cache (checked with -XX:AOTMode=on); the training run needs what the application needs at startup. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2 --- docs/docker.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/docker.md b/docs/docker.md index d84974c..f0106b3 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -46,6 +46,21 @@ Still the JVM and the same jar, plus two ahead-of-time optimizations: `application.aot`; containers start with `-XX:AOTCache=application.aot` and skip most of that work. Most of the speed-up comes from the cache (Spring AOT alone starts in about 4.3 s). +No application properties are needed, only these JVM flags, which the image already sets: + +| Flag | When | Purpose | +|---|---|---| +| `-Dspring.aot.enabled=true` | runtime (`ENTRYPOINT`) | use Spring's generated code. Must be a JVM system property: `SPRING_AOT_ENABLED` as an environment variable or `application.properties` is **not** read, and without it Spring silently starts the normal way | +| `-XX:AOTCache=application.aot` | runtime (`ENTRYPOINT`) | start from the AOT cache | +| `-XX:AOTCacheOutput=application.aot` | image build (training run) | write the AOT cache | +| `-Dspring.context.exit=onRefresh` | image build (training run) | stop as soon as the application has started | + +If you override the entrypoint, keep the two runtime flags; add other JVM options through `JAVA_TOOL_OPTIONS`. +Heap limits (`-Xmx`, `docker run -m`) and a different garbage collector still use the cache (checked with +`-XX:AOTMode=on`, which refuses to start when the cache can't be used). Because the training run starts the +application during `docker build`, everything it needs at startup must be available then; this application +only needs its in-memory H2 database. + The cache only fits the exact JVM and class path it was created with, which is why it is created on the runtime image itself. If it can't be used, the JVM ignores it and starts normally (just slower). A few warnings like `class ... cannot be archived` during the build are expected: those classes come from the From 5bae851501ed4c4ec06039a5e1f19d44f381479a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:28:54 +0000 Subject: [PATCH 4/4] docs/docker.md: training the AOT cache for apps with external services Explains why the training run needs dedicated settings when the application connects to a database, Redis or RabbitMQ at startup, shows a value-only training profile and the alternative of training against real services. Notes that bean-switching properties are ignored with spring.aot.enabled (checked: springdoc.api-docs.enabled=false at runtime had no effect) while profile property files still load (checked). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2 --- docs/docker.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/docker.md b/docs/docker.md index f0106b3..09e4700 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -66,6 +66,52 @@ runtime image itself. If it can't be used, the JVM ignores it and starts normall warnings like `class ... cannot be archived` during the build are expected: those classes come from the Java-version-specific part of a multi-release jar and are loaded normally. +### Training an application that needs external services + +The training run is a real startup inside `docker build`: it runs the whole context refresh, including +starting lifecycle beans, and only then exits. An application with a database, Redis or RabbitMQ +therefore connects to them during the image build, where they usually don't exist. Typically: + +- **Database:** Hibernate connects at boot to read database metadata; Flyway/Liquibase run migrations. +- **RabbitMQ:** listener containers start and connect; `RabbitAdmin` declares queues and exchanges. +- **Redis:** mostly connects lazily, but health checks, Spring Session or cache warm-up can touch it. + +There are two ways to handle this. + +**1. A `training` profile with value-only settings.** Activate it on the training command only: + +```dockerfile +RUN java -XX:AOTCacheOutput=application.aot -Dspring.aot.enabled=true -Dspring.profiles.active=training \ + -Dspring.context.exit=onRefresh -jar application.jar +``` + +```properties +# application-training.properties (example for PostgreSQL + RabbitMQ) +# Hibernate: don't connect at boot to detect the database +spring.jpa.properties.hibernate.boot.allow_jdbc_metadata_access=false +spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect +# Hikari: don't fail when no connection can be made at startup +spring.datasource.hikari.initialization-fail-timeout=-1 +# RabbitMQ listeners: created, but not started, so they don't connect +spring.rabbitmq.listener.simple.auto-startup=false +``` + +The settings must change **values**, not switch beans on or off. With `-Dspring.aot.enabled=true` the +bean decisions were made when the image was built, so a property that only appears in a condition (for +example `spring.flyway.enabled=false` or `springdoc.api-docs.enabled=false`) is silently ignored. Profile +property files themselves are still read. Both were checked on this image: `-Dspringdoc.api-docs.enabled=false` +left `/v3/api-docs` up, while a value from an `application-training.properties` was applied. Migrations +can't be skipped this way; they need a reachable database or a migration step outside the application. +(The example settings above are a pattern for such applications; this application doesn't need them.) + +**2. Train against real services.** Run the training where the database, Redis and RabbitMQ are +reachable, for example `docker compose up` for them plus `docker build --network host`, or a CI job with +service containers. This gives the better cache: it only covers the classes the training run loaded, so +every feature switched off for training (JDBC driver, Flyway, AMQP client, ...) is missing from the cache +and loads the slow way in production. + +Either way, compare startup with and without `-XX:AOTCache` to see what the training gives you. + ## Native executable: `Dockerfile.native` ```sh