The Eviction Cascade
Revenue Pods Restarting on Nodes That Report Perfectly Healthy
The situation you’re stepping into
Incident INC-2026-021, SEV-1, Under Investigation. You’re on a real-time payments platform running on AWS EKS — checkout, wallet debits, order confirmation, settlement, and analytics enrichment.
The environment is small and, importantly, undifferentiated:
- Region
ap-south-1, a single clusterpayments-prod, 2 managed nodes (t3.small), OIDC enabled. - Namespace
payments, with three workloads all sharing the same node pool:
| Component | Type | Replicas | Purpose |
|---|---|---|---|
checkout-gateway | Deployment | 2 | Live payment initiation (revenue-critical) |
analytics-batch | Deployment | 1 | Background processing |
reconciliation-job | CronJob | scheduled | Settlement reconciliation |
The architecture note that matters: there is no node isolation between the revenue workload and the batch workloads. They compete for the same CPU and memory on the same two small nodes.
What the pager and dashboards say
At ~14:02 IST the platform starts to wobble:
- Increased latency on checkout requests, intermittent HTTP 5xx, client retries.
- Spikes in pod restarts across the
paymentsnamespace. checkout-gatewaypods restart multiple times; the scheduledreconciliation-jobterminates prematurely.
And the “nothing changed” checklist is clean: no deployment in progress, no config change, no manual scaling, no ConfigMap update, no secret rotation, no network-policy change. Infra dashboards show nodes as Ready, and no node reported NotReady.
Business impact was concrete: an 18% checkout failure rate during the peak window, a 12-minute transaction-processing degradation, a spike in support tickets, and a client retry storm that piled even more load onto an already-stressed cluster. Blast radius stayed namespace-scoped — no control-plane crash, no other namespace affected — but every workload inside payments went unstable together.
?
Decision Point 1 Pods are restarting across the whole namespace with no deploy and no config change — and every node says Ready. Before you assume a crash-loop, what would distinguish pods that CRASHED from pods that were EVICTED, and does 'Ready' actually rule out resource pressure?
'Ready' is a node condition about kubelet health, not about free memory. Nodes carry separate pressure conditions. And a restart has a reason recorded.
Commit to your answer, then reveal the responder’s move
→
Pods are restarting across the whole namespace with no deploy and no config change — and every node says Ready. Before you assume a crash-loop, what would distinguish pods that CRASHED from pods that were EVICTED, and does 'Ready' actually rule out resource pressure?
'Ready' is a node condition about kubelet health, not about free memory. Nodes carry separate pressure conditions. And a restart has a reason recorded.
Commit to your answer, then reveal the responder’s move →”Ready” does not mean “has spare memory.” A node can be Ready and simultaneously carry a MemoryPressure condition (and a node.kubernetes.io/memory-pressure taint) while the kubelet is actively reclaiming resources. So you read the reason, not the node’s headline status:
kubectl get events -n payments --sort-by=.lastTimestamp | tail -40
kubectl describe pod -n payments <checkout-gateway-pod> # look at Last State / Reason
kubectl describe node <node> | sed -n '/Conditions:/,/Events:/p'
You find the tell: pod Reason: Evicted (with messages like “The node was low on resource: memory”) and/or container Last State: Terminated, Reason: OOMKilled, plus node MemoryPressure: True. This is not an application crash-loop — the kubelet is reclaiming memory by evicting pods. That reframes the entire investigation from “why is checkout crashing” to “what is starving the node.”
Two related mechanisms are at play. Node-pressure eviction is the kubelet proactively killing pods when a node crosses a memory threshold. A cgroup OOMKill is the kernel killing a container that exceeds its own memory limit. Under a noisy-neighbor squeeze you often see both. Either way, the signal is the same: memory demand on the node exceeded supply.
?
Decision Point 2 Memory demand spiked with no deployment. On a two-node pool where a revenue service shares hardware with a batch Deployment and a reconciliation CronJob, what is the most likely source of the spike — and how do you confirm it?
'No deployment' doesn't mean 'no new work'. A CronJob firing, or a batch job hitting a heavy input, changes memory demand without any human change.
Commit to your answer, then reveal the responder’s move
→
Memory demand spiked with no deployment. On a two-node pool where a revenue service shares hardware with a batch Deployment and a reconciliation CronJob, what is the most likely source of the spike — and how do you confirm it?
'No deployment' doesn't mean 'no new work'. A CronJob firing, or a batch job hitting a heavy input, changes memory demand without any human change.
Commit to your answer, then reveal the responder’s move →The most likely source is the batch side sharing the node: the reconciliation-job CronJob firing (settlement reconciliation is memory-heavy), and/or analytics-batch processing a larger-than-usual payload. No human “changed” anything — a schedule did. You confirm which pods are actually consuming node memory:
kubectl top pods -n payments --sort-by=memory
kubectl top nodes
kubectl get pods -n payments -o wide # who is co-scheduled on the pressured node?
The batch/reconciliation workload is the memory hog, and because there’s no isolation, it’s sitting on the same t3.small as a checkout-gateway replica. When the job’s working set grows, the node crosses its eviction threshold — and the kubelet has to pick victims.
?
Decision Point 3 The node is under memory pressure and the kubelet must evict something. Why does it so often evict the revenue-critical checkout-gateway rather than the batch job that caused the pressure?
The kubelet doesn't evict the guilty party — it evicts by QoS class and by how far a pod exceeds its memory *request*.
Commit to your answer, then reveal the responder’s move
→
The node is under memory pressure and the kubelet must evict something. Why does it so often evict the revenue-critical checkout-gateway rather than the batch job that caused the pressure?
The kubelet doesn't evict the guilty party — it evicts by QoS class and by how far a pod exceeds its memory *request*.
Commit to your answer, then reveal the responder’s move →Because the kubelet chooses victims by QoS class, not by blame. Eviction order under memory pressure is:
- BestEffort pods first (no requests/limits set at all).
- Then Burstable pods that are using more memory than their request, ranked by how far over they are.
- Guaranteed pods (requests == limits) last.
If checkout-gateway was deployed without carefully set requests/limits — i.e. BestEffort or loosely-Burstable — it becomes a first-choice victim, even though the reconciliation-job (which may declare requests) triggered the pressure. So the revenue service is evicted to protect the node, it restarts, the retry storm adds load, another node tips into pressure, and the cascade rolls across the namespace.
Root cause
No resource isolation on a shared, undersized node pool. A memory-heavy batch/reconciliation workload ran on the same two t3.small nodes as the revenue-critical checkout-gateway. When the batch working set grew at peak, the nodes crossed their memory-eviction threshold. Because checkout-gateway wasn’t pinned to a protective QoS (Guaranteed) or priority, the kubelet evicted it to reclaim memory — triggering restarts, a client retry storm, and a namespace-wide instability cascade, all while the nodes still reported Ready.
Containment and durable fix
# Immediate: relieve the pressure and protect revenue
kubectl -n payments scale deploy/analytics-batch --replicas=0 # shed batch load
# (or cordon/drain the pressured node's batch pods; pause the CronJob)
kubectl -n payments patch cronjob reconciliation-job -p '{"spec":{"suspend":true}}'
# Durable prevention:
# - Set requests == limits on checkout-gateway -> Guaranteed QoS (evicted last)
# - Assign a high PriorityClass to revenue workloads
# - Isolate batch to its own node pool via taints + tolerations / nodeSelector
# - Add ResourceQuota + LimitRange on the payments namespace
# - Right-size / autoscale the node pool so peak batch + revenue actually fit
Containment is about decoupling the victim from the cause: stop the batch pressure and protect the revenue pods; recovery follows within a couple of minutes as evictions stop and replicas stabilize.
A Ready node can still be evicting pods — read the eviction reason and node conditions, not the headline status. Node-pressure eviction picks victims by QoS class, so an un-tuned revenue service becomes collateral damage for a hungry neighbor. The durable fix is isolation + intent: Guaranteed QoS and a high PriorityClass for revenue workloads, a separate (tainted) node pool for batch, and quotas so one workload can never starve the node another one depends on.
Telling this story to a recruiter
The 30-second version:
“During peak hours our checkout service started restarting over and over — an 18% payment failure rate — with no deployment, no config change, and every node reporting Ready. I traced it to the kubelet evicting our revenue pods under memory pressure caused by a batch job sharing the same undersized nodes. I stopped the cascade by suspending the batch workload, then fixed the design: Guaranteed QoS and priority for revenue services, and a separate node pool for batch, so a background job could never again take checkout down with it.”
The detailed telling:
Situation. A real-time payments platform on Kubernetes: checkout, wallet debits, settlement. At 14:02 during peak, checkout latency spiked, clients started retrying, and pod restarts rippled across the entire payments namespace. A scheduled reconciliation job died mid-run. The “what changed” checklist was completely clean — no rollout, no scaling event, no config or secret change — and infrastructure dashboards showed every node Ready. Business impact was an 18% checkout failure rate and a 12-minute processing degradation, amplified by a client retry storm.
Task. Find out why healthy-looking infrastructure was shedding revenue-critical pods, stop the cascade, and make sure the pattern couldn’t recur.
Action. My first instinct was to distrust the word “Ready” — it describes kubelet health, not free memory. So instead of reading node status, I read reasons: pod events and describes showed Reason: Evicted with “node was low on resource: memory,” and the nodes carried MemoryPressure conditions. That reframed everything — this wasn’t an app crash-loop, it was the kubelet deliberately reclaiming memory. Next question: what was eating it, with no deploy? A schedule, not a human — the reconciliation CronJob had fired and its working set, plus the analytics batch, was co-located on the same two small nodes as checkout, because the cluster had no workload isolation at all. The cruel twist is that Kubernetes evicts by QoS class, not by blame: checkout had loose resource requests, so it was a first-choice victim while the batch job that caused the pressure kept running. Each eviction triggered restarts, retries piled more load on, and the second node tipped over — the cascade. I suspended the CronJob and scaled the batch deployment to zero to relieve pressure; recovery followed within minutes. Then the real fix: requests equal to limits on checkout for Guaranteed QoS, a high PriorityClass on revenue workloads, batch moved to its own tainted node pool, and namespace quotas.
Result. The cascade stopped the moment the batch pressure lifted; checkout stabilized at full replica count. Post-incident, revenue and batch workloads could no longer compete for the same memory, and eviction ordering worked for us instead of against us. The failure class was retired, not just the incident.
What this story demonstrates. Reading Kubernetes internals precisely instead of trusting dashboard summaries, root-causing to a design flaw (no isolation) rather than a trigger (one hungry job), and shipping prevention that changes the platform’s failure behavior permanently.
Interview deep-dive: the full case study
How the issue happened (the mechanism)
The cluster had a design flaw that stayed invisible until load exposed it: a revenue-critical service and memory-hungry batch workloads shared the same two small nodes (t3.small) with no isolation of any kind — no separate node pools, no taints, no priority classes, and loose (or missing) resource requests on the revenue service.
The trigger wasn’t a human change. The reconciliation-job CronJob fired on schedule during peak, and its working set (plus analytics-batch) drove node memory past the kubelet’s eviction threshold. That’s why the “what changed?” checklist — deploys, configs, secrets, scaling events — came back completely clean: a schedule changed the workload, not a person.
Once a node crosses its memory-eviction threshold, the kubelet must reclaim memory, and it selects victims by QoS class, not by blame:
- BestEffort pods (no requests/limits) are evicted first,
- then Burstable pods using more than their memory request, ranked by how far over they are,
- Guaranteed pods (requests == limits) last.
checkout-gateway had loosely-set requests, so it ranked as an early victim — while the batch job that actually caused the pressure kept running. Each eviction restarted a revenue pod; clients retried, adding load; the rescheduled pods landed on the other node and helped tip it into MemoryPressure; and the cascade rolled across the namespace. Meanwhile every node continued to report Ready, because Ready describes kubelet health, not free memory — the actual signal lived in the node’s separate MemoryPressure condition and in the pods’ Reason: Evicted / OOMKilled records.
Impact
- 18% checkout failure rate during the peak window — direct revenue loss on a payments platform.
- 12-minute degradation of transaction processing, amplified by a client retry storm that added load exactly when capacity was lowest.
- The scheduled reconciliation job terminated prematurely (settlement risk), and support tickets spiked.
- Blast radius stayed namespace-scoped — no control-plane or cluster-wide failure — but every workload in the payments namespace destabilized together.
Steps taken to resolve
- Distrusted the headline status and read reasons instead:
kubectl get events --sort-by=.lastTimestamp,kubectl describe pod(Last State / Reason),kubectl describe node(Conditions) → foundReason: Evicted, “node was low on resource: memory,”OOMKilled, andMemoryPressure: TrueonReadynodes. - Reframed the question from “why is checkout crashing?” to “what is starving the node?” — eviction is the kubelet acting deliberately, not an app bug.
- Attributed the memory:
kubectl top pods --sort-by=memory,kubectl top nodes, and-o wideco-scheduling showed the reconciliation/batch workloads as the hogs, co-located with checkout replicas. - Contained by decoupling victim from cause: suspended the CronJob (
kubectl patch cronjob reconciliation-job -p '{"spec":{"suspend":true}}') and scaledanalytics-batchto zero — evictions stopped and checkout stabilized within minutes.
Outcomes
- Cascade halted immediately once batch pressure lifted; checkout returned to full replica count and error rates normalized.
- The RCA identified the root cause as an architecture flaw (no workload isolation), with the CronJob only as trigger — which redirected the fix from “make the job smaller” to “make the platform unable to fail this way.”
What we learned
Ready≠ healthy. Node pressure conditions and pod eviction reasons are the ground truth; dashboard summaries hide them.- Kubernetes evicts by QoS class, not by blame — an un-tuned revenue service becomes collateral damage for any noisy neighbor.
- ”Nothing changed” is never literally true. Schedules, autoscaling, and data volume change workloads without a human commit; the timeline must include them.
- Retry storms are part of the blast radius: client behavior under failure adds load precisely when the system can least absorb it.
Prevention — what we changed so it won’t recur
- Guaranteed QoS for revenue workloads: requests == limits on
checkout-gateway, so it’s evicted last, not first. - PriorityClasses: high priority on revenue services so scheduling and preemption decisions favor them explicitly.
- Physical isolation: batch and cron workloads moved to their own tainted node pool (tolerations only on batch), so they can never share memory with checkout again.
- Namespace guardrails: ResourceQuota and LimitRange on the payments namespace, so no workload can be deployed without declared requests/limits.
- Capacity honesty: right-sized/autoscaled node pool so peak batch + revenue actually fit, with alerts on node memory pressure and eviction events — the cascade’s earliest signals.