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.
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)
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:
| Symptom | Most likely cause | Confirm with | Fix direction |
|---|---|---|---|
| CrashLoopBackOff | Container starts then exits/crashes repeatedly | logs --previous; exit code; probe config | Fix the crash / bad config / failing liveness probe |
| OOMKilled (exit 137) | Container exceeded its memory limit | describe → Last State: OOMKilled | Raise memory limit or fix the leak / cut usage |
| ImagePullBackOff | Image name/tag wrong or registry auth missing | describe events: pull error | Fix image ref / add imagePullSecret / registry access |
| Pending / Unschedulable | No node fits (requests, taints, affinity, no capacity) | describe pod → FailedScheduling reason | Lower requests / add nodes / fix taints/affinity |
| Node pressure / Evicted | Node low on memory/disk; kubelet evicts Pods | describe node → conditions/taints | Free resources / right-size / add capacity |
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
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 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 class | How you get it | Evicted… |
|---|---|---|
| Guaranteed | requests == limits for CPU and memory | Last (most protected) |
| Burstable | requests < limits (or only some set) | Middle |
| BestEffort | no requests or limits at all | First (most expendable) |
3 · Backup, restore & disaster recovery
'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:
| Layer | Tool | Protects against |
|---|---|---|
| Workload objects + PVs | Velero (backup/restore, scheduled) | Namespace deletion, bad migration, cluster loss |
| Control-plane state | etcd snapshot (or managed by EKS) | etcd corruption on self-managed clusters |
| Manifests | Git (GitOps, K6) | Config drift; re-apply desired state |
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
4 · Cluster upgrades & multi-cluster HA
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:
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
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.
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
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:
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
6 · Karpenter, Spot & bin-packing
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.
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:
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
✓ 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 --previousand 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.
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
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
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
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.
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.
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 TESTGitOps 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.
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.
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 nodesDiagnosis: 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.
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.