Docker
Docker packages Java applications as immutable images — filesystem snapshots with JRE, jar, and config — run as isolated containers on any host.
Introduction
Docker packages Java applications as immutable images — filesystem snapshots with JRE, jar, and config — run as isolated containers on any host. Multi-stage builds compile with JDK in one stage and ship slim JRE-only runtime in another, cutting image size from 800MB to 150MB.
JVM in containers requires explicit tuning: -XX:MaxRAMPercentage, container-aware defaults since Java 10+, and awareness that Docker memory limits ≠ host memory. This lesson is how staff engineers ship Temurin 21 Spring Boot services that start fast, fit CI/CD, and behave predictably under cgroup limits.
Business problem
"Works on my machine" without containers:
- Environment drift: Dev runs Java 17, prod runs Java 11 — subtle bytecode/runtime bugs.
- Deploy friction: SSH + manual jar copy — 45-minute deploy, no rollback artifact.
- Resource conflict: Two services on same VM fight for heap — OOM kills wrong process.
- Slow CI: 900MB fat JDK image pulled every build — pipeline queue grows.
- Security surface: Full JDK in production — javac, javadoc attack surface unnecessary.
Why this topic exists
Docker standardizes the unit of deployment — image digest is the contract:
- Image: Immutable layers — base OS + JRE + application jar + config.
- Container: Running instance of image — isolated PID, network, filesystem (copy-on-write).
- Multi-stage build: Stage 1 compile with JDK; stage 2 copy only jar + JRE — smaller, safer.
- JVM container flags: -XX:+UseContainerSupport (default Java 10+) — heap respects Docker memory limit.
- Orchestration handoff: Same image runs locally, CI, Kubernetes — parity guaranteed.
Core concepts
Docker + JVM core concepts:
- Dockerfile: Declarative image recipe — FROM, COPY, RUN, ENTRYPOINT, EXPOSE.
- Layer caching: Order Dockerfile for cache hits — dependency copy before source copy in builds.
- Distroless/JRE-alpine: Minimal base — eclipse-temurin:21-jre-alpine vs full ubuntu.
- ENTRYPOINT exec form:
["java", "-jar", "/app.jar"]— PID 1 receives SIGTERM for graceful shutdown. - MaxRAMPercentage:
-XX:MaxRAMPercentage=75.0— heap = 75% of container memory limit. - Spring Boot layertools: Extract layers (dependencies, snapshot, application) — Docker layer cache for deps.
Internal architecture
Multi-stage Java Docker build:
# Stage 1 — build (discarded from final image)FROM eclipse-temurin:21-jdk-alpine AS builderWORKDIR /appCOPY gradlew settings.gradle.kts build.gradle.kts ./COPY gradle ./gradleRUN ./gradlew dependencies --no-daemon # cached layerCOPY src ./srcRUN ./gradlew bootJar --no-daemon -x test# Stage 2 — runtime (what ships to prod)FROM eclipse-temurin:21-jre-alpineRUN addgroup -S app && adduser -S app -G appWORKDIR /appCOPY --from=builder /app/build/libs/*.jar app.jarUSER appEXPOSE 8080ENTRYPOINT ["java", \"-XX:+UseContainerSupport", \"-XX:MaxRAMPercentage=75.0", \"-XX:+UseZGC", \"-jar", "/app.jar"]# Image size: ~180MB (JRE-alpine + jar) vs ~900MB (JDK + build tools)
Image layers, container runtime, and JVM memory in cgroups:
Code walkthrough
Production Dockerfile with Spring Boot layertools:
- layertools extract: Splits jar into cache-friendly Docker layers — dependencies change rarely.
- JarLauncher: Spring Boot loader — same as java -jar but from extracted dirs.
- memory: 512M: Docker limit — JVM sets max heap ~384MB with MaxRAMPercentage=75.
- service_healthy: Wait for Postgres ready — avoid crash-loop on startup race.
# Dockerfile with layertools (Spring Boot 2.3+)FROM eclipse-temurin:21-jdk-alpine AS builderWORKDIR /buildCOPY build/libs/application.jar app.jarRUN java -Djarmode=layertools -jar app.jar extractFROM eclipse-temurin:21-jre-alpineWORKDIR /appCOPY --from=builder /build/dependencies/ ./COPY --from=builder /build/spring-boot-loader/ ./COPY --from=builder /build/snapshot-dependencies/ ./COPY --from=builder /build/application/ ./ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseZGC"ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]# docker-compose.yml — local stackservices:payment-api:build: .ports: ["8080:8080"]environment:SPRING_PROFILES_ACTIVE: dockerSPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/paymentsdeploy:resources:limits:memory: 512Mdepends_on:db:condition: service_healthy
Production example
JVM flags for containerized Java 21 services:
- ExitOnOutOfMemoryError: Kill container on OOM — K8s restarts fresh instance, alerts fire.
- InitialRAMPercentage: Faster startup — don't wait to grow heap to max.
- .dockerignore: Exclude .git and build cache — send only jar to daemon.
- HEALTHCHECK: Docker-native health; K8s uses separate probes but useful in compose.
# Recommended production ENTRYPOINTENTRYPOINT ["java", \"-XX:+UseContainerSupport", \"-XX:MaxRAMPercentage=75.0", \"-XX:InitialRAMPercentage=50.0", \"-XX:+UseZGC", \"-XX:+ExitOnOutOfMemoryError", \"-Djava.security.egd=file:/dev/./urandom", \"-jar", "/app.jar"]# .dockerignore — faster builds, smaller context.git.gradlebuild!build/libs/*.jar*.mdtarget# Health check in DockerfileHEALTHCHECK --interval=30s --timeout=3s --start-period=60s \CMD wget -qO- http://localhost:8080/actuator/health || exit 1# Build and scandocker build -t payment-api:1.2.3 .docker scout cves payment-api:1.2.3 # or trivy image payment-api:1.2.3
Enterprise case study
Spotify — Docker adoption: Spotify containerized Java services early — immutable artifacts from CI, identical local and prod. Key lesson: never run as root in container — add non-root USER; K8s PodSecurityStandards enforce this. Second lesson: pin base image digest, not floating :latest tag — reproducible builds and CVE tracking.
- Before: Pet servers, snowflake configs, manual JVM tuning per host.
- After: Image digest promoted through dev → staging → prod; rollback = previous digest.
- JVM win: Container-aware heap — eliminated OOM from JVM assuming host RAM (32GB heap in 512MB container).
- CI win: Layertools — dependency layer cache hit 90% of builds.
Performance considerations
Java container performance:
- Image pull time: Slim JRE-alpine — faster K8s pod schedule on new nodes.
- Startup time: CDS (Class Data Sharing) —
-XX:SharedArchiveFile=app.jsafor faster boot. - CPU limits: JVM sees cgroup CPU quota — GC threads adjust; don't set CPU limit too tight on latency services.
- ZGC in containers: Low pause GC pairs well with tight memory limits and K8s liveness probes.
- Build cache: Multi-stage + layertools — CI pipeline under 2 minutes for incremental builds.
Security considerations
Container security for Java:
- Non-root USER: Run as app user — container escape impact reduced.
- No secrets in image: DB passwords via env/Secrets at runtime — never COPY .env into image.
- Minimal base: JRE-alpine or distroless — no shell in prod image if possible.
- Scan images: Trivy/Scout in CI — fail build on critical CVE in base image.
- Read-only root FS: K8s securityContext.readOnlyRootFilesystem — tmp volume for logs if needed.
Scalability considerations
Docker in scaled deployments:
- Immutable scale: Same image, N containers — horizontal scale unit.
- Registry: ECR/GCR/Artifactory — geo-replicated for fast pulls globally.
- Build once, deploy many: CI builds image once; promote digest — no rebuild per environment.
- Resource requests: Set memory limit = JVM MaxRAMPercentage headroom + metaspace + native + 25% buffer.
Production challenges
Real Docker + Java pain points:
- OOMKilled: Container memory limit too low for heap + metaspace + direct buffers — increase limit or lower MaxRAMPercentage.
- Slow startup: Spring context + fat jar — layertools, CDS, reduce classpath scanning.
- PID 1 signal handling: Shell form ENTRYPOINT ignores SIGTERM — use exec form for graceful shutdown.
- Clock/timezone: Container UTC default — set TZ env or -Duser.timezone for logging consistency.
- File permissions: Volume mounts as root — app user cannot write — initContainer or fsGroup fix.
Common mistakes
- Running JDK in production image — bloated and unnecessary attack surface.
- No MaxRAMPercentage — JVM allocates heap based on host RAM, container OOMKilled.
- Shell ENTRYPOINT without exec — SIGTERM doesn't reach Java process — ungraceful kill.
- COPY entire project before dependency resolve — Docker cache bust every code change.
- Floating latest tag in prod — untraceable deploys, surprise breaking base image updates.
Debugging guide
Debug containerized Java:
- Container OOM:
dmesg | grep oomor K8s OOMKilled reason — tune memory limit and MaxRAMPercentage. - Exec into container:
docker exec -it payment-api sh— check env, curl localhost actuator. - JVM flags verify:
docker run ... java -XX:+PrintFlagsFinal | grep RAM. - Layer analysis:
dive payment-api:1.2.3— find bloated layers.
# Inspect container memory limit vs JVM heapdocker stats payment-api# Logsdocker logs -f --tail 100 payment-api# Run with shell override for debug (dev only)docker run -it --entrypoint sh payment-api:1.2.3# Check image size and layersdocker history payment-api:1.2.3 --human
Best practices
- Use multi-stage builds — JDK build, JRE runtime only.
- Set -XX:MaxRAMPercentage=75 — heap fits container memory limit.
- ENTRYPOINT exec form — proper SIGTERM for graceful Spring shutdown.
- Run as non-root USER — security baseline.
- Use Spring Boot layertools — cache dependency layers in CI.
- Pin base image digest — reproducible builds.
- Add .dockerignore — fast builds, lean context.
Anti-patterns
- Single-stage fat image with Maven/Gradle inside production container.
- Hardcoding config in Dockerfile — use env vars and external config.
- Running docker build on production server — CI builds, prod pulls digest.
- Ignoring HEALTHCHECK and actuator — deploy blind to broken startup.
- java -Xmx512m with container limit 512M — no room for metaspace/native — OOMKilled.
Staff engineer notes
- Staff engineers treat Dockerfile as production code — reviewed in PR, versioned, tested in CI.
- Container memory limit must include heap + non-heap + direct memory + 20% headroom — not equal to -Xmx.
- Distroless is ideal for security; alpine JRE acceptable when you need shell for debugging in staging.
- Graceful shutdown: Spring handles SIGTERM if PID 1 is java — set terminationGracePeriodSeconds ≥ 30 in K8s.
- Image promotion by digest, not tag — tag :1.2.3 points to sha256:abc... for audit trail.
Interview questions
Interview preparation
15 questions grouped by difficulty — expand any card for a model answer and follow-up probe.
Beginner
1What is a Docker image vs container?
BeginnerModel answer
Image: immutable read-only template with layers (OS, JRE, app).
Container: running instance with writable layer on top.
Many containers from one image.
Image built by Dockerfile; container started by docker run.
Follow-up probe
Image layer caching?
2What is a multi-stage Docker build?
BeginnerModel answer
Multiple FROM stages in one Dockerfile.
Early stage compiles with JDK; final stage copies only artifact to slim JRE base.
Intermediate stages discarded.
Smaller, more secure production image.
Follow-up probe
COPY --from?
3How JVM behaves in Docker containers?
BeginnerModel answer
- Java 10+ UseContainerSupport detects cgroup memory/CPU limits. MaxRAMPercentage sets max heap as percentage of container memory
- not host RAM. Prevents OOM from JVM over-allocating inside limited container.
Follow-up probe
Default before Java 10?
4Why use JRE not JDK in production image?
BeginnerModel answer
- JRE runs application only
- smaller image, faster pull, reduced attack surface. JDK includes compiler, javadoc, tools not needed at runtime. Multi-stage build compiles with JDK, ships JRE.
Follow-up probe
jlink custom runtime?
5Explain ENTRYPOINT exec vs shell form.
BeginnerModel answer
- Exec: ["java", "-jar", "app.jar"]
- java is PID 1, receives SIGTERM. Shell: sh -c java ...
- shell is PID 1, may not forward signals
- graceful shutdown breaks. Always exec form for Java.
Follow-up probe
SIGTERM Spring Boot?
Intermediate
6What is -XX:MaxRAMPercentage?
IntermediateModel answer
Sets maximum heap as percentage of container memory limit detected via cgroups.
g.
75% of 512MB limit ≈ 384MB max heap.
Leaves room for metaspace, thread stacks, direct buffers, native memory.
Follow-up probe
vs -Xmx?
7Spring Boot layertools in Docker?
IntermediateModel answer
- java -Djarmode=layertools -jar app.jar extract splits jar into dependencies, spring-boot-loader, snapshot-dependencies, application directories. COPY each as separate Docker layer
- dependency layer cached when only app code changes.
Follow-up probe
Boot 3 alternative?
8Why container gets OOMKilled despite -Xmx?
IntermediateModel answer
Container limit must cover heap + metaspace + code cache + direct memory + native + JVM overhead.
-Xmx=512m in 512m container leaves no headroom.
5× intended heap.
Follow-up probe
Native memory leak?
9Docker compose vs Kubernetes?
IntermediateModel answer
- Compose: multi-container local dev, single host, simple networking. Kubernetes: production orchestration
- scheduling, scaling, service discovery, self-healing across cluster. Compose for dev; K8s for prod at scale.
Follow-up probe
Same image both?
10How secure Java Docker images?
IntermediateModel answer
- Non-root user, minimal base (alpine/distroless), no secrets in layers, scan CVEs in CI, pin digest, read-only root FS, no SSH in container. Distroless removes shell
- reduces attack surface.
Follow-up probe
Secret in env?
Advanced
11Design CI/CD for Spring Boot Docker.
AdvancedModel answer
Gradle bootJar → multi-stage Dockerfile with layertools → build in CI → scan Trivy → push to ECR with semver+digest → deploy to K8s by digest.
Cache dependency layer.
Integration test with testcontainers.
Rollback previous digest.
Follow-up probe
Build cache in GitHub Actions?
12Optimize Java container startup time.
AdvancedModel answer
Spring lazy-init where safe, CDS archive, slim classpath (exclude unused auto-config), layertools, adequate CPU for JIT during warmup, readiness probe startPeriod 60s.
Consider GraalVM native-image for cold start critical (trade JIT peak throughput).
Follow-up probe
CDS how?
13Docker networking for microservices?
AdvancedModel answer
Compose: service name DNS (payment-api:8080).
Bridge network isolates stack.
K8s: ClusterIP Service DNS.
Don't use host networking unless performance critical.
Expose only through gateway/ingress.
Follow-up probe
Container-to-container?
14When NOT to containerize?
AdvancedModel answer
Legacy app with hard host dependencies, license tied to hardware, extreme low-latency bare metal trading (sometimes).
Most Java enterprise services benefit.
Windows containers niche.
Follow-up probe
VM vs container?
15JVM flags checklist for K8s Java pod?
AdvancedModel answer
UseContainerSupport (default), MaxRAMPercentage=75, InitialRAMPercentage=50, UseZGC or G1, ExitOnOutOfMemoryError, ActiveProcessorCount if CPU limit set.
Match memory request/limit.
Graceful shutdown on SIGTERM.
Actuator health for probes.
Follow-up probe
CPU limit affects GC?
Hands-on exercise
Lab: Docker for Java:
- Run playground — review simulated JVM memory calculation.
- Write minimal 2-stage Dockerfile pseudocode in comments.
- Calculate: 512MB limit, MaxRAMPercentage=75 → max heap?
- List ENTRYPOINT exec form for Spring Boot jar.
- Bonus: write .dockerignore entries for Gradle project.
JavaDocker for Java Services
Press Run to execute. Real javac + JVM via remote API. Falls back to simulator on network failure.Architecture trade-offs
- Alpine vs Debian JRE: Alpine wins size; Debian wins glibc compatibility (some JNI libs).
- MaxRAMPercentage vs fixed -Xmx: Percentage wins portable across envs; fixed -Xmx wins explicit tuning.
- Layertools vs fat jar COPY: Layertools wins CI cache; fat jar wins simplicity.
- Distroless vs alpine: Distroless wins security; alpine wins debug shell access.
Summary
Docker is how Java services ship — multi-stage builds for slim images, container-aware JVM flags for correct heap, and layertools for fast CI. Master images vs containers and JVM cgroup behavior before Kubernetes scheduling adds another layer. Next: deploy these images as Pods with Deployments and Services.
Key takeaways
- Multi-stage build: compile with JDK, ship JRE-only runtime image.
- MaxRAMPercentage — JVM heap must fit inside Docker memory limit.
- ENTRYPOINT exec form — Java as PID 1 for graceful SIGTERM shutdown.
- Spring layertools — cache dependency layers in CI.
- Non-root user, scan images, pin digest — production hygiene.