Kubernetes for Beginners: Hands-On Guide With Spring Boot

A hands-on Kubernetes guide for Java developers: deploy a Spring Boot API and PostgreSQL to a local kind cluster and learn pods, Deployments, Services, Ingress, ConfigMaps, Secrets, volumes and StatefulSets with real kubectl output.

Kubernetes is a system that runs our containers on a group of machines and keeps them running in the state we describe in YAML files. We tell it “run 3 copies of the greeting-api image and give them one address”, and Kubernetes starts the containers, replaces the ones that crash, spreads traffic across them and swaps them for a new version without downtime.

We use Kubernetes when an app runs as several containers on more than one server, for example a REST API that must stay up while its database restarts or a new version ships.

The following example lists the kubectl commands that create a local cluster, deploy the API, and scale, heal, restart and update it.

kind create cluster --name greeting --config kind/kind-config.yaml   # 3 nodes: 1 control plane, 2 workers
kubectl apply -f k8s/                                    # creates the ConfigMap, Secret, StatefulSet, Deployment, Services, Ingress
kubectl get pods                                         # greeting-api-58d4bcbbc5-pr22t   1/1   Running
kubectl scale deployment greeting-api --replicas=3       # 3 API pods
kubectl delete pod greeting-api-7844d44c9d-n2rqp         # a new pod greeting-api-7844d44c9d-xjqzf replaces it
kubectl rollout restart deployment greeting-api          # pods restart and read the changed ConfigMap
kubectl set image deployment/greeting-api greeting-api=greeting-api:1.1   # rolling update to 1.1.0
kubectl rollout undo deployment greeting-api             # back to 1.0.0

Notice that we never start a container ourselves. Each command changes the desired state, and Kubernetes creates or replaces the pods to match it.

This Kubernetes for beginners guide deploys a small Spring Boot API with a PostgreSQL database to a local cluster and shows each Kubernetes building block with its YAML, the kubectl command that applies it, and the real output for nodes, pods, Deployment, Service, Ingress, ConfigMap, Secret, volumes and StatefulSet.

1. What Is Kubernetes?

A container is our application packaged with everything it needs to run, such as a Spring Boot jar plus a Java runtime in one image. Docker runs a container on one machine. That is enough on a laptop, but in production a single container leaves several problems to us.

ProblemWith docker run on one serverWith Kubernetes
The container crashesIt stays down until someone restarts itKubernetes restarts it or starts a new one
The server diesThe application is downPods are started on another node
More users arriveWe start more containers by handkubectl scale or an autoscaler adds pods
A new version shipsStop old, start new, users see errorsA rolling update replaces pods one by one
Settings and passwordsBaked into the image or the serverConfigMap and Secret objects, injected at start
Database filesLost when the container is removedA PersistentVolumeClaim keeps them

Kubernetes works with desired state, which means we describe what should exist, and its controllers keep changing the cluster until it matches the description. We never say “start a container on server 2”. We say “there should be 3 pods with this label”, and Kubernetes decides where and how.

1.1. Kubernetes Architecture

A Kubernetes cluster is a group of machines called nodes. The control plane stores the desired state and decides what runs where. The worker nodes run our containers, which Kubernetes groups in pods.

Kubernetes cluster architecture: kubectl on the laptop talks to the control plane, which holds kube-apiserver, etcd, kube-scheduler and the controllers. Two worker nodes each run a kubelet and kube-proxy. greeting-worker runs one greeting-api pod and the postgres-0 pod with its PVC data-postgres-0; greeting-worker2 runs two greeting-api pods. A Service greeting-api with ClusterIP 10.96.215.54 sits in front of the three greeting-api pods
kubectl sends requests only to the API server. The control plane decides, the kubelet on each node starts the pods, and a Service gives the pods one stable address.

Each part has one job.

  • kube-apiserver is the REST API of the cluster. kubectl and every other component talk only to it.
  • The etcd store is a key-value database that stores every object we create.
  • The kube-scheduler component picks a node for each new pod, based on free CPU and memory.
  • The controllers (inside kube-controller-manager) compare desired and actual state, for example “3 replicas wanted, 2 running, so create one”.
  • The kubelet is an agent on every node that starts and watches the containers of its pods.
  • The kube-proxy component programs the network rules on each node so that a Service address reaches the pods.

