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.
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-servicepayments-devpayments-prod
Labels are arbitrary key-value pairs attached to any Kubernetes object. They are not just metadata: Kubernetes uses them actively: a ServiceHPADeploymentkubectl get pods -l app=payment-service
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
team: platform
env: production
# 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.
# 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
list per container. YAML does not merge twoenv keys; most tools keep only the last one, so variables in the first list silently disappear. Keep all variables in one list.env: - Graceful shutdown needs the application's help.
only gives the pod time. Spring Boot uses that time to finish in-flight requests whenterminationGracePeriodSeconds , which is the default from Spring Boot 3.4; set it yourself on older versions.server.shutdown=graceful
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:
| Setting | What happens at the limit | Recommendation for Java |
|---|---|---|
requests.memory | Nothing; it is used for scheduling | Set it equal to limits.memory |
limits.memory | The container is killed (OOMKilled | Heap plus room for the JVM's own memory (thread stacks, metaspace, buffers). With MaxRAMPercentage=75 |
requests.cpu | Nothing; it sets the pod's share of CPU when the node is busy | The CPU the service uses under normal load |
limits.cpu | The container is slowed down (throttled), not killed | Often best left unset. A tight CPU limit slows startup and garbage collection |
Measure before you choose numbers. kubectl topkubectl describe
# 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
-XX:+UseContainerSupport-XX:MaxRAMPercentage=75.0Service: 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).
# 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
# 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/paymentsapi.example.com/orders
An Ingress Controller (a separate pod running in the cluster, commonly nginx-ingressTraefikcert-manager
The path matters. With the rule below, a request for /payments/42/payments/42/paymentsserver.servlet.context-path=/payments
# 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
nginx.ingress.kubernetes.io/rewrite-target: //payments//payments/42//payments(/|$)(.*)pathType: ImplementationSpecificrewrite-target: /$2Rolling 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: maxSurgemaxUnavailablemaxUnavailable: 0kubectl rollout undo
# 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
# 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 behaviorscaleUp.stabilizationWindowSecondsscaleDown.stabilizationWindowSeconds
kubectl top podsFor 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.
# 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
# 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