Skip to content
Software Atlas

Kubernetes

How a cluster keeps your containers running: desired state and control loops, pods, Deployments, Services and Ingress, probes, resource limits, rolling updates, and the kubectl workflow for when things break.

⏱ ~55 min read16 sections9 interactive widgetsYAML · Shell · Go · JS
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceKubernetes runs your containers on a group of machines. You don't tell it what to do ("start a container on server 3"). You tell it what should be true ("3 copies of hello:v2 should be running"), and it keeps working, forever, until that's true.

That second half is the whole idea. Kubernetes (often written k8s: k, then 8 letters, then s) is an Orchestrator. It notices when reality drifts from what you asked for, because a container crashed or a machine died, and fixes it without waking anyone up.

Try being the bad luck. Each box below is one machine (Kubernetes calls a machine a node), and each green chip is one running copy of the app. The controller checks every second. Kill pods, take a whole machine down, change the number you asked for, and watch it bring the kitchen back to "3 running":

Desired state vs reality
The controller checks once a second. Break things and watch it put them back.
The note on the wall
replicas: 3
running now: 3 / 3
Click a pod to delete it. Click a node's power button to kill the whole machine.
node-1
node-2
node-3
Everything matches: the note says 3, and 3 copies are running. Try crashing a pod, or powering off a machine.

Time is compressed. A real cluster waits about 40 seconds before deciding a machine is unreachable, and by default about 5 more minutes before replacing its copies, in case the machine comes back.

What you'll learn

  1. Why you'd want this at all, and what it replaces (§02).
  2. The parts of a cluster: which program does what when you type kubectl apply (§03).
  3. The four objects you'll use every day: Pods (§04), Deployments (§05), Services (§06), and Ingress for outside traffic (§07).
  4. Making apps behave well inside it: config and secrets (§08), health probes (§09), CPU and memory budgets (§10).
  5. Shipping: rolling updates and rollbacks (§11), autoscaling and namespaces (§12).
  6. When it breaks: the debugging workflow (§13), then production lessons (§14).
Foundations§02

Why an orchestrator?

In one sentencedocker run starts a container once, on one machine. An orchestrator keeps containers running across many machines, all the time, through crashes, deploys and traffic spikes.

Start with one server

Say you've built a small Go web service and packaged it as an image, hello:v1. On one server, running it is one command:

docker run -d --restart=always -p 80:8080 hello:v1

That's genuinely fine for a side project. Docker even restarts the container if it crashes (--restart=always). Now the service gets popular, and the list of questions grows.

Then reality arrives

What happensWith plain docker run on servers you manageWhat an orchestrator does
The app crashesDocker restarts it, on that serverRestarts it, and stops sending it traffic while it's down
A whole server diesIts containers are gone until someone notices and starts them elsewhereNotices within a minute and recreates them on other servers (by default after a 5-minute grace period, in case the server comes back)
You need 10 copiesYou pick which servers, ssh into each, and run them by handYou change one number: replicas: 10
New versionStop, start, hope. Or write a careful scriptReplaces copies one at a time and stops if the new ones are unhealthy
Service A must call service BHard-code B's IP addresses and update them when B movesB gets a stable name (http://b) that always points at healthy copies
A server is fullYou track CPU and memory per server in a spreadsheetKubernetes only places containers where they fit

Every row is a small program someone has to write, run and keep correct. Kubernetes is those programs, already written, plus one shared database of "what should be running", which the programs all read.

From commands to a promise
Imperative: you run commands
ssh server-1 docker run hello:v1
ssh server-2 docker run hello:v1
server-2 dies at 3 a.m. → nobody knows
ssh server-3 docker run hello:v1 (next morning)
Declarative: you state the goal
want: 3 × hello:v1
controller: have 3 ✓ … have 3 ✓ …
node-2 dies → its pod is given up on → have 2 ✗ → start 1 on node-3
have 3 ✓ (03:06, nobody woke up)
Left: you're the loop. Every crash, move or deploy needs a person. Right: you write down the goal once; controllers inside the cluster run the loop for you, every few seconds, forever.

The vocabulary, in one breath

You'll see these words on every page from here on. Each gets its own section later; for now, just the shape:

  • A cluster is a group of machines managed together. Each machine is a node.
  • A pod is one running copy of your app: usually one container, plus its own IP address.
  • A Deployment says "keep N pods of this image running" and handles version upgrades.
  • A Service gives those pods one stable name and spreads requests across them.
  • You describe all of this in YAML files and send them to the cluster with kubectl.

When you don't need it

Kubernetes is powerful and also a lot of moving parts. One or two services with steady traffic often do better on a simpler platform: a PaaS (Heroku-style "push code, it runs"), a managed container service like AWS ECS or Google Cloud Run, or even one VM with Docker Compose. Those hide the cluster from you entirely.

Kubernetes earns its keep when you have many services, several teams, or traffic that changes a lot, and you want one consistent way to deploy, scale and heal all of it. Most companies don't run the cluster's brain themselves either: they rent it as managed Kubernetes (EKS, GKE, AKS). Cloud trade-offs are covered in Cloud basics.

Foundations§03

Anatomy of a cluster

In one sentenceA cluster has a brain (the Control plane) that decides what should run where, and workers (nodes) that actually run containers. Every piece talks through one front door: the API server.
Who's who in a cluster
Control plane (the brain)
kube-apiserver — the front door; checks and stores every request
etcd — the database of desired + actual state
kube-scheduler — picks a node for each new pod
kube-controller-manager — dozens of control loops (Deployments, ReplicaSets, nodes…)
Worker node-1
kubelet — starts this node's pods, runs probes
containerd — actually runs containers
kube-proxy — network rules for Services
podpod
Worker node-2
kubelet — starts this node's pods, runs probes
containerd — actually runs containers
kube-proxy — network rules for Services
podpod
Arrows all point at the API server: no component talks to etcd or to each other directly. Each one watches the API server for changes it cares about, then acts.

The control plane

  • The API server is a big HTTPS API. kubectl, your CI pipeline, the scheduler and every kubelet are all just clients of it. It checks who you are and whether you're allowed (RBAC), validates the object, and saves it.
  • etcd is where it saves things: a small, very reliable key-value database. In production it runs as 3 or 5 copies that agree on every write using Raft, so losing one machine loses no data.
  • The scheduler watches for pods that have no node yet and picks one, based on how much CPU and memory each pod asks for (§10) and what each node has free.
  • The controller manager runs the controllers: one loop per kind of object, each doing "compare wanted with actual, fix the gap".

Each node

  • The kubelet is the agent on every node. It watches for pods assigned to its node, asks the container runtime to start them, runs their health checks (§09) and reports back.
  • The container runtime (usually containerd) does the actual work of pulling images and starting containers, the same machinery Docker uses underneath.
  • kube-proxy programs the node's network so a Service's address reaches a real pod (§06).

Seeing it for real

kubectl is the command-line client. Here's our one-node kind cluster. In kind, the "node" is a Docker container pretending to be a machine:

$ kubectl get nodes -o wide
NAME                      STATUS   ROLES           AGE   VERSION   INTERNAL-IP   OS-IMAGE                       CONTAINER-RUNTIME
atlas-k8s-control-plane   Ready    control-plane   31s   v1.37.0   172.18.0.2    Debian GNU/Linux 13 (trixie)   containerd://2.3.4
# (two columns trimmed to fit)

The control plane itself runs as pods, in a namespace (a named folder for objects) called kube-system:

$ kubectl get pods -n kube-system
NAME                                              READY   STATUS    RESTARTS   AGE
coredns-559f6c778d-rw95f                          1/1     Running   0          22s
coredns-559f6c778d-wzzfq                          1/1     Running   0          22s
etcd-atlas-k8s-control-plane                      1/1     Running   0          27s
kindnet-9tsch                                     1/1     Running   0          22s
kube-apiserver-atlas-k8s-control-plane            1/1     Running   0          27s
kube-controller-manager-atlas-k8s-control-plane   1/1     Running   0          29s
kube-proxy-z67bk                                  1/1     Running   0          22s
kube-scheduler-atlas-k8s-control-plane            1/1     Running   0          27s

Every box from the diagram is there, plus two more. coredns is the cluster's DNS server (§06), and kindnet is the pod network plugin, which gives every pod its own IP address. (Only the kubelet and containerd are missing from the list: they run directly on the node, not as pods.)

What happens when you kubectl apply

Here's the central trick of Kubernetes, step by step. No component tells another what to do. Each one watches the API server and reacts when something it cares about changes:

kubectl apply: who does what
Follow one Deployment from YAML to running containers. Each component only talks to the API server.
Code
1apiVersion: apps/v1
2kind: Deployment
3metadata:
4 name: hello
5spec:
6 replicas: 3
7 selector:
8 matchLabels:
9 app: hello
10 template:
11 metadata:
12 labels:
13 app: hello
14 spec:
15 containers:
16 - name: hello
17 image: hello:v1
objects in etcd
nothing stored yet
who acts now
kubectl → API server
node-1
no containers yet
kubectl apply -f deploy.yaml turns the YAML into an HTTPS request to the API server. The API server checks who you are, whether you're allowed (RBAC), and whether the object is valid. kubectl's job ends here.
1 / 8

Notice that the Deployment controller never talks to the scheduler, and the scheduler never talks to the kubelet. They only read and write objects through the API server. That's why any one of them can crash and restart without losing anything: the state lives in etcd, and the loop just picks up where it left off.

Foundations§04

Pods: the smallest thing Kubernetes runs