1.2. Kubernetes Components at a Glance

A pod is the smallest unit Kubernetes runs. It consists of one or more containers that share an IP address and volumes. We rarely create pods by hand. We create higher-level objects, and they create the pods for us.

ComponentWhat it doesYAML kindkubectl command
NodeA machine (VM or server) that runs podsNode (created by the cluster)kubectl get nodes
PodOne running instance of our containerPodkubectl get pods, kubectl logs, kubectl describe pod
DeploymentKeeps N identical, stateless pods running and updates themDeploymentkubectl scale, kubectl set image, kubectl rollout
StatefulSetPods with fixed names and their own disk, for databasesStatefulSetkubectl get statefulset
ServiceOne stable IP and DNS name for a set of podsServicekubectl get service, kubectl port-forward
IngressHTTP routing from outside the cluster to ServicesIngresskubectl get ingress
ConfigMapPlain settings passed to podsConfigMapkubectl get configmap -o yaml
SecretPasswords and keys passed to podsSecretkubectl get secret
Volume claimA request for disk space that outlives the podPersistentVolumeClaimkubectl get pvc

Every object has the same four top-level fields in YAML, namely apiVersion, kind, metadata (name, labels) and spec (the desired state). The command kubectl apply -f file.yaml sends the file to the API server.

2. Deploying a Spring Boot API to Kubernetes

Our example is greeting-api, a Spring Boot 4.1.1 REST API on Java 25. GET /api/greeting returns a message from a ConfigMap, rejects calls without the API key from a Secret, and counts visits in PostgreSQL 18.6, which runs as a StatefulSet with its own disk.

The complete project contains the app, the Dockerfile, the manifests in k8s/ and 3 JUnit tests that check the API against PostgreSQL with Testcontainers. The cluster ran on kind 0.33.0 with Kubernetes 1.37.0 and kubectl 1.37.1.

2.1. Creating a Local Cluster With kind

kind (Kubernetes in Docker) runs each Kubernetes node as a Docker container, so a full multi-node cluster runs on a laptop. Besides Docker, we need two command-line tools, kubectl (the Kubernetes client) and kind.

curl -LO https://dl.k8s.io/release/v1.37.1/bin/linux/amd64/kubectl
chmod +x kubectl && sudo mv kubectl /usr/local/bin/
go install sigs.k8s.io/kind@v0.33.0      # or download the binary from the kind release page

kubectl version --client                 # Client Version: v1.37.1
kind version                             # kind v0.33.0 go1.24.7 linux/amd64

On macOS and Windows, brew install kubectl kind and choco install kubernetes-cli kind install the same tools. Our cluster has one control plane and two workers, and the extra port mapping sends port 8080 on our machine to port 80 of the control-plane node, where the ingress controller will listen in section 2.8.

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    kubeadmConfigPatches:
      - |
        kind: InitConfiguration
        nodeRegistration:
          kubeletExtraArgs:
            node-labels: "ingress-ready=true"
    extraPortMappings:
      - containerPort: 80
        hostPort: 8080
        protocol: TCP
  - role: worker
  - role: worker
kind create cluster --name greeting --config kind/kind-config.yaml
kubectl get nodes
Creating cluster "greeting" ...
...
Set kubectl context to "kind-greeting"

NAME                     STATUS   ROLES           AGE   VERSION
greeting-control-plane   Ready    control-plane   32m   v1.37.0
greeting-worker          Ready    <none>          32m   v1.37.0
greeting-worker2         Ready    <none>          32m   v1.37.0

Kubernetes 1.35 and later refuse to start on Linux hosts that still use cgroup v1. Docker Desktop and current Linux distributions use cgroup v2. On an older host, use kind/kind-config-cgroupv1.yaml from the project, which sets failCgroupV1: false.

2.2. Building the Spring Boot Image

The application code knows nothing about Kubernetes. It reads two properties and stores one row per visit.

@ConfigurationProperties(prefix = "greeting")
public record GreetingProperties(String message, String apiKey) { }

