Kubernetes: Deploying Spring Boot with Deployments & Services

Kubernetes is the de facto platform for running containerised Java services in production. This article walks through the full deployment lifecycle: writing a Deployment manifest with sensible resource settings for the JVM, exposing it via a Service, setting up an Ingress for external traffic, configuring rolling updates for zero-downtime deploys, and adding a HorizontalPodAutoscaler to scale with load.

Share

Contents

Namespace & Labels

A Namespace is a virtual cluster within a Kubernetes cluster. It provides an isolated scope for resource names: two teams can each have a payment-service Deployment without conflicting, as long as they live in different namespaces. Namespaces are the primary way to organise resources by team, application, or environment (e.g., payments-dev, payments-prod). They also act as the boundary for RBAC permissions and resource quotas.

Labels are arbitrary key-value pairs attached to any Kubernetes object. They are not just metadata: Kubernetes uses them actively: a Service finds the pods it should route to by matching labels, an HPA targets a Deployment by label, and kubectl get pods -l app=payment-service filters by label. Consistent labelling across all your manifests is essential for manageability.

YAML
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: payments
  labels:
    team: platform
    env: production
Shell
# Create the namespace from the manifest
kubectl apply -f namespace.yaml

# Set it as the default namespace for your current kubectl context
# so you don't have to add -n payments to every command
kubectl config set-context --current --namespace=payments

Deployment Manifest

A Deployment is the standard Kubernetes resource for running a stateless application. You describe the desired state, which container image to run, how many replicas to keep alive, what environment variables and resource limits to apply, and Kubernetes continuously reconciles the actual cluster state to match it. If a pod crashes or a node goes down, the Deployment controller automatically schedules a replacement. The manifest below adds what a Java service needs in production: memory settings for the JVM, resource requests and limits, health probes, and rules that spread the pods across nodes.

YAML
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-service
  namespace: payments
  labels:
    app: payment-service
    version: "1.0.0"
spec:
  replicas: 3
  selector:
    matchLabels:
      app: payment-service
  template:
    metadata:
      labels:
        app: payment-service
        version: "1.0.0"
    spec:
      containers:
        - name: payment-service
          image: myorg/payment-service:1.0.0
          ports:
            - containerPort: 8080
              name: http
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: "production"
            # Heap = 75% of the memory limit; exit on OutOfMemoryError so Kubernetes restarts the pod
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
          resources:
            requests:
              cpu: "500m"
              memory: "1Gi"
            limits:
              memory: "1Gi"
          # Probes covered in the dedicated article
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 15
      # Prefer to put each pod on a different node, so one node failure takes out only one pod
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: payment-service
      # Time Spring Boot gets to finish in-flight requests after Kubernetes asks it to stop
      terminationGracePeriodSeconds: 60

Two details in this manifest are easy to get wrong:

  • One env list per container. YAML does not merge two env: keys; most tools keep only the last one, so variables in the first list silently disappear. Keep all variables in one list.
  • Graceful shutdown needs the application's help. terminationGracePeriodSeconds only gives the pod time. Spring Boot uses that time to finish in-flight requests when server.shutdown=graceful, which is the default from Spring Boot 3.4; set it yourself on older versions.

Resource Requests & Limits

Requests and limits decide two different things. Requests are what the scheduler reserves for the pod on a node. Limits are the most the pod may use. For a Java service they work best set like this:

SettingWhat happens at the limitRecommendation for Java
requests.memoryNothing; it is used for schedulingSet it equal to limits.memory, so the node really has the memory the pod may use
limits.memoryThe container is killed (OOMKilled)Heap plus room for the JVM's own memory (thread stacks, metaspace, buffers). With MaxRAMPercentage=75, the heap gets 75% and the JVM keeps 25%
requests.cpuNothing; it sets the pod's share of CPU when the node is busyThe CPU the service uses under normal load
limits.cpuThe container is slowed down (throttled), not killedOften best left unset. A tight CPU limit slows startup and garbage collection

Measure before you choose numbers. kubectl top shows current use, and kubectl describe shows whether a container was killed for using too much memory:

Shell
# Current CPU and memory use of each pod
kubectl top pods -n payments

# "Last State: Terminated, Reason: OOMKilled" means the memory limit was too low
kubectl describe pod payment-service-abc123 -n payments
Since Java 10, the JVM reads the container's memory limit by itself, so -XX:+UseContainerSupport is not needed. What it does not do is size the heap well: by default it uses only 25% of the limit. -XX:MaxRAMPercentage=75.0 raises that to 75%.

Service: ClusterIP & LoadBalancer

Pods are ephemeral: they are created and destroyed constantly, and each gets a different IP address every time. A Service sits in front of your pods and gives your application a stable, permanent DNS name and IP address that other services (or external clients) can rely on regardless of how many times pods have been restarted or rescheduled.

Kubernetes offers several Service types. The two most common are:

  • ClusterIP (the default): exposes the Service on an internal cluster IP. Reachable only from within the cluster. Use this for service-to-service communication.
  • LoadBalancer: provisions an external load balancer from the cloud provider (AWS ELB, GCP LB, Azure LB). Gives the Service a public IP. Use sparingly: each LoadBalancer costs money and consumes a public IP. Prefer a single Ingress instead (see the next section).
YAML
# service.yaml — ClusterIP for internal cluster traffic
apiVersion: v1
kind: Service
metadata:
  name: payment-service
  namespace: payments
spec:
  selector:
    app: payment-service
  ports:
    - name: http
      port: 80           # port exposed within the cluster
      targetPort: 8080   # container port
  type: ClusterIP        # internal only — use Ingress for external access