In one sentenceA pod is one running copy of your app: usually one container, with its own IP address. Kubernetes never runs a bare container; it always runs pods. And pods are disposable: when one breaks, it's replaced by a new one, never repaired.

The app we'll deploy

Throughout this topic we deploy one tiny web server. (The Go is basic; if Go syntax is new to you, the 5-minute primer in Async / Await §02 covers it.) It answers with a greeting, the name of the machine it's running on, and its version. That will let us see which copy answered each request. Here is a simplified version in Go and in Node. (The Go server we actually deployed also has health endpoints and a clean shutdown on SIGTERM, which §09 and §14 add.)

main.go
package main

import (
	"fmt"
	"net/http"
	"os"
)

func main() {
	pod, _ := os.Hostname() // ← inside Kubernetes, the hostname is the pod's name
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "hello from %s (v1)\n", pod)
	})
	http.ListenAndServe(":8080", nil) // listen on every interface, port 8080
}

We built the Go version into a 2.4 MB image, hello:v1, using a FROM scratch Dockerfile (an image with nothing in it but our binary; see Containers §07 for how). In kind you load a local image straight into the cluster's node with kind load docker-image hello:v1, so no registry is needed. A real cluster pulls images from a registry instead.

Your first manifest

You describe what you want in a manifest, a YAML file. Every Kubernetes object has the same four top-level parts:

pod.yaml
apiVersion: v1          # which version of the Kubernetes API defines this kind
kind: Pod               # what type of object this is
metadata:
  name: hello-pod       # its name (unique per kind, per namespace)
  labels:
    app: hello          # ← a tag other objects can search for (§05, §06)
spec:                   # what you want: for a Pod, its containers
  containers:
    - name: hello
      image: hello:v1
      ports:
        - containerPort: 8080   # documentation: the app listens here

Send it to the cluster with kubectl apply, then look:

$ kubectl apply -f pod.yaml
pod/hello-pod created
$ kubectl get pods -o wide
NAME        READY   STATUS    RESTARTS   AGE   IP           NODE                      NOMINATED NODE   READINESS GATES
hello-pod   1/1     Running   0          5s    10.244.0.5   atlas-k8s-control-plane   <none>           <none>

READY 1/1 means one of one containers is running and ready (ready = passing its readiness check, which §09 covers). You can ignore the last two columns for now. The pod has its own IP address, 10.244.0.5, reachable from any other pod in the cluster. That's the pod network at work.

Why "pod" and not just "container"?

A pod is a small wrapper that can hold more than one container, and the containers in one pod share things:

  • One network. They share an IP address and can reach each other on localhost. (In Linux terms they share one network namespace.)
  • Volumes. They can mount the same folders.
  • One fate. They're scheduled onto the same node together and stop together.

That's useful for a Sidecar: a helper container that sits next to your app, such as a log shipper or a proxy. But the rule of thumb is simple: one app container per pod. Your web server and your database go in separate pods, so they can be scaled, upgraded and restarted separately.

Pods are cattle, not pets

Here's the surprising part. Delete that pod, and it's simply gone:

$ kubectl delete pod hello-pod
pod "hello-pod" deleted from default namespace
$ kubectl get pods
No resources found in default namespace.

Nothing brings it back, because nothing was told to keep it alive. And if its node dies, a bare pod dies with it. This is deliberate. Kubernetes treats pods like cattle, not pets: no pod is special, none is nursed back to health, and any one can be replaced by an identical new one, with a new name and a new IP address.

So in practice you never create pods by hand. You create a Deployment, which creates pods for you and replaces them when they vanish. That's the next section.

Practitioner§05

Deployments and ReplicaSets

In one sentenceA Deployment says "keep N pods that look like this running". It does that through a ReplicaSet, which counts pods by their labels and creates or deletes pods until the count is right.

The manifest

deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
spec:
  replicas: 3                 # ← desired state: three pods
  selector:
    matchLabels:
      app: hello              # ← "the pods I own are the ones labelled app=hello"
  template:                   # ← the pod template: what each pod looks like
    metadata:
      labels:
        app: hello            # ← every pod gets this label (must match the selector)
    spec:
      containers:
        - name: hello
          image: hello:v1
          ports:
            - containerPort: 8080

Read it from the bottom up. The pod template under template: is exactly the pod from §04, minus the name (pods made from it get generated names). replicas: 3 says how many. And selector is how the Deployment recognises its own pods: by a label.

Applying it: three objects for the price of one

$ kubectl apply -f deploy.yaml
deployment.apps/hello created
$ kubectl get deploy,rs,pods
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/hello   3/3     3            3           5s

NAME                               DESIRED   CURRENT   READY   AGE
replicaset.apps/hello-6b85969876   3         3         3       5s

NAME                         READY   STATUS    RESTARTS   AGE
pod/hello-6b85969876-rgpqn   1/1     Running   0          5s
pod/hello-6b85969876-t7fns   1/1     Running   0          5s
pod/hello-6b85969876-z2kjg   1/1     Running   0          5s

We created one object and got five. The Deployment created a ReplicaSet, and the ReplicaSet created three pods. The names show the family tree: hello → hello-6b85969876 → hello-6b85969876-rgpqn.

That middle number, 6b85969876, is a hash of the pod template. Change the template (say, a new image) and you get a new ReplicaSet with a different hash. That's how rolling updates work: the Deployment shifts pods from the old ReplicaSet to the new one (§11).

Who owns whom
Deployment hello · replicas: 3 · image: hello:v1
↓ creates and scales
ReplicaSet hello-6b85969876 · selector: app=hello, pod-template-hash=6b85969876
↓ creates and deletes pods to keep the count at 3
pod …rgpqn
pod …t7fns
pod …z2kjg
Each object records its owner (ownerReferences in its metadata). Delete the Deployment and Kubernetes deletes everything below it.

Self-healing, for real

Now delete one of the pods, as if it had crashed or its node had died. We watched the pod list while doing it (-w keeps printing changes):

$ kubectl delete pod hello-6b85969876-rgpqn
pod "hello-6b85969876-rgpqn" deleted from default namespace

$ kubectl get pods -w          # (started just before the delete)
NAME                     READY   STATUS              RESTARTS   AGE
hello-6b85969876-rgpqn   1/1     Running             0          11s
hello-6b85969876-t7fns   1/1     Running             0          11s
hello-6b85969876-z2kjg   1/1     Running             0          11s
hello-6b85969876-rgpqn   1/1     Terminating         0          12s
hello-6b85969876-9dr66   0/1     Pending             0          0s
hello-6b85969876-9dr66   0/1     ContainerCreating   0          0s
hello-6b85969876-rgpqn   0/1     Completed           0          13s
hello-6b85969876-9dr66   1/1     Running             0          1s

(Repeated lines trimmed.) In the same second the old pod started Terminating, a new pod, …9dr66, appeared as Pending (waiting for a node), then ContainerCreating, then Running. One second from deletion to replacement, measured on this machine. The cluster's event log says who did what:

$ kubectl get events --sort-by=.lastTimestamp
6s   Normal   Killing            pod/hello-6b85969876-rgpqn    Stopping container hello
6s   Normal   SuccessfulCreate   replicaset/hello-6b85969876   Created pod: hello-6b85969876-9dr66
6s   Normal   Scheduled          pod/hello-6b85969876-9dr66    Successfully assigned default/hello-6b85969876-9dr66 to atlas-k8s-control-plane
5s   Normal   Pulled             pod/hello-6b85969876-9dr66    Container image "hello:v1" already present on machine …
5s   Normal   Started            pod/hello-6b85969876-9dr66    Container started

The ReplicaSet controller saw "want 3, have 2" and created a pod. The scheduler assigned it to a node. The kubelet started it. That's the loop from §03, live.

The reconcile loop

Every controller in Kubernetes runs the same shape of loop. Here's the ReplicaSet controller, simplified to pseudocode:

replicaset controller (simplified sketch)
for {
	want := rs.Spec.Replicas                     // desired state, from etcd
	have := countPods(matching(rs.Spec.Selector)) // actual state, from the API server
	switch {
	case have < want:
		createPods(want - have) // too few → make more
	case have > want:
		deletePods(have - want) // too many → remove some
	}
	waitForChange() // wake up on the next event (or a periodic resync)
}

Two properties make this robust:

  • It's level-based, not event-based. The loop doesn't care why there are 2 pods instead of 3: a crash, a dead node, a person typing kubectl delete. It only looks at the current gap. So it can't "miss" an event: the next pass sees the gap anyway.
  • It's Idempotent. Running it again when everything is already right does nothing. So it's safe to run constantly, and safe to restart.

Now try it with the loop made visible. This version logs every pass, including the passes where there's nothing to do. Power off a node and notice that its pods first go Unknown and still count, so the controller waits before replacing them:

The ReplicaSet loop, pass by pass
The controller checks once a second. Break things and watch it put them back.
Desired state
replicas: 3
running now: 3 / 3
Click a pod to kubectl delete it. Click a node's power button to kill the whole machine.
node-1
node-2
node-3
want 3, have 3. Try crashing a pod, or powering off a node.

Time is compressed. A real node is marked NotReady after about 40 seconds without hearing from its kubelet. Its pods keep showing their last status (Running) with Ready=False and still count. By default they're evicted about 5 minutes later and stay Terminating until the node answers again, while replacements start elsewhere.

Scaling is just changing the number

$ kubectl scale deploy/hello --replicas=5
deployment.apps/hello scaled
$ kubectl get pods -l app=hello        # -l = filter by label
NAME                    READY   STATUS    RESTARTS   AGE
hello-779c6b7b5-5w49p   1/1     Running   0          8s
hello-779c6b7b5-6cnlg   1/1     Running   0          4m48s
hello-779c6b7b5-85zc4   1/1     Running   0          6s
hello-779c6b7b5-9qftf   1/1     Running   0          4m53s
hello-779c6b7b5-fjsqg   1/1     Running   0          6s