@GetMapping("/api/greeting")
public ResponseEntity<?> greeting(@RequestHeader(name = "X-API-Key", required = false) String apiKey) {
  if (apiKey == null || !apiKey.equals(properties.apiKey())) {
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "missing or wrong X-API-Key"));
  }
  long total = visits.recordVisit(pod);    // insert into visit, then count(*)
  return ResponseEntity.ok(new GreetingResponse(properties.message(), total, pod, version));
}

The field pod holds the host name, which Kubernetes sets to the pod name, so every response tells us which pod answered. Spring Boot’s relaxed binding maps the environment variable GREETING_MESSAGE to greeting.message, so the pods can override every value in application.properties without code changes.

greeting.message=Hello from my laptop
greeting.api-key=local-key
spring.datasource.url=jdbc:postgresql://localhost:5432/greetings
spring.datasource.username=greeting
spring.datasource.password=greeting
management.endpoints.web.exposure.include=health
management.endpoint.health.probes.enabled=true

The image copies the jar onto a Java 25 runtime, as in any Docker image for a Spring Boot app.

FROM eclipse-temurin:25-jre
WORKDIR /app
COPY target/greeting-api.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

A kind cluster cannot see the images on our machine, so kind load copies the image into every node.

mvn -DskipTests package
docker build -t greeting-api:1.0 .
kind load docker-image greeting-api:1.0 --name greeting
Image: "greeting-api:1.0" with ID "sha256:03d380d6..." not yet present on node "greeting-worker", loading...
Image: "greeting-api:1.0" with ID "sha256:03d380d6..." not yet present on node "greeting-control-plane", loading...
Image: "greeting-api:1.0" with ID "sha256:03d380d6..." not yet present on node "greeting-worker2", loading...

2.3. Passing Configuration With a ConfigMap and a Secret

A ConfigMap holds plain settings as key-value pairs, so the same image can run with a different message in test and production.

apiVersion: v1
kind: ConfigMap
metadata:
  name: greeting-config
data:
  GREETING_MESSAGE: "Hello from Kubernetes"

A Secret has the same shape but is meant for passwords and keys. Under stringData we write plain text, and Kubernetes stores it base64-encoded under data.

apiVersion: v1
kind: Secret
metadata:
  name: greeting-secret
type: Opaque
stringData:
  api-key: "k8s-demo-key"
  db-password: "greeting-pass"
kubectl apply -f k8s/configmap.yaml -f k8s/secret.yaml
kubectl get secret greeting-secret -o jsonpath='{.data.api-key}'               # azhzLWRlbW8ta2V5
kubectl get secret greeting-secret -o jsonpath='{.data.api-key}' | base64 -d   # k8s-demo-key

Base64 is an encoding, not encryption, so anyone who can read the Secret can decode it. Section 3.1 shows what to do about that. Neither object does anything alone. A pod reads them as environment variables, which section 2.5 sets up.

2.4. Running PostgreSQL as a StatefulSet With a Volume

A container’s file system is deleted with the container. A database needs a disk that survives, which in Kubernetes is a PersistentVolumeClaim (PVC). A PVC is a request such as “1Gi, mounted by one node”, and the cluster fulfills it with a PersistentVolume (PV). In kind, the built-in standard storage class creates the PV as a folder on the node.

A StatefulSet runs pods that need such an identity. Its pods have fixed names (postgres-0, postgres-1), and volumeClaimTemplates gives each of them its own PVC.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:18.6
          env:
            - name: POSTGRES_DB
              value: greetings
            - name: POSTGRES_USER
              value: greeting
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:            # value from the Secret
                  name: greeting-secret
                  key: db-password
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 1Gi

The same file defines a headless Service (clusterIP: None) named postgres, so the API can connect to postgres:5432 by name.

kubectl apply -f k8s/postgres.yaml
kubectl get statefulset,pods,pvc
NAME                        READY   AGE
statefulset.apps/postgres   1/1     20s

NAME             READY   STATUS    RESTARTS   AGE
pod/postgres-0   1/1     Running   0          20s

NAME                                    STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS
persistentvolumeclaim/data-postgres-0   Bound    pvc-ce9b3507-942c-4ce5-9552-c0a3f78c0a8f   1Gi        RWO            standard

The PVC name is the template name plus the pod name, for example data-postgres-0. When the pod is deleted, the StatefulSet starts a new pod with the same name and attaches the same PVC, so the data stays. We tested it after 15 visits.

