co-powered by
No experience needed

Kubernetes, Deep Dive

Nine steps on a real three-node cluster you build yourself. Deploy an app, trace exactly what happens when you press Enter, publish it on a real port, drain an entire machine out from under it β€” and watch Kubernetes shrug.

⏱ 2 h 45 m πŸ–§ 1 control plane + 2 workers 🐳 Docker lab recommended first

Nervous? Don't be. Every command on this page is safe. Your whole cluster is three Docker containers on your laptop, and the last step deletes them. If something breaks, good β€” three steps are designed to break so you can see the error and learn to read it. Nothing here can damage your machine.

πŸ“˜ This is the optional extended lab. The 60-minute class lab is Kubernetes, Hands-On. Do that one first. This page uses the same cluster, so you can come back to it later and nothing will need rebuilding.

The plan

Nine steps, about two and three-quarter hours, with spare minutes built in because everyone gets stuck somewhere.

MinStepWhat you'll be able to say afterwards
0–15Setup + what Kubernetes is"I built a three-node cluster on my laptop"
15–271 Walk the cluster"I know what every node and system Pod is for"
27–392 What happens when you press Enter"I can name the five components my command touches"
39–593 Deploy your first app"I wrote a manifest and the scheduler placed it for me"
59–794 Two ways to reach it"I published a port and opened it with no tunnel"
79–975 Scale, heal, drain a node"I emptied a whole machine and nothing went down"
97–1136 Update and undo"I control exactly how fast a release rolls out"
113–1257 Redeploy with no file change"I know the three ways, and which one causes an outage"
125–1398 Probes"I decide when a Pod is allowed to receive traffic"
139–1549 Break it on purpose"I can read an error and find the cause in 30 seconds"
154–165Recap + cleanup + take-homeβ€”

First: what is Kubernetes?

Kubernetes is a program you give a wish to, which then works nonstop to make that wish true. That's it. That's the whole idea.

You do not tell it how to do things. You tell it what you want β€” "three copies of this app should be running" β€” and it spends the rest of its life checking whether that is actually the case, and fixing it when it isn't.

🌑️ The thermostat analogy. You don't walk over to the heater and switch it on and off all evening. You set a dial to 22Β°C and walk away. The thermostat reads the room, compares it to 22, and turns the heat on or off β€” forever, without being asked again.

Kubernetes is a thermostat for software. You set the dial β€” "three copies of nginx" β€” and it reads the cluster, compares, and acts. Delete a copy and it makes another. The dial is called desired state, and the checking is called the reconciliation loop.

Today you will set the dial, then fight against it, and lose. That losing is the lesson.

This is the big difference from the Docker lab. docker run is an order: do this thing, once, now, on this machine. A Kubernetes manifest is a standing instruction: keep this true, on whichever machine is healthy, forever. Nobody has to be awake at 3 a.m.

You, in the terminal kubectl apply -f app.yaml Control plane node api-server Β· etcd scheduler Β· controllers decides, records, never sleeps Worker node 1 Pod Pod Worker node 2 Pod room for more wishes places each Pod on whichever node has room
Your cluster today. The left box is you; everything else is three Docker containers pretending to be three servers.

Ten words you'll hear all day

WordIn plain English
ClusterA group of machines Kubernetes manages as if they were one big computer. Yours will have three.
NodeOne machine in the cluster. Yours are Docker containers pretending to be servers β€” that's the trick kind plays.
Control planeThe node that does the thinking: records your wishes, decides where things go, notices when reality drifts. It normally runs no apps of your own.
WorkerA node that does the working. Your Pods land here. Add more workers β†’ more capacity. That's the whole scaling story.
PodThe smallest thing Kubernetes will run β€” a wrapper around one container (sometimes a couple that must live together). Pods are disposable; they get deleted and replaced constantly, and that's normal.
DeploymentYour standing instruction: "keep N copies of this image running, and here's how to upgrade them." The object you actually write.
ReplicaSetThe counter. Its only job is "exactly N Pods must exist." You never write one β€” the Deployment makes it.
ServiceOne permanent address in front of Pods that keep changing. Pods come and go; the Service name stays.
ManifestA YAML file describing what you want. This is the thing you write and commit to Git.
kubectlThe command you type. Say it "cube-control". Like the docker command, it's only a messenger β€” it sends your wish and prints the reply.
⚠️ This lab needs a real Linux terminal and a healthy Docker. Your entire cluster runs inside Docker, so if Docker isn't well, nothing below works. On Windows that means Ubuntu under WSL2 β€” if your prompt says PS C:\Users\... you are in PowerShell; type wsl and press Enter. On a lab PC running Ubuntu you're already set.
⚠️ Three nodes needs room. Budget roughly 4 GB of free RAM and 2 free CPU cores. If your laptop is tight, drop to one worker β€” delete one - role: worker line in the config below. Everything in this lab still works; only step 5's node-drain gets less dramatic.

Setup: build a three-node cluster 15 minutes

Three tools, in order: Docker (you have it), kubectl (the command), kind (builds clusters inside Docker). Check what you already have:

docker ps
kubectl version --client
kind version
CONTAINER ID   IMAGE   COMMAND   ...        # an empty list is fine - no error is what matters
Client Version: v1.34.0
kind v0.30.0 go1.24.0 linux/amd64

Missing one of the last two? Open the box, install it, come back.

🧰 Install kubectl and kind (2 minutes, Linux / WSL2)

kubectl β€” the official binary, straight from Kubernetes:

curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
rm kubectl
kubectl version --client

kind β€” one small Go binary:

curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.30.0/kind-linux-amd64
sudo install -o root -g root -m 0755 kind /usr/local/bin/kind
rm kind
kind version

On an Apple Silicon Mac or an ARM laptop, swap amd64 for arm64 in both URLs.

Make a folder for today and stay in it:

mkdir -p ~/k8s-lab && cd ~/k8s-lab

On WSL, keep this in your Linux home. Files under /mnt/c/... are slow and cause odd permission problems with manifests.

Setup 1 β€” describe the cluster you want

A one-node cluster is one command. A three-node cluster with published ports needs a config file β€” which is itself a nice lesson: even the cluster is declared in YAML. Here's the shape of what we're about to build:

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: k8s-lab
nodes:
  - role: control-plane          # the brain
    extraPortMappings:           # open a door from your laptop into the cluster
      - containerPort: 30000
        hostPort: 30000
        protocol: TCP
      # ... one block like that for every port, 30000 to 30050 ...
  - role: worker                 # where your Pods actually run
  - role: worker

πŸ’¬ Why the extraPortMappings? Your nodes are Docker containers, and a container's ports are sealed off unless you publish them. In step 4 you'll create a NodePort Service, which opens a port in the 30000–32767 range on every node. Without these mappings that port exists inside the cluster and is invisible from Windows.

We're publishing 30000–30050 β€” fifty-one doors, enough for the whole lab and your take-home, so you never have to rebuild the cluster just to open one more port.

Writing fifty-one of those blocks by hand is nobody's idea of a good time. Generate the file instead:

{
  echo 'kind: Cluster'
  echo 'apiVersion: kind.x-k8s.io/v1alpha4'
  echo 'name: k8s-lab'
  echo 'nodes:'
  echo '  - role: control-plane'
  echo '    extraPortMappings:'
  for p in $(seq 30000 30050); do
    echo "      - containerPort: $p"
    echo "        hostPort: $p"
    echo "        protocol: TCP"
  done
  echo '  - role: worker'
  echo '  - role: worker'
} > kind-config.yaml

Check what you produced before you use it:

head -n 12 kind-config.yaml
tail -n 5 kind-config.yaml
wc -l kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: k8s-lab
nodes:
  - role: control-plane
    extraPortMappings:
      - containerPort: 30000
        hostPort: 30000
        protocol: TCP
      - containerPort: 30001
        hostPort: 30001
        protocol: TCP

      - containerPort: 30050
        hostPort: 30050
        protocol: TCP
  - role: worker
  - role: worker

159 kind-config.yaml
⚠️ Only the control-plane node gets the port mappings, and that's deliberate β€” two containers cannot both claim port 30080 on your laptop. It doesn't limit you: a NodePort opens on every node, and the networking layer forwards from whichever node you knock on to whichever node holds a Pod. The control-plane container is simply your front door. You'll watch this work in step 4.

Setup 2 β€” build it

One command. It downloads a node image the first time, so give it two or three minutes:

kind create cluster --config kind-config.yaml
Creating cluster "k8s-lab" ...
 βœ“ Ensuring node image (kindest/node:v1.34.0)
 βœ“ Preparing nodes πŸ“¦ πŸ“¦ πŸ“¦
 βœ“ Writing configuration
 βœ“ Starting control-plane πŸ•ΉοΈ
 βœ“ Installing CNI πŸ”Œ
 βœ“ Installing StorageClass πŸ’Ύ
 βœ“ Joining worker nodes 🚜
Set kubectl context to "kind-k8s-lab"

Three πŸ“¦ and a "Joining worker nodes" line β€” that's your three machines. Now prove it:

kubectl get nodes
docker ps --format "{{.Names}}"
NAME                    STATUS   ROLES           AGE     VERSION
k8s-lab-control-plane   Ready    control-plane   2m10s   v1.34.0
k8s-lab-worker          Ready    <none>          1m52s   v1.34.0
k8s-lab-worker2         Ready    <none>          1m52s   v1.34.0

k8s-lab-control-plane
k8s-lab-worker
k8s-lab-worker2

πŸ’¬ Read those two outputs together. Kubernetes says you have three nodes. Docker says you have three containers. They are the same three things. kind stands for "Kubernetes IN Docker" β€” it runs a whole multi-machine cluster inside containers so you can learn on a laptop instead of renting servers.

That's also why cleanup takes two seconds: deleting the cluster is deleting three containers.

Confirm the doors are actually open β€” this asks Docker, not Kubernetes:

docker port k8s-lab-control-plane | head -n 3
docker port k8s-lab-control-plane | wc -l
30000/tcp -> 0.0.0.0:30000
30001/tcp -> 0.0.0.0:30001
30002/tcp -> 0.0.0.0:30002
52

Fifty-one of your ports, plus the API server's own port. Nothing is listening behind them yet β€” that's step 4's job.

What does Ready actually mean?

It's the node's own report card, filed by the kubelet β€” the agent running on every node. Ready means: the kubelet is alive and checking in, the container runtime answers, the network plugin is configured so Pods can get IP addresses, and there's enough disk and memory to accept work. Only a Ready node gets given Pods.

If a node sulks in NotReady for more than a minute, ask it why:

kubectl describe node k8s-lab-worker | sed -n '/Conditions:/,/Addresses:/p'

The Conditions block names the exact problem β€” MemoryPressure, DiskPressure, NetworkUnavailable.

If setup goes wrong

What you seeWhat's happeningWhat to do
Cannot connect to the Docker daemonDocker isn't runningsudo systemctl start docker, or launch Docker Desktop
C:\ in your promptYou're in PowerShell, not LinuxType wsl and press Enter
port is already allocated while creatingSomething on Windows already owns a port in 30000–30050Find it, or narrow the range in the generator (e.g. seq 30010 30030) and recreate
Creation hangs on "Joining worker nodes"Not enough memory for three nodeskind delete cluster --name k8s-lab, remove one - role: worker, try again
Only one node listedYou created it without --configkind delete cluster --name k8s-lab and redo with the config file
The connection to the server ... was refusedThe containers are stopped β€” normal after a rebootdocker start k8s-lab-control-plane k8s-lab-worker k8s-lab-worker2, wait 60s
A worker stuck on NotReadyStill joiningWait 60s, then kubectl describe node and read Conditions
node(s) already exist for a cluster with the nameYou already built itNothing β€” or kind delete cluster --name k8s-lab and retry
⚠️ Don't spend more than fifteen minutes here. If one machine refuses to cooperate, pair with a neighbour and carry on. You can rebuild the cluster after the session with two commands.
1

Walk the cluster

Meet all three machines, find out why one of them refuses to run your apps, and name every system Pod on sight.

~12 min🟒 Easy

Before you change anything, find out where you are:

kubectl config current-context
kubectl get nodes -o wide
kind-k8s-lab

