AI EngineeringZero to ProductionHome·About·What’s new·Contact
Kubernetes Orchestration · Part 12

Production ops, DR & cost

The on-call survival kit. When a Pod is wedged at 3am you need a decision table, not a search engine: this lesson is a troubleshooting playbook for the classics — CrashLoopBackOff, OOMKilled, ImagePullBackOff, Pending/unschedulable, node pressure — plus the things that save you around them: backup & DR (Velero, etcd snapshots), cluster upgrades, multi-cluster/HA, and the money side — capacity & cost with Karpenter, Spot, bin-packing, and right-sizing requests. Everything that turns a demo cluster into one you can be paged for.

⏱️ ~120 min🚨 On-call🎯 Advanced→Production
🌱 Honesty up frontAll kubectl/Velero/Karpenter commands and YAML here need a cluster (minikube/kind local, or EKS) and terminal output is illustrative. Velero, Karpenter, and cluster-upgrade mechanics differ by provider and version and change often — verify against current docs before running anything in production. The cost / bin-packing / right-sizing math is plain arithmetic and is given as offline-runnable Python needing no cluster.

Learning objectives

  • Diagnose the five classic Pod failures from symptoms using a decision table.
  • Run the first-5-minutes triage: get → describe → logs → events.
  • Back up and restore workloads with Velero and understand etcd snapshots for DR.
  • Perform a cluster upgrade safely (control plane then nodes) and know the multi-cluster/HA options.
  • Right-size requests/limits and understand how QoS drives eviction under node pressure.
  • Cut cost with Karpenter, Spot, and bin-packing — modeled offline — without wrecking reliability.

1 · The troubleshooting playbook (decision table)

It's 3am, your phone is buzzing, and a Pod is red. This is the moment that separates a calm ten-minute fix from a two-hour flail — and the difference is almost never how clever you are. It's whether you have a procedure. Under stress, freshly woken, you don't want to be inventing a debugging strategy; you want a checklist your fingers already know. That's what this section gives you: a way to turn "something's broken" into "I recognise this exact signature."

Here's the reassuring truth that makes on-call survivable: Kubernetes doesn't fail in a million creative ways. It fails in about five. A container that keeps crashing, one killed for using too much memory, one whose image can't be pulled, one that never gets scheduled, and one evicted because its node ran out of room. Each of those leaves a distinctive fingerprint — a specific status string, a specific exit code, a specific line in the events. Think of it like a doctor reading symptoms: chest pain plus shortness of breath points somewhere specific, and you confirm with a test rather than guessing. The decision table below is your differential diagnosis, and the "Confirm with" column is the test that turns a hunch into certainty before you touch anything.

The mistake that costs everyone their first year: fixing before confirming. You see a crashing Pod, you assume it's the memory limit, you bump the limit, redeploy, wait five minutes… and it crashes again, because the real cause was a typo in a config file. Every guess you act on costs a full deploy-and-observe cycle, and you can burn an hour that way. The discipline the table enforces is match the fingerprint first, change one thing second — read the status, read the exit code, read the events, then act. Slow is smooth, and smooth is fast, especially when you're barely awake.

Most Pod failures fall into a handful of buckets with distinctive signatures. Match the symptom to the cause, then confirm with the evidence column — this is the table to keep open on-call:

a red Pod → match its fingerprint → confirm → then fix Pod is red CrashLoop restarts loop OOMKilled exit 137 ImagePull bad ref / auth Pending no node fits Evicted node pressure logs --previous Last State field describe events FailedScheduling describe node green row = the "Confirm with" evidence — read it before you change anything Five fingerprints, one procedure. Kubernetes fails in about five recognisable ways (blue row); each leaves a distinctive confirming signal (green row). Match, confirm, then change one thing.
🗺️ How to read this diagram

Read it top-to-bottom: a red Pod at the top fans out to the five failure signatures, and each signature drops to the exact evidence that confirms it.

  • The red box at the top is the alert you woke up to — "a Pod is red" — with no diagnosis yet.
  • The blue row is the five buckets almost every failure lands in: CrashLoopBackOff, OOMKilled (exit 137), ImagePullBackOff, Pending, and node-pressure eviction. The arrows fan out because any red Pod is one of these.
  • The green row is the payoff — the single confirming command or field for each (e.g. logs --previous for a crash loop, the FailedScheduling reason for Pending). Following the arrow down turns a hunch into certainty before you touch anything.

In short: Don't fix a red Pod by guessing — drop from the symptom to its green evidence box, confirm the fingerprint, then change exactly one thing.

