Skip to main content
  1. Languages/
  2. Java Guides/

Java on Kubernetes: Production-Grade Deployment Patterns & Best Practices (2025 Edition)

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

The days of debating whether to run stateful monolithic Java applications on bare metal or virtual machines are largely behind us. In 2025, Kubernetes (K8s) is the de facto operating system for the cloud, and Java—specifically with the advancements in JDK 21+ and Spring Boot 3—remains the dominant language for enterprise backends.

However, “lifting and shifting” a Java application into a container often leads to mediocre performance, wasted resources, or catastrophic failures under load. The JVM, historically designed for long-running processes on static hardware, requires specific tuning to thrive in the ephemeral, resource-constrained world of Kubernetes pods.

This deep-dive guide is written for mid-to-senior Java developers and architects. We will move beyond kubectl apply -f and explore the architectural patterns, JVM configurations, and operational best practices required to build resilient, high-performance Java systems on Kubernetes.

Prerequisites and Environment
#

To follow the practical examples in this guide, ensure you have the following environment set up. We are assuming a modern stack relevant to 2025 development standards.

  • JDK: Java 21 LTS (Temurin or Oracle builds).
  • Framework: Spring Boot 3.4+ (or Quarkus/Micronaut).
  • Container Runtime: Docker or Podman.
  • Kubernetes Cluster: Minikube (local), Kind, or a managed provider (EKS/GKE/AKS).
  • Build Tool: Maven 3.9+ or Gradle 8.5+.

1. The Containerization Strategy: Beyond the Basics
#

The journey begins with the artifact. A bloated image results in slow deployment rollouts, higher storage costs, and a larger security attack surface.

The Perfect Java Dockerfile
#

In production, you should leverage Multi-Stage Builds and Distroless images. This approach separates the build environment (Maven/Gradle, JDK, source code) from the runtime environment (JRE only, minimal OS libs).

Here is a highly optimized Dockerfile for a Spring Boot application:

# Stage 1: Build the application
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /app

# Copy maven wrapper and pom.xml first to leverage Docker cache
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
# Download dependencies (this layer will be cached unless pom.xml changes)
RUN ./mvnw dependency:go-offline

