DEV Community

Cover image for 5 Essential Kubernetes Techniques for Seamless Java Application Deployment and Management
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

5 Essential Kubernetes Techniques for Seamless Java Application Deployment and Management

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

When I first started deploying Java applications, the process was often manual and error-prone. We'd package everything into a massive WAR or JAR file, deploy it to a server, and hope nothing broke. Then Kubernetes came along, and it changed everything. Now, I can manage Java apps in a way that's automated, scalable, and resilient. In this article, I'll share five key techniques I've used to deploy and manage Java applications on Kubernetes, making the process smoother and more reliable.

Let's start with container image optimization. When you're working with Java, the size of your container images can significantly impact deployment speed and security. I remember early on, my images were bloated with build tools and unnecessary dependencies. This made startups slow and increased the risk of vulnerabilities. To fix this, I began using multi-stage Docker builds. This approach separates the build environment from the runtime environment. In the first stage, I use a full JDK image to compile the code and run tests. Then, in the second stage, I copy only the compiled JAR and necessary files into a slim JRE image. This cuts down the image size dramatically and speeds up container startup times.

Here's a practical example of a multi-stage Dockerfile for a Spring Boot application:

# Build stage
FROM maven:3.8.4-openjdk-17 AS build
WORKDIR /workspace/app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests
RUN java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted

# Runtime stage
FROM openjdk:17-jre-slim
RUN addgroup --system spring && adduser --system spring --ingroup spring
USER spring:spring
WORKDIR /app
COPY --from=build /workspace/app/target/extracted/dependencies/ ./
COPY --from=build /workspace/app/target/extracted/spring-boot-loader/ ./
COPY --from=build /workspace/app/target/extracted/snapshot-dependencies/ ./
COPY --from=build /workspace/app/target/extracted/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
Enter fullscreen mode Exit fullscreen mode

This Dockerfile uses Maven to build the application and then extracts the layers for better caching. The final image is based on openjdk:17-jre-slim, which is much smaller than the full JDK. By doing this, I've reduced image sizes by over 50% in some projects, leading to faster downloads and startups. It's a simple change that makes a big difference in production environments.

Another technique I rely on is deployment strategies for zero-downtime updates. In the past, deploying a new version of an application meant taking the service offline, which wasn't ideal for users. Kubernetes offers rolling updates, which replace pods gradually while ensuring the application remains available. I configure this in the deployment manifest to control how many pods are updated at a time. Health checks are crucial here; they tell Kubernetes when a pod is ready to handle traffic. Without them, you might send requests to pods that are still starting up, causing errors.

Here's a Kubernetes deployment YAML that implements a rolling update with health checks:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: inventory-service
  labels:
    app: inventory-service
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 2
      maxUnavailable: 1
  selector:
    matchLabels:
      app: inventory-service
  template:
    metadata:
      labels:
        app: inventory-service
    spec:
      containers:
      - name: inventory-app
        image: inventory-service:2.1.0
        ports:
        - containerPort: 8080
        readinessProbe:
          httpGet:
            path: /actuator/health
            port: 8080
          initialDelaySeconds: 45
          periodSeconds: 15
          failureThreshold: 3
        livenessProbe:
          httpGet:
            path: /actuator/health
            port: 8080
          initialDelaySeconds: 60
          periodSeconds: 20
          failureThreshold: 5
        env:
        - name: SPRING_PROFILES_ACTIVE
          value: "kubernetes"
Enter fullscreen mode Exit fullscreen mode

In this example, the rolling update strategy allows up to two extra pods during the update and ensures at least three pods are always available. The readiness probe checks if the app is ready to serve requests, and the liveness probe restarts the pod if it becomes unresponsive. I've used this in production to update applications without users noticing any interruption. It's like changing the tires on a moving car—smooth and seamless.

Configuration externalization is another game-changer. I used to hardcode configuration values into my Java apps, which meant rebuilding images for different environments. This was tedious and error-prone. Now, I use Kubernetes ConfigMaps and Secrets to manage configuration outside the application code. ConfigMaps hold non-sensitive data, like environment variables or configuration files, while Secrets handle sensitive information like passwords and API keys. This separation makes it easy to promote the same image from development to production without changes.

Here's how I define a ConfigMap and Secret for a Java application:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  application.properties: |
    server.port=8080
    spring.datasource.url=jdbc:postgresql://postgres-service:5432/mydb
    logging.level.com.example=INFO
    app.feature.flags=enabled
---
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
data:
  db-password: cGFzc3dvcmQxMjM=  # base64 encoded 'password123'
  api-key: YXBpLWtleS1zZWNyZXQ=  # base64 encoded 'api-key-secret'
Enter fullscreen mode Exit fullscreen mode