(This was later in our session, after an upgrade, which is why the hash differs.) kubectl scale is a shortcut. The better habit is to change replicas: in the YAML file and kubectl apply it again, so the file in git stays the single source of truth. Otherwise the next apply of the old file quietly scales you back down.

Practitioner§06

Services and DNS: a stable address for moving pods

In one sentencePods come and go, and every new pod gets a new IP. A Service is a stable name and IP address in front of "whichever pods currently match my selector and are ready". Callers use the Service; they never need to know pod IPs.

The problem

In §05 a deleted pod was replaced by …9dr66, with a different IP address. If another service had hard-coded the old pod's IP, its requests would now fail. And with three pods, which IP should a caller pick anyway?

The manifest

svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: hello            # ← becomes the DNS name: http://hello
spec:
  selector:
    app: hello           # ← send traffic to pods with this label
  ports:
    - port: 80           # the port callers use
      targetPort: 8080   # the port the container listens on
$ kubectl apply -f svc.yaml
service/hello created
$ kubectl get svc hello
NAME    TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
hello   ClusterIP   10.96.116.97   <none>        80/TCP    0s
$ kubectl get endpointslices -l kubernetes.io/service-name=hello
NAME          ADDRESSTYPE   PORTS   ENDPOINTS                          AGE
hello-47dgx   IPv4          8080    10.244.0.7,10.244.0.6,10.244.0.8   0s

Two things appeared:

  • A ClusterIP, 10.96.116.97. This address never changes for the life of the Service.
  • An EndpointSlice: the current list of pods behind it, each with its IP and a ready: true/false flag. A controller keeps that list up to date as pods come, go, and become ready or unready.

Calling it, for real

We started a throwaway pod with a shell (kubectl run client --image=busybox:1.37 …; busybox is a tiny image of standard Unix tools) and sent 30 requests to http://hello from inside the cluster:

$ kubectl exec client -- sh -c 'for i in $(seq 30); do wget -qO- http://hello; done' | sort | uniq -c
     14 hello from hello-6b85969876-9dr66 (v1)
     10 hello from hello-6b85969876-t7fns (v1)
      6 hello from hello-6b85969876-z2kjg (v1)

Every request went to the same address, and all three pods answered. The split is random, not strict turns: in its default mode, kube-proxy picks a pod at random for each new connection. Over many requests that evens out.

How a Service actually works

Nobody "owns" the ClusterIP. No process listens on it. Instead, on every node, kube-proxy reads the EndpointSlices and writes kernel network rules for the ready entries only (iptables or nftables) that say: "a packet to 10.96.116.97:80? Rewrite its destination to one of 10.244.0.6:8080, 10.244.0.7:8080, 10.244.0.8:8080." The rewrite happens in the Linux kernel of whichever node the caller is on, so there's no extra hop through a central Load balancer.

Play with it. Change pod labels and readiness and watch where requests can go:

Who gets the traffic?
A Service sends traffic to pods whose labels match its selector AND that are ready. Change both and send requests.
Service hello10.96.116.97
selector: app=
EndpointSlice (every matching pod)
10.244.0.8:8080 ready: true
10.244.0.6:8080 ready: true
10.244.0.7:8080 ready: true
kube-proxy only sends traffic to entries with ready: true.
pod …9dr66 10.244.0.8
0 req
gets traffic
pod …t7fns 10.244.0.6
0 req
gets traffic
pod …z2kjg 10.244.0.7
0 req
gets traffic
pod …k4x2p 10.244.0.9
0 req
label doesn't match → not in the Service
Try: mark one pod not ready, then send 30. Click its label to change app=hello to app=billing. Or put a typo in the selector.

The rule to remember: a pod only receives Service traffic if its labels match the selector and it's ready. An unready pod still appears in kubectl get endpointslices, marked ready: false; it just gets no traffic. "Ready" is decided by its readiness probe (§09). That's how a pod that's still starting, or shutting down, stays out of rotation.

DNS: calling by name

You don't type ClusterIPs either. Every cluster runs cluster DNS (CoreDNS, the coredns pods from §03), and every Service gets a name:

$ kubectl exec client -- nslookup hello.default.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53

Name:	hello.default.svc.cluster.local
Address: 10.96.116.97

The full name is <service>.<namespace>.svc.cluster.local. You rarely type all of it, thanks to the pod's DNS settings:

$ kubectl exec client -- cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

The search line means a short name like hello is tried as hello.default.svc.cluster.local first. So from the same namespace, http://hello just works; from another namespace, use http://hello.<namespace> (§12). The ndots:5 line has a performance surprise for external names; DNS §08 explains it.

Types of Service

type:Reachable fromTypical use
ClusterIP (default)Inside the cluster onlyService-to-service calls: almost everything
NodePortEvery node's IP, on a high port (30000–32767)Quick external access; building block for the next one
LoadBalancerThe internet, via a cloud load balancer the cluster creates for youExposing one service directly (one cloud load balancer each, which costs money)

For HTTP apps with many services, you usually don't give each one a LoadBalancer. You put one entry point in front of all of them: that's Ingress, next.

Reaching a Service from your laptop

While developing or debugging, port-forward opens a tunnel from your machine into the cluster:

$ kubectl port-forward svc/hello 8081:80 &
$ curl localhost:8081
hello from hello-6b85969876-t7fns (v1)

The tunnel goes through the API server and connects to one pod picked when it starts (no spreading across pods), so it's slow and meant for one person. It isn't how real users reach your app.

Practitioner§07

Ingress: letting the outside world in

In one sentenceAn Ingress is a set of HTTP routing rules ("shop.example.com/api goes to the api Service, everything else to web"). An ingress controller, a reverse proxy running in the cluster, reads those rules and does the routing.

Services from §06 handle traffic inside the cluster. Real users arrive from the internet, usually over HTTPS, often for many different apps. You don't want one paid cloud load balancer per Service. You want one front door that looks at each request's hostname and path and forwards it to the right Service.

One front door, many Services
→
ingress controller
(e.g. Traefik, HAProxy, a cloud LB)
TLS ends here · reads Ingress rules
→
Service api ← /api…
Service web ← everything else
The cloud load balancer only knows about the ingress controller. The controller knows the routing rules and sends each request to the right Service, which picks a ready pod as usual.

The manifest

Illustrative: our kind cluster had no ingress controller installed, so we checked this file with kubectl apply --dry-run=server (the API server validated it and said ingress.networking.k8s.io/shop created (server dry run)) but didn't send real traffic through it.

ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
spec:
  ingressClassName: nginx       # ← which installed controller should handle this
  tls:
    - hosts: [shop.example.com]
      secretName: shop-tls      # a Secret holding the certificate and key (§08)
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /api
            pathType: Prefix    # /api, /api/, /api/cart… but not /apiary
            backend:
              service:
                name: api
                port:
                  number: 80
          - path: /
            pathType: Prefix    # everything else
            backend:
              service:
                name: web
                port:
                  number: 80

Three things the controller does with this:

  1. Matches the host. Requests for other hostnames don't use these rules; if nothing matches at all, they go to the controller's default backend.
  2. Matches the path. With Prefix, a path matches when it starts with the rule's path element by element, and the longest matching rule wins. So /api/cart goes to api even though / also matches.
  3. Terminates TLS. It decrypts HTTPS using the certificate in shop-tls, then talks plain HTTP to the pods inside the cluster.

Try some URLs against those rules:

Where does this URL go?
The rules from ingress.yaml above. Type a URL or pick one.
rules · host shop.example.com
Prefix /api → service api ✓ wins
Prefix / → service web (matches, shorter)
result
host: shop.example.com · path: /api/cart
→ Service api
Both /api and / match; the longest one (/api) wins.

No controller, no traffic

The most common Ingress surprise: you apply an Ingress, kubectl get ingress shows it, and nothing happens. Kubernetes doesn't ship an ingress controller. The Ingress object is only a set of rules; you must install a controller (Traefik, HAProxy, Contour, your cloud's own…) that reads them. ingressClassName says which one should pick this Ingress up. Managed clusters usually offer one as an add-on.

The successor: Gateway API

Ingress is deliberately small: host and path routing, TLS, and not much else. Everything beyond that (redirects, header matching, splitting traffic 90/10 between two versions) was done with controller-specific annotations that differed between controllers. The Gateway API is the newer standard that builds those features in. It splits the job into a Gateway (the entry point, owned by the platform team) and HTTPRoutes (routing rules, owned by each app team):

httproute.yaml (illustrative)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: shop
spec:
  parentRefs:
    - name: public-gateway      # attach to the shared Gateway
  hostnames: ["shop.example.com"]
  rules:
    - matches:
        - path: { type: PathPrefix, value: /api }
      backendRefs:
        - name: api
          port: 80
    - backendRefs:              # no matches = everything else
        - name: web
          port: 80
Practitioner§08

ConfigMaps and Secrets: configuration outside the image

In one sentenceBuild one image and run it everywhere. Anything that differs between environments (URLs, feature switches, passwords) comes from a ConfigMap (plain settings) or a Secret (sensitive ones), handed to the container as environment variables or files.

Containers §08 made the case for keeping config out of images: the image you tested in Staging environment should be byte-for-byte the image you run in production. Kubernetes gives that config a home in the cluster.

Two objects