# Copy source and build
COPY src ./src
# Extract layers for Spring Boot (crucial for efficient updates)
RUN ./mvnw clean package -DskipTests && \
    java -Djarmode=layertools -jar target/*.jar extract

# Stage 2: Production Runtime
# distinct JRE image, specifically "Distroless" for security (optional but recommended)
# Or use a slim alpine/jammy JRE image
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app

# Create a non-root user (Security Best Practice)
RUN addgroup --system javauser && adduser --system --ingroup javauser javauser
USER javauser

# Copy extracted layers
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./

# JVM Flags are critical here
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -XX:+UseG1GC"

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

Why Layered Jars?
#

By extracting the JAR layers (dependencies, spring-boot-loader, application code), we ensure that when you change one line of business logic, Docker only needs to rebuild and push the final few megabytes (the application layer), reusing the heavy dependency layers from the cache.

2. JVM Configuration for Kubernetes
#

The most common error in Java K8s deployments is the mismatch between Kubernetes resource limits and JVM heap settings.

Understanding MaxRAMPercentage
#

Prior to Java 10, the JVM looked at the host’s total memory. If your Node had 64GB RAM but your container limit was 1GB, the JVM might try to allocate a huge heap, leading to the OOM Killer terminating the pod immediately.

In Java 21, UseContainerSupport is enabled by default. However, you must explicitly tell the JVM how much of the container’s available memory to use for the Heap.

  • Avoid: -Xmx (Hardcoded values make changing K8s YAML limits difficult).
  • Use: -XX:MaxRAMPercentage.

A setting of 75.0 allows the Heap to take 75% of the container’s RAM limit. The remaining 25% is reserved for:

  1. Metaspace: Class metadata.
  2. Code Cache: JIT compiled code.
  3. Thread Stacks: Memory per thread.
  4. Direct Buffers: NIO off-heap memory.
  5. GC Structures.

If you set this too high (e.g., 90%), your application will likely be OOMKilled by Kubernetes when Metaspace or Direct Memory grows.

Architecture Diagram: Resource Allocation
#

graph TD subgraph Node ["Kubernetes Node (16GB RAM)"] subgraph Pod ["Pod (Limit: 2GB)"] A["Container Overhead"] subgraph JVM ["JVM Process"] B["Heap Memory<br/>~1.5GB (75%)"] C["Non-Heap<br/>Metaspace, Code Cache, Stacks"] end end end style B fill:#f9f,stroke:#333,stroke-width:2px style C fill:#bbf,stroke:#333,stroke-width:2px style A fill:#ddd,stroke:#666

3. Kubernetes Manifest Patterns
#

Deploying Java requires handling its startup latency and graceful shutdown characteristics carefully.

The Deployment Manifest
#

Below is a production-grade deployment.yaml. Pay close attention to the Probes and Lifecycle Hooks.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: java-service
  labels:
    app: java-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: java-service
  template:
    metadata:
      labels:
        app: java-service
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
      containers:
        - name: java-app
          image: my-registry/java-service:v1.0.0
          ports:
            - containerPort: 8080
          
          # Resource Management (QoS: Burstable)
          resources:
            requests:
              memory: "512Mi"
              cpu: "500m"
            limits:
              memory: "1Gi"
              cpu: "1000m"
          
          # ENVIRONMENT VARIABLES via ConfigMap
          envFrom:
            - configMapRef:
                name: java-app-config
          
          # PROBES
          # 1. Startup Probe: Gives the JVM time to warm up.
          # K8s won't check liveness until this passes.
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            failureThreshold: 30
            periodSeconds: 5
          
          # 2. Liveness Probe: Restarts the pod if deadlocked/crashed.
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
            
          # 3. Readiness Probe: Removes pod from Service LB if overloaded.
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
            
          # LIFECYCLE HOOKS for Graceful Shutdown
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 10"]

The “Slow Start” Problem & Probes
#

Java applications, particularly those heavily using Spring, take time to initialize beans and perform database migrations.

  1. Startup Probe: This is the most critical probe for Java. Without it, if your app takes 45 seconds to start, a standard Liveness probe (checking every 10s) might restart the container before it ever finishes booting, causing a CrashLoopBackOff.
  2. Readiness Probe: Ensure your readiness probe checks downstream dependencies (DB, Redis) smartly. If the DB is down, you might want to fail readiness so traffic stops, but you don’t necessarily want to restart the pod (Liveness) because a restart won’t fix the DB.

4. Graceful Shutdown: Zero Downtime Deployments
#

When Kubernetes terminates a pod (scaling down or rolling update), it sends a SIGTERM signal. The JVM catches this and initiates the shutdown hook. However, the Kubernetes Service (Load Balancer) updates asynchronously.

If your app shuts down immediately upon receiving SIGTERM, clients might still be sending requests to that IP address for a few seconds, resulting in 502/503 errors.

The Sequence of Events
#

sequenceDiagram participant K8s as Kubernetes Control Plane participant LB as Service/Ingress participant App as Java App (Pod) K8s->>App: Sends SIGTERM par Parallel Actions K8s->>LB: Remove Endpoint IP App->>App: preStop Hook (sleep 10s) end Note right of App: App continues serving existing requests<br/>LB stops sending NEW requests App->>App: Spring Boot Graceful Shutdown triggers App->>App: Wait for active requests to complete App->>K8s: Process Terminates

Configuring Spring Boot
#

To support this, you must enable graceful shutdown in application.properties:

server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s

Combined with the preStop hook sleep 10 in the YAML, this ensures:

  1. K8s marks Pod as terminating.
  2. preStop hook sleeps. K8s removes the Pod IP from iptables/Service.
  3. Traffic drains naturally.
  4. Sleep ends. SIGTERM hits the JVM.
  5. Spring Boot stops accepting new connections but finishes processing in-flight requests.
  6. Application exits.

5. CPU Throttling and Limits: The Great Debate
#

One of the most controversial topics in Kubernetes is CPU Limits.

Requests vs. Limits
#

  • Requests: Guaranteed resources. Used for scheduling.
  • Limits: The hard ceiling. If exceeded for CPU, the container is throttled (paused).

For Java, CPU throttling is disastrous. It increases GC pause times and latency (p99) significantly because the GC threads themselves get throttled.

Feature Best Practice Reasoning
Memory Requests Equal to Limits Creates Guaranteed QoS class. Prevents eviction.
Memory Limits Required Prevents a memory leak from taking down the Node.
CPU Requests Accurate estimate Ensures the scheduler places the pod on a node with room.
CPU Limits Remove (or set very high) Java threads need bursts of CPU during GC and startup. Throttling kills latency.

Recommendation: In production, try to remove CPU limits. If you must use them (for billing or strict multi-tenancy), ensure they are at least 2 full cores (2000m) for any serious Java app to allow parallel GC threads to run efficiently.

6. Observability: Metrics and Logs
#

You cannot manage what you cannot see.

Structured Logging
#

Do not log plaintext multi-line stack traces. They break log aggregators (ELK, Splunk, Datadog). Use JSON logging.

Add the Logstash encoder dependency:

<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>

Configure logback-spring.xml to output JSON:

<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
    </appender>
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

Prometheus Metrics
#

Spring Boot Actuator + Micrometer is the standard.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

In application.properties:

management.endpoints.web.exposure.include=health,info,prometheus
management.endpoint.prometheus.enabled=true

You can then add a ServiceMonitor (if using Prometheus Operator) to scrape the /actuator/prometheus endpoint.

7. Performance Pitfalls and Solutions
#

The “OOMKilled” but Heap is Free
#

Scenario: Kubernetes kills your pod with OOM code 137, but your heap monitoring shows only 40% usage. Cause: Native memory usage. Often caused by high thread counts (each thread has a stack, usually 1MB), unclosed ZipFileSystems, or intense NIO usage (Netty Direct Buffers). Solution:

  1. Increase the gap between -XX:MaxRAMPercentage and the container limit.
  2. Use Native Memory Tracking (NMT). Add flag -XX:NativeMemoryTracking=summary and inspect via jcmd.

CPU Spikes on Startup
#

Scenario: HPA (Horizontal Pod Autoscaler) scales up unnecessarily during rolling updates because new pods spike CPU to 100%. Cause: JIT (Just-In-Time) compilation. Java works hard at startup to compile bytecode to native code. Solution:

  1. JDK 21+: Use Generational ZGC (-XX:+UseZGC -XX:+ZGenerational) for better latency, or standard G1GC.
  2. AppCDS (Class Data Sharing): Create a shared archive of classes during the build to skip parsing/verification at runtime.
  3. HPA Tuning: Use behavior in HPA to prevent “flapping” (scaling up and down too fast).
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70 # Allow higher utilization before scaling
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60

8. Conclusion
#

Running Java on Kubernetes in 2025 is a mature, powerful combination, provided you respect the underlying constraints of containerization.

Key Takeaways:

  1. Optimize Images: Use multi-stage builds and layer extraction.
  2. Trust the JVM: Use -XX:MaxRAMPercentage instead of hardcoded Xmx.
  3. Be Patient: Use Startup Probes to prevent premature kills.
  4. Be Graceful: Configure preStop hooks and Spring Boot graceful shutdown.
  5. Let it Breathe: Be cautious with CPU limits; avoid throttling your GC.

By implementing these patterns, you move from a fragile deployment to a robust, self-healing system capable of handling production loads with ease.

Further Reading
#


Did this article help you optimize your Java deployments? Subscribe to the Java DevPro newsletter for more deep dives into cloud-native Java architecture.