YAML
# LoadBalancer — for direct external access (cloud providers provision an ELB/NLB)
apiVersion: v1
kind: Service
metadata:
  name: payment-service-lb
  namespace: payments
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
spec:
  selector:
    app: payment-service
  ports:
    - port: 80
      targetPort: 8080
  type: LoadBalancer

Ingress

An Ingress is a Kubernetes resource that manages external HTTP and HTTPS traffic into your cluster. The key advantage over a LoadBalancer Service is that a single Ingress can route traffic to many backend services using host-based or path-based rules (api.example.com/payments goes to the payment service, api.example.com/orders goes to the order service) all through one external IP address, which is significantly cheaper and simpler to manage at scale.

An Ingress Controller (a separate pod running in the cluster, commonly nginx-ingress or Traefik) watches for Ingress resources and configures the actual reverse proxy. The example below uses cert-manager to automatically provision and renew a TLS certificate from Let's Encrypt.

The path matters. With the rule below, a request for /payments/42 reaches the application as /payments/42, so the application must serve its endpoints under /payments, for example with server.servlet.context-path=/payments.

YAML
# ingress.yaml — route external traffic via the NGINX Ingress Controller
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: payment-service
  namespace: payments
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - api.example.com
      secretName: api-tls-cert
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /payments
            pathType: Prefix
            backend:
              service:
                name: payment-service
                port:
                  number: 80
Many examples add nginx.ingress.kubernetes.io/rewrite-target: / to strip the prefix. On its own, that sends every request under /payments to /, so /payments/42 becomes /. To strip the prefix correctly you need a capture group in the path, /payments(/|$)(.*) with pathType: ImplementationSpecific, and rewrite-target: /$2.

Rolling Updates & Rollback

Kubernetes performs a rolling update by default when you change a Deployment's image or configuration. Rather than stopping all old pods and starting new ones simultaneously (which causes downtime), it gradually replaces them, starting new pods, waiting for them to pass the readiness probe, then terminating old ones. The result is a zero-downtime deployment.

Two settings control the pace: maxSurge. How many extra pods are allowed above the desired count during the update, and maxUnavailable, how many pods can be unavailable. Setting maxUnavailable: 0 means capacity never drops below the desired replica count, which is the safest choice for production. If anything goes wrong, a single kubectl rollout undo reverts to the previous working version within seconds.

YAML
# In the Deployment spec — zero-downtime rolling update strategy
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1        # allow 1 extra pod during update
      maxUnavailable: 0  # never reduce below desired replica count
Shell
# Deploy a new image version
kubectl set image deployment/payment-service \
  payment-service=myorg/payment-service:1.1.0 \
  -n payments

# Watch rollout progress
kubectl rollout status deployment/payment-service -n payments

# View rollout history
kubectl rollout history deployment/payment-service -n payments

# Rollback to previous version immediately
kubectl rollout undo deployment/payment-service -n payments

# Rollback to a specific revision
kubectl rollout undo deployment/payment-service --to-revision=2 -n payments

HorizontalPodAutoscaler

The HorizontalPodAutoscaler (HPA) watches your Deployment's resource utilisation and automatically adjusts the replica count to match demand. When traffic spikes and average CPU climbs above your threshold, the HPA adds pods to spread the load. When traffic drops, it removes pods to reduce cost. This removes the need to manually scale and ensures your application is always right-sized.

The behavior block is important for production: scaleUp.stabilizationWindowSeconds prevents the HPA from adding pods too eagerly in response to a momentary spike, and scaleDown.stabilizationWindowSeconds prevents it from removing pods too quickly after a burst, giving traffic time to settle before cutting capacity.

The HPA requires the Metrics Server to be installed in the cluster. On managed clusters (EKS, GKE, AKS) it is usually pre-installed. Run kubectl top pods to verify metrics are available before applying an HPA.

For a Java service, scale on CPU, not memory. The JVM keeps memory it has used for the heap, so memory use rarely falls after a burst; an HPA that scales on memory adds pods and then never removes them.

YAML
# hpa.yaml — scale on CPU
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: payment-service
  namespace: payments
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: payment-service
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70     # aim for 70% of requested CPU on average
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30   # wait 30s before scaling up again
      policies:
        - type: Pods
          value: 2
          periodSeconds: 60            # add max 2 pods per minute
    scaleDown:
      stabilizationWindowSeconds: 300  # wait 5min before scaling down

Essential kubectl Commands

kubectl is the command-line tool for interacting with a Kubernetes cluster. The commands below cover the most common day-to-day operations you'll use when deploying, inspecting, and debugging your Spring Boot application: from applying manifests and watching rollouts to tailing logs and opening a shell inside a running pod.

Shell
# Apply all manifests in a directory
kubectl apply -f k8s/ -n payments

# Get all resources in namespace
kubectl get all -n payments

# Describe pod for events and status
kubectl describe pod payment-service-abc123 -n payments

# View logs (follow)
kubectl logs -f deployment/payment-service -n payments

# Open a shell in a running pod (needs a shell in the image; distroless images have none)
kubectl exec -it payment-service-abc123 -n payments -- sh

# Port-forward for local testing (no Ingress needed)
kubectl port-forward svc/payment-service 8080:80 -n payments

# Scale manually
kubectl scale deployment/payment-service --replicas=5 -n payments

# Check HPA status
kubectl get hpa -n payments

# Force pod restart (rolling)
kubectl rollout restart deployment/payment-service -n payments