kubectl delete pod postgres-0                                  # pod "postgres-0" deleted
kubectl get pods -l app=postgres                               # postgres-0   1/1   Running   16s
curl -s localhost:8080/api/greeting -H 'X-API-Key: k8s-demo-key'
# {"message":"Namaste from Kubernetes","visits":16,"pod":"greeting-api-649c549d96-sml9k","version":"1.1.0"}

The count went on from 15 to 16 because the new postgres-0 mounted the same volume.

2.5. Running the API With a Deployment

A Deployment runs a number of identical pods. Its template is the pod description, and the selector tells the Deployment which pods belong to it by their label app: greeting-api.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: greeting-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: greeting-api
  template:
    metadata:
      labels:
        app: greeting-api
    spec:
      containers:
        - name: greeting-api
          image: greeting-api:1.0
          imagePullPolicy: IfNotPresent     # use the image loaded by kind
          ports:
            - containerPort: 8080

The env section connects the pod to the ConfigMap and the Secret, and each variable names the object and the key.

          env:
            - name: GREETING_MESSAGE
              valueFrom:
                configMapKeyRef:
                  name: greeting-config
                  key: GREETING_MESSAGE
            - name: GREETING_API_KEY
              valueFrom:
                secretKeyRef:
                  name: greeting-secret
                  key: api-key
            - name: SPRING_DATASOURCE_URL
              value: jdbc:postgresql://postgres:5432/greetings
            - name: SPRING_DATASOURCE_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: greeting-secret
                  key: db-password

Probes tell Kubernetes how the app is doing. Spring Boot Actuator serves them at /actuator/health/liveness and /actuator/health/readiness (see Spring Boot Actuator).

          startupProbe:                 # gives the JVM up to 150 s to start
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            periodSeconds: 5
            failureThreshold: 30
          readinessProbe:               # ready to receive traffic from the Service?
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            periodSeconds: 5
          livenessProbe:                # still alive? if not, restart the container
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            periodSeconds: 10
          lifecycle:
            preStop:                    # keep serving while the Service and Ingress drop this pod
              sleep:
                seconds: 10
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              memory: 512Mi

The three probes answer different questions.

ProbeQuestionWhen it fails
startupProbeHas the app finished starting?The other probes wait; after 30 failures the container restarts
readinessProbeCan it take requests now?The pod is removed from the Service, no restart
livenessProbeIs it still working?The container is restarted

The requests value is what the scheduler reserves on a node (100m is one tenth of a CPU core), whereas limits.memory is the hard cap, so Kubernetes kills a container that uses more. We explain the preStop hook in section 2.12.

kubectl apply -f k8s/deployment.yaml
kubectl get deployment,replicaset,pods
NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/greeting-api   2/2     2            2           4m32s

NAME                                      DESIRED   CURRENT   READY   AGE
replicaset.apps/greeting-api-58d4bcbbc5   2         2         2       4m32s

NAME                                READY   STATUS    RESTARTS        AGE
pod/greeting-api-58d4bcbbc5-pr22t   1/1     Running   0               4m32s
pod/greeting-api-58d4bcbbc5-xnpkd   1/1     Running   1 (4m17s ago)   4m32s
pod/postgres-0                      1/1     Running   0               4m32s

The Deployment created a ReplicaSet, and the ReplicaSet created the pods. That is why pod names have two random parts: the ReplicaSet hash 58d4bcbbc5 and a pod suffix pr22t. Every change to the pod template creates a new ReplicaSet with a new hash, so the next sections show other hashes. The one restart of xnpkd is explained in section 2.10.

2.6. Checking Pods With get, describe and logs

Three commands find the cause of most pod problems. The first one, kubectl get pods -o wide, adds the pod IP and the node.

NAME                            READY   STATUS    RESTARTS   AGE     IP           NODE
greeting-api-7844d44c9d-n2rqp   1/1     Running   0          60s     10.244.2.5   greeting-worker
greeting-api-7844d44c9d-xflml   1/1     Running   0          60s     10.244.1.3   greeting-worker2
postgres-0                      1/1     Running   0          2m49s   10.244.2.3   greeting-worker

The command kubectl describe pod shows the full pod with its probes, environment and events. The events at the end are the first place to look when a pod does not start.