SymptomMost likely causeConfirm withFix direction
CrashLoopBackOffContainer starts then exits/crashes repeatedlylogs --previous; exit code; probe configFix the crash / bad config / failing liveness probe
OOMKilled (exit 137)Container exceeded its memory limitdescribe → Last State: OOMKilledRaise memory limit or fix the leak / cut usage
ImagePullBackOffImage name/tag wrong or registry auth missingdescribe events: pull errorFix image ref / add imagePullSecret / registry access
Pending / UnschedulableNo node fits (requests, taints, affinity, no capacity)describe pod → FailedScheduling reasonLower requests / add nodes / fix taints/affinity
Node pressure / EvictedNode low on memory/disk; kubelet evicts Podsdescribe node → conditions/taintsFree resources / right-size / add capacity
Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
bash · the first-5-minutes triage, in order (needs a cluster)
triage.shkubectl get pods -n web -o wide                 # phase, restarts, node, age
kubectl describe pod <pod> -n web               # events, last state, probe/schedule reasons
kubectl logs <pod> -n web --previous            # logs from the CRASHED container, not the new one
kubectl get events -n web --sort-by=.lastTimestamp   # what k8s did recently
kubectl top pod <pod> -n web                    # live CPU/mem (needs metrics-server)
NAME          READY   STATUS             RESTARTS      AGE   NODE
chat-7d9...   0/1     CrashLoopBackOff   6 (2m ago)    14m   ip-10-0-3-21
# describe -> Last State: Terminated, Reason: OOMKilled, Exit Code: 137
--previous and the exit code are your best friendsFor a crashing Pod, kubectl logs shows the new container; you want logs --previous for the one that died. And read the exit code: 137 = OOMKilled (SIGKILL, memory), 1/2 = app error, a probe failure shows as repeated restarts with the probe in describe. Match the signature before you start changing things. Verify field names vs current docs.

2 · Pending, eviction & QoS under node pressure

Two of the five failures aren't your app's fault at all — they're arguments about real estate. A Pod that won't schedule and a Pod that gets kicked off a node are both about the same scarce thing: room on a machine. Get these wrong and you'll waste an afternoon reading application logs that are perfectly fine, because the problem was never in the code — it was in how you asked for space.

Picture the Kubernetes scheduler as a host seating parties at a restaurant. When you submit a Pod you hand over a reservation slip — "I need a table for this much CPU and this much memory." That slip is your requests. The host walks the floor looking for a table that fits; if none does, your party waits at the door, and that waiting is Pending. The crucial, counter-intuitive part: the host seats you based on the slip, not on how much you'll actually eat. Reserve a table for eight when two of you show up, and you've blocked six seats nobody uses — which is exactly the cost bug section 5 comes back to.

Eviction is the darker cousin. When a node genuinely runs low on memory or disk, the kubelet can't politely wait — it has to throw someone out to save the machine, and it needs a rule for who goes first. That rule is QoS class, and here's the mistake almost everyone makes: they think QoS is a setting you choose. It isn't. It's derived from how you wrote your requests and limits. Set them equal and Kubernetes silently grants you Guaranteed (the last to be sacrificed); set none at all and you're BestEffort (first out the door). You're picking your Pod's survival odds without realising it every time you write a manifest. The lesson: for anything you can't afford to lose, make the request/limit relationship deliberate.

scheduling: does the slip fit a node? requests (the slip) fits → Scheduled no fit → Pending eviction: QoS decides who dies first requests == limits → Guaranteed requests < limits → Burstable nothing set → BestEffort evicted first ↑ ↓ last requests/limits set your survival odds Requests schedule; the request/limit gap sets QoS. Left: the scheduler seats you on your slip — no fit means Pending. Right: setting requests equal to limits earns Guaranteed (evicted last); setting nothing earns BestEffort (evicted first).
🗺️ How to read this diagram

Read the two halves separately: the left is scheduling (before the Pod runs), the right is eviction order (once nodes get tight). The dashed line divides them.

  • The left starts from your requests — the "reservation slip". The scheduler either finds a node that fits (green → Scheduled) or doesn't (red → Pending). Requests, not real usage, drive this.
  • The right is the QoS ladder, derived automatically from how requests relate to limits: equal → Guaranteed, less-than → Burstable, unset → BestEffort. You don't set QoS directly; it falls out of your manifest.
  • The colours are the payoff: green Guaranteed is safest (evicted last), amber Burstable is middle, red BestEffort is first out under node pressure — so the way you write requests/limits silently picks your Pod's survival odds.

In short: Requests decide whether you get seated at all; the request-vs-limit gap decides who gets thrown out first when the node runs low.

Two of the table's rows deserve depth because they're about scheduling and resources, not app bugs. A Pending Pod almost always means the scheduler can't find a node that fits — read the FailedScheduling message: 'Insufficient memory', 'had untolerated taint', 'didn't match node affinity'. Under node pressure (memory/disk low), the kubelet evicts Pods, and the order is decided by QoS class, which is set by how you wrote requests/limits:

