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.
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.
Nine steps, about two and three-quarter hours, with spare minutes built in because everyone gets stuck somewhere.
| Min | Step | What you'll be able to say afterwards |
|---|---|---|
| 0β15 | Setup + what Kubernetes is | "I built a three-node cluster on my laptop" |
| 15β27 | 1 Walk the cluster | "I know what every node and system Pod is for" |
| 27β39 | 2 What happens when you press Enter | "I can name the five components my command touches" |
| 39β59 | 3 Deploy your first app | "I wrote a manifest and the scheduler placed it for me" |
| 59β79 | 4 Two ways to reach it | "I published a port and opened it with no tunnel" |
| 79β97 | 5 Scale, heal, drain a node | "I emptied a whole machine and nothing went down" |
| 97β113 | 6 Update and undo | "I control exactly how fast a release rolls out" |
| 113β125 | 7 Redeploy with no file change | "I know the three ways, and which one causes an outage" |
| 125β139 | 8 Probes | "I decide when a Pod is allowed to receive traffic" |
| 139β154 | 9 Break it on purpose | "I can read an error and find the cause in 30 seconds" |
| 154β165 | Recap + cleanup + take-home | β |
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.
| Word | In plain English |
|---|---|
| Cluster | A group of machines Kubernetes manages as if they were one big computer. Yours will have three. |
| Node | One machine in the cluster. Yours are Docker containers pretending to be servers β that's the trick kind plays. |
| Control plane | The node that does the thinking: records your wishes, decides where things go, notices when reality drifts. It normally runs no apps of your own. |
| Worker | A node that does the working. Your Pods land here. Add more workers β more capacity. That's the whole scaling story. |
| Pod | The 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. |
| Deployment | Your standing instruction: "keep N copies of this image running, and here's how to upgrade them." The object you actually write. |
| ReplicaSet | The counter. Its only job is "exactly N Pods must exist." You never write one β the Deployment makes it. |
| Service | One permanent address in front of Pods that keep changing. Pods come and go; the Service name stays. |
| Manifest | A YAML file describing what you want. This is the thing you write and commit to Git. |
| kubectl | The command you type. Say it "cube-control". Like the docker command, it's only a messenger β it sends your wish and prints the reply. |
PS C:\Users\... you are in PowerShell; type wsl and press Enter. On a lab PC running Ubuntu you're already set.- role: worker line in the config below. Everything in this lab still works; only step 5's node-drain gets less dramatic.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.
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.
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
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.
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.
| What you see | What's happening | What to do |
|---|---|---|
Cannot connect to the Docker daemon | Docker isn't running | sudo systemctl start docker, or launch Docker Desktop |
C:\ in your prompt | You're in PowerShell, not Linux | Type wsl and press Enter |
port is already allocated while creating | Something on Windows already owns a port in 30000β30050 | Find 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 nodes | kind delete cluster --name k8s-lab, remove one - role: worker, try again |
| Only one node listed | You created it without --config | kind delete cluster --name k8s-lab and redo with the config file |
The connection to the server ... was refused | The containers are stopped β normal after a reboot | docker start k8s-lab-control-plane k8s-lab-worker k8s-lab-worker2, wait 60s |
A worker stuck on NotReady | Still joining | Wait 60s, then kubectl describe node and read Conditions |
node(s) already exist for a cluster with the name | You already built it | Nothing β or kind delete cluster --name k8s-lab and retry |
Meet all three machines, find out why one of them refuses to run your apps, and name every system Pod on sight.
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.
<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.
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).
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.
kube-systemkubectl 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.
| Component | Where | What it does |
|---|---|---|
kube-apiserver | control plane | The front door. Every command you type arrives here, and nothing in the cluster talks to anything else directly β they all go through it. |
etcd | control plane | The notebook. A key-value database holding the entire cluster state. Lose etcd and you've lost the cluster; back it up in production. |
kube-scheduler | control plane | Looks for Pods with no node assigned, and picks one. That's its whole job. Step 2 and step 3 are about this. |
kube-controller-manager | control plane | The 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. |
coredns | control plane | Turns Service names into IP addresses. Two copies for redundancy. Step 4 uses it. |
kube-proxy | every node | Programs 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. |
kindnet | every node | The 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. |
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.
kube-system is how you break your own cluster. (Deleting a Pod there is survivable β it comes straight back. Deleting the DaemonSet is not.)-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.Find out that kubectl does almost nothing, and that your command finishes long before your app starts.
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.
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
curl, no kubectl verbs at allThis 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:
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.
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.
| # | What happens | Who does it |
|---|---|---|
| 1 | Read ~/.kube/config, find the server URL and your client certificate | kubectl, on your laptop |
| 2 | Authentication β who are you? (certificate, token or cloud identity) | api-server |
| 3 | Authorization β are you allowed to do this? (RBAC) | api-server |
| 4 | Admission β rewrite or reject it (defaults filled in, policies applied) | api-server plugins |
| 5 | Validate the object and write it to etcd, then reply 201 Created | api-server β etcd |
| 6 | Deployment controller notices a Deployment with no ReplicaSet β creates one | controller-manager |
| 7 | ReplicaSet controller counts Pods, finds too few β creates Pod objects with no node yet | controller-manager |
| 8 | Scheduler spots unassigned Pods β filters impossible nodes, scores the rest, binds the winner | kube-scheduler |
| 9 | The kubelet on that node sees a Pod addressed to it β tells containerd to pull and run | kubelet β containerd |
| 10 | Status reported back; endpoints updated; network rules programmed | kubelet, 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.
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
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.
Write one file, apply it, and watch the scheduler spread three Pods across two machines you never named.
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.
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.
| Line | What it means |
|---|---|
kind: Deployment | What type of wish this is. Could be Service, Pod, DaemonSet, ConfigMap⦠here, "keep copies of an app running". |
apiVersion: apps/v1 | Which set of rules to read this file with. Deployments live in the apps group; you'll see a different value in step 4. |
metadata.name | What you'll call it: nginx. Unique within the namespace. |
replicas: 3 | The dial. Three copies, always. Three, on two workers, is deliberate β you'll see the result in a moment. |
selector.matchLabels | How the Deployment recognises the Pods it owns β by the sticker app: nginx. Immutable once created; to change it you delete and recreate. |
template | The cookie cutter. Everything below here describes one Pod, and gets stamped out replicas times. |
image: nginx:stable | Same image idea as Docker, pulled from Docker Hub β but pulled by containerd on whichever node runs it, not by Docker on your laptop. |
requests | What 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. |
limits | The 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. |
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.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
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.
For every unassigned Pod, kube-scheduler runs two phases:
requests? Does it match any nodeSelector or affinity rule? Is it Ready and unschedulable-free?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 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.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:
π¬ 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.
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.
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.
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.
Put one permanent address in front of three moving Pods β then open it in your browser on a real port, with no tunnel.
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.
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.
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.
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.
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.
| Field | Lives on | What it is |
|---|---|---|
nodePort: 30080 | every node | The 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: 80 | the Service | The port on the Service's ClusterIP. This is what other Pods inside the cluster connect to. |
targetPort: 80 | the Pod | The port the container is actually listening on. May differ from port β port: 80 β targetPort: 8080 is extremely common. |
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
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.
kubectl port-forward | NodePort | |
|---|---|---|
| Who can reach it | Only your own laptop | Anything that can reach a node β your browser, your phone on the same Wi-Fi, a teammate |
| Goes through the Service | No β straight to one Pod | Yes β load balanced across every ready Pod |
| Survives closing the terminal | No | Yes |
| Survives the Pod being replaced | No β it drops and you re-run it | Yes β endpoints update automatically |
| Needs anything set up first | Nothing | The port must be published β your kind-config.yaml did that |
| Good for | Inspecting one Pod, reaching a database or an admin UI, quick checks | Demos, integration tests, anything a second person must open |
| In production | Never β it's a debugging tool | Rarely β you'd put an Ingress or a cloud LoadBalancer in front instead |
: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.externalTrafficPolicy: LocalBy 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.
Turn the dial up, delete a running app, then take an entire node out of service β and keep the site up through all three.
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
: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
This is the reconciliation loop, running right now, every few seconds, forever:
app=nginx: two.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.
kubectl delete deployment nginx. The instruction is what's permanent; the Pods are temporary. (Don't run that yet.)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
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.
| Command | What it does | When |
|---|---|---|
kubectl cordon NODE | Stop placing new Pods here. Existing ones stay and keep serving. | "Something looks wrong with that machine, let me look." |
kubectl drain NODE | Cordon, then evict everything that can be moved. | Before a reboot, patch or shutdown. |
kubectl uncordon NODE | Allow scheduling again. Does not move anything back. | After the maintenance. |
kubectl delete node NODE | Remove it from the cluster entirely. | The machine is gone for good. |
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.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.
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
Ship a new version with measured zero downtime, control exactly how fast it goes, and take it back in two seconds.
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.
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.
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.
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 ....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).
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.
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.
A rolling update is not an edit-in-place. Kubernetes:
maxSurge, and waits for those Pods to be ready.maxUnavailable.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.
Running while every request returns a 500. Always verify behaviour, not just Pod status.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
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.
The job you'll do most often, the three ways to do it, and the one that quietly causes an outage.
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.
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.
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
imagePullPolicy | What the kubelet does | Set when |
|---|---|---|
IfNotPresent | Use 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 |
Always | Check 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 |
Never | Never pull. Fail if the image isn't already on the node. | Air-gapped clusters, or images you side-loaded yourself |
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.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.
| Reading | Before | After | Why |
|---|---|---|---|
| Image | nginx:stable | nginx:stable β identical | You redeployed; you didn't upgrade. Nothing about the app version changed. |
| Your YAML file | Untouched. deployment.yaml on disk is byte-for-byte the same | Which is the whole problem this step solves | |
| Pod name prefix | nginx-7c9f4d8b6d-β¦ | nginx-8a3e1c7d25-β¦ | The prefix is the Pod-template hash. A new hash means the template genuinely changed |
| Pod AGE | 23β41m | 14β22s | Brand-new Pods. And note they're staggered by a few seconds β that's the rolling update going one at a time |
| RESTARTS | 0 | 0 | Read this twice. Nothing was restarted. The old Pods were deleted and different Pods were created |
| Pod IPs | 10.244.2.3, β¦ | 10.244.1.11, β¦ | New Pods, new addresses β and the Service's endpoint list was rewritten for you |
| ReplicaSets | 1 | 2 β old one scaled to 0 | The empty one is your undo button, exactly as in step 6 |
| Annotations | none | kubectl.kubernetes.io/restartedAt: β¦ | The one and only thing rollout restart actually wrote |
rollout history | N revisions | N+1 | A 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.
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.
| Why you want to redeploy | What to run |
|---|---|
| You edited the manifest | kubectl apply -f deployment.yaml |
| New image, new tag | kubectl set image deployment/nginx nginx=img:v2, or edit the file and apply β the file is better |
| New image, same tag | kubectl rollout restart deployment/nginx β and make sure the pull policy is Always, or it will cheerfully reuse the cache |
| A ConfigMap or Secret changed | kubectl rollout restart deployment/nginx β mounted env vars are not re-read by a running process |
| One Pod is wedged | kubectl delete pod <that-one>. The controller replaces it; the others keep serving |
| Rebalance after node maintenance | kubectl rollout restart deployment/nginx |
| The last release was bad | kubectl rollout undo deployment/nginx |
| You want a clean slate | kubectl 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
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.
Recreate β when you want the downtimeSome 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
Stop Kubernetes from sending traffic to an app that has started but isn't ready yet.
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.
| Probe | The question it asks | What 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. |
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.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
| Field | Meaning |
|---|---|
httpGet | Make 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). |
initialDelaySeconds | Wait this long after the container starts before the first check. Deliberately set high here so you can watch it. |
periodSeconds | How often to check afterwards. |
timeoutSeconds | How long to wait for an answer before calling it a failure. |
failureThreshold | How many consecutive failures before acting. 3 Γ 5s means ~15 seconds of trouble before a Pod is pulled from the Service. |
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.
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
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
Cause a real failure, find the cause in thirty seconds, and repair 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.
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.
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.
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 recoveriesOption 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 | What it means | First command |
|---|---|---|
Pending | Not 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 |
ContainerCreating | Scheduled; pulling the image or mounting volumes | Normal for a few seconds. If stuck, describe |
ImagePullBackOff / ErrImagePull | Wrong image name or tag, or the registry needs credentials | kubectl describe pod <name> |
CrashLoopBackOff | Image pulled and started, but the process keeps exiting. Usually your code β or a bad liveness probe. | kubectl logs <name> --previous |
Running but 0/1 READY | Readiness probe failing. Alive, benched, receiving nothing. | kubectl describe pod <name> |
Running, but users get errors | Usually a Service/label mismatch β zero endpoints | kubectl get endpoints <service> |
OOMKilled in the restart reason | Exceeded its memory limit; the kernel killed it (exit 137) | kubectl describe pod <name>, then raise the limit or fix the leak |
Terminating for ages | Shutdown is stuck β often the PID 1 signal problem from the Docker lab | kubectl describe pod <name> |
Pending β the scheduler could not place it. Resources, taints, selectors.ImagePullBackOff β name, tag or registry credentials.CrashLoopBackOff β read the logs; it's your app or your probe.Running but unreachable β get endpoints and check your labels.Pending Pod and read the scheduler's excuseAsk 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
Nine steps, nine sentences. If you can say these, you've got it.
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.requests are a reservation, not a measurement.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.drain empties a whole machine with nobody noticing. Kubernetes never rebalances on its own β rollout restart is how you ask.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.maxSurge / maxUnavailable. 300 requests, 300 Γ 200 OK. The old ReplicaSet is kept, which is why undo takes two seconds.describe is for "it never started", logs for "it started but it's wrong". A failed rollout is not automatically an outage.| When you want toβ¦ | Run |
|---|---|
| Check which cluster you're on (do this first, always) | kubectl config current-context |
| See everything you made | kubectl get all |
| See Pods with IPs and nodes | kubectl get pods -o wide |
| Count Pods per node | kubectl get pods -o wide --no-headers | awk '{print $7}' | sort | uniq -c |
| Watch things change live | kubectl get pods -w |
| Find out why something is unhappy | kubectl describe pod NAME |
| Read an app's output | kubectl logs NAME Β· kubectl logs NAME --previous |
| Get a shell inside a Pod | kubectl exec -it NAME -- sh |
| Check a Service found its Pods | kubectl get endpoints NAME |
| Tunnel to one Pod for a look | kubectl port-forward service/NAME 8080:80 |
| Change the replica count right now | kubectl scale deployment NAME --replicas=N |
| Ship a new image | kubectl set image deployment/NAME container=image:tag |
| Undo the last rollout | kubectl rollout undo deployment/NAME |
| Replace every Pod safely (rebalance, reload config) | kubectl rollout restart deployment/NAME |
| Take a machine out of service / put it back | kubectl drain NODE --ignore-daemonsets Β· kubectl uncordon NODE |
| Check YAML without applying it | kubectl apply --dry-run=client -f file.yaml |
| Look up what any field means | kubectl explain deployment.spec.strategy |
| See the actual HTTP calls kubectl makes | kubectl get pods -v=6 |
| What you see | What's happening | What to do |
|---|---|---|
The connection to the server ... was refused | Your node containers are stopped | docker start k8s-lab-control-plane k8s-lab-worker k8s-lab-worker2, wait 60s |
error validating data: ... unknown field | YAML indentation is wrong β nearly always tabs | kubectl apply --dry-run=client -f deployment.yaml names the exact line |
mapping values are not allowed in this context | Missing space after a colon | YAML needs key: value, never key:value |
unable to find container named "web" | The container name in set image doesn't match the manifest | Check spec.template.spec.containers[0].name |
Endpoints show <none> | Service selector doesn't match the Pod labels β or no Pod is ready | Compare the app: values character by character, then check for 0/1 Running |
localhost:30080 refuses, but port-forward works | The port isn't published on the node container | docker 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 range | You picked a port outside 30000β32767 | Pick one in 30000β30050 so kind has published it |
port is already allocated | Two Services asking for the same nodePort | kubectl get svc -A and pick a free one from your range |
port-forward dies with lost connection to pod | The one Pod it pinned to was replaced | Expected. Ctrl+C and re-run it |
Browser can't reach localhost at all (WSL) | WSL localhost forwarding isn't working | hostname -I | awk '{print $1}', then browse to http://<that-ip>:30080 |
Pod stuck in Pending | No node passed the filter β resources, taints or selectors | kubectl describe pod NAME; the Events line counts the nodes and says why each failed |
| Everything on one worker, none on the other | Normal β Kubernetes doesn't rebalance | kubectl rollout restart deployment/nginx |
| Commands "not found" that worked yesterday | You're in PowerShell | Check the prompt; type wsl |
Editing YAML in Notepad breaks apply | Windows CRLF line endings and tabs | Edit with nano in Ubuntu, or VS Code with the WSL extension |
| Everything crawls | Your files are under /mnt/c/, or three nodes is too much for this laptop | cd ~; or rebuild with one worker |
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
Answer out loud, in your own words, before opening each one.
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.
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.
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.
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.
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.
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.
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.
Two options. Pick based on whether you're doing the take-home.
# 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.
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.
Everything from today, on a different app, in your own namespace, on your own port. Due in 48 hours.
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.
docker run can still land in ErrImagePull. One command fixes it: kind load docker-image notice-board:v1 --name k8s-lab.| # | Must be true | Covered in |
|---|---|---|
| R1 | Everything lives in a namespace called noticeboard, not default | step 1 + kubectl create namespace |
| R2 | A Deployment named notice-board, 4 replicas, written as YAML in a file | step 3 |
| R3 | CPU and memory requests and limits on the container | step 3 |
| R4 | Those 4 Pods spread across both workers β show the per-node count | step 3 |
| R5 | A readinessProbe and a livenessProbe, and a screenshot of a Pod sitting at 0/1 Running before it turns 1/1 | step 8 |
| R6 | A ClusterIP Service whose endpoints list 4 IPs, reached by DNS name from a throwaway Pod | step 4.2β4.3 |
| R7 | A 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't | step 4.4β4.7 |
| R8 | drain one worker; show the Pods move and the NodePort keeps answering. Then uncordon and rebalance | step 5.3 |
| R9 | A rolling update to a different tag with a change-cause recorded, measured with a 200-request loop showing zero failures | step 6 |
| R10 | A deliberate break that is not a bad image tag, its diagnosis pasted from describe, and a clean rollout undo back to 4/4 | step 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.
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.
PodDisruptionBudget with minAvailable: 3, then drain a worker and show, with timestamps, that evictions happened one at a time instead of all at once.ConfigMap and mount it as an environment variable, then rollout restart to pick up a change to it.maxUnavailable: 0 and maxSurge: 1, roll out again, and show with kubectl get pods -w that a fifth Pod appears before any of the four is removed.curl and kubectl proxy, as in step 2.2, with no kubectl apply anywhere.kind load it again, and show that kubectl apply reports unchanged while kubectl rollout restart actually picks up the new code. State which imagePullPolicy you used and why that choice was forced by how you got the image onto the nodes.