Node:             greeting-worker/172.18.0.2
Controlled By:    ReplicaSet/greeting-api-7844d44c9d
    Environment:
      GREETING_MESSAGE:            <set to the key 'GREETING_MESSAGE' of config map 'greeting-config'>  Optional: false
      GREETING_API_KEY:            <set to the key 'api-key' in secret 'greeting-secret'>               Optional: false
      SPRING_DATASOURCE_URL:       jdbc:postgresql://postgres:5432/greetings
Events:
  Type     Reason     Age                From               Message
  ----     ------     ----               ----               -------
  Normal   Scheduled  63s                default-scheduler  Successfully assigned default/greeting-api-7844d44c9d-n2rqp to greeting-worker
  Normal   Started    62s                kubelet            spec.containers{greeting-api}: Container started
  Warning  Unhealthy  33s (x6 over 58s)  kubelet            spec.containers{greeting-api}: Startup probe failed: Get "http://10.244.2.5:8080/actuator/health/liveness": dial tcp 10.244.2.5:8080: connect: connection refused

The Startup probe failed warnings are expected here, because Spring Boot needed 27 seconds to start on our small 2-CPU sandbox machine, and the startup probe waited for it. kubectl logs prints the console output of the container.

kubectl logs greeting-api-7844d44c9d-n2rqp | grep Started
# ... c.h.k8s.GreetingApiApplication : Started GreetingApiApplication in 27.011 seconds (process running for 32.782)
kubectl logs deployment/greeting-api --tail=20     # Found 2 pods, using pod/greeting-api-...
kubectl logs <pod> --previous                      # log of the container before its last restart

2.7. Reaching the Pods Through a Service

Pod IPs change every time a pod is replaced. A Service gives the pods one stable virtual IP and a DNS name, and spreads requests across all ready pods that match its selector.

apiVersion: v1
kind: Service
metadata:
  name: greeting-api
spec:
  type: ClusterIP
  selector:
    app: greeting-api
  ports:
    - port: 80            # port of the Service
      targetPort: 8080    # port of the container
kubectl apply -f k8s/service.yaml
kubectl get service greeting-api      # greeting-api   ClusterIP   10.96.215.54   <none>   80/TCP

A ClusterIP address works only inside the cluster, where other pods reach the API at http://greeting-api by name. From our machine, kubectl port-forward opens a tunnel for testing.

kubectl port-forward service/greeting-api 8081:80        # keep it running in a second terminal

curl -s localhost:8081/api/greeting -H 'X-API-Key: k8s-demo-key'
# {"message":"Hello from Kubernetes","visits":1,"pod":"greeting-api-7844d44c9d-n2rqp","version":"1.0.0"}
curl -s localhost:8081/api/greeting -H 'X-API-Key: k8s-demo-key'
# {"message":"Hello from Kubernetes","visits":2,"pod":"greeting-api-7844d44c9d-n2rqp","version":"1.0.0"}
curl -s -i localhost:8081/api/greeting
# HTTP/1.1 401
# {"error":"missing or wrong X-API-Key"}

The message came from the ConfigMap and the key from the Secret. Both answers came from the same pod, because port-forward picks one pod and stays with it, as section 3.4 explains.

2.8. Routing Outside Traffic With an Ingress

An Ingress is a set of HTTP routing rules, such as “send /api to the Service greeting-api“. The rules do nothing without an ingress controller, a reverse proxy that runs as a pod and reads them. We use ingress-nginx, the controller most Ingress examples are written for. (The current kind docs suggest cloud-provider-kind instead, which handles Ingress by itself.)

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.15.1/deploy/static/provider/kind/deploy.yaml
kubectl patch deployment ingress-nginx-controller -n ingress-nginx \
  -p '{"spec":{"template":{"spec":{"nodeSelector":{"ingress-ready":"true"}}}}}'
kubectl wait -n ingress-nginx --for=condition=ready pod -l app.kubernetes.io/component=controller --timeout=180s

The patch is needed on a cluster with workers. The v1.15.1 kind manifest has no nodeSelector, and the controller started on greeting-worker2, where port 80 is not mapped to our machine (curl got no answer, status 000). The patch moves it to the node labeled ingress-ready=true.