NAME                    STATUS   ROLES           AGE   VERSION   INTERNAL-IP   OS-IMAGE             CONTAINER-RUNTIME
k8s-lab-control-plane   Ready    control-plane   8m    v1.34.0   172.18.0.4    Debian GNU/Linux 12  containerd://2.1.4
k8s-lab-worker          Ready    <none>          7m    v1.34.0   172.18.0.2    Debian GNU/Linux 12  containerd://2.1.4
k8s-lab-worker2         Ready    <none>          7m    v1.34.0   172.18.0.3    Debian GNU/Linux 12  containerd://2.1.4

πŸ’¬ Look at the CONTAINER-RUNTIME column: containerd, not Docker. This surprises people. Kubernetes stopped talking to Docker directly years ago β€” it speaks to a runtime through a standard interface called the CRI, and containerd is the usual answer. The images are identical (same OCI format, same Docker Hub), and everything you learned in the Docker lab still applies. Docker is only here to host the nodes.

1.1 β€” Why do the workers say <none>?

Because "role" isn't a real field. It's just a label, and kubectl prints any label named node-role.kubernetes.io/* in that column:

kubectl get nodes --show-labels | tr ',' '\n' | grep node-role
node-role.kubernetes.io/control-plane=

Only one node has one. The workers aren't missing anything β€” nobody bothered to label them. Prove it costs nothing:

kubectl label node k8s-lab-worker node-role.kubernetes.io/worker=
kubectl label node k8s-lab-worker2 node-role.kubernetes.io/worker=
kubectl get nodes
NAME                    STATUS   ROLES           AGE   VERSION
k8s-lab-control-plane   Ready    control-plane   9m    v1.34.0
k8s-lab-worker          Ready    worker          8m    v1.34.0
k8s-lab-worker2         Ready    worker          8m    v1.34.0

πŸ’¬ You just changed what a column says by writing a sticker. Labels are the single most important idea in Kubernetes: they are how Services find Pods, how you select things in bulk, and how the scheduler is persuaded to put Pods in particular places. Almost nothing in Kubernetes is found by name β€” it's found by label.

1.2 β€” Why your apps will never land on the control plane

The control-plane node is a perfectly healthy machine with free capacity. Your Pods will still refuse to go there. Find out why:

kubectl describe node k8s-lab-control-plane | grep -i -A2 taints
kubectl describe node k8s-lab-worker | grep -i -A2 taints
Taints:   node-role.kubernetes.io/control-plane:NoSchedule
Taints:   <none>

πŸ’¬ A taint is a "keep out" sign on a node. A toleration is the pass that gets you in.

That taint says: unless a Pod explicitly tolerates node-role.kubernetes.io/control-plane, do not schedule it here. The control plane's own Pods carry that toleration. Your nginx will not, so the scheduler will only ever consider your two workers.

This is why real clusters keep the brain separate: if your app eats all the memory on the control-plane node, you lose not just the app but the ability to fix anything. The three suffixes are NoSchedule (don't place new Pods), PreferNoSchedule (try not to), and NoExecute (and evict the ones already here).

1.3 β€” How much room is there, really?

The scheduler does arithmetic, so you should see the numbers it works with:

kubectl get nodes -o custom-columns='NODE:.metadata.name,CPU:.status.allocatable.cpu,MEM:.status.allocatable.memory,PODS:.status.allocatable.pods'
NODE                    CPU   MEM        PODS
k8s-lab-control-plane   8     8123456Ki  110
k8s-lab-worker          8     8123456Ki  110
k8s-lab-worker2         8     8123456Ki  110

Allocatable is what's left for Pods after the kubelet and the OS take their cut. Because kind nodes are containers sharing your laptop, all three report your whole machine β€” they are not really separate hardware, and they will happily oversubscribe. On a real cluster these numbers differ per machine and matter enormously.

1.4 β€” Namespaces, and the machinery in kube-system

kubectl get namespaces
kubectl get pods            # your workspace: empty

"No resources found in default namespace" is correct, not an error. Kubernetes is being honest. Now look where the interesting things live:

kubectl get pods -n kube-system -o wide
NAME                                            READY   STATUS    NODE
coredns-66bc5c9577-8xm4d                        1/1     Running   k8s-lab-control-plane
coredns-66bc5c9577-qd4l2                        1/1     Running   k8s-lab-control-plane
etcd-k8s-lab-control-plane                      1/1     Running   k8s-lab-control-plane
kube-apiserver-k8s-lab-control-plane            1/1     Running   k8s-lab-control-plane
kube-controller-manager-k8s-lab-control-plane   1/1     Running   k8s-lab-control-plane
kube-scheduler-k8s-lab-control-plane            1/1     Running   k8s-lab-control-plane
kindnet-7x2mq                                   1/1     Running   k8s-lab-control-plane
kindnet-bl9vk                                   1/1     Running   k8s-lab-worker
kindnet-mn4rt                                   1/1     Running   k8s-lab-worker2
kube-proxy-fl9rz                                1/1     Running   k8s-lab-control-plane
kube-proxy-hc8dm                                1/1     Running   k8s-lab-worker
kube-proxy-vz6kp                                1/1     Running   k8s-lab-worker2

Read the NODE column and a pattern jumps out. Some things exist once, on the control plane. Others exist three times, once per node.

ComponentWhereWhat it does
kube-apiservercontrol planeThe front door. Every command you type arrives here, and nothing in the cluster talks to anything else directly β€” they all go through it.
etcdcontrol planeThe notebook. A key-value database holding the entire cluster state. Lose etcd and you've lost the cluster; back it up in production.
kube-schedulercontrol planeLooks for Pods with no node assigned, and picks one. That's its whole job. Step 2 and step 3 are about this.
kube-controller-managercontrol planeThe thermostat β€” actually dozens of them in one process. The Deployment controller, the ReplicaSet controller, the endpoints controller… all the loops that compare desired with actual.
corednscontrol planeTurns Service names into IP addresses. Two copies for redundancy. Step 4 uses it.
kube-proxyevery nodePrograms the node's network rules so Service IPs and NodePorts actually route somewhere. This is why you can knock on any node and reach a Pod on another.
kindnetevery nodeThe network plugin (CNI) β€” gives Pods IP addresses and lets a Pod on worker 1 talk to a Pod on worker 2. On a real cluster this would be Calico, Cilium, or your cloud's plugin.

How does something run on every node automatically?

kubectl get daemonsets -n kube-system
NAME         DESIRED   CURRENT   READY   NODE SELECTOR   AGE
kindnet      3         3         3       <none>          11m
kube-proxy   3         3         3       kubernetes.io/os=linux   11m

πŸ’¬ A DaemonSet is a Deployment's cousin with a different wish. A Deployment says "N copies, anywhere". A DaemonSet says "exactly one copy on every node" β€” and it means every, including ones that don't exist yet. Add a fourth node tomorrow and kube-proxy appears on it within seconds, unasked.

That's what log collectors, monitoring agents and network plugins are. Notice DESIRED 3 β€” it counted your nodes. Also notice they tolerate the control-plane taint, which is how they got onto a node your apps can't touch.

⚠️ Look, but don't touch. Deleting things in kube-system is how you break your own cluster. (Deleting a Pod there is survivable β€” it comes straight back. Deleting the DaemonSet is not.)
⚠️ Cheat codes for the rest of the lab: -o wide adds columns (IPs, nodes) to any get. -A means every namespace. -w watches live. kubectl explain <thing> is built-in documentation for every field β€” try kubectl explain pod.spec.tolerations right now.
2

What happens when you press Enter

Find out that kubectl does almost nothing, and that your command finishes long before your app starts.

~12 min🟑 Mediumthe "oh!" moment

Most people treat kubectl apply as magic. It isn't, and the day something breaks you will need to know which of six components let you down. So let's take one command apart.

2.1 β€” kubectl is an HTTP client. That's all.

kubectl config view --minify --raw=false | grep -E 'server|name:'
kubectl get nodes -v=6
    server: https://127.0.0.1:45231
    name: kind-k8s-lab

I1016 11:04:22.118   round_trippers.go:553] GET https://127.0.0.1:45231/api/v1/nodes?limit=500 200 OK in 11 milliseconds
NAME                    STATUS   ROLES    AGE   VERSION
...

πŸ’¬ There it is. kubectl get nodes is an HTTPS GET to /api/v1/nodes, and the pretty table is just kubectl formatting the JSON that came back. Add -v=8 to see the headers and the raw body too.

That number after 127.0.0.1: is a random port on your laptop that Docker published for the control-plane container β€” the same mechanism as your 30000–30050 range, set up by kind automatically.

You can skip the formatting entirely and talk to the API yourself:

kubectl get --raw '/api/v1/nodes?limit=1' | head -c 300; echo
kubectl get --raw '/readyz?verbose' | head -n 6

2.2 β€” Create a Pod with curl, no kubectl verbs at all

This is the Docker lab's "the command is only a messenger" moment, in Kubernetes form. kubectl proxy opens an unauthenticated local door to the API, handling the certificates for you.

⚠️ Terminal 2 β€” this must stay running. Open a new Ubuntu tab (Ctrl+Shift+T), start the proxy, and leave it alone.

Terminal 2:

kubectl proxy --port=8001
Starting to serve on 127.0.0.1:8001

Terminal 1 β€” create a Pod by posting JSON, exactly as kubectl would:

curl -s -X POST http://127.0.0.1:8001/api/v1/namespaces/default/pods \
  -H 'Content-Type: application/json' \
  -d '{
    "apiVersion": "v1",
    "kind": "Pod",
    "metadata": { "name": "hello-raw" },
    "spec": { "containers": [ { "name": "hello", "image": "nginx:stable-alpine" } ] }
  }' | head -c 200; echo
kubectl get pod hello-raw -o wide
NAME        READY   STATUS    RESTARTS   AGE   IP           NODE
hello-raw   1/1     Running   0          9s    10.244.1.3   k8s-lab-worker2

πŸ’¬ You never ran a single kubectl create, and a container is running on a worker node. kubectl is a convenience, not an engine. Anything it can do, a CI pipeline, a dashboard or a Go program can do with the same REST calls β€” which is exactly how the whole ecosystem is built.

And notice the NODE column: you never said which machine. The scheduler chose. Yours may well say k8s-lab-worker instead β€” either is correct, but it will never say control-plane, because of the taint from step 1.

2.3 β€” The evidence, in order

kubectl get events --field-selector involvedObject.name=hello-raw --sort-by=.metadata.creationTimestamp
LAST SEEN   TYPE     REASON      OBJECT          MESSAGE
25s         Normal   Scheduled   pod/hello-raw   Successfully assigned default/hello-raw to k8s-lab-worker2
24s         Normal   Pulling     pod/hello-raw   Pulling image "nginx:stable-alpine"
21s         Normal   Pulled      pod/hello-raw   Successfully pulled image in 2.9s
21s         Normal   Created     pod/hello-raw   Created container: hello
20s         Normal   Started     pod/hello-raw   Started container hello

That list is the handover, written down by the components themselves. Scheduled was the scheduler. Pulling / Pulled / Created / Started were the kubelet on worker 2, driving containerd. Five lines, two different machines, nobody coordinating them except through the API server.

The whole path, start to finish

kubectl an HTTPS client kube-apiserver authenticate Β· authorise admit Β· validate etcd written down 201 Created β€” your command is finished here, nothing is running yet everything below just watches the api-server and reacts controllers Deployment β†’ ReplicaSet β†’ Pod objects scheduler picks a node that fits kubelet on that node takes the job containerd pulls the image, starts the container
Nobody in the bottom row talks to anybody else. They each watch the API server and act on what they see.
#What happensWho does it
1Read ~/.kube/config, find the server URL and your client certificatekubectl, on your laptop
2Authentication β€” who are you? (certificate, token or cloud identity)api-server
3Authorization β€” are you allowed to do this? (RBAC)api-server
4Admission β€” rewrite or reject it (defaults filled in, policies applied)api-server plugins
5Validate the object and write it to etcd, then reply 201 Createdapi-server β†’ etcd
6Deployment controller notices a Deployment with no ReplicaSet β†’ creates onecontroller-manager
7ReplicaSet controller counts Pods, finds too few β†’ creates Pod objects with no node yetcontroller-manager
8Scheduler spots unassigned Pods β†’ filters impossible nodes, scores the rest, binds the winnerkube-scheduler
9The kubelet on that node sees a Pod addressed to it β†’ tells containerd to pull and runkubelet β†’ containerd
10Status reported back; endpoints updated; network rules programmedkubelet, controllers, kube-proxy

πŸ’¬ Steps 1–5 are your command. Steps 6–10 happen after you already have your prompt back. This is the single most useful thing in this step: kubectl apply returning created means "your wish was recorded", not "your app is running".

That's exactly why step 3 uses kubectl wait instead of guessing, and why an error can surface thirty seconds after a command that looked like it succeeded.

2.4 β€” Who are you, and what are you allowed to do?

kubectl auth whoami
kubectl auth can-i create deployments
kubectl auth can-i delete nodes --as=system:serviceaccount:default:default
ATTRIBUTE   VALUE
Username    kubernetes-admin
Groups      [kubernetes:masters system:authenticated]

yes
no

You are cluster admin, which is why nothing has stopped you. That third command asks the same question as somebody else β€” a plain service account β€” and gets no. That's RBAC, step 3 of the path above, and it's how real clusters stop one team deleting another's work.

Tidy up before moving on. Delete the Pod, then press Ctrl+C in Terminal 2 to stop the proxy β€” step 4 reuses that terminal for something else:

kubectl delete pod hello-raw
πŸ§ͺ Try it: watch the API server stream changes to you

Controllers don't poll β€” they hold a long-lived watch connection and get pushed updates. You can open one yourself. In Terminal 1:

curl -s -N 'http://127.0.0.1:8001/api/v1/namespaces/default/pods?watch=true' | head -n 3

It hangs β€” correct, it's waiting for news. In Terminal 3, run kubectl run ping --image=nginx:stable-alpine and watch JSON appear instantly in Terminal 1. That push, multiplied by every controller in the cluster, is the reconciliation loop. Clean up with kubectl delete pod ping and Ctrl+C.

3

Deploy your first app

Write one file, apply it, and watch the scheduler spread three Pods across two machines you never named.

~20 min🟑 The big one

You're going to run nginx β€” the same web server from the Docker lab. In Docker that was docker run -d nginx on one machine. Here you write the wish down and let the cluster decide where it goes.

3.1 β€” Write the manifest

nano deployment.yaml

Type this in. YAML uses spaces, never tabs β€” two spaces per level. If your editor inserts tabs, fix that now or every command for the next hour will fail.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx:stable
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"

Save with Ctrl+O, Enter, then exit with Ctrl+X.

What did you just write?

LineWhat it means
kind: DeploymentWhat type of wish this is. Could be Service, Pod, DaemonSet, ConfigMap… here, "keep copies of an app running".
apiVersion: apps/v1Which set of rules to read this file with. Deployments live in the apps group; you'll see a different value in step 4.
metadata.nameWhat you'll call it: nginx. Unique within the namespace.
replicas: 3The dial. Three copies, always. Three, on two workers, is deliberate β€” you'll see the result in a moment.
selector.matchLabelsHow the Deployment recognises the Pods it owns β€” by the sticker app: nginx. Immutable once created; to change it you delete and recreate.
templateThe cookie cutter. Everything below here describes one Pod, and gets stamped out replicas times.
image: nginx:stableSame image idea as Docker, pulled from Docker Hub β€” but pulled by containerd on whichever node runs it, not by Docker on your laptop.
requestsWhat the scheduler reserves to place the Pod. This is the number that decides which nodes are even eligible. 50m is 50 millicores β€” five hundredths of one CPU core.
limitsThe ceiling the container may never cross. Exceed the memory limit and the kernel kills it β€” that's the OOMKilled / exit code 137 you met in the Docker lab, now with a Kubernetes name on it.
⚠️ Three things must match or nothing works: spec.selector.matchLabels, spec.template.metadata.labels, and (in step 4) the Service's selector. All three say app: nginx. Kubernetes matches labels exactly β€” app: nginx and app: nginx-web are unrelated strings to it, and the mismatch fails silently.

3.2 β€” Check it before you apply it

Get into this habit now; it costs one second and catches every indentation mistake:

kubectl apply --dry-run=client -f deployment.yaml
deployment.apps/nginx created (dry run)

Then do it for real, and ask when it's ready instead of guessing:

kubectl apply -f deployment.yaml
kubectl wait --for=condition=Available deployment/nginx --timeout=120s
deployment.apps/nginx created
deployment.apps/nginx condition met

3.3 β€” Where did they go?

kubectl get deployments
kubectl get replicasets
kubectl get pods -o wide
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
nginx   3/3     3            3           25s

NAME               DESIRED   CURRENT   READY   AGE
nginx-7c9f4d8b6d   3         3         3       25s

NAME                     READY   STATUS    RESTARTS   AGE   IP           NODE
nginx-7c9f4d8b6d-k2xvp   1/1     Running   0          25s   10.244.1.4   k8s-lab-worker
nginx-7c9f4d8b6d-m8ztq   1/1     Running   0          25s   10.244.2.3   k8s-lab-worker2
nginx-7c9f4d8b6d-p4wnc   1/1     Running   0          25s   10.244.1.5   k8s-lab-worker

Count them per machine:

kubectl get pods -l app=nginx -o wide --no-headers | awk '{print $7}' | sort | uniq -c
      2 k8s-lab-worker
      1 k8s-lab-worker2

πŸ’¬ You never mentioned a machine, and your app is now on two of them. Also look at the IPs: 10.244.1.x and 10.244.2.x. Each node owns its own slice of the Pod network, handed out by the CNI plugin (kindnet) you met in step 1 β€” and Pods on different nodes can still reach each other directly.

Your split may be 2+1 the other way round. It will never be 3+0 on the control plane, and it will never be 3+0 on one worker by accident β€” read on for why.

How the scheduler actually chose

For every unassigned Pod, kube-scheduler runs two phases:

  1. Filter β€” throw out every node that cannot work. Does the node have an untolerated taint? (Goodbye, control plane.) Does it have enough unreserved CPU and memory for this Pod's requests? Does it match any nodeSelector or affinity rule? Is it Ready and unschedulable-free?
  2. Score β€” rank the survivors out of 100 each. Favour the node with more free resources (LeastAllocated), the one that already has the image cached (ImageLocality), and the one that keeps Pods of the same app spread apart (PodTopologySpread).

Highest score wins; ties are broken at random. Then it binds the Pod to that node β€” which is nothing more than writing a nodeName field back through the API server. The kubelet on that node takes it from there.

Read the decision in writing:

kubectl describe pod -l app=nginx | grep -E 'Node:|Successfully assigned' | head -n 6
⚠️ Requests are a promise, not a measurement. The scheduler adds up the requests of everything already on a node β€” it does not look at real usage. Three Pods requesting 50m each reserve 150m whether they're busy or idle. Ask for more than any node has free and your Pod sits in Pending forever, no matter how quiet the cluster is. That's the single most common scheduling failure, and you can trigger it in step 9's challenge.

3.4 β€” Read the names: the whole story is in them

Deployment:   nginx
ReplicaSet:   nginx-7c9f4d8b6d
Pod:          nginx-7c9f4d8b6d-k2xvp

You created one object. You now have four layers, each stamping its name onto the next:

Deployment manages versions β€” "nginx:stable, Γ—3" ReplicaSet manages the count β€” "exactly 3 must exist" k8s-lab-worker Pod Β· 10.244.1.4 Pod Β· 10.244.1.5 k8s-lab-worker2 Pod Β· 10.244.2.3 room for more creates
You wrote the top box. The controllers built the rest, and the scheduler decided which grey box each Pod landed in.

πŸ’¬ That hash β€” 7c9f4d8b6d β€” is computed from your Pod template. Write yours down. In step 6 you'll change the image and a brand-new ReplicaSet with a different hash will appear. That is not a quirk; it is exactly how rolling updates and rollbacks work.

3.5 β€” Every object keeps a diary

kubectl describe deployment nginx | tail -n 12

That Events block lists, in plain English, everything that has happened to this object. When something breaks later today, this is the first place you look β€” not Google.

πŸ§ͺ Try it: pick a fight with the controller

Delete the ReplicaSet directly and watch what happens:

kubectl delete replicaset -l app=nginx
kubectl get replicasets

A new one appears within a second or two. You cannot win an argument with a controller. The Deployment's job is to make sure a matching ReplicaSet exists, so it just makes another. The only way to stop it is to change the wish itself.

πŸ”¬ Going deeper: force the Pods onto one specific node

Sometimes you genuinely need a Pod on a particular machine β€” the one with the GPU, the one with the fast disk. nodeSelector is the blunt instrument, and it works on any label, including the ones you wrote in step 1:

kubectl patch deployment nginx --type merge -p \
  '{"spec":{"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":"k8s-lab-worker2"}}}}}'
kubectl rollout status deployment/nginx
kubectl get pods -l app=nginx -o wide --no-headers | awk '{print $7}' | sort | uniq -c

All three on worker 2 now. That's the filter phase doing as it's told β€” every other node was eliminated before scoring even started. Now put it back, because a lab where everything lives on one node is a boring lab:

kubectl patch deployment nginx --type json \
  -p '[{"op":"remove","path":"/spec/template/spec/nodeSelector"}]'
kubectl rollout status deployment/nginx
kubectl get pods -l app=nginx -o wide --no-headers | awk '{print $7}' | sort | uniq -c

Spread again. For real work, prefer topologySpreadConstraints (a request to spread evenly) or affinity (a preference with fallbacks) over nodeSelector β€” a hard pin means an unschedulable Pod the day that node dies.

4

Two ways to reach it

Put one permanent address in front of three moving Pods β€” then open it in your browser on a real port, with no tunnel.

~20 min🟑 Mediumthe payoff

4.1 β€” Why you need this

kubectl get pods -o wide

Note a Pod IP β€” something like 10.244.1.4. That address belongs to that one Pod, on that one node, and it vanishes the moment the Pod does β€” which you'll prove in step 5. You cannot build anything on an address that disappears. That is what a Service is for.

4.2 β€” The Service

nano service.yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  type: ClusterIP
  selector:
    app: nginx
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80

Notice apiVersion: v1 here, versus apps/v1 in the Deployment. Services are part of the original core API; Deployments were added later, in the apps group. Copying the wrong one is a classic beginner error with a confusing message.

kubectl apply -f service.yaml
kubectl get services
kubectl get endpoints nginx
service/nginx created

NAME         TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1      <none>        443/TCP   31m
nginx        ClusterIP   10.96.142.88   <none>        80/TCP    4s

NAME    ENDPOINTS                                 AGE
nginx   10.244.1.4:80,10.244.1.5:80,10.244.2.3:80   4s

πŸ’¬ Stop at the ENDPOINTS column. It must list three IP addresses β€” your three Pod IPs, from two different nodes. Endpoints are the proof that your label selector matched something real.

If it says <none>, your labels don't match and everything below will fail. Fix it first. kubectl get endpoints is the one-second command that exposes a bug people lose afternoons to.

Endpoints are a legacy view. The object Kubernetes actually maintains now is an EndpointSlice, and it carries more β€” including which node each Pod is on:

kubectl get endpointslices -l kubernetes.io/service-name=nginx \
  -o custom-columns='SLICE:.metadata.name,ADDRS:.endpoints[*].addresses,NODES:.endpoints[*].nodeName'
SLICE         ADDRS                                 NODES
nginx-x7k2m   10.244.1.4,10.244.1.5,10.244.2.3     k8s-lab-worker,k8s-lab-worker,k8s-lab-worker2

The endpoints controller wrote that, by watching Pods and matching labels. Nobody maintains this list by hand, and nothing you did in service.yaml mentioned a Pod.

4.3 β€” Reach it from inside the cluster (DNS)

kubectl run tmp-shell --rm -it --image=curlimages/curl --restart=Never -- \
  sh -c 'curl -s http://nginx | head -n 4; echo; nslookup nginx 2>/dev/null | tail -n 4'

Plain http://nginx worked. That's coredns, one of the Pods from step 1: every Service automatically gets the DNS name <service>.<namespace>.svc.cluster.local, and inside the same namespace the short name is enough. This is how microservices find each other β€” no config files, no IP addresses, just names.

4.4 β€” Way one: port-forward (the developer's tunnel)

Terminal 2 β€” the one you just freed up. Start this and leave it running:

kubectl port-forward service/nginx 8080:80
Forwarding from 127.0.0.1:8080 -> 80
Forwarding from [::1]:8080 -> 80

The command looks frozen. That's correct β€” it's holding a tunnel open until you press Ctrl+C. Open http://localhost:8080 in your browser and you get the "Welcome to nginx!" page. πŸŽ‰ Reload a few times and watch Terminal 2 log a Handling connection line each time.

Leave it up. You're about to get a second route to the same app, and comparing the two is the point of this step.

4.5 β€” Way two: a NodePort on a real port

nano nodeport.yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx-public
  labels:
    app: nginx
spec:
  type: NodePort
  selector:
    app: nginx
  ports:
    - name: http
      protocol: TCP
      port: 80          # the Service's own port, inside the cluster
      targetPort: 80    # the port inside the container
      nodePort: 30080   # the port opened on EVERY node
kubectl apply -f nodeport.yaml
kubectl get services
NAME           TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
kubernetes     ClusterIP   10.96.0.1       <none>        443/TCP        38m
nginx          ClusterIP   10.96.142.88    <none>        80/TCP         6m
nginx-public   NodePort    10.96.201.17    <none>        80:30080/TCP   3s

Now open your normal browser on Windows β€” Chrome or Edge, not anything inside Ubuntu:

http://localhost:30080

πŸ’¬ No tunnel. No terminal holding anything open. You could close every Ubuntu window and that page would still load. It works because three separate things line up:

1. The NodePort Service opened 30080 on all three nodes. 2. Your kind config published 30080 from the control-plane container through to your laptop. 3. kube-proxy on the control-plane node forwards the request to a Pod β€” which is on a worker, a different machine entirely.

The three ports, which everybody mixes up

FieldLives onWhat it is
nodePort: 30080every nodeThe outside door. Must be in 30000–32767, and for us also inside 30000–30050 so kind published it. Omit it and Kubernetes picks one at random from that range.
port: 80the ServiceThe port on the Service's ClusterIP. This is what other Pods inside the cluster connect to.
targetPort: 80the PodThe port the container is actually listening on. May differ from port β€” port: 80 β†’ targetPort: 8080 is extremely common.
Your browser localhost:30080 control-plane node nodePort 30080 open kube-proxy forwards (runs none of your Pods) Service port 80 picks by label app=nginx Pod 10.244.1.4:80 on k8s-lab-worker Pod 10.244.2.3:80 on k8s-lab-worker2 targetPort 80 β€” the port inside the container
You knock on a machine that runs none of your app, and still get an answer. That hop is kube-proxy's whole reason to exist.

4.6 β€” Make the Pods tell you apart

Every nginx serves the same welcome page, so you cannot tell which one answered. Fix that: give each Pod a page with its own name on it.

for p in $(kubectl get pods -l app=nginx -o jsonpath='{.items[*].metadata.name}'); do
  node=$(kubectl get pod $p -o jsonpath='{.spec.nodeName}')
  kubectl exec $p -- sh -c "echo '$p on $node' > /usr/share/nginx/html/index.html"
done
curl -s http://localhost:30080
nginx-7c9f4d8b6d-m8ztq on k8s-lab-worker2

4.7 β€” Both doors at once, and they don't behave the same

You now have two live routes to the identical Service. Hit each one twelve times and count who answered:

echo "--- NodePort, localhost:30080 ---"
for i in $(seq 1 12); do curl -s http://localhost:30080; done | sort | uniq -c

echo "--- port-forward, localhost:8080 ---"
for i in $(seq 1 12); do curl -s http://localhost:8080; done | sort | uniq -c
--- NodePort, localhost:30080 ---
      5 nginx-7c9f4d8b6d-k2xvp on k8s-lab-worker
      3 nginx-7c9f4d8b6d-m8ztq on k8s-lab-worker2
      4 nginx-7c9f4d8b6d-p4wnc on k8s-lab-worker

--- port-forward, localhost:8080 ---
     12 nginx-7c9f4d8b6d-m8ztq on k8s-lab-worker2

πŸ’¬ Same app, same Service, two completely different answers β€” and this one surprises professionals.

The NodePort request really goes through the Service: kube-proxy picks a ready endpoint per connection, so all three Pods on both workers take a share.

kubectl port-forward service/nginx uses the Service only to look up one Pod, once, when it starts, and then tunnels straight to that Pod's IP. The Service is not in the path at all. That's why you see one name twelve times.

So: port-forward is brilliant for "let me poke at this one thing", and misleading for "is my load balancing working?". People debug for hours because their port-forward keeps hitting the one healthy Pod while real users keep hitting the broken one.

Which one should you use?

kubectl port-forwardNodePort
Who can reach itOnly your own laptopAnything that can reach a node β€” your browser, your phone on the same Wi-Fi, a teammate
Goes through the ServiceNo β€” straight to one PodYes β€” load balanced across every ready Pod
Survives closing the terminalNoYes
Survives the Pod being replacedNo β€” it drops and you re-run itYes β€” endpoints update automatically
Needs anything set up firstNothingThe port must be published β€” your kind-config.yaml did that
Good forInspecting one Pod, reaching a database or an admin UI, quick checksDemos, integration tests, anything a second person must open
In productionNever β€” it's a debugging toolRarely β€” you'd put an Ingress or a cloud LoadBalancer in front instead
⚠️ Keep both running for the rest of the lab. Terminal 2 holds the port-forward on :8080; the NodePort on :30080 needs no terminal at all. From here on, use Terminal 3 for watches and load tests. And when the port-forward dies in step 5 with lost connection to pod β€” that is not a bug, it's the table above coming true.
⚠️ Remember what you just did to those containers in 4.6. You wrote a file into a container's writable layer β€” the thin, temporary sheet from the Docker lab. It is not in the image and not in your manifest. In step 5 you'll delete a Pod and its custom page will vanish with it. Watch for that; it's the clearest proof in this lab that Pods are disposable.
πŸ”¬ Going deeper: the trap of externalTrafficPolicy: Local

By default a NodePort Service uses externalTrafficPolicy: Cluster β€” any node will forward to a Pod on any other node, which is why knocking on the control plane works. The alternative, Local, only ever serves Pods on the node you connected to. It preserves the client's real IP address, which some apps need.

On this cluster that would break everything, because the node with the published port is the one node your Pods are forbidden to run on. Try it and watch it hang:

kubectl patch service nginx-public -p '{"spec":{"externalTrafficPolicy":"Local"}}'
curl -s --max-time 5 http://localhost:30080 || echo ">>> no answer - no local Pods on that node"
kubectl patch service nginx-public -p '{"spec":{"externalTrafficPolicy":"Cluster"}}'
curl -s http://localhost:30080

A real production fix is a DaemonSet-style ingress controller on every node, or a cloud load balancer that only targets nodes with Pods. Worth knowing before you meet it at 2 a.m.

5

Scale, heal, and empty a whole machine

Turn the dial up, delete a running app, then take an entire node out of service β€” and keep the site up through all three.

~18 min🟒 Easythe fun one

5.1 β€” Scale up

Terminal 1:

kubectl scale deployment nginx --replicas=5
kubectl get pods -l app=nginx -o wide --no-headers | awk '{print $7}' | sort | uniq -c
kubectl get endpoints nginx
deployment.apps/nginx scaled

      3 k8s-lab-worker
      2 k8s-lab-worker2

NAME    ENDPOINTS                                                        AGE
nginx   10.244.1.4:80,10.244.1.5:80,10.244.1.6:80 + 2 more...            14m

The existing three Pods were not restarted β€” Kubernetes created only what was missing, and the scheduler placed the new ones where there was room. And you never touched either Service: they match by label, so any new Pod wearing app=nginx joins the pool the moment it's ready.

Go back to three, so the next part is easier to read:

kubectl scale deployment nginx --replicas=3

5.2 β€” Delete a Pod on purpose

⚠️ Use Terminal 3 for the watch β€” Terminal 2 is still holding your port-forward on :8080, and you want it alive for what happens next.

Terminal 3 β€” start watching and leave it:

kubectl get pods -o wide -w

Terminal 1 β€” copy a real Pod name from your output, not from this page:

kubectl get pods -o wide
kubectl delete pod <paste-one-of-your-nginx-pod-names>
nginx-7c9f4d8b6d-k2xvp   1/1   Terminating         0   18m   10.244.1.4   k8s-lab-worker
nginx-7c9f4d8b6d-z9rtb   0/1   Pending             0   0s    <none>       <none>
nginx-7c9f4d8b6d-z9rtb   0/1   ContainerCreating   0   0s    <none>       k8s-lab-worker2
nginx-7c9f4d8b6d-z9rtb   1/1   Running             0   2s    10.244.2.4   k8s-lab-worker2

A replacement appeared in about two seconds, with a new name, a new IP β€” and look at the NODE column: possibly a different machine entirely. Notice the Pending line with <none> for the node: that is the half-second window where the Pod object existed but the scheduler hadn't chosen yet. That's step 2's pipeline, caught in the act.

Press Ctrl+C in Terminal 3. Now, back in Terminal 1, ask who is answering:

for i in $(seq 1 10); do curl -s http://localhost:30080 | head -n 1; done | sort | uniq -c
      3 <!DOCTYPE html>
      4 nginx-7c9f4d8b6d-m8ztq on k8s-lab-worker2
      3 nginx-7c9f4d8b6d-p4wnc on k8s-lab-worker

πŸ’¬ There it is. Two Pods still serve the custom page you wrote in step 4. The replacement serves the stock nginx welcome page, because the file you wrote was never part of the image β€” it lived in one container's writable layer, and that layer died with the Pod.

The site stayed up the entire time. Nobody was paged. But every unsaved thing inside that container is gone, and that's not a bug β€” it's the contract. Anything that must survive goes in a volume, a database, or the image.

Now glance at Terminal 2. If the Pod you deleted happened to be the one your port-forward latched onto in step 4, it has died:

E1016 12:14:03.882  portforward.go:409] an error occurred forwarding 8080 -> 80:
  lost connection to pod

Meanwhile http://localhost:30080 never flinched. That is the table from step 4.7 happening to you: the NodePort follows the Service, the tunnel was pinned to one Pod. Restart it and it picks a new Pod:

# Terminal 2
kubectl port-forward service/nginx 8080:80

What just happened, precisely

This is the reconciliation loop, running right now, every few seconds, forever:

  1. Observe β€” the ReplicaSet controller counts Pods labelled app=nginx: two.
  2. Compare β€” desired is three.
  3. Act β€” create one more Pod object from the template, with no node set.
  4. Hand off β€” the scheduler picks a node; the kubelet there starts it; the endpoints controller adds its IP to both Services.
  5. Repeat.

The new Pod is a different Pod, not the old one revived. New name, new IP, possibly a new machine, no memory of its predecessor. This is what people mean by "cattle, not pets" β€” and exactly why you must never hard-code a Pod IP anywhere.

⚠️ So how do you actually delete this app? Not by deleting Pods β€” you've just watched that fail. You change the wish: kubectl delete deployment nginx. The instruction is what's permanent; the Pods are temporary. (Don't run that yet.)

5.3 β€” Now take a whole machine out of service

This is the part you could not do on a one-node cluster, and it is the single most common real maintenance task in the world: "I need to reboot that server."

First, see where everything is:

kubectl get pods -o wide

Then evict everything from one worker, in one command:

kubectl drain k8s-lab-worker2 --ignore-daemonsets --delete-emptydir-data
node/k8s-lab-worker2 cordoned
Warning: ignoring DaemonSet-managed Pods: kube-system/kindnet-mn4rt, kube-system/kube-proxy-vz6kp
evicting pod default/nginx-7c9f4d8b6d-z9rtb
pod/nginx-7c9f4d8b6d-z9rtb evicted
node/k8s-lab-worker2 drained
kubectl get nodes
kubectl get pods -o wide
curl -s http://localhost:30080 | head -n 1
NAME                    STATUS                     ROLES           AGE   VERSION
k8s-lab-control-plane   Ready                      control-plane   48m   v1.34.0
k8s-lab-worker          Ready                      worker          47m   v1.34.0
k8s-lab-worker2         Ready,SchedulingDisabled   worker          47m   v1.34.0

NAME                     READY   STATUS    RESTARTS   AGE   IP           NODE
nginx-7c9f4d8b6d-p4wnc   1/1     Running   0          22m   10.244.1.5   k8s-lab-worker
nginx-7c9f4d8b6d-m8ztq   1/1     Running   0          22m   10.244.1.7   k8s-lab-worker
nginx-7c9f4d8b6d-t8kqn   1/1     Running   0          18s   10.244.1.8   k8s-lab-worker

nginx-7c9f4d8b6d-p4wnc on k8s-lab-worker

πŸ’¬ You just emptied a server and the website never blinked. Three things happened in that one command:

Cordon β€” the node was marked SchedulingDisabled, so no new Pods will be placed there. Evict β€” each existing Pod was politely asked to terminate; the ReplicaSet controller immediately noticed the shortfall and made replacements, which the scheduler put on the only remaining eligible node. DaemonSets were skipped β€” kube-proxy and kindnet are supposed to be on every node, so draining them would be pointless; that's what --ignore-daemonsets acknowledges.

In the real world this is what happens before every kernel patch, every disk swap, every node resize, on every cluster, every week.

Now put the node back to work:

kubectl uncordon k8s-lab-worker2
kubectl get nodes
kubectl get pods -o wide
⚠️ Notice what did not happen. The node is Ready again, but your Pods did not move back. Kubernetes does not rebalance running Pods. Scheduling is a one-time decision made at birth; once a Pod is on a node it stays there until something kills it. This surprises nearly everyone.

To spread out again, give the Pods a reason to be reborn:

kubectl rollout restart deployment/nginx
kubectl rollout status deployment/nginx
kubectl get pods -l app=nginx -o wide --no-headers | awk '{print $7}' | sort | uniq -c

rollout restart replaces every Pod one at a time, with no downtime β€” the same machinery as step 6's image update, just with the same image. It's the standard way to pick up a changed secret, re-balance after maintenance, or simply "turn it off and on again" safely.

CommandWhat it doesWhen
kubectl cordon NODEStop placing new Pods here. Existing ones stay and keep serving."Something looks wrong with that machine, let me look."
kubectl drain NODECordon, then evict everything that can be moved.Before a reboot, patch or shutdown.
kubectl uncordon NODEAllow scheduling again. Does not move anything back.After the maintenance.
kubectl delete node NODERemove it from the cluster entirely.The machine is gone for good.
⚠️ An honest caveat. Self-healing restarts and reschedules. It does not fix broken code. A Pod with a bug will crash, restart, crash again, and settle into CrashLoopBackOff forever. And drain only stayed graceful here because you had a second worker with room β€” drain your last worker and those Pods go to Pending and stay there. Capacity is still your problem.
πŸ”¬ Going deeper: stopping a drain from taking you down

Drain evicts as fast as the API lets it. With three replicas on two nodes that's fine, but if every replica happened to be on the node you're draining, you would briefly have zero. A PodDisruptionBudget is the guardrail β€” it makes voluntary disruptions like drain wait rather than break your promise:

kubectl create poddisruptionbudget nginx-pdb --selector=app=nginx --min-available=2
kubectl get pdb
kubectl drain k8s-lab-worker --ignore-daemonsets --delete-emptydir-data
kubectl get pods -o wide

The drain still succeeds, but it evicts one at a time, waiting for each replacement to be ready before taking the next β€” because dropping below two was forbidden. Put everything back when you're done:

kubectl uncordon k8s-lab-worker
kubectl delete pdb nginx-pdb
kubectl rollout restart deployment/nginx
kubectl rollout status deployment/nginx

PDBs protect against voluntary disruption only. A node that catches fire does not consult your budget.

πŸ§ͺ Try it: scale to zero
kubectl scale deployment nginx --replicas=0
kubectl get pods
kubectl get deployment nginx
kubectl get endpoints nginx
curl -s --max-time 5 http://localhost:30080 || echo ">>> nothing left to serve it"

All Pods gone. The Deployment still there, reporting 0/0. Endpoints empty. Nothing was deleted β€” the dial was turned to zero. This is a real production technique for pausing a workload without losing its configuration. Put it back:

kubectl scale deployment nginx --replicas=3
kubectl rollout status deployment/nginx
6

Update and undo

Ship a new version with measured zero downtime, control exactly how fast it goes, and take it back in two seconds.

~16 min🟑 Medium

You'll move from nginx:stable to nginx:stable-alpine β€” the same nginx release built on Alpine Linux, so a much smaller image. Both are official, maintained tags, so this works today and will still work when you repeat the lab next month.

6.1 β€” Write down what you have now

kubectl get replicasets
kubectl get deployment nginx -o jsonpath='{.spec.template.spec.containers[0].image}'; echo
kubectl get deployment nginx -o jsonpath='{.spec.strategy}'; echo
NAME               DESIRED   CURRENT   READY   AGE
nginx-7c9f4d8b6d   3         3         3       32m

nginx:stable

{"rollingUpdate":{"maxSurge":"25%","maxUnavailable":"25%"},"type":"RollingUpdate"}

πŸ’¬ You never wrote a strategy block, so Kubernetes filled one in β€” that's the admission stage from step 2, quietly adding defaults. Read it as a promise about the rollout:

maxUnavailable: 25% β€” "at no point may more than a quarter of the replicas be missing." maxSurge: 25% β€” "you may temporarily run up to a quarter extra while swapping." With three replicas, both round to 1: at least 2 serving at all times, at most 4 Pods alive at once.

6.2 β€” Measure the downtime while it rolls

Terminal 3 β€” start hammering the NodePort, and leave it running:

for i in $(seq 1 300); do
  curl -s -o /dev/null -w "%{http_code}\n" --max-time 2 http://localhost:30080
  sleep 0.1
done | sort | uniq -c

Terminal 1 β€” immediately roll out the new image:

kubectl set image deployment/nginx nginx=nginx:stable-alpine
kubectl rollout status deployment/nginx
Waiting for deployment "nginx" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "nginx" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "nginx" rollout to finish: 1 old replicas are pending termination...
deployment "nginx" successfully rolled out

When Terminal 3 finishes, read its summary:

    300 200

πŸ’¬ Three hundred requests, three hundred 200 OK, zero failures β€” through a complete version change of every single Pod. That is not luck and it is not a coincidence. New Pods are created and confirmed healthy before old ones are removed, and an unready Pod is never in the Service's endpoint list, so traffic literally cannot reach it.

If you see a handful of non-200s, you've found something real β€” most likely you're on externalTrafficPolicy: Local from step 4's deep-dive, or the node ran out of memory. Both are worth investigating rather than ignoring.

⚠️ Read the set image command carefully. deployment/nginx is the Deployment name; the nginx= on the right is the container name from your manifest. They happen to be the same word here. Had you named the container web, it would be web=nginx:stable-alpine β€” and getting it wrong gives you error: unable to find container named ....

6.3 β€” See what changed

kubectl get replicasets
NAME               DESIRED   CURRENT   READY   AGE
nginx-5d7b9c4f8a   3         3         3       40s
nginx-7c9f4d8b6d   0         0         0       35m

πŸ’¬ Two ReplicaSets now, and a new hash β€” exactly as promised in step 3. The new one runs three Pods; the old one is scaled to zero but kept. That empty ReplicaSet is not clutter: it is your undo button, it costs nothing, and Kubernetes keeps the last ten by default (revisionHistoryLimit).

6.4 β€” Read the history

kubectl rollout history deployment/nginx
REVISION  CHANGE-CAUSE
1         <none>
2         <none>
3         <none>

CHANGE-CAUSE is empty because nobody wrote down why. On a real team you would:

kubectl annotate deployment/nginx kubernetes.io/change-cause="switch to alpine base image"
kubectl rollout history deployment/nginx --revision=3

That second command prints the full Pod template for one revision β€” image tag, resources, labels, everything.

6.5 β€” Undo it

kubectl rollout undo deployment/nginx
kubectl rollout status deployment/nginx
kubectl get deployment nginx -o jsonpath='{.spec.template.spec.containers[0].image}'; echo
kubectl get endpoints nginx

Back to nginx:stable, 3/3, three endpoints. Reload http://localhost:30080 once more.

Why that was instant

A rolling update is not an edit-in-place. Kubernetes:

  1. Creates a new ReplicaSet for the new Pod template β€” hence the new hash.
  2. Scales the new one up, within maxSurge, and waits for those Pods to be ready.
  3. Scales the old one down, never breaching maxUnavailable.
  4. Repeats until the old one is at zero.

rollout undo runs that in reverse. Nothing is rebuilt and nothing is re-downloaded, because the old ReplicaSet object was never deleted β€” which is why rollback takes two seconds instead of twenty minutes. And the information came from etcd, not from a file on your laptop: a classmate with cluster access could roll your release back from their machine.

⚠️ The caveat that matters more than the mechanism. Rollback reverts containers. It does not revert database migrations, emails you already sent, or rows you already wrote. And a successful rollout does not mean a working app β€” Pods can be happily Running while every request returns a 500. Always verify behaviour, not just Pod status.
πŸ”¬ Going deeper: control the speed, and pause mid-release

Strictest possible β€” never lose a single replica:

kubectl patch deployment nginx -p \
  '{"spec":{"strategy":{"rollingUpdate":{"maxUnavailable":0,"maxSurge":1}}}}'

Now the only way to replace a Pod is to add a fourth one first. Safest, slowest, and needs spare capacity. The opposite β€” maxUnavailable: 1, maxSurge: 0 β€” needs no spare capacity but runs degraded. Pick based on whether you have room or not.

A hand-rolled canary: start a release, freeze it with one new Pod live, inspect, then decide.

kubectl set image deployment/nginx nginx=nginx:stable-alpine
kubectl rollout pause deployment/nginx
kubectl get pods -l app=nginx -o custom-columns='POD:.metadata.name,IMAGE:.spec.containers[0].image'
# ... check your dashboards, your logs, your error rate ...
kubectl rollout resume deployment/nginx      # happy? finish the rollout
kubectl rollout undo deployment/nginx        # unhappy? back out, having risked one Pod

Put the strategy back to the defaults when you're done:

kubectl patch deployment nginx -p \
  '{"spec":{"strategy":{"rollingUpdate":{"maxUnavailable":"25%","maxSurge":"25%"}}}}'
kubectl rollout status deployment/nginx
πŸ§ͺ Try it: jump to a specific revision
kubectl rollout history deployment/nginx
kubectl rollout undo deployment/nginx --to-revision=1
kubectl get deployment nginx -o jsonpath='{.spec.template.spec.containers[0].image}'; echo

undo alone means "the one before this". --to-revision=N means "that exact one" β€” which is what you want when the last three releases were all broken.

7

Redeploy, when nothing in the file changed

The job you'll do most often, the three ways to do it, and the one that quietly causes an outage.

~12 min🟑 Mediumthe daily one

Step 6 was a new version: the image tag changed, so the Pod template changed, so Kubernetes had something to react to. Most real redeployments aren't like that. You pushed a rebuilt image under the same tag, or a config value changed, or a Pod is wedged and you just want it replaced. Nothing in your YAML is different β€” and that is exactly the problem.

7.1 β€” Apply the same file again and watch nothing happen

kubectl apply -f deployment.yaml
deployment.apps/nginx unchanged

πŸ’¬ unchanged is not a warning, it's the whole design. apply is declarative: it compares your file with the live object and acts only on the difference. No difference, no action, no Pods touched.

That's why you can safely run kubectl apply in a CI pipeline a hundred times a day. It's also why "I redeployed and nothing happened" is one of the most common confused messages in any team chat.

7.2 β€” The same-tag trap

Say you rebuild and push myapp:latest with a bug fix. Your manifest still says image: myapp:latest, so apply does nothing. Fine β€” you force Pods to be recreated. They may still run the old code. Here's why:

kubectl get deployment nginx -o jsonpath='{.spec.template.spec.containers[0].imagePullPolicy}'; echo
IfNotPresent
imagePullPolicyWhat the kubelet doesSet when
IfNotPresentUse the node's cached copy if it has one; only pull when it doesn't. The default for any normal tag.You use immutable tags, so a tag never means two different images
AlwaysCheck the registry on every Pod start. The default when your tag is :latest or you gave no tag at all.You're stuck with mutable tags. Costs a registry round-trip per start
NeverNever pull. Fail if the image isn't already on the node.Air-gapped clusters, or images you side-loaded yourself
⚠️ The rule that makes this whole problem disappear: don't use mutable tags. Tag every build with something unique β€” a git commit SHA, a build number, v1.4.2. Then a new version is always a genuine change to the manifest, apply always does the right thing, rollout history tells the truth, and rollout undo goes somewhere meaningful. :latest breaks all four of those at once.

7.3 β€” The right way to force a redeploy

Before you run anything, take a snapshot. A redeploy is only interesting if you can see what moved:

kubectl get pods -l app=nginx -o wide
kubectl get replicasets
kubectl get deployment nginx -o jsonpath='{.spec.template.metadata.annotations}'; echo "  <-- annotations"
kubectl get pods -l app=nginx -o name | sort > ~/k8s-lab/pods-before.txt
NAME                     READY   STATUS    RESTARTS   AGE   IP           NODE
nginx-7c9f4d8b6d-m8ztq   1/1     Running   0          41m   10.244.2.3   k8s-lab-worker2
nginx-7c9f4d8b6d-p4wnc   1/1     Running   0          41m   10.244.1.5   k8s-lab-worker
nginx-7c9f4d8b6d-t8kqn   1/1     Running   0          23m   10.244.1.8   k8s-lab-worker

NAME               DESIRED   CURRENT   READY   AGE
nginx-7c9f4d8b6d   3         3         3       41m

  <-- annotations

One ReplicaSet, three Pods up to forty-one minutes old, and no annotations at all. Now redeploy:

kubectl rollout restart deployment/nginx
kubectl rollout status deployment/nginx
deployment.apps/nginx restarted
Waiting for deployment "nginx" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "nginx" rollout to finish: 2 old replicas are pending termination...
deployment "nginx" successfully rolled out

And take the same three readings again:

kubectl get pods -l app=nginx -o wide
kubectl get replicasets
kubectl get deployment nginx -o jsonpath='{.spec.template.metadata.annotations}'; echo
NAME                     READY   STATUS    RESTARTS   AGE   IP            NODE
nginx-8a3e1c7d25-6rzv2   1/1     Running   0          22s   10.244.1.11   k8s-lab-worker
nginx-8a3e1c7d25-hq4nd   1/1     Running   0          18s   10.244.2.7    k8s-lab-worker2
nginx-8a3e1c7d25-xp9ck   1/1     Running   0          14s   10.244.1.12   k8s-lab-worker

NAME               DESIRED   CURRENT   READY   AGE
nginx-8a3e1c7d25   3         3         3       22s
nginx-7c9f4d8b6d   0         0         0       42m

{"kubectl.kubernetes.io/restartedAt":"2026-10-16T12:33:10Z"}

Prove every single Pod was replaced, rather than squinting at the names:

kubectl get pods -l app=nginx -o name | sort > ~/k8s-lab/pods-after.txt
diff ~/k8s-lab/pods-before.txt ~/k8s-lab/pods-after.txt
1,3c1,3
< pod/nginx-7c9f4d8b6d-m8ztq
< pod/nginx-7c9f4d8b6d-p4wnc
< pod/nginx-7c9f4d8b6d-t8kqn
---
> pod/nginx-8a3e1c7d25-6rzv2
> pod/nginx-8a3e1c7d25-hq4nd
> pod/nginx-8a3e1c7d25-xp9ck

Three out, three in. Not one name survived.

What changed, and what didn't

ReadingBeforeAfterWhy
Imagenginx:stablenginx:stable β€” identicalYou redeployed; you didn't upgrade. Nothing about the app version changed.
Your YAML fileUntouched. deployment.yaml on disk is byte-for-byte the sameWhich is the whole problem this step solves
Pod name prefixnginx-7c9f4d8b6d-…nginx-8a3e1c7d25-…The prefix is the Pod-template hash. A new hash means the template genuinely changed
Pod AGE23–41m14–22sBrand-new Pods. And note they're staggered by a few seconds β€” that's the rolling update going one at a time
RESTARTS00Read this twice. Nothing was restarted. The old Pods were deleted and different Pods were created
Pod IPs10.244.2.3, …10.244.1.11, …New Pods, new addresses β€” and the Service's endpoint list was rewritten for you
ReplicaSets12 β€” old one scaled to 0The empty one is your undo button, exactly as in step 6
Annotationsnonekubectl.kubernetes.io/restartedAt: …The one and only thing rollout restart actually wrote
rollout historyN revisionsN+1A redeploy is a revision like any other, and rollout undo will walk back over it

πŸ’¬ There is no "restart" feature in Kubernetes. Look at that table again: rollout restart wrote one timestamp annotation into the Pod template. That changed the template, which changed its hash, which produced a new ReplicaSet β€” and from there it is the ordinary rolling update from step 6, with the ordinary guarantees.

So a redeploy respects maxSurge and maxUnavailable, waits for readiness probes, keeps the old ReplicaSet as an undo button, and takes zero downtime. All of that, from an annotation nobody reads.

And the RESTARTS 0 row is the one students get wrong in interviews. "Restart" is a lie the command's name tells you. Nothing restarts β€” Pods are replaced. If a container had genuinely restarted in place, that counter would say 1 and the Pod name would be unchanged.

Want to see the whole thing happen rather than its before and after? Run the watch in Terminal 3 first, then restart again from Terminal 1:

# Terminal 3
kubectl get pods -l app=nginx -w
nginx-8a3e1c7d25-6rzv2   1/1   Running             0     3m
nginx-4d2b8e6f03-k7t2w   0/1   Pending             0     0s     <-- new Pod created first
nginx-4d2b8e6f03-k7t2w   0/1   ContainerCreating   0     0s
nginx-4d2b8e6f03-k7t2w   1/1   Running             0     2s     <-- it is ready...
nginx-8a3e1c7d25-6rzv2   1/1   Terminating         0     3m     <-- ...only THEN is an old one removed
nginx-4d2b8e6f03-9wscl   0/1   Pending             0     0s     <-- and the next pair begins

Read the order of those lines. Ready-then-terminate, ready-then-terminate, one pair at a time. That ordering is the entire reason the next section's measurement comes out the way it does.

7.4 β€” The blunt way, and the price of it

Plenty of people "redeploy" by deleting Pods and letting the controller rebuild them. Let's measure what that costs.

Terminal 3 β€” start the probe loop:

for i in $(seq 1 120); do
  curl -s -o /dev/null -w "%{http_code}\n" --max-time 2 http://localhost:30080
  sleep 0.1
done | sort | uniq -c

Terminal 1 β€” delete every Pod at once:

kubectl delete pod -l app=nginx
    103 200
     17 000

πŸ’¬ Seventeen failed requests. (000 is curl's way of saying "no HTTP response at all".) You asked for all three Pods to go at the same time, so for a couple of seconds the Service had zero ready endpoints and there was genuinely nothing to answer with.

Compare with step 6: 300 requests, 300 Γ— 200 OK, through a complete version change. Same end state, same number of new Pods β€” the difference is entirely whether you let the Deployment sequence it or did it yourself.

Deleting one Pod is a different matter and perfectly reasonable: the other two keep serving while the replacement starts, exactly as in step 5. It's deleting the whole set that bites.

So which one do I actually run?

Why you want to redeployWhat to run
You edited the manifestkubectl apply -f deployment.yaml
New image, new tagkubectl set image deployment/nginx nginx=img:v2, or edit the file and apply β€” the file is better
New image, same tagkubectl rollout restart deployment/nginx β€” and make sure the pull policy is Always, or it will cheerfully reuse the cache
A ConfigMap or Secret changedkubectl rollout restart deployment/nginx β€” mounted env vars are not re-read by a running process
One Pod is wedgedkubectl delete pod <that-one>. The controller replaces it; the others keep serving
Rebalance after node maintenancekubectl rollout restart deployment/nginx
The last release was badkubectl rollout undo deployment/nginx
You want a clean slatekubectl delete -f deployment.yaml then apply β€” accepts downtime, loses rollout history

Put yourself back in a good state before moving on:

kubectl rollout status deployment/nginx
kubectl get pods -l app=nginx -o wide
kubectl get endpoints nginx
πŸ”¬ Going deeper: your nodes have their own image store

This is the single most common surprise when you first deploy an image you built. Your laptop's Docker and the cluster's nodes are different machines with different image caches:

docker exec k8s-lab-worker crictl images | head -n 6
IMAGE                      TAG            SIZE
docker.io/library/nginx    stable         75.2MB
registry.k8s.io/kube-proxy v1.34.0        30.3MB
...

That's containerd's store inside the worker container, listed with crictl β€” the containerd equivalent of docker images. An image you just built with docker build is not in there, which is why it lands in ErrImagePull even though docker run works fine. Two fixes:

# push it to a registry the nodes can reach, the real-world answer
docker push myregistry.example.com/notice-board:v1

# or side-load it straight into every kind node, the lab answer
kind load docker-image notice-board:v1 --name k8s-lab

If you side-load, set imagePullPolicy: IfNotPresent β€” with Always the kubelet will try the registry, not find it, and fail despite the image sitting right there. You'll need this for the take-home.

πŸ”¬ Going deeper: Recreate β€” when you want the downtime

Some applications cannot have two versions running at once: a single-writer process, an exclusive database lock, a schema migration that the old code would choke on. For those, rolling is wrong and you say so:

kubectl patch deployment nginx -p '{"spec":{"strategy":{"type":"Recreate","rollingUpdate":null}}}'
kubectl rollout restart deployment/nginx
kubectl get pods -l app=nginx -w     # Ctrl+C after ~20 seconds

Every Pod terminates first, and only then do the new ones start β€” a real gap with no endpoints. Run the Terminal 3 loop alongside it and you'll see the 000s again, this time on purpose and with your eyes open. Put the default back:

kubectl patch deployment nginx -p \
  '{"spec":{"strategy":{"type":"RollingUpdate","rollingUpdate":{"maxSurge":"25%","maxUnavailable":"25%"}}}}'
kubectl rollout status deployment/nginx
8

Probes: deciding when a Pod is allowed to work

Stop Kubernetes from sending traffic to an app that has started but isn't ready yet.

~14 min🟑 Mediumthe production one

So far, "the container started" has meant "send it traffic". For nginx that's nearly true. For a real application β€” one that loads a model, warms a cache, or opens thirty database connections β€” it is dangerously false. It can take twenty seconds to be useful, and for those twenty seconds Kubernetes would cheerfully route users to it and they would all get errors.

Probes are how you tell the kubelet what "ready" and "alive" actually mean for your app.

ProbeThe question it asksWhat failing it does
readinessProbe"May I receive traffic right now?"The Pod is removed from Service endpoints. It keeps running. No restart. Traffic goes to its siblings.
livenessProbe"Am I still alive, or wedged?"The container is killed and restarted. Use it for deadlocks β€” a process that's up but stuck forever.
startupProbe"Have I finished booting yet?"Suspends the other two until it passes once. For slow starters, so a long boot isn't mistaken for a hang.
⚠️ The classic self-inflicted outage: a livenessProbe that's really a readiness check. Your database gets slow, every Pod's liveness probe times out, Kubernetes restarts all of them at once, and now you have an outage on top of a slow database. Liveness answers "restarting me would help." If the honest answer is "no", don't use a liveness probe.

8.1 β€” Add probes to your manifest

nano deployment.yaml

Add these two blocks inside the container, at the same indentation as resources:

          readinessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 10
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 20
            periodSeconds: 10
            failureThreshold: 3
FieldMeaning
httpGetMake an HTTP request. Any 2xx or 3xx is a pass. Alternatives: tcpSocket (can I open the port?) and exec (run a command, exit 0 = pass).
initialDelaySecondsWait this long after the container starts before the first check. Deliberately set high here so you can watch it.
periodSecondsHow often to check afterwards.
timeoutSecondsHow long to wait for an answer before calling it a failure.
failureThresholdHow many consecutive failures before acting. 3 Γ— 5s means ~15 seconds of trouble before a Pod is pulled from the Service.

8.2 β€” Apply it and watch the gap

Terminal 3 β€” watch:

kubectl get pods -l app=nginx -w

Terminal 1:

kubectl apply -f deployment.yaml
kubectl rollout status deployment/nginx
nginx-6f8c2a91b4-w4ltp   0/1   Pending             0   0s
nginx-6f8c2a91b4-w4ltp   0/1   ContainerCreating   0   0s
nginx-6f8c2a91b4-w4ltp   0/1   Running             0   2s    <-- running, but NOT ready
nginx-6f8c2a91b4-w4ltp   1/1   Running             0   13s   <-- readiness probe passed

πŸ’¬ Look at the eleven seconds between those last two lines. The container was up and the process was serving the whole time β€” but the column said 0/1, and during that window the Pod was not in any Service's endpoint list and received not one request.

That gap is you, in control. Before you added the probe, Kubernetes had no way to know the difference between "started" and "useful", so it assumed the best. Now it waits to be told.

Catch it in the act β€” run this immediately after a restart:

kubectl rollout restart deployment/nginx
sleep 4
kubectl get pods -l app=nginx
kubectl get endpoints nginx

You'll see more Pods than endpoints. The unready ones exist, cost memory, and are invisible to users. Wait fifteen seconds, run it again, and the numbers match.

8.3 β€” Break the readiness probe

Point it at a port nothing is listening on:

kubectl patch deployment nginx --type json -p \
  '[{"op":"replace","path":"/spec/template/spec/containers/0/readinessProbe/httpGet/port","value":9999}]'
sleep 30
kubectl get pods -l app=nginx
kubectl get endpoints nginx
curl -s http://localhost:30080 | head -n 1
NAME                     READY   STATUS    RESTARTS   AGE
nginx-6f8c2a91b4-w4ltp   1/1     Running   0          4m
nginx-6f8c2a91b4-x2dqk   1/1     Running   0          4m
nginx-84b7d5e3f1-j9vmr   0/1     Running   0          32s     <-- never going to be ready

NAME    ENDPOINTS                     AGE
nginx   10.244.1.9:80,10.244.2.6:80   58m

<!DOCTYPE html>

πŸ’¬ Three things to notice, all of them good news.

1. The new Pod is Running but 0/1 β€” alive, and deliberately benched. 2. It is not in the endpoint list, so it has never received a request. 3. The rollout stalled instead of continuing, and the old Pods are still serving. Your site is fine.

A failing readiness probe is Kubernetes doing its job loudly, not a crash. This is the mechanism that made step 6's 300-out-of-300 possible.

Now read the reason in the Pod's own words:

kubectl describe pod -l app=nginx | grep -A3 'Readiness probe failed' | head -n 8
Warning  Unhealthy  12s (x5 over 40s)  kubelet  Readiness probe failed:
  Get "http://10.244.1.10:9999/": dial tcp 10.244.1.10:9999: connect: connection refused

Repair it the way you already know:

kubectl rollout undo deployment/nginx
kubectl rollout status deployment/nginx
kubectl get pods -l app=nginx
kubectl get endpoints nginx
πŸ”¬ Going deeper: watch a liveness probe restart a container

Readiness benches a Pod. Liveness kills it. Break the liveness probe instead and watch the RESTARTS column, which stays at 0 in every other part of this lab:

kubectl patch deployment nginx --type json -p \
  '[{"op":"replace","path":"/spec/template/spec/containers/0/livenessProbe/httpGet/port","value":9999}]'
kubectl get pods -l app=nginx -w     # watch for ~90 seconds, then Ctrl+C
nginx-9c1f72a84e-lq8sv   0/1   Running              1 (5s ago)    52s
nginx-9c1f72a84e-lq8sv   0/1   Running              2 (31s ago)   1m22s
nginx-9c1f72a84e-lq8sv   0/1   CrashLoopBackOff     2 (18s ago)   1m48s

RESTARTS is climbing, and it ends in CrashLoopBackOff β€” the same state a genuinely buggy app reaches, caused here purely by a bad probe. The back-off is exponential (10s, 20s, 40s… capped at 5 minutes) so a broken Pod can't hammer your cluster.

This is why the warning above matters: nothing was wrong with nginx. The probe was wrong, and the probe won. Undo it:

kubectl rollout undo deployment/nginx
kubectl rollout status deployment/nginx
9

Break it on purpose

Cause a real failure, find the cause in thirty seconds, and repair it.

~15 minπŸ”΄ The skill you'll actually use

9.1 β€” Break it

kubectl set image deployment/nginx nginx=nginx:this-tag-does-not-exist

Wait about twenty seconds, then:

kubectl get pods -o wide
curl -s http://localhost:30080 | head -n 1
NAME                     READY   STATUS             RESTARTS   AGE   NODE
nginx-7c9f4d8b6d-m8ztq   1/1     Running            0          25m   k8s-lab-worker
nginx-7c9f4d8b6d-p4wnc   1/1     Running            0          25m   k8s-lab-worker2
nginx-6b4d9f2c1e-t5qmx   0/1     ImagePullBackOff   0          22s   k8s-lab-worker

<!DOCTYPE html>

πŸ’¬ Notice what Kubernetes did for you before you even looked. The old Pods are still Running and still serving. The rollout stalled rather than destroying a working application, and the broken Pod never entered the endpoint list.

A failed deployment is not automatically an outage. That's maxUnavailable and your readiness probe working together, and most people never notice it happening.

9.2 β€” Diagnose it: the standard five

Run these in order, every time, for the rest of your career. Use your broken Pod's name:

kubectl get pods -o wide                              # 1. what's the status, and where?
kubectl describe pod <broken-pod-name>                # 2. why is THIS one unhappy?
kubectl logs <broken-pod-name>                        # 3. what does the app itself say?
kubectl get events --sort-by=.metadata.creationTimestamp | tail   # 4. what's been happening?
kubectl get endpoints nginx                           # 5. is traffic still being served?

Scroll to the Events block at the bottom of describe pod:

Events:
  Type     Reason     Age   From               Message
  ----     ------     ----  ----               -------
  Normal   Scheduled  45s   default-scheduler  Successfully assigned default/nginx-6b4d9f2c1e-t5qmx to k8s-lab-worker
  Normal   Pulling    44s   kubelet            Pulling image "nginx:this-tag-does-not-exist"
  Warning  Failed     42s   kubelet            Failed to pull image "nginx:this-tag-does-not-exist": not found
  Warning  Failed     42s   kubelet            Error: ErrImagePull
  Warning  Failed     15s   kubelet            Error: ImagePullBackOff

There's your answer, in plain English: the image tag doesn't exist. No searching required. And read the From column β€” default-scheduler succeeded, kubelet failed. You know which component to blame and which node it was on.

Now look at what command 3 gave you

Error from server (BadRequest): container "nginx" in pod "nginx-6b4d9f2c1e-t5qmx" is
waiting to start: trying and failing to pull image

πŸ’¬ This is the whole lesson of the step. kubectl logs had nothing to show you, because the container never started β€” you cannot read the output of a process that does not exist.

logs answers "it started but it's misbehaving". describe answers "it never started." Choosing the right one is the difference between a two-minute fix and a lost afternoon. And notice: that red error message was useful information, not a wall. Read errors; don't flinch at them.

9.3 β€” Repair it, and finish healthy

kubectl rollout undo deployment/nginx
kubectl rollout status deployment/nginx
kubectl get deployments
kubectl get pods -o wide
kubectl get endpoints nginx
kubectl get deployment nginx -o jsonpath='{.spec.template.spec.containers[0].image}'; echo

You must end with: READY 3/3, three Pods Running across your two workers, three endpoints, image nginx:stable, and http://localhost:30080 still serving.

πŸ†˜ rollout undo didn't fix it? Two recoveries

Option A β€” reapply your known-good file:

kubectl apply -f deployment.yaml
kubectl rollout status deployment/nginx

Option B β€” delete the app and rebuild it (~30 seconds, cluster untouched):

kubectl delete deployment nginx
kubectl delete service nginx nginx-public
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
kubectl apply -f nodeport.yaml
kubectl wait --for=condition=Available deployment/nginx --timeout=120s

Do not delete and recreate the cluster to fix an application problem. It costs minutes you don't have and teaches the wrong instinct. Option B takes thirty seconds.

Status β†’ cause β†’ first command

StatusWhat it meansFirst command
PendingNot scheduled β€” no node passed the filter. Too few resources, an untolerated taint, or an impossible nodeSelector.kubectl describe pod <name> β€” the Events name the exact reason
ContainerCreatingScheduled; pulling the image or mounting volumesNormal for a few seconds. If stuck, describe
ImagePullBackOff / ErrImagePullWrong image name or tag, or the registry needs credentialskubectl describe pod <name>
CrashLoopBackOffImage pulled and started, but the process keeps exiting. Usually your code β€” or a bad liveness probe.kubectl logs <name> --previous
Running but 0/1 READYReadiness probe failing. Alive, benched, receiving nothing.kubectl describe pod <name>
Running, but users get errorsUsually a Service/label mismatch β€” zero endpointskubectl get endpoints <service>
OOMKilled in the restart reasonExceeded its memory limit; the kernel killed it (exit 137)kubectl describe pod <name>, then raise the limit or fix the leak
Terminating for agesShutdown is stuck β€” often the PID 1 signal problem from the Docker labkubectl describe pod <name>

The four questions, in order

  1. Is it scheduled? Pending β†’ the scheduler could not place it. Resources, taints, selectors.
  2. Did the image pull? ImagePullBackOff β†’ name, tag or registry credentials.
  3. Did the process start and stay up? CrashLoopBackOff β†’ read the logs; it's your app or your probe.
  4. Is it reachable? Running but unreachable β†’ get endpoints and check your labels.
⚠️ The one rule of debugging: change one thing, then check. Never apply five fixes at once β€” if it starts working you won't know which one did it, and you won't be able to fix it next time.
πŸ§ͺ Try it: cause a Pending Pod and read the scheduler's excuse

Ask for more memory than any machine on earth will give you:

kubectl patch deployment nginx --type json -p \
  '[{"op":"replace","path":"/spec/template/spec/containers/0/resources/requests/memory","value":"64Gi"}]'
sleep 10
kubectl get pods -l app=nginx
kubectl describe pod -l app=nginx | grep -A6 'Events:' | tail -n 5
0/3 nodes are available: 1 node(s) had untolerated taint
{node-role.kubernetes.io/control-plane: }, 2 Insufficient memory.
preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.

That one message contains the whole of step 1 and step 3. It counted all three nodes, eliminated the control plane for its taint, and eliminated both workers for insufficient memory β€” judged against your requests, not against actual free RAM. Put it back:

kubectl rollout undo deployment/nginx
kubectl rollout status deployment/nginx
βœ“

What you learned

Nine steps, nine sentences. If you can say these, you've got it.

  1. Kubernetes is a thermostat for software. You write down a wish; controllers spend forever making it true. Your cluster is three nodes, and three nodes is three Docker containers.
  2. The control plane thinks, workers work, and a taint is what keeps your apps off the brain. A DaemonSet is how something lands on every node automatically.
  3. kubectl is an HTTPS client β€” you created a Pod with curl. Your command finishes at 201 Created; the controllers, scheduler, kubelet and containerd do the real work afterwards, each just watching the API server.
  4. One Deployment manifest produced Deployment β†’ ReplicaSet β†’ Pod β†’ Container, and the scheduler filtered then scored your nodes to place each Pod. requests are a reservation, not a measurement.
  5. A Service is one permanent address found by label. port-forward pins to one Pod; a NodePort goes through the Service and load-balances across nodes. kubectl get endpoints is the proof either works.
  6. Deleting a Pod doesn't delete an application, and drain empties a whole machine with nobody noticing. Kubernetes never rebalances on its own β€” rollout restart is how you ask.
  7. A redeploy with no file change is kubectl rollout restart β€” which just writes a timestamp annotation, changing the template hash. Deleting all the Pods yourself reaches the same end state with real downtime, and :latest plus IfNotPresent will serve you yesterday's code.
  8. A rolling update makes a second ReplicaSet and moves across within maxSurge / maxUnavailable. 300 requests, 300 Γ— 200 OK. The old ReplicaSet is kept, which is why undo takes two seconds.
  9. Readiness controls traffic; liveness controls restarts. And describe is for "it never started", logs for "it started but it's wrong". A failed rollout is not automatically an outage.

Commands worth keeping

When you want to…Run
Check which cluster you're on (do this first, always)kubectl config current-context
See everything you madekubectl get all
See Pods with IPs and nodeskubectl get pods -o wide
Count Pods per nodekubectl get pods -o wide --no-headers | awk '{print $7}' | sort | uniq -c
Watch things change livekubectl get pods -w
Find out why something is unhappykubectl describe pod NAME
Read an app's outputkubectl logs NAME Β· kubectl logs NAME --previous
Get a shell inside a Podkubectl exec -it NAME -- sh
Check a Service found its Podskubectl get endpoints NAME
Tunnel to one Pod for a lookkubectl port-forward service/NAME 8080:80
Change the replica count right nowkubectl scale deployment NAME --replicas=N
Ship a new imagekubectl set image deployment/NAME container=image:tag
Undo the last rolloutkubectl rollout undo deployment/NAME
Replace every Pod safely (rebalance, reload config)kubectl rollout restart deployment/NAME
Take a machine out of service / put it backkubectl drain NODE --ignore-daemonsets Β· kubectl uncordon NODE
Check YAML without applying itkubectl apply --dry-run=client -f file.yaml
Look up what any field meanskubectl explain deployment.spec.strategy
See the actual HTTP calls kubectl makeskubectl get pods -v=6

If something goes wrong

What you seeWhat's happeningWhat to do
The connection to the server ... was refusedYour node containers are stoppeddocker start k8s-lab-control-plane k8s-lab-worker k8s-lab-worker2, wait 60s
error validating data: ... unknown fieldYAML indentation is wrong β€” nearly always tabskubectl apply --dry-run=client -f deployment.yaml names the exact line
mapping values are not allowed in this contextMissing space after a colonYAML needs key: value, never key:value
unable to find container named "web"The container name in set image doesn't match the manifestCheck spec.template.spec.containers[0].name
Endpoints show <none>Service selector doesn't match the Pod labels β€” or no Pod is readyCompare the app: values character by character, then check for 0/1 Running
localhost:30080 refuses, but port-forward worksThe port isn't published on the node containerdocker port k8s-lab-control-plane | grep 30080. Nothing? Your cluster was built without the config file
NodePort Service won't apply: provided port is not in the valid rangeYou picked a port outside 30000–32767Pick one in 30000–30050 so kind has published it
port is already allocatedTwo Services asking for the same nodePortkubectl get svc -A and pick a free one from your range
port-forward dies with lost connection to podThe one Pod it pinned to was replacedExpected. Ctrl+C and re-run it
Browser can't reach localhost at all (WSL)WSL localhost forwarding isn't workinghostname -I | awk '{print $1}', then browse to http://<that-ip>:30080
Pod stuck in PendingNo node passed the filter β€” resources, taints or selectorskubectl describe pod NAME; the Events line counts the nodes and says why each failed
Everything on one worker, none on the otherNormal β€” Kubernetes doesn't rebalancekubectl rollout restart deployment/nginx
Commands "not found" that worked yesterdayYou're in PowerShellCheck the prompt; type wsl
Editing YAML in Notepad breaks applyWindows CRLF line endings and tabsEdit with nano in Ubuntu, or VS Code with the WSL extension
Everything crawlsYour files are under /mnt/c/, or three nodes is too much for this laptopcd ~; or rebuild with one worker
πŸ†˜ Completely lost? Reset the app and carry on

This wipes your app without touching the cluster, so you can restart any step from the beginning:

kubectl delete deployment nginx --ignore-not-found
kubectl delete service nginx nginx-public --ignore-not-found
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
kubectl apply -f nodeport.yaml
kubectl wait --for=condition=Available deployment/nginx --timeout=120s
kubectl uncordon k8s-lab-worker k8s-lab-worker2 2>/dev/null; true

Check yourself

Answer out loud, in your own words, before opening each one.

Q1 β€” Your cluster has three Ready nodes with plenty of free memory, yet every Pod you create lands on just two of them. Why?

The control-plane node carries the taint node-role.kubernetes.io/control-plane:NoSchedule. During the scheduler's filter phase, any node with a taint your Pod doesn't tolerate is eliminated before scoring even begins β€” so the control plane is never a candidate. Real clusters do this deliberately: if your app exhausts the control plane, you lose the ability to fix anything. Check it with kubectl describe node k8s-lab-control-plane | grep -i taints.

Q2 β€” kubectl apply printed deployment.apps/nginx created. Is your app running?

No β€” not necessarily. That message means the API server authenticated you, authorised you, ran admission, validated the object and wrote it to etcd. Nothing has started. Afterwards, and asynchronously, the Deployment controller makes a ReplicaSet, the ReplicaSet controller makes Pod objects with no node, the scheduler binds them, and a kubelet pulls the image. That whole pipeline happens after you have your prompt back β€” which is exactly why you use kubectl wait or kubectl rollout status instead of assuming.

Q3 β€” Your Service selector says app: nginx-web but your Pods are labelled app: nginx. Every Pod says Running. What does the user see, and what's the fastest command?

A connection error or a timeout, because the Service has zero endpoints β€” there is nothing for it to route to. Kubernetes requires an exact label match and fails silently: nothing turns red, because the Pods genuinely are healthy, they're just not selected. Fastest command: kubectl get endpoints nginx, which prints <none> instead of a list of IPs. Note the second cause of an empty endpoint list: Pods that are Running but 0/1, failing readiness.

Q4 β€” You curl the NodePort twenty times and get three different Pods. You curl your port-forward twenty times and get the same Pod every time. Is the port-forward broken?

No β€” it's working as designed, and this is the single most useful gotcha in step 4. A NodePort request travels through the Service, and kube-proxy picks a ready endpoint per connection. kubectl port-forward service/nginx only uses the Service to resolve one Pod once, at startup, then tunnels straight to that Pod's IP; the Service is not in the data path. That's why port-forward is a one-Pod debugging tool, why it dies with lost connection to pod when that Pod is replaced, and why you must never use it to judge whether load balancing works.

Q5 β€” You need to reboot k8s-lab-worker2. Walk through it, and say what happens to the Pods on it.

kubectl drain k8s-lab-worker2 --ignore-daemonsets --delete-emptydir-data. That cordons the node (no new Pods) and then evicts the existing ones. Each eviction leaves the ReplicaSet short, so it creates replacements, which the scheduler places on the remaining eligible node β€” users see nothing. DaemonSet Pods are skipped because they're meant to be everywhere. Afterwards, kubectl uncordon k8s-lab-worker2 allows scheduling again, but nothing moves back β€” Kubernetes does not rebalance. Use kubectl rollout restart deployment/nginx if you want the Pods spread again. Add a PodDisruptionBudget if you need a guarantee about how many stay up during the drain.

Q6 β€” What's the difference between kubectl scale --replicas=5 and editing replicas: 5 in the file?

Both change the live cluster to five immediately. The difference is durability of intent. kubectl scale is a one-off that never touches your file, so the next kubectl apply -f deployment.yaml silently resets it to whatever the file says. Editing the file is the declarative change β€” it survives, and it lives in Git where your team can see it. Use scale for an emergency; edit-and-apply for anything permanent.

Q7 β€” A new Pod sits at 0/1 Running and the rollout is stuck. Walk the diagnosis, and say why users are unaffected.

0/1 Running means the container started but the readiness probe is failing β€” so the Pod was never added to the Service's endpoints and has received no traffic. The rollout is stuck because maxUnavailable forbids removing more old Pods until a new one becomes ready, so the old healthy Pods keep serving every request. Diagnose with kubectl describe pod NAME and look for Readiness probe failed in the Events β€” it prints the exact URL and error. Recover with kubectl rollout undo deployment/nginx. Contrast with a failing liveness probe, which would restart the container repeatedly and land it in CrashLoopBackOff.

🧹

Cleanup

Two options. Pick based on whether you're doing the take-home.

⚠️ Doing the take-home? Use Option A only, or skip cleanup entirely. You'll need this cluster again, and rebuilding it costs more time than it saves.

Option A β€” keep the cluster, remove the app

# stop the port-forward first: Ctrl+C in Terminal 2
kubectl delete -f nodeport.yaml
kubectl delete -f service.yaml
kubectl delete -f deployment.yaml
kubectl get all

Only the built-in kubernetes Service should remain. Note that you never deleted a Pod or a ReplicaSet β€” deleting the Deployment removed everything it owns, through ownership links. That's the chain from step 3, working in reverse.

Option B β€” delete the whole cluster

kind delete cluster --name k8s-lab
kind get clusters      # prints nothing
docker ps -a           # all three node containers are gone

This destroys the three Docker containers that were your nodes, and releases all fifty-one published ports. Everything inside is gone permanently. Your kind-config.yaml, deployment.yaml, service.yaml and nodeport.yaml on disk are untouched, so you can rebuild the entire lab in about four minutes.

πŸ’¬ Keep those four YAML files. They are the real output of today β€” not the Pods, which were always disposable, and not the cluster, which you can recreate from a file. Commit them to Git. That's the declarative model in one sentence: the files are the system; the cluster is just the current rendering of them.

β˜…

Take-home: run the Notice Board properly

Everything from today, on a different app, in your own namespace, on your own port. Due in 48 hours.

~60 min at homeπŸ”΄ Graded

Same cluster, same tools, a new app β€” plus three things we didn't have room for in the session. You'll deploy a small web service called Notice Board, publish it on your own NodePort, and prove it survives a node going away.

If you did the Docker lab, use the image you built and pushed. If not, any small web image works β€” httpd:alpine and nginx:stable-alpine both serve a page on port 80.

⚠️ Load your image into the cluster first if you built it yourself. Your nodes are separate machines with their own image stores β€” they cannot see your laptop's Docker images, which is why an image that runs fine in docker run can still land in ErrImagePull. One command fixes it: kind load docker-image notice-board:v1 --name k8s-lab.

The checklist

#Must be trueCovered in
R1Everything lives in a namespace called noticeboard, not defaultstep 1 + kubectl create namespace
R2A Deployment named notice-board, 4 replicas, written as YAML in a filestep 3
R3CPU and memory requests and limits on the containerstep 3
R4Those 4 Pods spread across both workers β€” show the per-node countstep 3
R5A readinessProbe and a livenessProbe, and a screenshot of a Pod sitting at 0/1 Running before it turns 1/1step 8
R6A ClusterIP Service whose endpoints list 4 IPs, reached by DNS name from a throwaway Podstep 4.2–4.3
R7A NodePort Service on 30090, opened in your browser, and a port-forward on 8090 β€” plus the 12-request comparison showing one spreads and one doesn'tstep 4.4–4.7
R8drain one worker; show the Pods move and the NodePort keeps answering. Then uncordon and rebalancestep 5.3
R9A rolling update to a different tag with a change-cause recorded, measured with a 200-request loop showing zero failuresstep 6
R10A deliberate break that is not a bad image tag, its diagnosis pasted from describe, and a clean rollout undo back to 4/4step 9

Submit: your YAML files, plus a short document with the terminal output proving each of R1–R10. Screenshots are fine; pasted text is better, because we can read it.

πŸ’‘ Hints β€” open these only after you've tried

R1, namespaces. kubectl create namespace noticeboard makes it. Then either add namespace: noticeboard under metadata in every file, or pass -n noticeboard on every single command. Pick one and be consistent β€” mixing them is how you end up with two copies of your app and a lot of confusion. kubectl get all -n noticeboard shows just your work.

R4, spreading. Four replicas on two workers usually lands 2+2 on its own, but "usually" isn't a guarantee and the grader wants a guarantee. Look up topologySpreadConstraints with kubectl explain deployment.spec.template.spec.topologySpreadConstraints: a maxSkew: 1 over topologyKey: kubernetes.io/hostname with whenUnsatisfiable: DoNotSchedule turns "probably even" into "even, or it doesn't schedule at all". Then prove it with the awk one-liner from step 3.3.

R7, two doors. 30090 is inside the 30000–30050… no it isn't. Read that again β€” your kind config published 30000–30050 only. You have two honest options, and either is acceptable if you explain which you chose and why: pick a port your cluster actually publishes, or regenerate kind-config.yaml with a wider range and recreate the cluster. Working out how you discovered the problem (docker port k8s-lab-control-plane | grep 30090) is worth more marks than avoiding it.

R10, picking a break. The bad image tag is used up. Pick something that fails differently: a readiness probe on a port nothing listens on, a Service selector matching no Pod, a requests.memory of 64Gi, or a nodeSelector for a node that doesn't exist. Each produces a different symptom and needs a different first command β€” that's the entire point of the exercise. Say in one line which of the four questions from step 9 found it.

Stuck on YAML indentation? kubectl apply --dry-run=client -f yourfile.yaml validates without changing anything and names the offending line. Run it before every real apply and you'll save yourself ten minutes.

Bonus marks