QoS classHow you get itEvicted…
Guaranteedrequests == limits for CPU and memoryLast (most protected)
Burstablerequests < limits (or only some set)Middle
BestEffortno requests or limits at allFirst (most expendable)
Requests schedule; limits cap; QoS decides who dies firstThree roles that people conflate. requests are what the scheduler reserves (they decide whether a Pod is Pending). limits are the hard cap (exceed memory → OOMKilled; exceed CPU → throttled). And the request/limit relationship sets QoS, which decides eviction order under node pressure. Setting requests==limits (Guaranteed) protects critical Pods; leaving them unset (BestEffort) makes a Pod the first sacrificed. Verify eviction/QoS specifics vs current docs.

3 · Backup, restore & disaster recovery

The worst day of an operator's career usually starts with a command that worked exactly as asked. Someone runs kubectl delete namespace against the wrong context, or a bad migration wipes a database, or a cluster simply falls over — and in that instant the only thing standing between you and a résumé-updating disaster is whether you can restore. Not whether you have backups. Whether you can restore. The gap between those two words is where careers quietly end.

The seductive trap here is a phrase you'll hear from smart people: "everything's in Git, so we're covered." Think about what that actually promises. Git holds your recipes — the manifests that describe what should exist. It does not hold the meal: the rows in your database, the files on a PersistentVolume, the vector embeddings your RAG index spent hours building, the state that accumulated while the app ran. Re-applying your manifests recreates empty pots and pans. It's like keeping every blueprint for your house but none of the furniture, then calling that a fire-safety plan. GitOps restores structure; it cannot restore data, and it cannot restore the cluster's own brain — the etcd datastore that remembers what actually exists.

So real disaster recovery comes in layers, each protecting against a different kind of loss, and the diagram below lays them out: your manifests in Git, your live objects and volume data via a tool like Velero, and the control-plane state in etcd. But the single most important idea in this whole section isn't any tool — it's a discipline. A backup you have never restored is not a backup; it's a hope. Backups fail silently in the most heartbreaking ways: they captured the objects but not the volume snapshots, or the scope was wrong, or the restore only works against a version you no longer run. You find out at the exact moment you can least afford to. The professionals test their restores on a schedule, into a scratch cluster, and watch the app come up with its data — because a restore that has actually run is the only backup that counts.

'The cluster is declarative and in Git, so I don't need backups' is a dangerous half-truth. GitOps restores your manifests, but not PersistentVolume data, not dynamically-created state, and not the etcd datastore itself. DR has two layers:

App manifests Git / Velero Velero backup objects + PVs PV snapshots volume data etcd snapshot control-plane state
🗺️ How to read this diagram

Read it left-to-right, following the arrows: each box is a distinct layer of what you have to protect, moving from the recipes toward the cluster's own brain.

  • The first box — "App manifests (Git / Velero)" — is the declarative recipes. This is the layer people assume covers everything, but it only describes what should exist.
  • The arrows carry you rightward through the layers Git alone can't restore: "Velero backup (objects + PVs)" captures the live objects, then "PV snapshots (volume data)" captures the actual data on your volumes — the meal, not just the recipe.
  • The last box — "etcd snapshot (control-plane state)" — is the payoff: the cluster's own memory of what exists, the layer neither Git nor Velero recreates on its own.

In short: GitOps restores structure, but full DR needs the layers to its right — object/volume backups (Velero) and the etcd control-plane snapshot — because manifests can't restore data or the cluster's brain.

LayerToolProtects against
Workload objects + PVsVelero (backup/restore, scheduled)Namespace deletion, bad migration, cluster loss
Control-plane stateetcd snapshot (or managed by EKS)etcd corruption on self-managed clusters
ManifestsGit (GitOps, K6)Config drift; re-apply desired state
bash · Velero backup + restore of a namespace (needs a cluster + Velero; verify vs docs)
velero.sh# Back up one namespace, including its PVs, on a schedule:
velero backup create web-daily --include-namespaces web
velero schedule create web-nightly --schedule="0 2 * * *" --include-namespaces web

# Restore after an accidental delete / into a new cluster (DR):
velero restore create --from-backup web-daily
velero backup describe web-daily        # verify it actually captured what you think
An untested backup is a hope, not a planThe failure everyone regrets: backups ran for months, then the restore didn't work (wrong scope, no PV snapshots, incompatible versions). Test restores on a schedule — restore into a scratch cluster/namespace and verify the app actually comes up with its data. On managed control planes (EKS) etcd is the provider's responsibility; on self-managed clusters, etcd snapshots are yours. Verify Velero and etcd backup procedures against current docs.

4 · Cluster upgrades & multi-cluster HA

Here's the cruel irony of cluster upgrades: the procedure meant to keep you safe is the one most likely to cause the outage. Kubernetes moves fast and only supports a handful of recent versions, so falling behind isn't an option — an unsupported cluster stops getting security patches and eventually can't be helped when it breaks. Upgrading is a chore you will do, repeatedly, forever. The question is only whether you do it like a surgeon or like someone yanking a tablecloth.