NAME                                       READY   STATUS    RESTARTS   AGE   IP           NODE
ingress-nginx-controller-7756f6b94-5l57j   1/1     Running   0          16s   10.244.0.5   greeting-control-plane

The Ingress itself is short. The setting pathType: Prefix matches /api and every path under it.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: greeting-ingress
spec:
  ingressClassName: nginx
  rules:
    - http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: greeting-api
                port:
                  number: 80
kubectl apply -f k8s/ingress.yaml
kubectl get ingress           # greeting-ingress   nginx   *   localhost   80
for i in 1 2 3 4 5 6; do curl -s localhost:8080/api/greeting -H 'X-API-Key: k8s-demo-key'; echo; done
{"message":"Namaste from Kubernetes","visits":10,"pod":"greeting-api-649c549d96-dcnlt","version":"1.1.0"}
{"message":"Namaste from Kubernetes","visits":11,"pod":"greeting-api-649c549d96-dcnlt","version":"1.1.0"}
{"message":"Namaste from Kubernetes","visits":12,"pod":"greeting-api-649c549d96-sml9k","version":"1.1.0"}
{"message":"Namaste from Kubernetes","visits":13,"pod":"greeting-api-649c549d96-bwtdv","version":"1.1.0"}
{"message":"Namaste from Kubernetes","visits":14,"pod":"greeting-api-649c549d96-sml9k","version":"1.1.0"}
{"message":"Namaste from Kubernetes","visits":15,"pod":"greeting-api-649c549d96-dcnlt","version":"1.1.0"}

We ran the loop after the steps in sections 2.9 to 2.12, so the cluster runs 3 pods of version 1.1.0 with a new message, and the requests spread over all three pods. A path without a rule, such as /hello, returns HTTP/1.1 404 Not Found from nginx. The ingress controller reads the pod list of the Service and sends each request to one of the pods.

Request path: curl localhost:8080/api/greeting goes to the ingress-nginx controller pod (kind maps host port 8080 to its port 80), the Ingress rule sends path /api to service greeting-api port 80, the Service greeting-api at 10.96.215.54:80 with selector app=greeting-api spreads requests to three ready pods dcnlt, sml9k and bwtdv on port 8080. Six calls were answered by dcnlt, dcnlt, sml9k, bwtdv, sml9k, dcnlt
Ingress picks the Service by path, and the Service picks one of its ready pods. Our six calls reached all three pods.

ingress-nginx was retired in March 2026, and v1.15.1 is the last release and gets no more security fixes. ingress-nginx still works for learning on kind. For a new production cluster, pick an implementation of the Gateway API, the successor of Ingress.

2.9. Scaling the Deployment

Scaling changes one number in the desired state. The ReplicaSet notices that 2 pods exist but 3 are wanted, and the scheduler places the new pod.

kubectl scale deployment greeting-api --replicas=3
kubectl get pods -l app=greeting-api -o wide
NAME                            READY   STATUS    RESTARTS   AGE    IP           NODE
greeting-api-7844d44c9d-8wpt4   1/1     Running   0          52s    10.244.1.4   greeting-worker2
greeting-api-7844d44c9d-n2rqp   1/1     Running   0          2m8s   10.244.2.5   greeting-worker
greeting-api-7844d44c9d-xflml   1/1     Running   0          2m8s   10.244.1.3   greeting-worker2

The Service picked up the new pod by its label without any change. Its EndpointSlice, the list of ready pod addresses behind a Service, grew to three IPs.

kubectl get endpointslices -l kubernetes.io/service-name=greeting-api
# greeting-api-szmd5   IPv4   8080   10.244.2.5,10.244.1.3,10.244.1.4

2.10. Self-Healing After a Pod Is Deleted

We delete a pod by hand, as if its node had crashed. The ReplicaSet sees 2 pods instead of 3 and creates a replacement at once.

kubectl delete pod greeting-api-7844d44c9d-n2rqp
kubectl get pods -l app=greeting-api
NAME                            READY   STATUS    RESTARTS   AGE
greeting-api-7844d44c9d-8wpt4   1/1     Running   0          58s
greeting-api-7844d44c9d-xflml   1/1     Running   0          2m14s
greeting-api-7844d44c9d-xjqzf   0/1     Running   0          2s
104s   Normal   SuccessfulCreate   replicaset/greeting-api-7844d44c9d   Created pod: greeting-api-7844d44c9d-8wpt4
48s    Normal   SuccessfulCreate   replicaset/greeting-api-7844d44c9d   Created pod: greeting-api-7844d44c9d-xjqzf