Then, I mount these into the deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-java-app
spec:
  template:
    spec:
      containers:
      - name: app
        image: my-java-app:latest
        volumeMounts:
        - name: config-volume
          mountPath: /app/config
        - name: secret-volume
          mountPath: /app/secrets
          readOnly: true
        env:
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: app-secrets
              key: db-password
      volumes:
      - name: config-volume
        configMap:
          name: app-config
      - name: secret-volume
        secret:
          secretName: app-secrets
Enter fullscreen mode Exit fullscreen mode

In my Java code, I can read these configurations using Spring Boot's @Value annotation or by loading properties files. For example:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class DatabaseConfig {

    @Value("${spring.datasource.url}")
    private String dbUrl;

    @Value("${db-password}")
    private String dbPassword;

    // Use these in your data source configuration
}
Enter fullscreen mode Exit fullscreen mode

This approach has saved me countless hours. I can update configurations on the fly without redeploying the entire application. It also improves security by keeping secrets out of the image and version control.

Service discovery is essential for microservices architectures. In a Kubernetes cluster, pods are ephemeral—they can come and go as scaling or failures occur. Hardcoding IP addresses in your Java apps isn't practical. Instead, I use Kubernetes Services to provide stable endpoints for pods. Services act as load balancers, routing traffic to healthy pods based on labels. For external access, I configure Ingress resources to handle routing from outside the cluster.

Here's a simple Service and Ingress setup:

apiVersion: v1
kind: Service
metadata:
  name: user-service
spec:
  selector:
    app: user-service
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: main-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
  - host: api.mycompany.com
    http:
      paths:
      - path: /users
        pathType: Prefix
        backend:
          service:
            name: user-service
            port:
              number: 80
Enter fullscreen mode Exit fullscreen mode

In my Java application, I can use service names directly for communication. For instance, if I have a order-service that needs to call the user-service, I can use the URL http://user-service:80. Kubernetes handles the DNS resolution internally. Here's a snippet from a Spring Boot RestTemplate call:

import org.springframework.web.client.RestTemplate;
import org.springframework.stereotype.Service;

@Service
public class UserServiceClient {

    private final RestTemplate restTemplate;

    public UserServiceClient(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public User getUserById(Long id) {
        String url = "http://user-service/users/" + id;
        return restTemplate.getForObject(url, User.class);
    }
}
Enter fullscreen mode Exit fullscreen mode

I remember when I first set this up; it felt like magic. No more worrying about IP changes or manual load balancer configurations. Services and Ingress make inter-service communication reliable and straightforward.

Resource management is the fifth technique I want to highlight. In a shared cluster, it's easy for one application to hog all the resources, causing others to suffer. By setting CPU and memory requests and limits, I ensure fair resource allocation. Requests are what the container is guaranteed to get, and limits are the maximum it can use. This prevents resource exhaustion and helps the scheduler place pods efficiently. Additionally, Horizontal Pod Autoscaling (HPA) automatically adjusts the number of pods based on CPU or memory usage.

Here's a deployment with resource limits and an HPA configuration:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-service
spec:
  template:
    spec:
      containers:
      - name: payment-app
        image: payment-service:1.0.0
        resources:
          requests:
            memory: "256Mi"
            cpu: "100m"
          limits:
            memory: "512Mi"
            cpu: "500m"
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: payment-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: payment-service
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 75
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80
Enter fullscreen mode Exit fullscreen mode

In this example, the payment-service requests 100 milliCPU units and 256Mi of memory, with limits set to 500m CPU and 512Mi memory. The HPA will scale the pods between 2 and 10 based on CPU and memory usage. I've seen this in action during traffic spikes; the system scales up automatically and scales down when load decreases, saving costs and maintaining performance.

To make this work well with Java, I often tune the JVM settings based on the container limits. For instance, I set the heap size to stay within the memory limits to avoid the JVM being killed by Kubernetes. Here's a sample Dockerfile entry that sets JVM options:

FROM openjdk:17-jre-slim
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC"
WORKDIR /app
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Enter fullscreen mode Exit fullscreen mode

And in the Kubernetes deployment, I might pass environment variables for further customization:

env:
- name: JAVA_TOOL_OPTIONS
  value: "-Xms256m -Xmx512m"
Enter fullscreen mode Exit fullscreen mode

This ensures the JVM respects the container's resource boundaries. I learned this the hard way when an app kept getting killed due to memory issues; now, I always set these options.

Putting it all together, these techniques have transformed how I deploy Java applications. They make the process more reliable, scalable, and easier to manage. I encourage you to start small—perhaps with image optimization or configuration externalization—and gradually incorporate the others. The investment in learning Kubernetes pays off in reduced operational overhead and happier users.

If you're new to this, don't be intimidated. The Kubernetes community is vast, and there are plenty of resources to help. I often refer to the official documentation and experiment in a local setup using tools like Minikube or Kind. Practice with sample applications, and soon, you'll be deploying Java apps on Kubernetes like a pro.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)