Two rules make it surgical, and both come from the same simple fact: a Kubernetes cluster is not one thing, it's a control plane (the brain: API server, scheduler, etcd) plus a fleet of worker nodes (the muscle running your Pods). Rule one is order: upgrade the brain before the muscle, one minor version at a time. Why? A newer worker talking to an older brain is like an employee following next year's policy manual while their manager still works from last year's — they'll disagree in ways nobody tested. The API server must always be at least as new as the kubelets talking to it. Skipping versions (1.28 straight to 1.31) is the same gamble: each version's migrations assume the previous one ran.

Rule two is how you evacuate a node. To upgrade a worker you drain it — evict its Pods so they reschedule elsewhere. The naive way evicts everything at once, and if all your replicas happened to live on that node, you just took the service down to "upgrade" it. The fix is cordon then drain (stop new Pods landing, then gracefully evict) combined with a PodDisruptionBudget — a promise Kubernetes enforces that says "never voluntarily drop below N healthy replicas." The PDB is what turns a drain from a coin-flip into a guarantee. And when one cluster isn't resilient enough, the honest-but-expensive answer is running several — covered below, along with why "just run multi-region" is a decision to make on purpose, not by reflex.

Kubernetes releases often and supports only a few recent minors, so upgrading is routine, not optional. The safe order is control plane first, then node groups, one minor version at a time, draining nodes so Pods reschedule gracefully:

upgrade the brain before the muscle — one minor at a time 1 · control plane (API/etcd) 2 · then each worker node, in turn: cordon no new pods drain evict gracefully upgrade node replace / patch uncordon Ready again PodDisruptionBudget: keep N replicas ready Surgical, not tablecloth-yank. Control plane upgrades first (a newer kubelet must not outrun an older API server); then each node walks cordon → drain → upgrade → uncordon, with a PodDisruptionBudget guaranteeing the drain never drops you below N healthy replicas.
🗺️ How to read this diagram

Read it top-to-bottom for the order, then left-to-right for the per-node loop.

  • The top box is step 1: upgrade the control plane (API server, etcd) first — the brain must never be older than the workers talking to it.
  • The middle row is step 2, repeated for every worker: cordon (stop new Pods landing) → drain (evict the running Pods gracefully) → upgrade the node → uncordon (green: back in service). One node at a time.
  • The amber band underneath is the guard: a PodDisruptionBudget ties into the drain step and refuses to evict below N healthy replicas — that's what turns a drain from a coin-flip into a guarantee.

In short: Brain before muscle, one minor at a time; each node is cordon→drain→upgrade→uncordon, and the PDB keeps the drain from ever taking the service down.

bash · draining a node for an upgrade (needs a cluster; verify vs provider docs)
drain.sh# 1) Upgrade the control plane one minor at a time (provider-specific; EKS via console/eksctl/IaC).
# 2) For each node: cordon (no new pods), drain (evict gracefully), then replace/upgrade:
kubectl cordon ip-10-0-3-21
kubectl drain ip-10-0-3-21 --ignore-daemonsets --delete-emptydir-data --timeout=5m
# PodDisruptionBudgets keep enough replicas up during the drain.
kubectl uncordon ip-10-0-4-15                 # after the new node is Ready
yaml · a PodDisruptionBudget so a drain can't take the service down (verify vs docs)
pdb.yamlapiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: llm-api-pdb, namespace: web }
spec:
  minAvailable: 2                 # never voluntarily evict below 2 ready pods
  selector: { matchLabels: { app: llm-api } }

Multi-cluster / HA. One cluster is a single failure/blast-radius domain. Higher tiers run multiple clusters (per-region or per-blast-radius) behind global load balancing, replicate data across them, and manage them with a fleet tool (Argo CD app-of-apps, cluster APIs). It buys resilience and blast-radius isolation at a real cost in complexity — adopt when the availability requirement (or regulatory isolation) justifies it, not by default.

PodDisruptionBudgets make drains safeA drain evicts Pods; without a PodDisruptionBudget, a rolling node upgrade can briefly evict all your replicas at once and cause an outage. A PDB (minAvailable/maxUnavailable) tells the eviction API to keep enough Pods running throughout — the same PDBs also protect you during Karpenter consolidation (section 6). Verify PDB semantics vs current docs.

5 · Right-sizing requests — the biggest cost lever

Somewhere in your company there is a cloud bill three times larger than it should be, and the cause is almost boringly simple. When finance asks why the Kubernetes cluster costs so much while the dashboards show nodes sitting at 30% utilisation, the answer is nearly always the same one word: over-requesting. It's the single highest-leverage cost fix in this entire chapter, and it requires no new tools — just the courage to ask for what you actually use.