The new pod gets a new name and IP. Kubernetes replaces pods and does not repair them, so nothing important may live only inside a pod. The same mechanism restarts a container that crashes. In a fresh cluster, kubectl apply -f k8s/ started the API before PostgreSQL was up. One pod failed with Connection to postgres:5432 refused, Kubernetes restarted it, and the second start worked.

NAME                            READY   STATUS    RESTARTS      AGE
greeting-api-58d4bcbbc5-pr22t   1/1     Running   0             76s
greeting-api-58d4bcbbc5-xnpkd   1/1     Running   1 (61s ago)   76s
postgres-0                      1/1     Running   0             76s

2.11. Changing the ConfigMap

We change the message in the ConfigMap and call the API again.

kubectl patch configmap greeting-config -p '{"data":{"GREETING_MESSAGE":"Namaste from Kubernetes"}}'
curl -s localhost:8081/api/greeting -H 'X-API-Key: k8s-demo-key'
# {"message":"Hello from Kubernetes","visits":3,"pod":"greeting-api-7844d44c9d-xflml","version":"1.0.0"}

The old message is still there. Environment variables are read once, when the container starts, so a changed ConfigMap reaches the app only after the pods restart. The command kubectl rollout restart replaces the pods one by one.

kubectl rollout restart deployment greeting-api
kubectl rollout status deployment greeting-api      # deployment "greeting-api" successfully rolled out
curl -s localhost:8081/api/greeting -H 'X-API-Key: k8s-demo-key'
# {"message":"Namaste from Kubernetes","visits":4,"pod":"greeting-api-59d9495747-f4hls","version":"1.0.0"}

A ConfigMap mounted as a volume behaves differently. Its files are updated inside the running pod. In a test pod with both forms, the file /config/GREETING_MESSAGE showed the new value within 5 seconds, while printenv GREETING_MESSAGE still printed the old one. The app must re-read the file to use it. The Kubernetes docs say the delay can be as long as the kubelet sync period plus its cache delay, so it is not instant on every cluster.

2.12. Rolling Update and Rollback

A new version is a new image. We changed <version> in pom.xml to 1.1.0 (the API returns it in the version field), built greeting-api:1.1, loaded it into kind and changed the image of the Deployment.

docker build -t greeting-api:1.1 . && kind load docker-image greeting-api:1.1 --name greeting
kubectl set image deployment/greeting-api greeting-api=greeting-api:1.1
kubectl get replicaset -l app=greeting-api
NAME                      DESIRED   CURRENT   READY   AGE
greeting-api-59d9495747   3         3         3       3m17s
greeting-api-649c549d96   1         1         0       9s
greeting-api-7844d44c9d   0         0         0       6m31s

The Deployment created a second ReplicaSet for 1.1 and moves pods over one at a time. With the default maxSurge and maxUnavailable of 25%, a 3-replica Deployment adds at most 1 extra pod and never drops below 3 ready pods. kubectl rollout status waits until it is done, and kubectl rollout undo goes back to the previous ReplicaSet.

kubectl rollout status deployment greeting-api     # deployment "greeting-api" successfully rolled out
kubectl rollout history deployment greeting-api    # REVISION 1, 2, 3
kubectl rollout undo deployment greeting-api       # deployment.apps/greeting-api rolled back

Our first version of the Deployment had no preStop hook. During a rolling update, we called the API through the Ingress every 0.2 seconds. 3 of 301 calls failed, one for each old pod that stopped while the ingress controller still sent it requests. Removing a pod from the Service and stopping its container happen at the same time. The Spring Boot docs recommend a short preStop sleep, so the pod keeps serving until the routing has caught up. With sleep: seconds: 10, the same test had no failures.

Rolling update in five steps: start with three 1.0 pods, a new 1.1 pod starts, then old pods are replaced one at a time until three 1.1 pods run. Below, the measured result of calling the API every 0.2 seconds during the update: without a preStop hook 298 calls answered and 3 calls failed; with preStop sleep 10 seconds 352 calls answered and 0 failed
A rolling update keeps 3 pods ready at every step. The preStop sleep removes the last few failed calls (measured on a small sandbox machine).