config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: hello-config
data:                          # plain key/value settings
  GREETING: "hi"
  LOG_LEVEL: "info"
---
apiVersion: v1
kind: Secret
metadata:
  name: hello-secret
type: Opaque                   # "arbitrary data" (other types: TLS certs, registry logins)
stringData:                    # write plain text here; Kubernetes stores it encoded
  DB_PASSWORD: "s3cr3t-dev-only"

A ConfigMap is for anything you'd be happy to see in a log. A Secret is for passwords, API keys and certificates (the Ingress in §07 read its TLS certificate from one). The difference is mostly in who may read them, as you'll see.

Handing them to the container

The usual way is as environment variables. This goes inside the container in the Deployment's pod template:

deploy.yaml (container section)
      containers:
        - name: hello
          image: hello:v2
          envFrom:
            - configMapRef:
                name: hello-config     # ← every key becomes an env var
          env:
            - name: DB_PASSWORD        # ← one env var, taken from one Secret key
              valueFrom:
                secretKeyRef:
                  name: hello-secret
                  key: DB_PASSWORD

The app just reads its environment, exactly as it would on your laptop:

greeting := os.Getenv("GREETING") // "hi" — from the ConfigMap
if greeting == "" {
	greeting = "hello" // a default, so the app also runs without Kubernetes
}
dbPass := os.Getenv("DB_PASSWORD") // from the Secret

After the update, our app greeted differently, with no image rebuild:

$ kubectl exec client -- wget -qO- http://hello
hi from hello-779c6b7b5-9qftf (v2)

And kubectl describe pod shows where each value came from, without printing the secret itself:

    Environment Variables from:
      hello-config  ConfigMap  Optional: false
    Environment:
      DB_PASSWORD:  <set to the key 'DB_PASSWORD' in secret 'hello-secret'>  Optional: false

Or as files

Some apps want a config file. Mount the ConfigMap as a volume (a folder the pod mounts) and each key becomes a file:

    spec:
      containers:
        - name: hello
          image: hello:v2
          volumeMounts:
            - name: config
              mountPath: /etc/hello    # → /etc/hello/GREETING, /etc/hello/LOG_LEVEL
              readOnly: true
      volumes:
        - name: config
          configMap:
            name: hello-config

Changing config: the trap

Edit the ConfigMap and apply it. What happens to running pods?

  • Env vars don't change. A process's environment is fixed when it starts. The pods keep the old values until they're replaced.
  • Mounted files do change, after a delay. Your app must notice and re-read the file, which most don't. (Exception: a file mounted with subPath never updates.)

We tested both at once: a pod that prints its env var and its mounted file every 5 seconds. We changed GREETING from hi to hey at 23:33:23. kubectl patch edits fields of an object in place, without a YAML file:

$ kubectl patch configmap hello-config -p '{"data":{"GREETING":"hey"}}'
configmap/hello-config patched
$ kubectl logs cmwatch
23:33:23 env=hi file=hi
…
23:34:43 env=hi file=hi
23:34:48 env=hi file=hey      ← the file caught up after ~85 s
23:34:53 env=hi file=hey      ← the env var never will

So after changing config, restart the pods deliberately. This does a rolling restart (§11), with no downtime:

$ kubectl rollout restart deploy/hello
deployment.apps/hello restarted

A common pattern is to put a version in the ConfigMap's name (hello-config-v7) and point the Deployment at the new name. Then changing config is a change to the pod template, which triggers a normal rollout you can undo. Kustomize (§12) can generate those names for you, adding a hash of the contents; Helm charts often get the same effect by putting a checksum of the config into the pod template.

Secrets are not encrypted (by default)

Here's what the Secret looks like when you read it back:

$ kubectl get secret hello-secret -o yaml
data:
  DB_PASSWORD: czNjcjN0LWRldi1vbmx5
$ kubectl get secret hello-secret -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
s3cr3t-dev-only

That's Base64: an encoding, not encryption. Anyone who can read the Secret object can decode it in one command. What actually protects Secrets is:

  1. Access control. RBAC rules decide who may get Secrets in each namespace. Many people can see Deployments; very few should see Secrets.
  2. Encryption at rest. The cluster can be configured to encrypt Secrets inside etcd. Managed clusters usually do this, or offer it as a setting. Check yours.
  3. Not putting them in git. A Secret manifest with stringData is a plaintext password. Don't commit it.

In production, teams usually keep the real values in a secrets manager (Vault, AWS Secrets Manager…) and sync them into the cluster with a tool like External Secrets Operator. Secrets §10 covers those options and their trade-offs.

Practitioner§09

Probes: liveness, readiness and startup

In one sentenceThe kubelet asks each container three questions on a timer. Startup: "have you finished starting?" Readiness: "should you get traffic right now?" (no → removed from the Service). Liveness: "are you stuck beyond repair?" (no → restarted).

Load balancing §06 explains health checks in general, and why liveness and readiness must check different things. This section is the Kubernetes version: how to write the probes, and what really happens when they fail.

The app's side: two endpoints

Our app gets two health endpoints. /healthz answers if the process can answer at all. /readyz answers 200 only after a 3-second warm-up (pretend it's loading a cache), and 503 before that:

health.go
mux := http.NewServeMux()   // a router: maps URL paths to handler functions
var ready atomic.Bool       // false until warm-up is done

mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("ok")) // liveness: "I can answer". No database checks!
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
	if !ready.Load() {
		http.Error(w, "warming up", http.StatusServiceUnavailable) // 503 → no traffic yet
		return
	}
	w.Write([]byte("ready"))
})

go func() {
	time.Sleep(3 * time.Second) // pretend to load caches
	ready.Store(true)
}()

The cluster's side: the probe config

deploy.yaml (container section)
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            periodSeconds: 2        # ask every 2 s
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            periodSeconds: 10       # ask every 10 s…
            failureThreshold: 3     # …restart after 3 failures in a row (~30 s)

A probe passes when the HTTP status is 200–399 within timeoutSeconds (default 1 s). Besides httpGet, there are tcpSocket (can I connect?), grpc (the standard health check for gRPC servers) and exec (run a command in the container; exit code 0 = pass).

kubectl describe pod prints the full settings, including the defaults we didn't write:

    Liveness:   http-get http://:8080/healthz delay=0s timeout=1s period=10s successThreshold=1 failureThreshold=3
    Readiness:  http-get http://:8080/readyz delay=0s timeout=1s period=2s successThreshold=1 failureThreshold=3

delay is initialDelaySeconds (wait before the first probe), timeout is how long one probe may take, and successThreshold=1 means one pass is enough to count as healthy again.

Play with the three probes on one pod. Pick a scenario, press play, and watch what the kubelet does:

Probe lab
One pod, 60 seconds. Green dot = probe passed, red = failed. Watch what the kubelet does about it.

Starts listening at once, warm after 3 s.

readiness · 2 s
liveness · 10 s
app
gets traffic?
0 s30 s60 s
servingup, not readystartingstuckrestarting
t = 0 s · restarts: 0 · got traffic 0 of 1 s
Press play.

Simplified: failureThreshold 3 for both probes, timings from our manifest, back-off doubling from 10 s. Real kubelets add some jitter.

Readiness failing, for real

We shipped a broken version, v3-broken, whose /readyz always returns 503 ("cannot reach database"). The pod starts fine but never becomes ready:

$ kubectl get pods -l app=hello
NAME                    READY   STATUS    RESTARTS   AGE
hello-779c6b7b5-6cnlg   1/1     Running   0          35s
hello-779c6b7b5-9qftf   1/1     Running   0          40s
hello-779c6b7b5-zfrbh   1/1     Running   0          45s
hello-f8db94ffd-q5vqw   0/1     Running   0          20s

Running but 0/1 READY: the process is up, the probe says "don't send me traffic". describe shows why:

$ kubectl describe pod hello-f8db94ffd-q5vqw
…
Events:
  Type     Reason     Age                From     Message
  Normal   Started    20s                kubelet  spec.containers{hello}: Container started
  Warning  Unhealthy  1s (x11 over 20s)  kubelet  spec.containers{hello}: Readiness probe failed: HTTP probe failed with statuscode: 503

And users? We sent requests to the Service while this pod existed. Every one went to the healthy v2 pods; the unready pod got nothing:

$ kubectl exec client -- sh -c 'for i in 1 2 3 4 5; do wget -qO- http://hello; done'
hi from hello-779c6b7b5-zfrbh (v2)
hi from hello-779c6b7b5-9qftf (v2)
hi from hello-779c6b7b5-zfrbh (v2)
hi from hello-779c6b7b5-9qftf (v2)
hi from hello-779c6b7b5-6cnlg (v2)

Readiness also stopped the rollout from going any further: no old pod was removed, because the new one never became ready. That's §11's safety net.

Liveness failing, for real

Now a typo: a liveness probe pointing at port 8081 when the app listens on 8080. The app is perfectly healthy, but the kubelet can't reach it. This pod, badlive, had this probe:

      livenessProbe:
        httpGet:
          path: /healthz
          port: 8081          # typo: the app listens on 8080
        periodSeconds: 5
        failureThreshold: 3
$ kubectl get pod badlive
NAME      READY   STATUS    RESTARTS     AGE
badlive   1/1     Running   3 (4s ago)   50s
$ kubectl describe pod badlive
…
  Warning  Unhealthy  4s (x9 over 44s)  kubelet  Liveness probe failed: Get "http://10.244.0.17:8081/healthz": dial tcp 10.244.0.17:8081: connect: connection refused
  Normal   Killing    4s (x3 over 34s)  kubelet  Container hello failed liveness probe, will be restarted

