Kubernetes basics
Desired state, Deployments and Services, labels, probes and rolling updates.
Create a free account Try part of it here
Why it matters
Compose runs a stack on one machine. When one machine isn't enough, or must never be a single point of failure, teams run containers on a cluster of many machines, and the standard way to do that is Kubernetes. Even if a platform team runs the cluster, application developers write and review its configuration files every week, and many incidents trace back to a handful of misreadings: a selector that matches nothing, a rollout that takes the service down, a container with no memory limit. This lesson builds the ideas in Python, so a manifest reads as a plan you can check.
What you'll be able to do
- Explain Kubernetes' core idea: you declare the state you want, and controllers keep making it true
- Read a Deployment and a Service: replicas, labels and selectors, images, probes and resources
- Work through a rolling update with surge and unavailability limits
- Check a manifest for the mistakes that cause outages
What it covers
- Say what you want, not what to do
- Reading a Deployment and its Service
- Which pods does a selector pick?
- Rolling updates without downtime
- Mistakes a manifest review catches
Show the answer
Kubernetes starts a new copy elsewhere to get back to 3
You declare the desired state, and Kubernetes keeps comparing it with what's running and fixes the difference. That's how clusters recover from failures and roll out updates without downtime.
Say what you want, not what to do
With Compose you run commands: start this, stop that. Kubernetes works the other way around. You write down the desired state ("three copies of shop-api version 1.4, reachable on port 80"), and controllers, small programs inside the cluster, compare it with what's actually running and act to close the gap, over and over. A crashed copy gets replaced, and a deleted one comes back, without anyone running a command. That loop is called reconciliation.
The objects you'll meet first:
| object | what it is |
|---|---|
| Pod | one or more containers that run together on one machine; the unit Kubernetes starts and stops |
| Node | a machine in the cluster that runs pods |
| Deployment | the desired state for a set of identical pods: which image, how many copies (replicas), how to update them |
| Service | one stable address that spreads traffic across the matching pods, however often they're replaced |
| ConfigMap and Secret | settings and sensitive values handed to pods as environment variables or files |
def reconcile(desired, running): """One pass of a controller: what to start and stop so running matches desired.""" start = max(0, desired - len(running)) stop = running[desired:] if len(running) > desired else [] return start, stop print(reconcile(3, ["pod-a", "pod-b"])) # one crashed: start one print(reconcile(3, ["pod-a", "pod-b", "pod-c", "pod-d"])) # someone scaled down: stop one print(reconcile(3, ["pod-a", "pod-b", "pod-c"])) # nothing to do
Reading a Deployment and its Service
Manifests are YAML, like Compose files. Here are the two that run the shop's API:
import yaml manifest = """ apiVersion: apps/v1 kind: Deployment metadata: name: shop-api spec: replicas: 3 selector: matchLabels: {app: shop-api} template: metadata: labels: {app: shop-api, tier: backend} spec: containers: - name: api image: shop-api:1.4 ports: [{containerPort: 8000}] readinessProbe: httpGet: {path: /healthz, port: 8000} resources: requests: {cpu: 250m, memory: 256Mi} limits: {memory: 512Mi} --- apiVersion: v1 kind: Service metadata: name: shop-api spec: selector: {app: shop-api} ports: [{port: 80, targetPort: 8000}] """ deployment, service = yaml.safe_load_all(manifest) pod = deployment["spec"]["template"] container = pod["spec"]["containers"][0] print(deployment["kind"], deployment["spec"]["replicas"], "replicas of", container["image"]) print("pods are labeled", pod["metadata"]["labels"]) print("the Service sends port", service["spec"]["ports"][0]["port"], "to pods matching", service["spec"]["selector"])
The connections are all labels: small key-value tags. The Deployment's template stamps every pod it makes with app: shop-api, its selector says "the pods I manage are the ones labeled app: shop-api", and the Service's selector picks the same pods to send traffic to. Nothing refers to a pod by name, because pods come and go. Three more fields matter in every review:
- The readiness probe is a check Kubernetes calls on each pod; until it passes, the Service sends that pod no traffic. (A liveness probe, separately, restarts a container that has hung.)
- Requests reserve CPU and memory for scheduling a pod onto a node (
250mis a quarter of a CPU), and the memory limit is where the container gets killed and restarted if it uses more. - The
---line separates two documents in one file, which is why the code usessafe_load_all.
Settings come from ConfigMaps and Secrets rather than being baked into the image, so one image runs in every environment. A container's env can say valueFrom: {secretKeyRef: {name: shop, key: db-password}}, and the cluster fills in the value when the pod starts. That keeps passwords out of the manifest, which lives in git, and lets an operator rotate a password without rebuilding anything (pods read environment variables when they start, so they need a restart to see the new value).
{app: shop-api}, but a new Service was written with selector: {app: shopapi}. What happens to requests sent to the Service?Show the answer
They fail: the Service matches no pods
Selectors match labels exactly, and nothing checks that a Service's selector matches any pod: the pods run fine, but none are behind it. (A Deployment whose own selector doesn't match its template is rejected outright, which is the one case Kubernetes catches for you.)
def reconcile(desired, running): start = max(0, desired - len(running)) stop = running[desired:] if len(running) > desired else [] return start, stop running = ["api-a", "api-b", "api-c"] running.remove("api-b") # a node fails and takes this pod with it print(reconcile(3, running))
Show the answer
(1, [])
Two pods are left and three are wanted, so the controller starts one more. Nobody had to notice the failure: the next reconcile pass closes the gap.
Which pods does a selector pick?
A selector matches a pod when every key-value pair in the selector appears in the pod's labels; the pod may have extra labels. Write matches(selector, labels) and picked(selector, pods), where pods maps pod names to their labels, returning the sorted names that match.
def matches(selector, labels): return selector == labels def picked(selector, pods): return [] pods = { "api-1": {"app": "shop-api", "tier": "backend", "version": "1.4"}, "api-2": {"app": "shop-api", "tier": "backend", "version": "1.5"}, "web-1": {"app": "shop-web", "tier": "frontend"}, } print(picked({"app": "shop-api"}, pods))
Rolling updates without downtime
Change the image to shop-api:1.5 and the Deployment replaces its pods gradually, a rolling update, governed by two numbers:
- maxSurge: how many pods above the replica count may exist during the update (extra capacity, briefly).
- maxUnavailable: how many pods below the replica count may be missing or not ready.
With 3 replicas, a surge of 1 and no unavailability allowed, the cluster starts one new pod (4 running), waits for its readiness probe, stops one old pod (back to 3), and repeats. Traffic never drops below 3 ready pods. A bigger surge is faster but needs spare room in the cluster; allowing unavailability is faster still but runs with less capacity for a while. Both at zero is impossible: there'd be no way to start a new pod or stop an old one.
If the new version's readiness probe never passes, the rollout stalls with the old pods still serving, which is the safety net that makes probes so important, and kubectl rollout undo goes back to the previous version.
replicas 3, maxSurge 1, maxUnavailable 0: never fewer than 3 ready pods.
Show the answer
At most 3 pods, and at least 2 ready
No surge means never more than 3 pods. Allowing 1 unavailable means it may drop to 2 ready while a replacement starts. It needs no spare room in the cluster, but runs a third short for a while.
Mistakes a manifest review catches
Many Kubernetes incidents trace back to a few lines that a careful review, or a checker, would have caught:
- An image with no tag, or the tag
latest: two pods can end up running different code, and you can't roll back to "the previous latest". - No readiness probe: new pods get traffic before they can answer, so every rollout causes a burst of errors.
- No memory limit: one leaking pod can starve everything else on its node.
- One replica: a crash, a failed machine or a node drained for maintenance takes the service down until the pod is replaced.
- A password written as a plain
valuein the manifest, which lives in git, instead of a reference to a Secret.
Day to day you talk to a cluster with kubectl. kubectl apply -f api.yaml sends the desired state; kubectl get pods lists what's running and whether each pod is ready; kubectl rollout status deployment/shop-api watches an update; kubectl describe pod <name> explains why a pod won't start (a failing probe, an image that can't be pulled, no node with enough memory); and kubectl logs <name> shows what the app printed. When a rollout goes wrong, kubectl rollout undo deployment/shop-api returns to the previous version while you investigate. apply and rollout undo change the desired state and get, describe and logs only read; none of them starts a pod itself. The controllers do that.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why do new pods need a readiness probe for a rolling update to be safe?