Config, health & autoscaling
A Deployment that runs isn't a Deployment that's production-ready. This lesson is the hardening layer: liveness / readiness / startup probes so the cluster knows when a Pod is healthy and when to send it traffic; resource requests & limits so the scheduler can pack nodes and one Pod can't starve the rest; rolling updates & rollbacks for zero-downtime deploys; the HorizontalPodAutoscaler to add replicas under load; and a PodDisruptionBudget so maintenance never takes you below quorum.
Learning objectives
- Configure liveness, readiness, and startup probes and say what each one gates.
- Set resource requests (for scheduling) and limits (for capping), and explain QoS.
- Perform a zero-downtime rolling update and a rollback.
- Add a HorizontalPodAutoscaler that scales on CPU (and understand custom metrics).
- Protect availability during maintenance with a PodDisruptionBudget.
30-second recap
- Pods, Deployments & Services: A Pod runs containers, a Deployment keeps N identical Pods alive and rolls out updates, a Service gives them one stable address.
1 · The three probes — and what each gates
Here's the outage nobody forgets. Your app is fine — a little slow to boot because it warms a model cache — but Kubernetes decides it's dead, kills it, and restarts it. It's slow to boot again, so Kubernetes kills it again. You've built a machine that murders your app on a loop, and the app was never actually broken. That whole disaster comes from getting probes wrong, which is why we start here.
Think of probes as the questions a hospital nurse asks a patient, not a single "are you OK?" A nurse doesn't collapse three very different checks into one. "Are you conscious?" (if not, we intervene) is a different question from "Are you ready to be discharged and see visitors?" (if not, we just keep you resting) — and both differ from "Have you woken up from the anaesthetic yet?" (don't judge the other two until they have). Kubernetes has exactly these three questions, and each triggers a different response. The single most common beginner mistake is treating them as interchangeable — copy-pasting the same endpoint into all three — which is precisely how a briefly-slow database turns into a restart storm.
Why does this matter more than it looks? Because a probe isn't passive monitoring — it's a control loop that takes action. Answer the wrong question and Kubernetes takes the wrong action: it pulls a healthy Pod out of rotation, or restarts one that just needed a moment. The three probes are readiness (should you get traffic?), liveness (should you be restarted?), and startup (has boot finished, so hold off the other two?). Readiness gates traffic — failing it quietly removes the Pod from the load balancer with no restart. Liveness gates restarts — failing it kills and recreates the container. Startup is the referee that suspends the first two until a slow-booting app has finished coming up. Now the precise version:
Kubernetes can't read your app's mind; you tell it health via probes. The three do different jobs and confusing them causes real outages:
Read top-to-bottom: the one Pod is checked by three probes (middle row), and each probe's failure triggers a different action (bottom row).
- The top box is the single container. The three blue arrows show the same Pod being asked three separate questions.
- The middle row is the three probes: readiness ("can I serve?"), liveness ("am I wedged?"), and startup ("have I booted?").
- The bottom row is the payoff — the action each failure causes: readiness failing quietly drops you from the load balancer (green, no restart); liveness failing restarts the container (red); startup doesn't punish at all — it just holds off the other two while boot runs.
In short: the three probes are not interchangeable because each failure takes a different action — mixing them is how a brief slowdown becomes a restart storm.
| Probe | Question it answers | What failing it does |
|---|---|---|
| readiness | "Can I serve traffic right now?" | Removed from Service endpoints (no restart) |
| liveness | "Am I alive, or wedged?" | Container is restarted |
| startup | "Have I finished booting?" | Holds off the other probes until boot is done |
probes.yaml readinessProbe: # gates traffic: pull from LB until ready
httpGet: { path: /healthz/ready, port: 8000 }
periodSeconds: 5
livenessProbe: # gates restart: kill+restart if wedged
httpGet: { path: /healthz/live, port: 8000 }
periodSeconds: 10
failureThreshold: 3
startupProbe: # slow starters: don't let liveness kill boot
httpGet: { path: /healthz/live, port: 8000 }
failureThreshold: 30
periodSeconds: 5 # allows up to ~150s to start
2 · Resource requests and limits
This is where cloud bills quietly explode — or where apps mysteriously die at 3 a.m. Set these two numbers too high and you pay for reserved capacity nobody uses; set them too low and Kubernetes packs your Pods onto a node that can't actually feed them, and they start getting throttled or killed under load. Almost every "why is our cluster so expensive?" and every "why does this Pod keep restarting with no error in the logs?" conversation traces back to requests and limits.
Here's an analogy that makes the two numbers click. Booking a restaurant, you tell them "party of four" — that's your request: the restaurant reserves a table for four and won't give it away, even if only two of you show up early. Separately, the fire marshal posts a limit: this room holds at most twenty people, full stop. The request is a guarantee to you (this much is set aside); the limit is a wall around you (you may never exceed it). They are answering completely different questions — "how much do I plan to use?" versus "what's the absolute ceiling?" — and beginners constantly conflate them.
The subtlety that bites hardest is that CPU and memory behave differently when you cross the limit, because one is compressible and the other isn't. CPU is like clock time: if you want more than your cap, Kubernetes simply hands you less — you get throttled, slowed down, but nobody dies. Memory is like physical shelf space: once it's full, you can't be "slowed down" into using less, so the kernel does the only thing it can and OOM-kills the container outright. That asymmetry is the whole reason the guidance differs — you can be a little aggressive with CPU limits (worst case: slow), but a too-low memory limit is a loaded gun (worst case: dead). And the request has a second job people forget: it's the only thing the scheduler looks at when deciding which node a Pod fits on. Get the request wrong and Pods land on nodes that are already overcommitted. Now the precise version:
Two numbers per resource, and they mean different things. The request is what the scheduler reserves to place the Pod (and what other Pods can't use). The limit is the hard cap while running. Getting these right is most of good cluster economics.
Read the left panel bottom-to-top as a stack of capacity, then the right panel top-to-bottom for what a breach costs.
- The green band at the bottom is the request — the amount the scheduler sets aside and bin-packs onto a node; other Pods can't claim it.
- The space above it up to the dashed top line is burst room, capped by the limit (the ceiling).
- The right panel is the payoff: crossing the limit is harmless-ish for CPU (throttled, green→amber) but fatal for memory (OOM-killed, red). Setting request equal to limit earns the highest-priority Guaranteed QoS class.
In short: size the request from typical use so nodes pack well, and set the memory limit above observed peak — because a too-low memory limit kills the container, it doesn't just slow it.
resources.yaml resources:
requests: # scheduler reserves this; used for bin-packing
cpu: "250m" # 0.25 of a core
memory: "256Mi"
limits: # hard cap at runtime
cpu: "1" # throttled above 1 core
memory: "512Mi" # OOM-killed above 512Mi
| Behavior | CPU (compressible) | Memory (incompressible) |
|---|---|---|
| Over the limit | Throttled (slowed, not killed) | OOM-killed — the container dies |
| Set request only | Can burst above request if node has room | Same — no cap |
| No request/limit | "BestEffort" — first to be evicted | First to be evicted under pressure |
3 · Rolling updates and rollbacks
Deploying a new version used to mean a maintenance window and a held breath. You'd take the app down, swap the code, bring it back up, and pray it came back healthy — with users staring at an error page the whole time. Kubernetes makes that ritual obsolete: it can swap versions while the app keeps serving, and if the new one is broken it stops and backs out on its own. That capability is the rolling update, and it's a big part of why teams deploy ten times a day instead of once a quarter.
Picture repainting a long hallway that people are still walking through. You don't close the whole hallway — you rope off one section, paint it, let it dry and reopen it, then move to the next section. At every moment most of the hallway is open, so foot traffic never stops. A rolling update does exactly this with your Pods: it brings up a Pod on the new version, waits until that Pod is actually ready to serve, and only then retires one old Pod — shifting your replicas from all-old to all-new a slice at a time. The number of Pods answering requests barely dips, so users notice nothing.
The load-bearing detail — and the one beginners skip — is the phrase "waits until it's actually ready." That readiness check from §1 is what makes the whole thing safe: a new Pod is added to the load balancer only after it passes readiness, and an old Pod is removed only after the new one is in. The common mistake is shipping a new version with a broken or missing readiness probe. Then Kubernetes thinks each new Pod is ready the instant it starts, tears down the old ones, and cheerfully replaces a working app with a broken one at full speed. Wire readiness correctly and the opposite happens: a bad version can't pass readiness, so the rollout stalls with the old Pods still serving — a failed deploy instead of an outage. And because the previous ReplicaSet is kept around, undoing a bad rollout is a single command, not a frantic redeploy. Now the precise version:
Changing the image on a Deployment triggers a rolling update by default: new Pods come up and pass readiness before old ones are removed, so traffic never drops. If it goes wrong, rollback is one command because the old ReplicaSet is still there.
Read the four boxes left-to-right, following the blue arrows — they trace one Deployment moving from all-old to all-new a slice at a time.
- The leftmost green box is the steady state before the deploy: three replicas all on
v1(v1 ×3). - The two amber boxes in the middle are the transitional mix — first
v1 ×2 + v2 ×1, thenv1 ×1 + v2 ×2; each blue arrow is one step where a newv2Pod comes up and passes readiness before an oldv1Pod is retired, so the total serving count barely dips. - The rightmost green box is the payoff: all three replicas are now on
v2(v2 ×3) — the swap finished with no downtime.
In short: a rolling update shifts replicas from the old version to the new one one Pod at a time, so traffic never drops during the deploy.
rollout.shkubectl set image deploy/web app=ghcr.io/example/web:1.3.0
kubectl rollout status deploy/web # blocks until healthy or fails
kubectl rollout history deploy/web # revisions you can return to
kubectl rollout undo deploy/web # instant rollback to previous revision
kubectl rollout undo deploy/web --to-revision=2
deployment "web" successfully rolled out
4 · HorizontalPodAutoscaler
Traffic isn't flat, but a fixed replica count is. Provision for your busiest hour and you burn money all night running idle Pods; provision for the average and you fall over the moment a spike hits. Sizing by hand means either overpaying or getting paged — and you can't sit there watching a dashboard and typing kubectl scale at 2 a.m. anyway. The HorizontalPodAutoscaler is the robot that watches the dashboard for you and adds or removes Pods to match demand.
The mental model is a thermostat, and it's worth holding onto because it explains the HPA's whole personality. You set a target ("keep the room at 20°C"), the thermostat reads the actual temperature, and it acts to close the gap — more heat when it's cold, less when it's warm. The HPA does the same with a metric: you say "keep average CPU at 60%," it reads the current average across your Pods, and it computes how many replicas would bring the reading back to target. Too hot (CPU above 60%) → add Pods; comfortably cool → remove some. It's a feedback loop, not a fixed rule.
Two things about that loop trip people up. First, it scales up fast and down slow on purpose: adding capacity late means dropped requests, but removing it too eagerly causes flapping — a jittery add-remove-add cycle that's worse than doing nothing — so the HPA is deliberately cautious about scaling in. Second, and this is the mistake almost everyone makes once: "60% CPU" means 60% of the CPU request you set in §2, not 60% of a whole core. The request is the denominator. If you never set a CPU request, there's no denominator, the HPA can't compute a percentage, and it just sits there inert while your app melts — no error, no scaling, nothing. The HPA also needs a metrics source feeding it numbers (metrics-server for CPU and memory, or an adapter for custom metrics like queue depth). Note too that the autoscaling API and its metric options evolve across Kubernetes versions, so verify the current shape against the docs rather than trusting a snippet's age. Now the precise version:
The HPA adjusts a Deployment's replica count to hit a target metric — classically CPU. It needs a metrics source (metrics-server for CPU/memory, or an adapter for custom metrics). It scales up quickly under load and down conservatively to avoid flapping.
Follow the blue arrows left-to-right through one cycle, then the arrow curving back proves it's a repeating loop.
- The first box reads the live metric — average CPU across the Pods.
- The middle box compares it to the target; note "60%" is 60% of the CPU request you set in §2 — that request is the denominator, so with no request there's nothing to divide by and the HPA sits inert.
- The third box writes a new replica count, clamped between min and max. Hot means add Pods (green), cool means remove some (amber).
- The curved arrow back to the start is the payoff: it's a feedback loop that runs continuously, deliberately scaling up fast and down slow to avoid flapping.
In short: the HPA closes the gap between actual and target CPU by adjusting replicas — but only if a CPU request exists to measure utilization against.
hpa.yamlapiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: web
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60 # % of the CPU *request*
autoscaling/v2 API and available metric types evolve — verify the current API version and options against the Kubernetes docs. For bursty, spiky traffic (like LLM inference, K7), CPU may be the wrong signal — consider queue depth or a custom metric.5 · PodDisruptionBudget
The outage you cause yourself is the most embarrassing kind. You have three replicas spread across three nodes — plenty of redundancy — and then a routine node upgrade drains all three nodes at once because the cluster was in a hurry. For a few seconds you have zero Pods serving, and the pager goes off during scheduled maintenance you thought was safe. Your replicas were fine; the timing of the maintenance wasn't. A PodDisruptionBudget exists to stop precisely this.
The key idea is that not all Pod deaths are the same, and you can only govern one kind. Think of a 24-hour shop with a staff of three. Some absences you simply can't plan for — someone calls in sick, a bus breaks down; that's involuntary disruption (a node crashes), and no policy prevents it. But scheduled things — sending people on training, closing for a deep-clean — those are voluntary, and for those a sensible manager sets a floor: "never send more than one person off at a time; at least two must be on the floor." A PodDisruptionBudget is that floor, applied to voluntary disruptions like node drains, cluster autoscaler scale-downs, and rolling node upgrades.
What makes it work is that it doesn't stop maintenance — it makes maintenance wait its turn. When something tries to voluntarily evict a Pod, Kubernetes checks the budget first: if evicting now would drop you below minAvailable, the eviction blocks until enough Pods are healthy again, then proceeds one at a time. The mistake to avoid is expecting a PDB to save you from a hard node crash — it can't, because a crash never asks permission. Involuntary loss is what spreading replicas across nodes and wiring readiness are for; the PDB is strictly a brake on the planned disruptions you'd otherwise let run too fast. (PDB semantics also shift between versions, so confirm the current behaviour against the docs.) Now the precise version:
There are two kinds of disruption: involuntary (a node crashes — you can't prevent it) and voluntary (you drain a node for an upgrade). A PodDisruptionBudget constrains the voluntary kind so routine maintenance can't take you below a safe number of Pods.
minAvailable, the eviction waits its turn. A hard node crash never asks — the PDB can't protect against it.
Follow the top arrow left-to-right: a voluntary eviction hits the PDB check, which branches into two outcomes.
- The left box is a voluntary disruption — a
kubectl drain, an autoscaler scale-down, or a node upgrade. - The middle diamond-style box is the budget check: would evicting now still leave at least
minAvailablePods? - The upper-right (green) branch is "yes" — the eviction is allowed and proceeds one Pod at a time. The lower-right (amber) branch is "no" — the eviction is blocked and waits until a replacement is Ready, rather than losing availability.
- The red box at the bottom-left is the payoff caveat: an involuntary crash never reaches the check at all, so a PDB is powerless against it — that's what replicas spread across nodes are for.
In short: a PDB makes planned maintenance wait its turn to respect a floor; it slows voluntary disruptions but cannot stop an involuntary node crash.
pdb.yamlapiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web
spec:
minAvailable: 2 # or: maxUnavailable: 1
selector:
matchLabels:
app: web
kubectl drain, a cluster autoscaler scale-down, a node upgrade) wait until it can respect the budget — it slows or blocks the disruption rather than losing availability. It does not protect against involuntary loss (a hard node crash); that's what replicas across nodes and readiness are for. Verify current PDB semantics against the Kubernetes docs.✓ Checkpoint — you can move on when you can…
- Say what each probe gates: readiness → traffic, liveness → restart, startup → boot window.
- Explain the difference between a resource request and a limit, and what happens over each.
- Perform a rolling update and roll it back with
kubectl rollout. - Write an HPA on CPU and explain why it needs a CPU request set.
- Explain what a PodDisruptionBudget protects and what it doesn't.
An app takes ~90 seconds to warm a model cache on boot. With a plain liveness probe (10s period, 3 failures) it enters CrashLoopBackOff and never starts. What's wrong and how do you fix it?
Show answer
failureThreshold × periodSeconds window (e.g. 30 × 5s = ~150s). While the startup probe is still passing/pending, liveness and readiness are suspended, so the slow warm-up is allowed to finish; only after startup succeeds does liveness begin guarding against wedging.You set an HPA to keep CPU at 60% but it never adds replicas even under obvious load. The Deployment has no resources.requests. Why is the HPA inert?
Show answer
resources.requests.cpu on the container; then "60%" becomes 60% of that request and the HPA can react. (Also confirm metrics-server is installed, or the HPA has no metrics at all.)🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Probes are the first thing separating a demo Deployment from a real one.
Your task: Add a readiness and a liveness probe to a web container and state, in one line each, what failing each probe causes.
Requirements:
- Readiness on a cheap /ready endpoint
- Liveness on a cheap /live endpoint (no dependency calls)
- State the effect of each failing
💡 Hint: Liveness must be cheap and in-process; readiness may check whether it can serve.
Show solution
readinessProbe:
httpGet: { path: /healthz/ready, port: 8000 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz/live, port: 8000 }
periodSeconds: 10
failureThreshold: 3Effects: a failing readiness probe removes the Pod from the Service's endpoints so it stops receiving traffic (no restart) — used to shed a temporarily-busy Pod. A failing liveness probe restarts the container — used to recover a wedged process. Keep liveness cheap and dependency-free so a slow database can't trigger needless restarts.
Context: Right-sizing resources is most of cluster cost control.
Your task: Given an app that idles ~200m CPU / 180Mi and peaks 900m / 480Mi, choose requests and limits and justify each number.
Requirements:
- Set the request from typical usage, the memory limit from peak + headroom
- Explain why CPU over-limit and memory over-limit behave differently
- Note what QoS class request==limit would give
💡 Hint: Memory over-limit kills; CPU over-limit only throttles — size memory more carefully.
Show solution
resources:
requests: { cpu: "250m", memory: "256Mi" }
limits: { cpu: "1", memory: "640Mi" }Reasoning: the request (250m / 256Mi) sits at or just above typical usage so the scheduler packs nodes efficiently and the Pod is guaranteed its baseline. The CPU limit (1 core) lets it burst for spikes; exceeding it only throttles the container, so a generous cap is safe. The memory limit (640Mi) is peak (480Mi) plus headroom, because exceeding a memory limit OOM-kills the container — you never want a normal peak to kill it. Setting request == limit on both would give the Guaranteed QoS class (least likely to be evicted), at the cost of no bursting.
Context: A rollout that stalls safely on a bad version is the whole point of readiness-gated deploys.
Your task: Roll out a new image, then explain what happens if the new version fails its readiness probe, and how you'd recover.
Requirements:
- Give the rollout and status commands
- Explain why a failing readiness probe stalls rather than breaks the rollout
- Give the rollback command
💡 Hint: Old Pods are only removed once new ones are Ready — so a never-Ready new version can't take the app down.
Show solution
kubectl set image deploy/web app=ghcr.io/example/web:1.3.0
kubectl rollout status deploy/web # watch it progress or stallIf v1.3.0's readiness probe never passes: the rolling update creates a new Pod but won't mark it Ready, so it won't remove an old (working) Pod to make room beyond maxUnavailable. The rollout stalls — old Pods keep serving traffic, and rollout status reports it isn't progressing. The bad version never takes the app down on its own.
Recover: kubectl rollout undo deploy/web scales the previous ReplicaSet back up and the broken one down — an instant return to v1.2.0 because the old ReplicaSet was never deleted.
Context: Choosing the right autoscaling signal is what separates a working HPA from a flapping one.
Your task: Add a CPU HPA to a web Deployment, then argue whether CPU is the right metric for a latency-sensitive, bursty API and what you'd use instead.
Requirements:
- Write the HPA (2–10 replicas, 60% CPU)
- Note the CPU-request prerequisite
- Argue for/against CPU vs a custom metric for bursty inference-style load
💡 Hint: For request/response latency, in-flight requests or queue depth track user pain better than CPU.
Show solution
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: web }
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: web }
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 60 } }Prerequisite: a CPU request must be set, since utilization is measured against it, plus a running metrics-server.
Is CPU the right signal? For a CPU-bound service, yes. But for a latency-sensitive, bursty API — or LLM inference (K7) — CPU can lag the actual pain: requests queue up before CPU saturates, so users see latency while the HPA sits idle. Better signals are in-flight requests, queue depth, or p95 latency, exposed as custom/external metrics via an adapter. Verify the current custom-metrics API against the docs before relying on it.
Context: Voluntary disruptions (node upgrades, autoscaler scale-down) will take your Pods down unless a budget constrains them.
Your task: Add a PodDisruptionBudget for a 3-replica service and explain exactly what it does — and doesn't do — during a rolling node upgrade.
Requirements:
- Write a PDB keeping at least 2 of 3 available
- Explain how it interacts with
kubectl drain - State clearly what it does NOT protect against
💡 Hint: A PDB gates voluntary evictions only; involuntary loss still needs replicas across nodes.
Show solution
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: web }
spec:
minAvailable: 2
selector: { matchLabels: { app: web } }During a node upgrade: the upgrade drains nodes one at a time. When a drain would evict a web Pod, the PDB checks whether at least 2 would remain available; if not, the eviction blocks and waits until a replacement Pod is Ready elsewhere. The upgrade proceeds only as fast as availability allows — so you never drop below 2 serving Pods.
What it does NOT do: a PDB is powerless against involuntary disruption — if a node crashes, both its Pods are gone regardless of the budget. Real resilience needs replicas spread across nodes/AZs (anti-affinity / topology spread) so no single node failure can breach your target. The PDB only governs the disruptions Kubernetes initiates.
Context: Representative scenario: a service passed review functionally but the SRE team blocks its prod promotion until it meets a reliability checklist.
Your task: Produce the full container spec and supporting objects that take a bare Deployment to the production bar: probes, resources, rollout strategy, HPA, and PDB, each with a one-line justification.
Requirements:
- All three probe types where appropriate, correctly scoped
- Requests and limits set from a profile, memory limit above peak
- Rolling strategy with maxUnavailable: 0 for a critical service
- HPA on an appropriate metric and a PDB
- One line on what each control buys you
💡 Hint: Tie each control to a specific failure it prevents — that's how SRE reviews think.
Show solution
Container spec (probes + resources):
readinessProbe: { httpGet: { path: /healthz/ready, port: 8000 }, periodSeconds: 5 }
livenessProbe: { httpGet: { path: /healthz/live, port: 8000 }, periodSeconds: 10, failureThreshold: 3 }
startupProbe: { httpGet: { path: /healthz/live, port: 8000 }, failureThreshold: 30, periodSeconds: 5 }
resources:
requests: { cpu: "250m", memory: "256Mi" }
limits: { cpu: "1", memory: "640Mi" }Deployment strategy (critical service — never dip below capacity):
strategy:
type: RollingUpdate
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }HPA + PDB: a CPU (or custom-metric) HPA from minReplicas: 3 to 12, and a PDB with minAvailable: 2.
What each control buys: readiness → no traffic to a not-ready Pod; liveness → auto-recovery from wedging; startup → slow boots don't crash-loop; requests → the scheduler reserves capacity; memory limit above peak → no surprise OOM-kills; maxUnavailable: 0 → zero-downtime deploys; HPA → capacity follows load; PDB → maintenance can't breach quorum. Spread replicas across AZs (topology spread) so involuntary node loss can't either. Every line maps to a failure it prevents — which is exactly what the SRE checklist is asking for.