Spring Boot on Kubernetes: Images, JVM Settings and Probes
'We containerized it, deployed it to Kubernetes, and the pods keep restarting.' Running a Spring Boot application healthily on Kubernetes: small layered images, fast startup with CDS, JVM memory and CPU settings, correctly configured startup/readiness/liveness probes, graceful shutdown and configuration management, with an example Dockerfile and manifest.
"We containerized the application and deployed it to Kubernetes; the pods keep restarting, deployments throw errors for five minutes, we've raised the memory limit three times." That sentence is a symptom not of Kubernetes but of a Spring Boot application that wasn't prepared for it. When the JVM's resource model, the application's startup time and its health signals aren't tuned for the container world, the orchestrator makes wrong decisions with the best intentions: it kills the slow-starting pod, sends traffic to the pod that isn't ready, silently cuts the pod that exceeds its memory.
This article covers the five topics of running Spring Boot healthily on Kubernetes: a small layered image, fast startup with CDS, JVM memory and CPU settings, correctly configured startup/readiness/liveness probes and graceful shutdown, configuration and scaling. With an example Dockerfile and manifest.
1. The image: small, layered, non-root
A single-layer 300 MB "fat jar" image is re-uploaded in full on every release, even when one line of code changed. Spring Boot's layered-jar support puts dependencies (rarely changing) and application code (changing every release) into separate image layers; the registry and nodes pull only the changed layer.
# Stage 1: build
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY . .
RUN ./mvnw -q -DskipTests package
# Stage 2: extract layers and create the CDS archive
FROM eclipse-temurin:21-jre AS layers
WORKDIR /app
COPY --from=build /src/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
# CDS: the application is started once in "training" mode and a class archive is produced (startup 30–50% shorter)
RUN cd extracted && java -XX:ArchiveClassesAtExit=/app/app.jsa -Dspring.context.exit=onRefresh \
-cp "dependencies/*:spring-boot-loader/*:snapshot-dependencies/*:application/*" org.springframework.boot.loader.launch.JarLauncher || true
# Stage 3: runtime image
FROM eclipse-temurin:21-jre
RUN useradd -r -u 1001 app
WORKDIR /app
COPY --from=layers --chown=app /app/extracted/dependencies/ ./
COPY --from=layers --chown=app /app/extracted/spring-boot-loader/ ./
COPY --from=layers --chown=app /app/extracted/snapshot-dependencies/ ./
COPY --from=layers --chown=app /app/extracted/application/ ./
COPY --from=layers --chown=app /app/app.jsa ./
USER app
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:SharedArchiveFile=app.jsa -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp -Xlog:gc:stdout"
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]Alternative: mvn spring-boot:build-image with Buildpacks does the same layering and CDS (BP_JVM_CDS_ENABLED=true) without writing a Dockerfile. Either way, the image tag is a version number or commit hash; latest is banned in production.
2. The JVM: memory and CPU in the container model
The summary of the rule detailed in the GC selection article: the heap is at most 75% of the container limit; MaxRAMPercentage instead of a fixed -Xmx; room is left for metaspace, threads and native memory. On the Kubernetes side, requests = limits for memory (the JVM doesn't return memory, there is no fluctuation).
CPU is less understood and causes more trouble. The JVM reads the cgroup CPU quota as the processor count: limits.cpu: 500m → the JVM believes it's on a single core → JIT compilation and GC run single-threaded → startup is 3× slower, latency is high in the first minutes and CPU throttling triggers probe timeouts. Recommendation: set CPU requests (for scheduling), either omit limits or set them at 2–4× the request; alternatively tell the JVM the real parallelism with -XX:ActiveProcessorCount=2.
3. Probes: three questions, three answers
Probe | Its question | Spring Boot endpoint | What Kubernetes does on failure | What it should check |
|---|---|---|---|---|
startup | Has the application started? |
| Waits; restarts if the threshold is exceeded | Only "the process is up"; a high threshold for a slow JVM |
readiness | Can it take traffic? |
| Removes it from the Service (no restart) | Dependencies: DB pool, cache, required external services |
liveness | Is it stuck, should it be restarted? |
| Kills and restarts the container | Only the application's internal state; never the DB or an external service |
The most destructive mistake is a liveness probe that checks the database: the DB slows down for 30 seconds, every pod misses liveness, Kubernetes restarts all of them at once, a connection storm hits the DB — a small slowdown becomes a full outage. Liveness asks "is the process healthy", readiness asks "can it do work right now"; dependencies live in readiness.
apiVersion: apps/v1
kind: Deployment
metadata: { name: order-service }
spec:
replicas: 3
strategy:
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } # full capacity at all times
template:
spec:
terminationGracePeriodSeconds: 45 # longer than graceful shutdown + preStop
containers:
- name: app
image: registry.acme.com/order-service:2026.11.24-a1b2c3
ports: [{ containerPort: 8080 }]
resources:
requests: { cpu: "1", memory: "1536Mi" }
limits: { memory: "1536Mi" } # no CPU limit: don't starve JIT and GC
env:
- { name: SPRING_PROFILES_ACTIVE, value: prod }
- { name: SPRING_CONFIG_IMPORT, value: "optional:configtree:/etc/config/" }
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 5
failureThreshold: 36 # up to 3 minutes of startup tolerance
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
failureThreshold: 3
lifecycle:
preStop:
exec: { command: ["sh", "-c", "sleep 5"] } # let the endpoint removal propagate
volumeMounts:
- { name: config, mountPath: /etc/config, readOnly: true }
volumes:
- name: config
secret: { secretName: order-service-config }# application.yaml
server:
shutdown: graceful # default since Spring Boot 3.4; stating it explicitly doesn't hurt
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # wait for in-flight requests
management:
endpoint:
health:
probes:
enabled: true # enabled automatically on Kubernetes; explicit is documentation
group:
readiness:
include: readinessState, db, redis # what's needed to take traffic
liveness:
include: livenessState # only the application itselfThe graceful shutdown flow
When a pod is deleted, Kubernetes does two things at the same time: it sends SIGTERM and removes the pod from the Service. Propagating the endpoint list to all nodes takes a few seconds; requests arriving meanwhile land on the pod that's shutting down. The preStop sleep closes that window; then Spring Boot stops accepting new connections, finishes in-flight requests for up to 30 seconds, closes Kafka consumers and pools. terminationGracePeriodSeconds must exceed that total; if it's shorter, SIGKILL arrives and half-finished requests are lost.
4. Configuration and secrets
No environment-specific value lives in the image: the same image runs in test, staging and production. ConfigMaps and Secrets are mounted as files, and spring.config.import=configtree:/etc/config/ turns every file into a property; environment variables work too, but files are cleaner for long lists. For secrets, the External Secrets Operator or a Vault integration; a project that writes Secrets to Git in plain text isn't secure.
5. Scaling and availability
HPA: CPU percentage is misleading on the JVM (GC and JIT fluctuate); custom metrics such as request rate, queue depth or p95 latency are better signals.
PodDisruptionBudget:
minAvailable: 1(or 50%) so at least one replica stays up during node maintenance.Anti-affinity: Prevent three replicas from landing on the same node.
Virtual threads: Removing the per-thread stack cost gives more concurrency in the same memory;
spring.threads.virtual.enabled=trueis a visible gain in small pods.Logs: To stdout only, in JSON; file logs and log rotation don't exist in a container.
Common mistakes in the field
Liveness without a startup probe: The JVM starts in 40 seconds, liveness kills it at 30 → CrashLoopBackOff. The startup probe's tolerance must be long.
A DB check in liveness: The cascading restart described above.
Fixed
-Xmx= memory limit: OOMKilled, no heap dump.Low CPU limit: Slow startup, throttling, probe timeouts.
Short
terminationGracePeriodSeconds: A wave of 5xx on every deployment.Root user and the
latesttag: Security and reproducibility problems.
Frequently asked questions
GraalVM native image to cut startup even further?
Startup dropping from 40 seconds to 12–15 with CDS is enough for most enterprise services. Native image goes down to milliseconds but costs build time, reflection constraints and library compatibility; keep it for scale-to-zero and function scenarios.
One JVM per container, or big pods?
Small and many: horizontal scaling, fast rollback and fault isolation. Four pods of 1.5–2 GB instead of one 8 GB giant; the JVM's startup overhead stays reasonable with CDS.
Do sidecars (service mesh) affect the probes?
Yes: the mesh proxy must be ready before the application and shut down after it; otherwise the DB is unreachable at startup and requests drop at shutdown. Use the mesh's own ordering settings (such as holdApplicationUntilProxyStarts).
Conclusion
Healthy Spring Boot on Kubernetes: a layered, non-root image, short startup with CDS, a percentage-based heap and unthrottled CPU, probes that check dependencies in readiness and the application's own state in liveness, a long-tolerance startup probe, and zero-downtime deployments through preStop and graceful shutdown. These settings go into a template once, and every service is born from the same template.
To move your applications to Kubernetes or improve the stability of your existing deployment, see our maintenance and support and enterprise application development services.