Three restarts in 50 seconds (5 s period × 3 failures ≈ one restart every 15 s). Every restart throws away in-flight requests and warm caches. A wrong liveness probe is worse than none.

1

The catch-all route that always says 'alive'

Our first attempt at this demo used the path /health instead of /healthz. Nothing restarted. Why? Go's http.ServeMux sends every unknown path to the "/" handler, which returned 200. The probe "passed" against the greeting page. A probe checks only what its URL actually does, so make sure that URL is a real health handler.

Startup probes: for slow starters

Some apps take a long time to start (a big Java app warming up, a large cache load, database migrations). With only a liveness probe, you'd have to pick: a long initialDelaySeconds (wait that long before the first probe; slow to catch real hangs forever after), or a short one (the app is killed while still starting, restarts, gets killed again…).

A startup probe fixes that. Until it succeeds, liveness and readiness are paused:

          startupProbe:
            httpGet:
              path: /healthz
              port: 8080
            periodSeconds: 5
            failureThreshold: 24    # 24 × 5 s = up to 2 minutes to start
          livenessProbe:            # only starts once the startup probe has passed
            httpGet:
              path: /healthz
              port: 8080
            periodSeconds: 10

What each probe should check

ProbeCheckNever check
Startup"Has my own startup finished?" Usually the same endpoint as liveness—
ReadinessThings about this pod: warmed up? overloaded? shutting down?A shared database or service that every pod uses: when it blips, all pods go unready and the Service has nobody to send to
Liveness"Can this process still answer at all?" Cheap, local, no I/OAnything outside the process. A database outage would make Kubernetes restart every pod at once, which fixes nothing
Practitioner§10

Requests, limits and OOMKilled

In one sentenceEach container says two numbers per resource. The request is what the scheduler reserves for it on a node: "I need at least this much". The limit is the hard ceiling. Go over the memory limit and the container is killed (OOMKilled). Hit the CPU limit and it's slowed down (throttled).

The settings

deploy.yaml (container section)
          resources:
            requests:
              cpu: 50m          # reserve 5% of one CPU core
              memory: 32Mi      # reserve 32 MiB of RAM
            limits:
              memory: 64Mi      # hard ceiling: above this, the container is killed