Recall the restaurant from section 2: the scheduler seats you by your reservation slip, not your appetite. Now scale that up. Every Pod reserves, say, 2 GiB of memory but truly uses 700 MiB. The scheduler dutifully packs nodes full of reservations, sees no room left, and the autoscaler buys more machines — all while those machines run near-idle. You're paying for a restaurant booked solid with tables of eight where everyone ordered a side salad. The utilisation graph looks embarrassingly low and the cluster keeps growing, which feels contradictory until you realise the scheduler was never looking at usage. It was looking at your slip.

The fix is to make the slip honest: set requests from observed usage — typically the p95 (the level 95% of samples stay under, so you're covered on all but the spikiest moments) plus some headroom for safety. That one change frees enormous amounts of "reserved but unused" capacity to be packed with real work. But there's a genuine trade-off to respect, and it's where beginners over-correct into a new disaster: memory and CPU behave differently under pressure. Memory is incompressible — blow past your limit and the kernel kills you (the OOMKilled you met in section 1), so you want memory requests set close to a real, generous ceiling. CPU is compressible — it just gets throttled, slowing you down invisibly rather than crashing you — so a too-tight CPU limit shows up as a baffling latency mystery, not an error. Cut fat, yes; but cut it knowing which resource forgives you and which one doesn't. The math below makes the waste concrete and offline-runnable.

The single most common cluster cost bug is over-requesting: Pods reserve far more CPU/memory than they use, so nodes fill up on reservations while sitting near-idle, and you pay for nodes you don't need. Requests should be set from observed usage (K8 metrics), with headroom. The gap is easy to quantify offline:

over-requested: reserve 2048, use 700 used p95 ≈ 700 reserved 2048 = wasted ~1348 / pod ×20 replicas → ~26 GiB paid, idle right-size honest: p95 + 30% headroom request ≈ 910 fits real usage + safety frees ~22 GiB to bin-pack The slip, made honest. The tall red block is what an over-provisioned Pod reserves; the green sliver is what it actually uses. Right-sizing to p95+headroom collapses the reservation to ~910 MiB and hands the wasted capacity back to the scheduler.
🗺️ How to read this diagram

Compare the two blocks left-to-right: the left is today's wasteful reservation, the right is the honest one after right-sizing.

  • The left red block is the memory a Pod reserves (2048 MiB). The green sliver inside it is what it truly uses at p95 (~700 MiB) — everything above the green is reserved-but-idle waste, multiplied across every replica.
  • The arrow is the fix: set the request from observed p95 plus ~30% headroom.
  • The right green block is the result — a request of ~910 MiB that fits real usage with safety margin. The difference (~22 GiB across 20 replicas) is freed capacity the scheduler and Karpenter can now bin-pack with real work.

In short: The scheduler reserves your slip, not your appetite — shrink the slip to p95+headroom and the "reserved but unused" gap becomes usable capacity.

python · quantify over-provisioning and a right-sized request (offline-runnable)
right_size.pydef right_size(observed_p95_mib, headroom=0.30):
    """Set the memory request from observed p95 usage plus headroom."""
    return round(observed_p95_mib * (1 + headroom))

def waste(requested_mib, observed_p95_mib, replicas):
    """Reserved-but-unused memory across all replicas (what you pay for, unused)."""
    return max(0, requested_mib - observed_p95_mib) * replicas

# A pod requests 2048 MiB but its p95 usage is only 700 MiB, x20 replicas:
print(right_size(700))                 # -> 910 MiB  (a sane request)
print(waste(2048, 700, 20), "MiB")     # -> 26960 MiB (~26 GiB reserved and wasted)
# Cutting the request from 2048 -> 910 frees ~22 GiB of schedulable memory to bin-pack.
910
26960 MiB
Set memory requests≈limits, but never overcommit CPU limits blindlyTwo right-sizing rules that bite people. Memory: it's incompressible — exceed the limit and you're OOMKilled — so set memory request close to limit from real p95+headroom. CPU: a too-low request starves the Pod under contention; but overly tight CPU limits cause invisible throttling that looks like a mysterious latency problem. Size requests from observed usage, and be cautious with hard CPU limits on latency-sensitive services. Verify current CPU-throttling behavior vs docs.

6 · Karpenter, Spot & bin-packing

Right-sizing made your reservations honest; now you get to spend that honesty. Once Pods ask for what they truly need, you can pack far more of them onto each machine and buy fewer, cheaper machines to hold them — and that's the second half of the cost story. If section 5 was about not booking tables for eight, this section is about seating the restaurant efficiently and choosing when to rent the room by the hour instead of the year.

Start with bin-packing, because it's the intuition everything else rests on. Imagine boxes of different sizes and a supply of identical shipping crates: your job is to fit all the boxes into as few crates as possible. Pods are the boxes, nodes are the crates, and the classic greedy trick — sort boxes largest-first, drop each into the first crate it fits — is exactly what the offline model below does. Here's the payoff that ties the two sections together: honest requests are what make the boxes their true size, so they pack tight. Over-sized reservations are boxes stuffed with packing foam — they waste crate space no algorithm can reclaim. Right-sizing pays off twice: once by freeing reservations, again by letting the packer do its job.

Karpenter is the piece that automates this on AWS, and it's smarter than a fixed pool of pre-bought machines. It watches for Pods stuck Pending (nobody could seat them) and launches a node shaped to fit — often one well-chosen instance rather than another cookie-cutter box — then, as load drops, consolidates: it rebin-packs the survivors onto fewer nodes and deletes the empties. Layer on Spot instances — spare capacity the cloud rents at a steep discount — and the savings compound. But Spot comes with an honest catch you must design around: the provider can reclaim it with about two minutes' notice. That's fine for interruptible, stateless work (batch jobs, background inference) and reckless for a stateful database. The reliability guard is the same PodDisruptionBudget from section 4 — it keeps both Spot reclamation and Karpenter's own consolidation from ever dropping you below a safe number of replicas. Cheap and reliable aren't opposites here; they're two dials you tune together.

Once requests are honest, node provisioning is the next lever. Karpenter (on AWS) watches for unschedulable (Pending) Pods and launches right-sized nodes to fit them — often a single well-chosen instance instead of a fixed node group — then consolidates (bin-packs workloads onto fewer nodes and removes empty/underused ones) as load drops. Combined with Spot instances for interruptible work, this is where the big savings live.

yaml · a Karpenter NodePool preferring Spot, with consolidation (needs EKS; verify CRD vs docs)
nodepool.yaml# Karpenter CRDs/fields change across versions — verify against the current Karpenter docs.
apiVersion: karpenter.sh/v1
kind: NodePool
metadata: { name: default }
spec:
  template:
    spec:
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: [spot, on-demand]    # prefer Spot, fall back to on-demand
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized   # bin-pack + remove waste
    consolidateAfter: 1m

Bin-packing is why right-sizing (section 5) pays off twice: honest requests let the scheduler and Karpenter pack more Pods per node. The idea is a first-fit-decreasing packing — modeled offline:

over-requested pods → 5 nodes nodes half-empty, but full of foam right-sized pods → 3 nodes packed tight → 40% fewer nodes same 20 pods, same work — honest requests are what let the packer win Right-sizing pays off twice. The same 20 Pods need 5 nodes when each is padded with reserved-but-unused foam (left), but pack into 3 nodes once requests are honest (right) — the crates fill because the boxes are their true size.
🗺️ How to read this diagram

Compare the two sides left-to-right: same Pods, same total work, different node count — the only thing that changed is how honestly each Pod asked for memory.

  • Each tall outlined box is a node (a shipping crate of fixed size, e.g. 8192 MiB). The filled block inside is how much the Pods on it actually reserve.
  • On the left, the red fills are over-requested Pods — each crate carries only a few because the "boxes" are stuffed with packing foam, so it takes 5 nodes even though the crates look half-empty.
  • On the right, the green fills are right-sized Pods; the boxes shrank to their true size, so the packer fits the same 20 Pods into 3 nodes — the ~40% saving that ties directly back to section 5.

In short: Bin-packing can only be as tight as your requests are honest — shrink the boxes and the same work fits into far fewer crates.

python · bin-packing pods onto nodes (first-fit-decreasing), offline-runnable
binpack.pydef pack(pod_mib, node_mib):
    """Greedy first-fit-decreasing: how many nodes to fit these pods (memory only)."""
    nodes = []                       # each entry = remaining free MiB on that node
    for size in sorted(pod_mib, reverse=True):
        for i, free in enumerate(nodes):
            if free >= size:
                nodes[i] -= size
                break
        else:
            nodes.append(node_mib - size)   # open a new node
    return len(nodes)

pods_over  = [2048] * 20            # over-requested
pods_right = [910]  * 20            # right-sized (from section 5)
print(pack(pods_over,  node_mib=8192))    # -> 5 nodes
print(pack(pods_right, node_mib=8192))    # -> 3 nodes  (~40% fewer for the same work)
5
3
Spot means interruption — design for it, and protect drains with PDBsSpot instances are cheap because they can be reclaimed with ~2 minutes' notice. Use them for interruptible/stateless work (batch inference, workers), keep latency-critical or stateful Pods on on-demand, and spread replicas so one reclamation doesn't take the service down. Karpenter consolidation also moves Pods to pack tighter — the same PodDisruptionBudgets (section 4) keep those moves from causing an outage. Verify Karpenter's disruption/consolidation behavior and CRD version against current docs.

✓ Checkpoint — you can move on when you can…

  • Match CrashLoopBackOff / OOMKilled / ImagePullBackOff / Pending / node-pressure to cause and confirming evidence.
  • Run the first-5-minutes triage and know why logs --previous and exit code 137 matter.
  • Explain requests vs limits vs QoS and how QoS drives eviction order under node pressure.
  • Back up and (test-)restore with Velero, and say what GitOps does NOT protect.
  • Upgrade a cluster safely (control plane then drained nodes) with a PodDisruptionBudget.
  • Right-size requests from observed usage and explain how it plus Karpenter/Spot/bin-packing cuts cost.
✓ Knowledge check

A Pod shows OOMKilled with exit code 137, but the node has plenty of free memory. The on-call wants to add nodes. Why is that the wrong fix, and what's the right one?

Show answer
OOMKilled at exit code 137 means the container exceeded its own memory limit and the kernel killed it — it's a per-container limit problem, not a node-capacity problem, which is why free node memory is irrelevant. Adding nodes won't help; the container will hit the same limit on any node. The right fix is to look at observed usage vs the limit: either the limit is set too low for legitimate usage (raise the memory request/limit based on p95 + headroom) or the app has a memory leak (fix the app). Add capacity only when the symptom is Pending/unschedulable from insufficient requests across the cluster — a different row of the table.
✓ Knowledge check

Finance says the cluster costs 3x what usage suggests it should. Nodes are ~30% utilized but keep scaling out. What's the most likely root cause, and how do you fix it without hurting reliability?

Show answer
The classic cause is over-requesting: Pods reserve far more CPU/memory than they use, so the scheduler fills nodes on reservations (and the autoscaler adds more) while actual utilization stays ~30%. Fix it in order: (1) right-size requests from observed p95 usage + headroom (K8 metrics) so reservations reflect reality; (2) let the scheduler/Karpenter bin-pack the now-honest requests onto fewer nodes and consolidate away the empties; (3) move interruptible work to Spot. Protect reliability with PodDisruptionBudgets during consolidation, keep memory request≈limit, and don't set CPU limits so tight you cause throttling. The lever is honest requests — capacity follows.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Triage a failing PodBeginner

Context: The first five minutes of any incident is the same sequence — muscle memory saves you at 3am.

Your task: List the triage commands in order for a Pod stuck in CrashLoopBackOff and what each tells you.

Requirements:

  • get pods for phase/restarts/node
  • describe for events and last state
  • logs --previous for the crashed container
  • Say what exit code 137 means

💡 Hint: The new container's logs are empty of the crash — use --previous.

Show solution
kubectl get pods -n web -o wide          # phase, RESTARTS, node, age
kubectl describe pod  -n web        # Events + Last State (why it died)
kubectl logs  -n web --previous     # logs from the CRASHED container
kubectl get events -n web --sort-by=.lastTimestamp

get shows the crash-loop and restart count; describe shows the reason (e.g. Last State: OOMKilled, or a failing liveness probe); logs --previous shows what the dead container printed (the live one may be empty). Exit 137 = SIGKILL from OOM — the container exceeded its memory limit. Needs a cluster; verify vs docs.

Exercise 2 · Explain requests, limits & QoSIntermediate

Context: Half of production Pod problems come from misunderstanding these three.

Your task: Explain how requests, limits and QoS relate, and how to make a critical Pod the last to be evicted.

Requirements:

  • requests = what the scheduler reserves
  • limits = the hard cap (OOM/throttle)
  • QoS classes and how each is achieved
  • How to protect a critical Pod under node pressure

💡 Hint: requests==limits for CPU and memory → Guaranteed.

Show solution

requests are reserved by the scheduler (they decide Pending vs scheduled). limits are hard caps — exceed memory → OOMKilled, exceed CPU → throttled.

QoS: requests==limits for CPU+mem → Guaranteed (evicted last); requests<limits → Burstable (middle); nothing set → BestEffort (evicted first).

Protect a critical Pod: give it Guaranteed QoS (requests==limits) so under node pressure the kubelet evicts BestEffort/Burstable Pods before it. Verify eviction/QoS specifics vs current docs.

Exercise 3 · Design a tested backup/DR planAdvanced

Context: GitOps restores manifests but not data — an untested backup is the classic DR failure.

Your task: Design a DR plan for a stateful namespace and explain what GitOps does and doesn't cover.

Requirements:

  • Use Velero for objects + PV snapshots on a schedule
  • Note etcd's role and who owns it on EKS
  • State what GitOps does NOT protect
  • Include restore testing

💡 Hint: If you never tested the restore, you don't have a backup.

Show solution
velero schedule create web-nightly --schedule="0 2 * * *" \
  --include-namespaces web            # objects + PV snapshots
velero restore create --from-backup web-nightly   # into a scratch cluster to TEST

GitOps covers manifests (re-apply desired state) but not PersistentVolume data, dynamically-created state, or etcd — so you need Velero for objects+PVs and, on self-managed clusters, etcd snapshots (EKS manages etcd for you).

Test restores on a schedule into a scratch namespace/cluster and verify the app comes up with its data. Needs a cluster + Velero; verify vs current docs.

Exercise 4 · Upgrade a cluster without an outageExpert

Context: Node upgrades evict Pods; done carelessly they cause the outage they were meant to avoid.

Your task: Describe a safe upgrade procedure and the object that keeps a drain from taking the service down.

Requirements:

  • Control plane first, then nodes, one minor at a time
  • cordon + drain each node
  • A PodDisruptionBudget to bound disruption
  • Why order and PDBs matter

💡 Hint: minAvailable stops the eviction API from taking too many replicas at once.

Show solution

Order: upgrade the control plane first, then node groups, one minor version at a time. For each node: kubectl cordon (stop new pods) then kubectl drain --ignore-daemonsets --delete-emptydir-data (evict gracefully), replace/upgrade, uncordon.

kind: PodDisruptionBudget
spec: { minAvailable: 2, selector: { matchLabels: { app: llm-api } } }

The PDB makes the eviction API refuse to drop below 2 ready pods, so a drain can't take the service down. Order matters because a newer kubelet must not run against an older API server. Needs a cluster; verify provider upgrade steps vs docs.

Exercise 5 · Right-size and bin-pack to cut costProfessional

Context: A cluster at 30% utilization that keeps scaling out is burning money on reservations.

Your task: Diagnose the over-provisioning and show, with numbers, how right-sizing plus bin-packing cuts node count.

Requirements:

  • Right-size a request from observed p95 + headroom
  • Quantify reserved-but-unused memory
  • Show bin-packing needs fewer nodes after right-sizing
  • Name the reliability guards (PDB, memory req≈limit, CPU throttling)

💡 Hint: Honest requests let the scheduler and Karpenter pack tighter.

Show solution
right_size = lambda p95, h=0.30: round(p95*(1+h))
right_size(700)            # 910 MiB request (was 2048)
# waste per replica = 2048-700 = 1348 MiB; x20 = ~26 GiB reserved unused
# bin-pack 20 pods on 8192-MiB nodes: 2048-sized -> 5 nodes; 910-sized -> 3 nodes

Diagnosis: nodes fill on reservations (2048 MiB) while real usage is 700 MiB, so the autoscaler adds nodes though utilization is ~30%. Fix: right-size requests from p95+headroom → the scheduler/Karpenter bin-packs onto ~40% fewer nodes and consolidates the rest; move interruptible work to Spot.

Reliability guards: keep memory request≈limit (incompressible), avoid over-tight CPU limits (throttling), and use PDBs so consolidation/Spot reclamation can't drop you below quorum. Math runs offline; verify Karpenter behavior vs docs.

Exercise 6 · The on-call + cost + DR playbook for a platformIndustry scenario

Context: Representative scenario: you're taking over on-call and cost ownership for an LLM platform on EKS with no runbook, high bills, and no tested DR — leadership wants it 'operable and affordable'.

Your task: Produce the operational plan: a troubleshooting playbook, a tested DR/upgrade strategy, and a cost program — naming the failure mode each part prevents.

Requirements:

  • A symptom→cause→fix decision table wired to runbooks (and K8 alerts)
  • Tested Velero backups + safe upgrade procedure with PDBs
  • Right-sizing + Karpenter/Spot/bin-packing with reliability guards
  • How this ties to K8 (observability) and K9 (security)
  • Name the failure mode each part prevents

💡 Hint: Diagnosis, durability, and cost are three separate disciplines that share the same guards (PDBs, metrics).

Show solution

Troubleshooting playbook: a symptom→cause→confirm→fix decision table (CrashLoop, OOMKilled, ImagePull, Pending, node pressure) plus the fixed first-5-minutes triage, each row linking a runbook. Wire the pages to K8 SLO burn-rate alerts so on-call is woken for user pain, not noise. → prevents slow, ad-hoc, 3am debugging.

Durability & change: scheduled Velero backups (objects+PVs) with tested restores into a scratch cluster; know EKS owns etcd. Upgrades: control plane then drained nodes, one minor at a time, guarded by PodDisruptionBudgets. → prevents data loss and self-inflicted upgrade outages.

Cost program: right-size requests from K8 metrics (biggest lever), let Karpenter bin-pack + consolidate, move interruptible work to Spot; guard with PDBs, memory req≈limit, and no over-tight CPU limits. → prevents paying 3x for idle reservations without trading away reliability.

Ties: it all rests on K8 observability (you right-size and alert from real metrics) and K9 security (the on-call/automation tooling runs under least-privilege RBAC). This is the capstone of operating the cluster: see it (K8), secure it (K9), ship to it safely (K10), extend it (K11), and keep it alive and affordable (K12). Verify every Velero/Karpenter/upgrade detail against current docs — these drift by provider and version.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in