# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894 # ============================================================================= # feature-container-runtime-contract — multi-stage image build # # Build requirements: # docker build -f src/Dockerfile src/ -t caskeleton:local \ # --build-arg RELEASE_VERSION=1.2.3 \ # --build-arg BUILD_VERSION=1.2.3+a1b2c3d4e5f6 \ # --build-arg GIT_SHA=a1b2c3d4e5f6 \ # --build-arg SOURCE_URL=https://github.com/your-org/your-repo # # Design decisions (feature-container-runtime-contract D2/D3/D4): # D2 — read-only root filesystem: writable mounts must be declared explicitly. # D3 — non-root user, JRE-only final stage (no full JDK). # D4 — JVM ergonomics via JAVA_TOOL_OPTIONS and ExitOnOutOfMemoryError. # ============================================================================= ARG RELEASE_VERSION ARG BUILD_VERSION ARG GIT_SHA ARG SOURCE_URL # ---- Stage 1: builder ------------------------------------------------------- # Uses the full JDK only in the build stage, never in the final image. FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder ARG RELEASE_VERSION ARG GIT_SHA WORKDIR /build/src # Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST, # so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle # or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a # single structure-preserving glob replaces the former per-module COPY list: new modules are # picked up automatically and this stage never drifts out of sync with settings.gradle again. # STRICT lock mode still rejects missing/drifted state at verifyDependencyLocks below. # (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.) COPY gradlew ./ COPY gradle/ gradle/ COPY config/ ./config/ COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./ # The convention plugins, whole. The glob above copies files named build.gradle and gradle.lockfile, # which picks up build-logic's own build script and misses the precompiled script plugins beside it — # so settings.gradle's `includeBuild('build-logic')` resolved against a build that declared no # plugins and every leaf failed on an unknown plugin id, in the dependency-resolution stage below. COPY build-logic/ ./build-logic/ # Resolve every module configuration in STRICT mode (no --write-locks in a release build). This # custom task fails on drift; Gradle's diagnostic `dependencies` report can print FAILED entries # while still returning exit code 0 and therefore is not a release gate. RUN test -n "${RELEASE_VERSION}" \ && test -n "${GIT_SHA}" \ && ./gradlew verifyDependencyLocks --no-daemon --quiet \ -PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}" # Copy full source and stage the executable JAR at Gradle's declared Docker output path. COPY . . RUN ./gradlew :app-bootstrap:stageDockerJar --no-daemon -x test \ -PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}" # ---- Layer extraction (D8) -------------------------------------------------- # Split the uber JAR into Spring Boot's four layers before it reaches the runtime stage. # # Without this the whole fat JAR — every dependency and the application classes in one file — is a # single image layer, so a release that changes one line of application code re-pushes and re-pulls # every dependency in the graph. The layers are ordered least- to most-frequently-changed # (dependencies, loader, snapshot dependencies, application), which is what makes the expensive # layer cacheable across releases. # # `extract` WITHOUT `--launcher` is the layout Spring Boot 4 documents: a thin application JAR whose # manifest Class-Path points at the extracted lib/ directory, rather than a nested-JAR uber JAR the # loader has to open and index on every start. It is also the AOT-cache/CDS-friendly layout, which # is the layout any later startup-time work would need. # # Absolute paths on both sides, and no WORKDIR change: DeveloperExperienceContractTest asserts that # every Dockerfile names the exact Gradle-staged artifact path rather than selecting a JAR, and the # input here is that same fixed path Gradle wrote. Nothing in this stage may pick a JAR by pattern. RUN java -Djarmode=tools \ -jar /build/src/app-bootstrap/build/docker/application.jar \ extract --layers --destination /build/src/app-bootstrap/build/docker/extracted # ---- Stage 2: runtime image ------------------------------------------------- # JRE-only slim image (D3: no full JDK in production image). # Uses eclipse-temurin:21-jre-jammy — the Adoptium-supported JRE variant. FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime ARG BUILD_VERSION ARG GIT_SHA ARG SOURCE_URL # OCI image labels (build-arg placeholders — supply at docker build time). LABEL org.opencontainers.image.title="caskeleton" \ org.opencontainers.image.source="${SOURCE_URL}" \ org.opencontainers.image.revision="${GIT_SHA}" \ org.opencontainers.image.version="${BUILD_VERSION}" # A release image without source/version metadata is not an artifact this contract permits. RUN test -n "${BUILD_VERSION}" && test -n "${GIT_SHA}" && test -n "${SOURCE_URL}" # ---- Locale / timezone (D4) ------------------------------------------------- # C.UTF-8 is available in eclipse-temurin:21-jre-jammy without installing extra packages. # Do NOT use en_US.UTF-8 — it requires the locales package and may not exist in a slim image. ENV TZ=UTC \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 # ---- Writable HOME under read-only root fs (D2) ----------------------------- # The app user is created with --no-create-home and the root filesystem is # read-only at runtime. Point $HOME at the writable /tmp tmpfs so libraries that # write under $HOME (e.g. java.util.prefs -> ~/.java/.userPrefs, some SDK caches) # do not fail with a read-only-filesystem error. ENV HOME=/tmp # ---- JVM ergonomics (D4) ---------------------------------------------------- # -XX:MaxRAMPercentage=75 — use up to 75% of the container memory limit for heap. # -XX:+UseContainerSupport — respect cgroup memory limits (default on JDK 10+, explicit here). # -XX:+ExitOnOutOfMemoryError — terminate immediately on OOM so the orchestrator can restart. # -XX:+HeapDumpOnOutOfMemoryError / -XX:HeapDumpPath — write a heap dump to the writable # /var/tmp/heap mount (see tmpfs mounts in compose files, D2). # -Dserver.tomcat.basedir=/tmp — redirect Tomcat temp files to /tmp (D2: read-only root fs). ENV JAVA_TOOL_OPTIONS="\ -XX:MaxRAMPercentage=75 \ -XX:+UseContainerSupport \ -XX:+ExitOnOutOfMemoryError \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/var/tmp/heap \ -Dserver.tomcat.basedir=/tmp" # ---- Filesystem layout (D2: read-only root filesystem) ---------------------- # /var/tmp/heap — heap dump landing zone; must be a writable mount at runtime. # /tmp — Tomcat working directory (see JAVA_TOOL_OPTIONS above). # Both directories are declared here so tooling is aware of them; at runtime they # MUST be mounted as tmpfs (or host volumes) by the orchestrator (see compose files). RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap # ---- Non-root user (D3) ----------------------------------------------------- RUN groupadd --system --gid 1000 app \ && useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app # ---- Fileserver storage root ------------------------------------------------ # Created in the image with the runtime user's ownership and 0750, so a fresh named volume # mounted here inherits both. Without it the Fileserver platform's default root does not exist # on a read-only root filesystem, and the capability fails on its first upload rather than at # startup. This directory is a mount point, not a place to keep data in the image: an unmounted # container writes into the container layer and loses everything on replacement. RUN mkdir -p /var/lib/backend/files \ && chown app:app /var/lib/backend/files \ && chmod 0750 /var/lib/backend/files VOLUME ["/var/lib/backend/files"] WORKDIR /app # ---- Application layers (D8) ------------------------------------------------ # One COPY per Spring Boot layer, ordered least- to most-frequently-changed. Each COPY is its own # image layer, so a release that only changes application code re-pushes and re-pulls the last one # instead of the whole dependency graph. This replaced a single `COPY application.jar app.jar`, # under which every release shipped every dependency again because they lived in the same file as # the code that changed. # # All four land in /app: the extracted application.jar is a thin JAR whose manifest Class-Path # points at ./lib, which is what the dependencies layer unpacks to. COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/dependencies/ ./ COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/spring-boot-loader/ ./ COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/snapshot-dependencies/ ./ COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/application/ ./ USER app # ---- Ports ------------------------------------------------------------------ # 8080 — application HTTP port # 9001 — management / actuator port, from management.server.port in config/observability.yml EXPOSE 8080 9001 # ---- Health check ----------------------------------------------------------- # Targets the actuator readiness probe on the management port (9001). # # The endpoint is real: config/observability.yml sets management.server.port to 9001 and # management.endpoint.health.probes.enabled to true, which is what publishes # /actuator/health/readiness. (This block used to carry a note saying the path might 404 because # the actuator work lived on an unmerged branch. It has been merged for some time, and a stale # warning about a healthcheck is the kind of comment that gets a real red container ignored.) # # A readiness failure here is a correct UNHEALTHY, not a broken probe: the readiness group includes # the datasource, so a container started with no reachable PostgreSQL is genuinely not ready. # Kubernetes ignores HEALTHCHECK and uses its own probes against the same path; this exists for # docker and Compose. # # wget is present in eclipse-temurin:21-jre-jammy, so nothing is installed for it. HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ CMD wget --no-verbose --tries=1 --spider \ http://localhost:9001/actuator/health/readiness || exit 1 # ---- Entrypoint ------------------------------------------------------------- # The extracted thin JAR, not the uber JAR the builder produced. It carries only application classes # and a Class-Path pointing at ./lib, so the loader does not open and index a nested-JAR archive on # every start, and the layout stays AOT-cache/CDS friendly for any later startup work. ENTRYPOINT ["java", "-jar", "application.jar"]