3. Kubernetes FAQs

3.1. Is a Kubernetes Secret Encrypted?

No, not by default. The data field holds base64 text, and kubectl apply also stores the whole file in an annotation. Our Secret showed the API key in plain text.

data:
  api-key: azhzLWRlbW8ta2V5
  db-password: Z3JlZXRpbmctcGFzcw==
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"v1","kind":"Secret",...,"stringData":{"api-key":"k8s-demo-key","db-password":"greeting-pass"},"type":"Opaque"}

We limit who may read Secrets with RBAC (role-based access control) and turn on encryption at rest in etcd. Real secret values never go into Git. In production, a tool such as an external secrets operator or a sealed secret fills the Secret from a vault.

3.2. What Is the Difference Between a Deployment and a StatefulSet?

Both keep a number of pods running. They differ in how much each pod matters.

DeploymentStatefulSet
Pod namesRandom: greeting-api-649c549d96-sml9kFixed: postgres-0, postgres-1
StorageShared or noneOne PVC per pod, kept after deletion
Start and stop orderAny order, in parallelOne by one, in order
Network nameThrough the Service onlyEach pod has its own DNS name: postgres-0.postgres
Typical useREST APIs, web appsDatabases, Kafka, ZooKeeper

For a Spring Boot API, we almost always use a Deployment, and many teams run the database outside the cluster (for example, a managed PostgreSQL) and keep only stateless apps in Kubernetes.

3.3. Why Does My Spring Boot Pod Restart During Startup?

In most cases, the livenessProbe fails while the JVM is still starting. Our first Deployment used livenessProbe with initialDelaySeconds: 30 and no startupProbe. On the 2-CPU sandbox, startup took 32 seconds, and the events showed two failed liveness checks, one short of a restart.

Warning  Unhealthy  27s (x2 over 37s)  kubelet  Liveness probe failed: Get "http://10.244.1.2:8080/actuator/health/liveness": dial tcp 10.244.1.2:8080: connect: connection refused

A startupProbe fixes this without slowing down the liveness check later.

3.4. Why Does kubectl port-forward Stop Working?

Because kubectl port-forward service/greeting-api picks one pod when it starts and tunnels to that pod only. When that pod is deleted, for example by a rollout, the tunnel breaks.

E1003 17:43:19.617180 portforward.go:549] "An error occurred forwarding" err="error forwarding port 8080 to pod 820892ca..., uid : network namespace for sandbox \"820892ca...\" is closed"
error: lost connection to pod

We start port-forward again. It is a debugging tool. Real traffic goes through a Service of type LoadBalancer or an Ingress.

3.5. Why Did kubectl apply Reset My Replica Count?

The YAML file is the desired state. We scaled to 3 with kubectl scale, but deployment.yaml says replicas: 2, so the next kubectl apply set it back. kubectl diff shows this before applying.

-  replicas: 3
+  replicas: 2

We change the YAML file and apply it, instead of mixing imperative commands with files. The same rule applies to kubectl set image and kubectl patch, which we used here only to show each feature in one line.

3.6. Do I Need Kubernetes for Every Java Application?

No. Kubernetes adds a lot to learn and to operate, such as YAML for every object and a cluster that someone must upgrade and secure. It also needs more CPU and memory per application. For one or two services, a managed platform that runs containers for us, or a VM with Docker Compose, is often enough. Kubernetes is useful when a team runs many services that all need scaling and rolling updates done the same way.

4. Conclusion

Kubernetes runs our containers from a description of the desired state. Nodes run pods, a Deployment keeps identical API pods running, scales them and replaces them version by version, and a StatefulSet gives a database pod a fixed name and its own PersistentVolumeClaim.

A Service puts one address in front of the pods, an Ingress routes outside HTTP traffic to it, and ConfigMap and Secret pass settings and passwords in as environment variables. With kind, the whole setup runs on a laptop in a few minutes, which is the best place to break pods on purpose and watch Kubernetes fix them. The microservices tutorials build on these basics.

5. References

Happy Learning !!

Source Code on Github

Leave a Comment

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.