AI EngineeringZero to ProductionHome·About·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: getdescribelogs → 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:

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 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 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
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

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:

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

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:

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

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:

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<limitsBurstable (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