The full picture
FROM eclipse-temurin:21-jre-alpine AS base
# --- build stage extracts layers ---
FROM base AS build
COPY app.jar .
RUN java -Djarmode=tools -jar app.jar extract --layers --destination out
# --- runtime: layers ordered by change frequency ---
FROM base
RUN adduser -D app
USER app
COPY --from=build out/dependencies/ ./
COPY --from=build out/spring-boot-loader/ ./
COPY --from=build out/snapshot-dependencies/ ./
COPY --from=build out/application/ ./ # only this layer changes per build
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-XX:+ExitOnOutOfMemoryError", "-XX:MaxMetaspaceSize=192m", "org.springframework.boot.loader.launch.JarLauncher"]
# 1GiB limit budget (why 75%, not 90%):
# heap ~750m | metaspace ~150m | stacks (200 thr) ~200m? -> tune -Xss or use VTs
# + code cache ~100m + direct buffers + malloc overhead => headroom is survival- Buildpacks alternative (
bootBuildImage): reproducible, maintained base, memory-calculator included — the 'no Dockerfile expertise required' paved road; custom Dockerfiles when you need control. - `-XX:+ExitOnOutOfMemoryError`: in containers, a half-dead JVM after OOM is worse than a fast restart — die loudly, let the orchestrator replace you.
- OOMKilled (exit 137) vs OutOfMemoryError are different diagnoses: 137 = the container limit (native total exceeded — often direct buffers/threads, heap innocent); OOME = heap/metaspace. Metrics for both or you'll chase the wrong one.
- Startup extras: CDS (
spring.context.exit=onRefreshtraining run) or AOT cache cut cold-start ~40–60%; non-root user + read-only filesystem are table-stakes hardening.