These are the requests and limits we ran with. (Our demo deliberately sets the memory request below the limit, to show the QoS class further down; the staff tip at the end explains why you'd usually make them equal.)

Requests: where a pod fits

The scheduler only looks at requests, never at actual usage. A node can host a pod only if the sum of requests already on it, plus the new pod's request, fits in the node's allocatable capacity. Here's our node:

$ kubectl describe node
Allocatable:
  cpu:                4
  memory:             16477352Ki
  pods:               110
…
Allocated resources:
  Resource           Requests     Limits
  cpu                1100m (27%)  0 (0%)
  memory             386Mi (2%)   532Mi (3%)

So what happens if we ask for 64 CPUs on a 4-CPU node?

$ kubectl get pod greedy
NAME     READY   STATUS    RESTARTS   AGE
greedy   0/1     Pending   0          4s
$ kubectl describe pod greedy
Events:
  Type     Reason            Age              From               Message
  Warning  FailedScheduling  4s (x3 over 4s)  default-scheduler  0/1 nodes are available: 1 Insufficient cpu. preemption: 0/1 nodes are available: 1 Preemption is not helpful for scheduling.

(The second half is about preemption: making room by evicting lower-priority pods. All our pods have the same priority, so that can't help.)

Pending means "no node has room". It waits forever, or until a node frees up, or until the cluster autoscaler (§12) adds a node. The message tells you which resource is missing.

Limits: the ceiling

The two kinds of limit fail very differently, because memory and CPU are different kinds of resource:

Memory limitCPU limit
Can the container use more?No. Memory can't be "taken back" from a running processNo, but it can be delayed: CPU time can always be handed out later
What happens at the limitThe kernel's out-of-memory killer kills the process: OOMKilled, exit code 137Throttled: the process is paused until the next 100 ms window
What you noticeRestarts; Last State: OOMKilledNothing crashes. Latency gets worse, especially p99

These limits are enforced by Linux cgroups, exactly as with docker run -m in Containers.

OOMKilled, for real

Our app has a /hog?mb=N endpoint that allocates N MB and keeps it. The container's memory limit is 64 Mi. We asked one pod for 100 MB:

$ kubectl exec client -- wget -qO- "http://10.244.0.12:8080/hog?mb=100"
wget: error getting response
$ kubectl get pods -l app=hello
NAME                    READY   STATUS    RESTARTS     AGE
hello-779c6b7b5-6cnlg   0/1     Running   1 (3s ago)   54s
…
$ kubectl describe pod hello-779c6b7b5-6cnlg
    State:          Running
      Started:      Fri, 30 Oct 2026 22:58:30 +0000
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Fri, 30 Oct 2026 22:57:38 +0000
      Finished:     Fri, 30 Oct 2026 22:58:29 +0000
    Restart Count:  1

The kernel killed the process mid-request (so wget got no response). The kubelet restarted the container in the same pod (RESTARTS 1), and it was briefly 0/1 while warming up again. Exit code 137 = 128 + 9: killed by SIGKILL. The current state looks fine; the clue is in Last State. That's why "it keeps restarting" investigations start with describe.

Try it yourself: place pods on two nodes, then push one pod's memory use past its limit:

Pack the nodes
Each node can give out 2000m CPU and 4096Mi of memory in requests. Memory limit = request here (the recommended setting).
add a pod:
node-a
CPU req
500/2000mmemory req
1024/4096Mi
pod-0 medium · RESTARTS 0
node-b
CPU req
1750/2000mmemory req
2304/4096Mi
pod-1 small · RESTARTS 0
pod-2 large · RESTARTS 0
Add large pods until one goes Pending. Then drag a pod's memory slider past its limit.

Notice the scheduler only adds up requests, never actual use. Drag a pod's memory down to almost nothing: its full request is still reserved on the node, and new pods still go Pending.

Tell your runtime about the limit

Runtimes with a garbage collector decide how much memory to use before cleaning up. If their idea of "how much is allowed" is bigger than the container's limit, they grow straight past it and get killed, when collecting garbage a bit earlier would have been fine. Go doesn't look at the limit at all by default. Node (20 and newer) does size its default heap from the limit, but never below roughly 256 MB, so under a small limit like our 64Mi it will happily overshoot:

# Go: a soft memory target. The GC works harder as the heap nears it.
ENV GOMEMLIMIT=56MiB          # ~90% of a 64Mi limit, leaving headroom

You can also set GOMEMLIMIT from the pod spec, so it follows the limit automatically. The resourceFieldRef feature fills an env var with the container's own limit. Note that this gives Go 100% of the limit, with no headroom for memory outside Go's heap; if you see OOM kills anyway, switch to a fixed value around 90% as above:

          env:
            - name: GOMEMLIMIT
              valueFrom:
                resourceFieldRef:
                  resource: limits.memory   # e.g. 67108864 (bytes); Go accepts a plain number

For CPU, Go 1.25 and newer set GOMAXPROCS from the container's CPU limit automatically; older Go versions see every core on the node.

QoS classes: who goes first when memory runs out

From your requests and limits, Kubernetes puts each pod in a QoS class (kubectl describe showed ours as QoS Class: Burstable):

ClassWhenIf the node runs out of memory
GuaranteedEvery container has requests = limits, for both CPU and memoryRoughly last
BurstableSome requests set, but not equal to limitsIn between
BestEffortNo requests or limits at allRoughly first

That order is a simplification. When a node runs short, the kubelet evicts pods mainly by how far their usage is above their request (and by priority), and the QoS class mostly decides who the kernel's OOM killer picks first. In practice it works out roughly as the table says.

Practitioner§11

Rolling updates and rollbacks

In one sentenceChange anything in a Deployment's pod template (usually the image tag) and Kubernetes starts a rolling update, one kind of rollout: start a new pod, wait until it's ready, remove an old one, repeat. If new pods never become ready, the rollout stops with the old pods still serving.

Deployment strategies compares rolling updates with blue-green and canary releases. Here we look at how Kubernetes does the rolling kind, which is the Deployment default.

Triggering one

We changed image: hello:v1 to image: hello:v2 in the YAML (along with the probes and config from §08–§10) and applied it:

$ kubectl apply -f deploy.yaml
deployment.apps/hello configured
$ kubectl rollout status deploy/hello
Waiting for deployment "hello" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "hello" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "hello" rollout to finish: 1 old replicas are pending termination...
deployment "hello" successfully rolled out

(Repeated lines trimmed.) 16 seconds from apply to done, measured on this machine. Here's the pod list during that time, trimmed to the moments that matter:

$ kubectl get pods -l app=hello -w
hello-6b85969876-9dr66   1/1     Running             0          52s    ← three v1 pods
hello-6b85969876-t7fns   1/1     Running             0          64s
hello-6b85969876-z2kjg   1/1     Running             0          64s
hello-779c6b7b5-zfrbh    0/1     ContainerCreating   0          0s     ← v2 pod #1 starts (4 pods now)
hello-779c6b7b5-zfrbh    0/1     Running             0          0s     ← running, not ready yet
hello-779c6b7b5-zfrbh    1/1     Running             0          5s     ← ready after warm-up
hello-6b85969876-9dr66   1/1     Terminating         0          59s    ← only now: one v1 pod stops
hello-779c6b7b5-9qftf    0/1     Pending             0          0s     ← v2 pod #2
hello-779c6b7b5-9qftf    1/1     Running             0          5s
hello-6b85969876-t7fns   1/1     Terminating         0          76s
hello-779c6b7b5-6cnlg    1/1     Running             0          5s     ← v2 pod #3 ready
hello-6b85969876-z2kjg   1/1     Terminating         0          81s    ← last v1 pod stops

Notice the order: a v1 pod was only stopped after a v2 pod turned ready. The readiness probe from §09 is what makes this safe. Without one, Kubernetes would treat "process started" as "ready" and could send users to an app that's still loading.

Under the hood, the Deployment made a second ReplicaSet and moved the count across:

$ kubectl get rs
NAME               DESIRED   CURRENT   READY   AGE
hello-6b85969876   0         0         0       83s    ← v1, scaled to 0 but kept
hello-779c6b7b5    3         3         3       17s    ← v2

The old ReplicaSet is kept at 0 replicas on purpose: it's the recipe for rolling back.

The two knobs

deploy.yaml
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # may run 1 pod above 3 during the update (so up to 4)
      maxUnavailable: 0    # must never drop below 3 ready pods

maxSurge and maxUnavailable set the pace. Each can be a number or a percentage of replicas, and both default to 25%:

  • maxSurge: 1, maxUnavailable: 0 (what we used): never lose capacity, but needs room for one extra pod. Safest, slowest.
  • maxSurge: 0, maxUnavailable: 1: no extra pods (good when the cluster is full), but you run one short during the update.
  • Bigger numbers go faster and take bigger risks.

The other strategy, type: Recreate, kills all old pods first and then starts the new ones. That means downtime, but it's sometimes needed when two versions can't run side by side (say, both would migrate the same database).

Try the knobs, and a broken version:

Rolling update simulator
replicas: 4. Each new pod needs 2 ticks to become ready.
maxSurge
maxUnavailable
v2 is
v1 · ready
v1 · ready
v1 · ready
v1 · ready
ready pods per tick: blue = v1, green = v2
Pick the knobs, then start a rollout from v1 to v2.

Try: surge 1 / unavailable 0 (never below 4 ready), then surge 0 / unavailable 1 (no extra pods, one short). Then make v2 broken and compare surge 0 / unavailable 2: how many pods are still serving?

A broken release, for real

Now v3-broken, whose readiness probe always fails (§09). We used kubectl set image, a shortcut that edits the image in place:

$ kubectl set image deploy/hello hello=hello:v3-broken
deployment.apps/hello image updated
$ kubectl rollout status deploy/hello --timeout=20s
Waiting for deployment "hello" rollout to finish: 1 out of 3 new replicas have been updated...
error: timed out waiting for the condition
$ kubectl get deploy hello
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
hello   3/3     1            3           116s

Read get deploy carefully: 3/3 ready and 3 available (the old v2 pods, still serving), and 1 up-to-date (the new v3 pod, never ready). With maxUnavailable: 0, Kubernetes won't remove a v2 pod until a v3 pod is ready, and that never happens. The rollout is stuck, and users don't notice.

How long until Kubernetes calls it failed? That's the progress deadline, progressDeadlineSeconds, 600 s by default. We set it to 20 s to see it:

$ kubectl rollout status deploy/hello
Waiting for deployment "hello" rollout to finish: 1 out of 3 new replicas have been updated...
error: deployment "hello" exceeded its progress deadline
$ kubectl get deploy hello -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'
Available=True MinimumReplicasAvailable
Progressing=False ProgressDeadlineExceeded

Kubernetes reports the failure (and rollout status exits with an error, which fails a CI job), but it does not roll back by itself. The broken pod stays there until you act. Automatic rollback based on health or error rate needs extra tooling, covered in Deployment strategies.

Rolling back

$ kubectl rollout undo deploy/hello
deployment.apps/hello rolled back
$ kubectl rollout status deploy/hello
deployment "hello" successfully rolled out
$ kubectl rollout history deploy/hello
REVISION  CHANGE-CAUSE
1         <none>
3         <none>
4         <none>

undo scaled the v2 ReplicaSet back up and the v3 one to zero. It's a rollout like any other, just towards an older template. In the history, revision 2 (v2) was re-used as revision 4; that's why it's missing. (CHANGE-CAUSE stays empty unless you set the kubernetes.io/change-cause annotation (a free-form note on the object), which some CI pipelines do.)

undo also printed a warning, which points at a real trap:

Warning: resource deployments/hello was previously managed with 'kubectl apply'. Rolling back will not
update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior
on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.

The cluster now runs v2, but your YAML file (and git) still say v3. The next kubectl apply of that file brings v3 right back. rollout undo is for emergencies; the real fix is reverting the change in git and applying again. With a GitOps tool, the git revert is the rollback.

Practitioner§12

Autoscaling, namespaces and packaging YAML

In one sentenceThree tools for running more than one app: the HPA changes the replica count for you based on load, namespaces split one cluster into separate folders with their own budgets, and Helm or Kustomize stop you from copy-pasting the same YAML for every app and environment.

Autoscaling pods: the HPA

In §05 we scaled by changing replicas ourselves. A HorizontalPodAutoscaler changes it for you, aiming to keep a metric near a target. ("Horizontal" means more copies, as opposed to bigger ones.) Load balancing discusses Autoscaling in general; this is the Kubernetes version.

hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: hello
spec:
  scaleTargetRef:              # which Deployment to scale
    apiVersion: apps/v1
    kind: Deployment
    name: hello
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70   # aim for 70% of the pods' CPU *request*

The arithmetic, with real numbers. Every 15 seconds the HPA computes:

desired = ceil( current replicas × current usage ÷ target )

Say 3 pods each request 200m CPU and are using 140m on average. That's 70% of their request, exactly on target: ceil(3 × 70 ÷ 70) = 3. Traffic doubles, usage goes to 280m = 140%: ceil(3 × 140 ÷ 70) = 6. The HPA sets replicas: 6. When traffic drops, it scales back down, but slowly: by default it waits for 5 minutes of lower readings first, so a short dip doesn't throw away pods you'll need again a minute later.

Notice the target is a percentage of the request (§10). Pods without a CPU request can't be autoscaled on CPU.

It needs metrics, for real

We created the HPA with the shortcut command (--cpu=70% in kubectl 1.37; older versions spell it --cpu-percent=70), and here's what happened on our fresh kind cluster:

$ kubectl autoscale deploy hello --cpu=70% --min=3 --max=10
horizontalpodautoscaler.autoscaling/hello autoscaled
$ kubectl get hpa
NAME    REFERENCE          TARGETS              MINPODS   MAXPODS   REPLICAS   AGE
hello   Deployment/hello   cpu: <unknown>/70%   3         10        3          20s
$ kubectl describe hpa hello
  ScalingActive  False   FailedGetResourceMetric  the HPA was unable to compute the replica count: failed to get
  cpu utilization: unable to get metrics for resource cpu: unable to fetch metrics from resource metrics API: the
  server could not find the requested resource (get pods.metrics.k8s.io)

<unknown>: the HPA can't see CPU usage, because nothing is collecting it. It needs metrics-server, an add-on that kind doesn't install (most managed clusters do). If your HPA never scales, check TARGETS first. Scaling on other metrics, like requests per second or queue length, needs a further adapter (KEDA is a popular one).

Namespaces: folders in one cluster

A namespace is a named folder for objects. Names only have to be unique within one namespace, so two teams can both have a Deployment called hello:

$ kubectl create namespace staging
namespace/staging created
$ kubectl -n staging create deployment hello --image=hello:v1 --port=8080
$ kubectl -n staging expose deployment hello --port=80 --target-port=8080
$ kubectl get pods                       # the "default" namespace
NAME                    READY   STATUS    RESTARTS        AGE
client                  1/1     Running   0               5m1s
hello-779c6b7b5-6cnlg   1/1     Running   1 (3m37s ago)   4m28s
hello-779c6b7b5-9qftf   1/1     Running   0               4m33s
hello-779c6b7b5-zfrbh   1/1     Running   0               4m38s
$ kubectl get pods -n staging
NAME                     READY   STATUS    RESTARTS   AGE
hello-6b85969876-dm2wl   1/1     Running   0          1s

Two Services named hello, in different namespaces. DNS tells them apart by the namespace part of the name (§06):

$ kubectl exec client -- sh -c 'wget -qO- http://hello; wget -qO- http://hello.staging'
hi from hello-779c6b7b5-zfrbh (v2)
hello from hello-6b85969876-dm2wl (v1)

Namespaces are where teams draw lines:

  • Permissions. RBAC rules are usually per namespace: the payments team can deploy to payments, and not to search.
  • Budgets. A ResourceQuota caps what a namespace can request in total:
quota.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-budget
  namespace: staging
spec:
  hard:
    requests.cpu: "4"        # all pods in staging together may request at most 4 cores
    requests.memory: 8Gi
    pods: "40"

Packaging all this YAML

A real app has a Deployment, a Service, an Ingress, a ConfigMap, an HPA… times three environments (dev, staging, prod) that differ in a few values. Copy-pasting those files gets out of sync quickly. Two tools dominate.

Kustomize: a base plus patches

Kustomize is built into kubectl. You keep one plain-YAML base and small overlays that change only what differs:

base/
  deploy.yaml            ← the Deployment from §05: replicas: 1, image: hello:v1,
  kustomization.yaml        envFrom: configMapRef: hello-config
overlays/prod/
  kustomization.yaml     ← below
base/kustomization.yaml
resources:
  - deploy.yaml
overlays/prod/kustomization.yaml
resources:
  - ../../base          # start from the shared base
namespace: prod         # put everything in the prod namespace
images:
  - name: hello
    newTag: v2          # hello:v1 → hello:v2
replicas:
  - name: hello
    count: 5            # 1 → 5
configMapGenerator:
  - name: hello-config  # generated, with a hash of its contents in the name
    literals:
      - GREETING=hi

kubectl kustomize shows the result without applying it (kubectl apply -k applies it). Real output, trimmed:

$ kubectl kustomize overlays/prod
apiVersion: v1
kind: ConfigMap
metadata:
  name: hello-config-hc7d4825hb       ← hash of the contents
  namespace: prod
…
kind: Deployment
metadata:
  name: hello
  namespace: prod
spec:
  replicas: 5
  …
      containers:
      - envFrom:
        - configMapRef:
            name: hello-config-hc7d4825hb   ← reference rewritten to match
        image: hello:v2

That hashed name is the trick from §08: change the config, and the name changes, so the pod template changes, so a normal rollout restarts the pods.

Helm: templates plus values

Helm takes the other approach: YAML templates with placeholders, filled in from a values file. A chart is a folder of these. helm create makes a starter chart (we ran Helm 4.3):

$ helm create hello
Creating hello
$ grep -n "replicas\|image:" hello/templates/deployment.yaml
9:  replicas: {{ .Values.replicaCount }}
41:          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
$ helm template web ./hello --set replicaCount=3 --set image.repository=hello --set image.tag=v2 | grep -E "replicas:|image:"
  replicas: 3
          image: "hello:v2"
      image: busybox

(The last line comes from the starter chart's test pod.) helm template renders without installing; helm install web ./hello -f prod-values.yaml renders and applies, and helm upgrade / helm rollback manage versions of that installed release. The real draw is the ecosystem: databases, monitoring stacks and ingress controllers all ship as charts you can install with your own values.

Practitioner§13

Debugging with kubectl

In one sentenceAlmost every Kubernetes problem is found with five commands, in this order: get (what state is it in?), describe (what happened to it? read the Events), logs (what did the app say?), exec / debug (look around inside), and events (what else is going on?).

Step 1: get — read the STATUS column

$ kubectl get pods
NAME      READY   STATUS             RESTARTS      AGE
crashy    0/1     Error              4 (58s ago)   98s
typo      0/1     ImagePullBackOff   0             98s
badlive   0/1     CrashLoopBackOff   4 (2s ago)    98s
greedy    0/1     Pending            0             97s

Four broken pods we created on purpose, each broken a different way. The STATUS column, READY and RESTARTS already tell you which part of the pod's life went wrong:

You seeIt meansLook at
PendingNo node has been picked yet, usually because none has room for the requests (§10)describe → FailedScheduling event
ErrImagePull → ImagePullBackOffThe node can't download the image: typo in name or tag, private registry without credentialsdescribe → Failed to pull image
ContainerCreating (for minutes)Waiting on something before start: usually a missing ConfigMap/Secret or a volumedescribe → events
CrashLoopBackOff (flips with Error between retries)The container keeps stopping and being restarted, and Kubernetes waits longer before each retry. Usually the app exits by itself (like crashy); a failing liveness probe causes it too (like badlive, from §09)logs --previous, then describe
Running, 0/1 READYThe process is up, but its readiness probe fails, so it gets no traffic (§09)describe → Unhealthy event
Running, RESTARTS climbingIt keeps dying and coming back: OOM kills or a failing liveness probedescribe → Last State

Step 2: describe — the Events at the bottom

kubectl describe pod <name> prints everything about the pod. The most useful part is at the very end: Events, a log of what the scheduler and kubelet did to it. For the typo pod (image hello:v9, which doesn't exist):

$ kubectl describe pod typo
Events:
  Type     Reason     Age                From     Message
  Normal   Scheduled  76s                default-scheduler  Successfully assigned default/typo to atlas-k8s-control-plane
  Normal   Pulling    37s (x3 over 75s)  kubelet  Pulling image "hello:v9"
  Warning  Failed     37s (x3 over 75s)  kubelet  Failed to pull image "hello:v9": … failed to resolve reference "docker.io/library/hello:v9" …
  Warning  Failed     37s (x3 over 75s)  kubelet  Error: ErrImagePull
  Normal   BackOff    12s (x4 over 75s)  kubelet  Back-off pulling image "hello:v9"
  Warning  Failed     12s (x4 over 75s)  kubelet  Error: ImagePullBackOff

Notice docker.io/library/hello:v9: a short image name with no registry means Docker Hub. When an image "works on my laptop" but not in the cluster, it's often because your laptop had it locally and the cluster has to download it.

Events are kept for about an hour by default, so check them soon after the problem.

Step 3: logs — what the app said

$ kubectl logs crashy
config file missing
$ kubectl logs deploy/hello            # picks one pod of the Deployment
Found 3 pods, using pod/hello-779c6b7b5-zfrbh
2026/10/30 22:57:28 v2 listening on :8080
2026/10/30 22:57:31 ready

Useful flags:

  • --previous (-p): logs of the last crashed container. For a crash loop, the current container may have only just started (or not yet), so the error is in the previous one.
  • -f: keep streaming new lines, like tail -f.
  • -c <name>: pick the container, if the pod has more than one.
  • -l app=hello --prefix: logs from all pods with that label, each line tagged with its pod.

The kubelet keeps only the current and the previous container's logs, on that node. Once a pod is deleted its logs are gone, which is why production clusters ship logs to a central store (Observability).

Step 4: exec and debug — look inside

kubectl exec -it <pod> -- sh opens a shell in a running container, if the image has a shell. Ours doesn't: it's built FROM scratch, with nothing but the Go binary.

$ kubectl exec hello-779c6b7b5-9qftf -- sh
error: … exec: "sh": executable file not found in $PATH

That's good for security (an attacker gets no tools either), but how do you debug it? kubectl debug adds an ephemeral debug container with tools, into the running pod, sharing its network and (with --target) its process list:

$ kubectl debug hello-779c6b7b5-9qftf --image=busybox:1.37 --target=hello --container=dbg -- sh -c 'ps; wget -qO- localhost:8080/readyz; echo'
Targeting container "hello". If you don't see processes from this container it may be because the container runtime doesn't support this feature.
PID   USER     TIME  COMMAND
    1 65532     0:00 /hello                          ← our app (user 65532, from the Dockerfile)
   24 root      0:00 sh -c ps; wget -qO- localhost:8080/readyz; echo
   34 root      0:00 ps
ready                                                ← localhost = the app's own network

For an interactive shell instead, use kubectl debug -it <pod> --image=busybox:1.37 --target=hello -- sh. You can see the app's processes and call it on localhost, without changing its image or restarting it.

Step 5: events — the whole namespace

When you don't know which object is unhappy, list recent events across the namespace, oldest first:

$ kubectl get events --sort-by=.lastTimestamp

Look for Warning lines: FailedScheduling, BackOff, Unhealthy, FailedMount, Evicted. One deployment's pods failing to schedule can be the real cause behind another service's errors.

Practice: the pod doctor

Pick a broken pod and walk through the diagnosis. All outputs are the real ones from our cluster:

The pod doctor
Pick a patient. Run the next command until you can name the cause.
$ kubectl get pods
NAME      READY   STATUS             RESTARTS      AGE
crashy    0/1     Error              3 (60s ago)   76s
Staff-level§14

Staff-level: Kubernetes in production

In one sentenceKubernetes keeps the count right on its own. Keeping users happy through deploys, node upgrades and zone failures takes a few deliberate settings: a clean shutdown path, disruption budgets, spreading, and sane security defaults.

1. Shutting down without dropping requests

Every rollout, scale-down and node drain deletes pods. Here's what happens when a pod is deleted, and the race hidden in it:

Pod deletion: two things start at the same moment
without preStop
routing
endpoint removal spreads to every node…
no new traffic
app
SIGTERM: closed, still getting traffic ✗
with preStop sleep 5
routing
endpoint removal spreads…
no new traffic
app
preStop: still serving
SIGTERM → drain
Both lanes start when the pod is marked for deletion. If SIGTERM wins the race, the app closes while some nodes still route new connections to it → connection errors. A short preStop sleep makes the app keep serving until routing has caught up.

When you delete a pod, the API server marks it as terminating, and two independent things start at once:

  • Routing catches up. The endpoints controller marks the pod as terminating and not ready in the Service's EndpointSlice, then every node's kube-proxy and every ingress controller has to see that change and update its rules. That takes from milliseconds to a few seconds.
  • The kubelet stops the container. It runs the preStop hook if there is one, then sends SIGTERM (the "please shut down" signal), waits up to terminationGracePeriodSeconds (default 30 s) in total, then SIGKILL.

If SIGTERM arrives before routing has caught up, your app stops accepting connections while some nodes are still sending it new ones. The fix is a preStop hook that just waits a few seconds:

deploy.yaml (pod template)
    spec:
      terminationGracePeriodSeconds: 30   # total budget: preStop + shutdown
      containers:
        - name: hello
          image: hello:v2
          lifecycle:
            preStop:
              sleep:
                seconds: 5                # keep serving while routing catches up

We measured it. With that hook, kubectl delete pod took 5.9 s, and the app's log shows SIGTERM arriving 5 seconds after the delete was issued at 23:05:24.98:

2026-10-30T23:05:22.150Z  ready
2026-10-30T23:05:30.053Z  SIGTERM: draining     ← preStop slept 5 s first
2026-10-30T23:05:30.055Z  bye

(Timestamps trimmed to milliseconds.) The built-in sleep action needs Kubernetes 1.30 or newer. On older clusters, people used exec: {command: ["sleep", "5"]}, which needs a sleep binary in the image, and a FROM scratch image like ours has none.

Then the app does its part on SIGTERM: stop accepting, finish in-flight requests, exit. Containers §09 covers the signal handling, and Worker pools §09 the draining. Our Go app does this (shown here with a 20 s drain budget):

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer stop()
<-ctx.Done() // ← blocks until SIGTERM arrives
log.Print("SIGTERM: draining")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// srv is the *http.Server; earlier, main started it in its own goroutine with go srv.ListenAndServe()
if err := srv.Shutdown(shutdownCtx); err != nil { // stop accepting; wait for in-flight requests
	log.Printf("shutdown: %v", err) // the 20 s ran out with requests still open
}
log.Print("bye")

Keep the total under the grace period: 5 s preStop + up to 20 s drain < 30 s. If you need longer, raise terminationGracePeriodSeconds.

2. Voluntary disruptions: drains and PodDisruptionBudgets

Nodes get replaced all the time: OS patches, Kubernetes upgrades, the cluster autoscaler removing an empty node. Each starts with a cordon and drain: no new pods there, and evict the existing ones so they're recreated elsewhere. If all three hello pods happen to be on that node, a drain could evict all three at once.

A PodDisruptionBudget forbids that:

pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: hello
spec:
  minAvailable: 2          # voluntary evictions must leave at least 2 ready pods
  selector:
    matchLabels:
      app: hello

We drained our node with the PDB in place (--pod-selector limited the drain to our app). Real output, trimmed:

$ kubectl get pdb
NAME    MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
hello   2               N/A               1                     22s
$ kubectl drain atlas-k8s-control-plane --pod-selector=app=hello --ignore-daemonsets --timeout=15s
node/atlas-k8s-control-plane cordoned
evicting pod default/hello-779c6b7b5-6cnlg
evicting pod default/hello-779c6b7b5-5w49p
evicting pod default/hello-779c6b7b5-9qftf
error when evicting pods/"hello-779c6b7b5-5w49p" -n "default" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
error when evicting pods/"hello-779c6b7b5-6cnlg" -n "default" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
pod/hello-779c6b7b5-9qftf evicted
…

One pod was allowed to go; the others were refused until a replacement became ready somewhere. (On our one-node cluster there was nowhere else to go, so the drain timed out and we uncordoned the node.) A PDB only covers voluntary disruptions. It doesn't stop a node from crashing, which is what the next setting is for.

3. Don't put every copy in one basket

The scheduler only promises your pods fit somewhere. By default nothing stops all three from landing on the same node, or in the same Availability zone, which a single failure then takes out. Topology spread constraints ask for an even spread:

    spec:
      topologySpreadConstraints:
        - maxSkew: 1                                 # zones may differ by at most 1 pod
          topologyKey: topology.kubernetes.io/zone   # spread across zones (use kubernetes.io/hostname for nodes)
          whenUnsatisfiable: ScheduleAnyway          # prefer, don't refuse (DoNotSchedule = strict)
          labelSelector:
            matchLabels:
              app: hello

4. Least privilege by default

A container that's broken into should find as little as possible. These securityContext settings cost nothing for most apps (our scratch image ran fine with all of them):

    spec:
      securityContext:
        runAsNonRoot: true                 # refuse to start if the image would run as root
      containers:
        - name: hello
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true   # the app can't modify its own files
            capabilities:
              drop: ["ALL"]                # no special Linux powers

Every pod runs as a ServiceAccount (its identity when calling the Kubernetes API), and gets that account's token mounted as a file. Add automountServiceAccountToken: false on pods that never call the Kubernetes API, so a compromised container can't use the pod's API credentials. Clusters can enforce a baseline with Pod Security Admission (namespace labels like pod-security.kubernetes.io/enforce: restricted). The image side of security is in Containers §11.

5. Not everything is a Deployment

Deployments are for stateless apps: any pod can be replaced by any other, and nothing is stored on its disk. Other shapes have their own controllers:

ObjectForExample
StatefulSetPods that need a stable identity and their own persistent diskDatabases, Kafka, ZooKeeper
DaemonSetExactly one pod per nodeLog shippers, monitoring agents, kube-proxy
Job / CronJobRun to completion, once or on a scheduleDatabase migrations, nightly reports

And you can add your own. An operator adds a new object type (a CRD) plus a controller that reconciles it, so you can write kind: PostgresCluster and let the operator handle replicas, backups and failover. That's the same reconcile loop from §05, applied to anything.

6. Running the cluster itself

  • Use managed Kubernetes unless you have a platform team: the provider runs etcd, the API server and control-plane upgrades.
  • Upgrade regularly. Kubernetes releases a minor version about three times a year, and each gets roughly 14 months of patches upstream. Falling several versions behind turns upgrades into projects. Clusters upgrade one minor version at a time.
  • Keep everything in git. Manifests, Helm values, cluster add-ons. Rebuilding a cluster from git is your disaster recovery, and GitOps tools (Argo CD, Flux) make "what's in git" and "what's running" the same thing, continuously reconciled.
  • Watch cost. Requests reserve capacity whether you use it or not. Inflated requests (§10) mean paying for idle nodes.
Practitioner§15

Check yourself

Predict the answer before you click. Every one is covered above: Q1 in §05, Q2 in §06, Q3 in §09 and §11, Q4 in §10, Q5 in §08, Q6 in §11, Q7 in §14.

QUESTION 1 / 7
Delete a pod

A Deployment has replicas: 3 and three Running pods. You run kubectl delete pod hello-6b85969876-rgpqn. What does kubectl get pods show a few seconds later?

QUESTION 2 / 7
Where do requests go?

A Service selects app=hello. Three pods match. One of them shows Running with READY 0/1. You send 30 requests to the Service. Roughly how many reach the 0/1 pod?

QUESTION 3 / 7
The release that never gets ready

You deploy a new image whose readiness probe always fails. Fifteen minutes later, what's true? (progressDeadlineSeconds is the default 600.)

strategy:
  rollingUpdate:
    maxSurge: 1
    maxUnavailable: 0
QUESTION 4 / 7
Exit code 137

A Node.js pod has limits: memory: 256Mi. Under load the process's total memory (JavaScript heap plus buffers) grows to about 300 MB. What does kubectl describe pod show?

QUESTION 5 / 7
The config change that didn't

Pods read GREETING via envFrom: configMapRef. You change GREETING in the ConfigMap and kubectl apply it. An hour later the pods still say the old greeting. Why, and what fixes it?

QUESTION 6 / 7
The 2 a.m. rollback

At 2 a.m. the on-call engineer runs kubectl rollout undo to get off a bad v3 back to v2. Nobody touches git. At 9 a.m. an unrelated change merges, and CI runs kubectl apply -f k8s/ from main. What runs after that?

QUESTION 7 / 7
Errors on every deploy

Your app drains in-flight requests correctly on SIGTERM, and it's behind a Service with a readiness probe. Still, every rolling update causes a few connection refused errors. What's the most likely fix?

Answered 0 / 7
Foundations§16

Cheat sheet

🔁
Desired state, reconciledYou declare what should exist; controllers loop forever closing the gap. kubectl apply is safe to repeat.
🐄
Pods are cattleNever create bare pods. A Deployment → ReplicaSet → pods, found by labels. New pod = new name, new IP.
☎️
Services are the phone numberStable ClusterIP + DNS name (svc.ns). Traffic goes only to pods that match the selector and are ready.
🚪
Ingress needs a controllerHost/path rules for outside HTTP traffic. No controller installed = nothing happens. Gateway API is the successor.
🩺
Readiness ≠ livenessReadiness failing → no traffic. Liveness failing → restart. Neither checks shared dependencies. Slow start → startup probe.
📏
Requests place, limits capScheduler uses requests. Over memory limit → OOMKilled (137). At CPU limit → throttled. Doesn't fit → Pending.
🌊
Rollouts wait for readyNew pod ready → old pod removed. Broken release stalls, doesn't roll back itself. Revert in git; rollout undo in emergencies.
🛬
Graceful exitpreStop sleep a few seconds, then drain on SIGTERM, all inside terminationGracePeriodSeconds. PDBs protect against drains.
I want to…Command
Apply / remove what's in a file or folderkubectl apply -f x.yaml · kubectl delete -f x.yaml · kubectl apply -k overlays/prod
See what's runningkubectl get pods -o wide · kubectl get deploy,rs,svc · add -n ns or -A
Find out why a pod is unhappykubectl describe pod <p> (read Events and Last State)
Read logskubectl logs <p> · --previous after a crash · -f to follow · deploy/<d>
Look insidekubectl exec -it <p> -- sh · no shell? kubectl debug -it <p> --image=busybox:1.37 --target=<c>
Recent problems in a namespacekubectl get events --sort-by=.lastTimestamp
Watch / undo a rolloutkubectl rollout status deploy/<d> · rollout history · rollout undo
Restart pods (e.g. after config change)kubectl rollout restart deploy/<d>
Scalechange replicas: and apply (or kubectl scale deploy/<d> --replicas=5)
Reach a Service from my laptopkubectl port-forward svc/<s> 8080:80
Generate starter YAMLkubectl create deployment web --image=x --dry-run=client -o yaml
STATUSFirst move
Pendingdescribe → FailedScheduling: requests too big, or no node fits
ImagePullBackOffCheck image name, tag and registry credentials
CrashLoopBackOfflogs --previous
Running 0/1Readiness probe failing: describe → Unhealthy
RESTARTS climbingdescribe → Last State: OOMKilled (raise limit / fix leak) or liveness failures
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in DevOps & CloudDeployment Strategies
Read →