Skip to main content
RunBook Academy

← All break/fix scenarios in Kubernetes

intermediatekubernetes-node-pressure~35 min

MemoryPressure eviction

Reported symptoms

  • Pods on worker-07 fail several times an hour with phase Failed and reason Evicted, across three namespaces that have nothing to do with each other
  • No container was OOMKilled: no exit code 137, no container hit its own memory limit, and the application memory dashboards are flat
  • `kubectl top node worker-07`, run after each page, reports memory around 70%, which everyone reads as ample headroom
  • The node monitoring agent is one of the Pods that keeps being evicted, so the node memory graph has gaps in exactly the minutes that matter
  • MemoryPressure on the node goes True and back to False within a couple of minutes, so a `describe node` run after the page shows a perfectly healthy node
  • Evicted Pod objects accumulate until a namespace hits its ResourceQuota Pod count, which arrives as an unrelated ticket about being unable to create Pods
  • A fleet-wide log shipper was rolled out three weeks ago, but it went to every node and only two nodes misbehave, so nobody connects it

Evidence

  • · `kubectl get events --field-selector reason=Evicted` clusters almost every eviction on worker-07 and names memory as the exhausted resource
  • · `kubectl describe node worker-07` shows Allocatable memory almost equal to Capacity memory - the node reserves nothing for the operating system or the kubelet
  • · The Allocated resources block on the same node shows memory requests at roughly 55% of allocatable, so the scheduler still considers the node half empty
  • · On the node itself, the system slice is holding several gigabytes that Allocatable says do not exist, most of it in one systemd unit
  • · Every evicted Pod is BestEffort, and none of them is among the top memory consumers on the node
  • · The kubelet journal shows the eviction loop reclaiming memory and the pressure returning within a few minutes, repeatedly
  • · The two affected nodes are the two that carry the highest log volume in the cluster
Diagnosis and resolutionclick to reveal

Root cause

The node was never told to keep anything back. With kubeReserved and systemReserved unset, allocatable is almost identical to capacity, so the scheduler sizes the node as though the operating system, the kubelet, the container runtime and every systemd agent on the host need no memory at all. For most of a year that was survivable, because the slack absorbed it. Then a fleet-wide log shipper arrived whose working set grows with log volume, and on the two busiest nodes it grew past the slack. From that point the node is genuinely short of memory while the scheduler, which reasons only about requests against allocatable, still sees it as half empty and keeps filling it. The kubelet measures the real thing and defends the node the only way it can, by evicting Pods - but the consumer is not a Pod, so eviction relieves the pressure without touching the cause. Victims are chosen by QoS class: BestEffort first, ranked by absolute usage. That selects the Pods with no requests rather than the Pods responsible, and two of them are the node monitoring agent, so the node loses its own instrumentation at exactly the moment it is in trouble. The 70% on the dashboard is not wrong; it is the average of the minutes in which the agent was alive.

Remediation

Measure before changing anything. Establish what is actually resident on the node outside any Pod cgroup, at peak rather than at rest, because the whole defect is that this number was assumed to be zero and a guessed replacement is the same mistake with a different value. Then set systemReserved and kubeReserved from that measurement plus headroom, and accept the consequence: allocatable falls, the node holds fewer Pods, and the cluster may need more nodes. That is not a side effect to be minimised - it is the honest capacity the cluster always had. Bound the agent as well as reserving for it, with a memory limit on its systemd unit, so that a change in log volume produces a restart on one host instead of an eviction cascade across the fleet. Give agents that must survive node pressure requests equal to limits so they rank last rather than first. Do this on one node, drained, and watch it through a peak before touching the second. Holding is reasonable if peak is imminent: cordon the two worst nodes and bound the shipper immediately, with a named owner and a date, rather than reducing allocatable across the fleet in a hurry.

Verification

The verification is a peak survived, not a graph that looks better. Watch the repaired node through a full peak log period with no MemoryPressure transition and no eviction event, because the fault only appears when the agent working set is at its largest. Confirm allocatable is now visibly below capacity by roughly the measured amount, and that the sum of Pod requests plus the reservations no longer exceeds what the host actually has. Prove the bound works by pushing the shipper past its limit deliberately on a scratch node and confirming that the unit is restarted rather than the node evicting Pods. Confirm the monitoring agent now survives pressure: induce it on a scratch node and check the agent is still running and still reporting, since an eviction record with no metrics behind it is what made this take three weeks. Finally, clear the accumulated Evicted Pod objects and confirm the namespace quota that tripped is back under its limit.

Prevention

Treat rolling out a node agent as a capacity change, because that is what it is. Anything installed on every host consumes memory the scheduler cannot see, and it should be sized, bounded and reserved for before it ships rather than discovered through evictions on the busiest nodes. Set kubeReserved and systemReserved on every node from a measurement of that node class, and re-measure when the agent set changes. Alert on Evicted events grouped by node: it is a leading indicator that costs nothing and it separates a node fault from a workload fault immediately, which is the distinction that was missing here for three weeks. Never let a node-level agent run without a memory bound, so that its growth is its own problem rather than the workload's. Put the agents you need during an incident into Guaranteed QoS, so the node does not go blind at the moment you need to see it. And garbage collect Evicted Pod objects, so a node problem cannot present itself three namespaces away as a quota problem.

Reported symptoms

For three weeks, Pods on worker-07 have been dying a few times an hour. Their phase is Failed, their reason is Evicted, and they belong to three unrelated namespaces: a CI runner pool, a batch queue, and the platform namespace that carries the node monitoring agent.

Every investigation so far has bounced:

  • Nothing was OOMKilled. No exit code 137, no container reached its own memory limit, and the application teams can show flat memory graphs across every window in which they lost a Pod.
  • kubectl top node worker-07, run after each page, reports memory around 70%. That reads as ample headroom, so capacity planning closes the ticket - and it is only comfortable because the evictions have already reclaimed the memory by the time anyone looks.
  • The node’s MemoryPressure condition goes True and then back to False within a couple of minutes. By the time anyone runs describe node after a page, the node looks perfectly healthy.
  • The node monitoring agent is itself one of the things being evicted, so the node’s memory graph has holes precisely in the intervals under investigation.

Two other threads are running in parallel, and neither team knows about the other. The batch namespace has started hitting its ResourceQuota Pod count and has raised a ticket about being unable to create Pods; nobody has noticed the quota is being consumed by dozens of Evicted Pod objects that were never cleaned up. And three weeks ago the platform team rolled out a log shipper to every node in the cluster - which is dismissed early on, because it went everywhere and only two nodes misbehave.

Evidence provided

Read-only / Safethree namespaces, one node, one resource
$ kubectl get events -A --field-selector reason=Evicted --sort-by=.lastTimestamp | tail -5
batch     4m    Warning   Evicted   pod/import-runner-4q8sd   The node was low on resource: memory. Threshold quantity: 100Mi, available: 84Mi.
ci        9m    Warning   Evicted   pod/runner-77c9f6d4b-x2plq   The node was low on resource: memory. Threshold quantity: 100Mi, available: 91Mi.
platform  9m    Warning   Evicted   pod/node-agent-l8kkr          The node was low on resource: memory. Threshold quantity: 100Mi, available: 91Mi.
batch     23m   Warning   Evicted   pod/import-runner-mm4vz       The node was low on resource: memory. Threshold quantity: 100Mi, available: 88Mi.

Illustrative output

Read-only / Safeallocatable is capacity minus 100Mi - the node reserves nothing at all
$ kubectl describe node worker-07 | grep -A 6 -E '^(Capacity|Allocatable)'
Capacity:
cpu:                16
memory:             65806132Ki
pods:               110
Allocatable:
cpu:                16
memory:             65703732Ki
pods:               110

Illustrative output

Read-only / Safethe scheduler thinks this node is half empty, and keeps sending it work
$ kubectl describe node worker-07 | grep -A 5 'Allocated resources'
Allocated resources:
Resource           Requests      Limits
--------           --------      ------
cpu                6200m (38%)   19400m (121%)
memory             34Gi (54%)    58Gi (92%)

Illustrative output

Read-only / Safethe system slice is holding 8.4G that Allocatable says does not exist
# systemd-cgtop -m --iterations=1 -n0 | head -6
Control Group                    Tasks   %CPU   Memory  Input/s Output/s
/                                 1284    412    60.9G        -        -
/kubepods.slice                   1102    355    52.1G        -        -
/system.slice                      164     44     8.4G        -        -
/system.slice/logshipper.service    38     29     5.8G        -        -
/system.slice/containerd.service    92      9     1.1G        -        -

Illustrative output

Read-only / Safeevery evicted Pod so far has been BestEffort
$ kubectl get pod -n ci runner-77c9f6d4b-x2plq -o jsonpath='{.status.qosClass}'
BestEffort

Illustrative output

Read-only / Safeand every one of them is still an object, still counting against a quota
$ kubectl get pods -A --field-selector status.phase=Failed -o wide | grep -c worker-07
61

Illustrative output

Work the evidence before reading on

Nothing here contradicts anything else. Every number is correct. The node is short of memory and the scheduler thinks it is half empty, and both of those are true at the same time.

  1. Compare Capacity and Allocatable for memory. What is the difference, and what is it supposed to cover?
  2. The scheduler places Pods using requests against allocatable. Which memory consumers on this node does that arithmetic include, and which does it not?
  3. The evicted Pods are BestEffort. What does the kubelet use QoS class for, and does that ranking say anything about which Pod caused the shortage?
  4. Only two nodes in the fleet misbehave, and the agent was rolled out to all of them. What is different about those two?

Before continuing: the kubelet can only evict Pods. What happens when the thing consuming the memory is not one?

Root cause

1. Allocatable is a promise the node cannot keep

Allocatable is what the kubelet advertises to the scheduler. It is meant to be capacity minus what the operating system needs, minus what the kubelet and the container runtime need, minus the eviction threshold that keeps the node off the cliff.

On this node it is capacity minus 100Mi. kubeReserved and systemReserved are unset, so the node is telling the scheduler that everything on the host outside of Pods runs on 100 megabytes: systemd, journald, sshd, the kubelet itself, containerd, the CNI agent and the log shipper.

That was survivable for a long time because there was slack. The node was never full, so the gap between the promise and reality was absorbed by memory nobody had claimed.

2. The agent grew into the slack, on exactly two nodes

The log shipper’s working set scales with log volume. It was rolled out everywhere, which is why the rollout was dismissed as a suspect: if it were the cause, surely every node would break.

But only the two busiest log producers in the cluster ever grew it past the slack. On every other node the same agent, from the same rollout, sits well inside the unclaimed memory and is invisible. The rollout did not create the defect - the defect was the missing reservation - but it is what turned a latent mis-sizing into a daily incident, and only where the load was highest.

3. The kubelet defends the node with the only tool it has

When memory.available crosses the threshold, the kubelet evicts. That is the correct behaviour and it works: memory is reclaimed, pressure clears, the condition flips back to False.

It also cannot possibly fix anything. The memory is being held by a systemd unit; the kubelet has no authority over it and evicts Pods instead. So the node recovers, reports itself healthy, the scheduler refills the space that was just freed, the agent keeps growing, and the whole thing repeats. The flapping condition is not a symptom of instability - it is the loop working exactly as designed against a cause it cannot reach.

4. The ranking chose the worst possible victims

Eviction order is BestEffort first, ranked by absolute usage; then Burstable, ranked by how far usage exceeds request; Guaranteed last. It is a ranking of which Pods the node can most afford to lose, not a finding about which Pod is responsible.

Every Pod evicted here was BestEffort, and none was a significant consumer. Two of them were the node monitoring agent, which had no requests set - so the node lost its own instrumentation every time it got into trouble, and the graph everyone was reading showed 70% because it is an average over the minutes the agent was alive to report.

Resolution

  1. Stop the bleeding without changing capacity yet. Bound the log shipper with a memory limit on its systemd unit, so its growth restarts one unit on one host instead of evicting Pods across the node. This is reversible, it is node-local, and it buys time to do the rest properly.
  2. Measure what the node actually needs outside Pods, at peak rather than at rest. Everything under the system slice, plus the kubelet and the runtime, at the busiest hour of the day. The defect was assuming this number was zero; guessing a replacement repeats it.
  3. Set systemReserved and kubeReserved from that measurement plus headroom, on one drained node first. Restart the kubelet and confirm allocatable has dropped by roughly the amount you reserved.
  4. Watch that node through a full peak before touching a second one. This fault only appears when the agent working set is at its largest, so a quiet afternoon proves nothing at all.
  5. Give the monitoring agent requests equal to limits so it becomes Guaranteed and ranks last for eviction. A node that loses its instrumentation under pressure cannot be diagnosed under pressure.
  6. Work out what the reservation costs the cluster before applying it fleet-wide. Reducing allocatable everywhere at once can put a large number of Pods into Pending simultaneously, and that recovery is a purchase order.
  7. Roll out node by node, or node class by node class, and re-measure for classes with different agent sets. A control plane node and a log-heavy worker do not need the same reservation.
  8. Clear the accumulated Evicted Pod objects and confirm the batch namespace quota is back under its limit. That ticket was always this incident wearing a different name.

Verification

  1. The repaired node survives a full peak log period with no MemoryPressure transition and no eviction event. Peak is the only condition under which this fault exists, so it is the only condition in which the absence of the fault means anything.
  2. Allocatable is now visibly below capacity by approximately the measured amount. If the two are still nearly equal, the configuration was not applied - check the kubelet actually restarted with it.
  3. Pod requests plus the reservations no longer exceed what the host physically has. This is the arithmetic that was wrong; confirm it is now right rather than inferring it from the absence of alerts.
  4. The bound on the agent works. Push it past its limit deliberately on a scratch node and confirm the unit is restarted rather than the node starting to evict Pods.
  5. The monitoring agent survives node pressure. Induce pressure on a scratch node and confirm the agent is still running and still reporting - an eviction record with no metrics behind it is what made this take three weeks.
  6. The Evicted Pod objects are cleared and the namespace that hit its quota is back under it, with the quota ticket closed against this incident rather than separately.
  7. An alert on Evicted events grouped by node exists and has been tested by generating one. An alert that has never fired is a plan, not a control.

Prevention

  • Treat rolling out a node agent as a capacity change. Anything installed on every host consumes memory the scheduler cannot see. Size it, bound it and reserve for it before it ships, rather than discovering it through evictions on the two busiest nodes three weeks later.

  • Set the reservations from measurement, on every node class. The numbers differ between a log-heavy worker, a control plane node and a GPU node, because the agent sets differ:

apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
systemReserved:
  cpu: 500m
  memory: 8Gi
kubeReserved:
  cpu: 500m
  memory: 2Gi

The value above is this node class after measurement, not a default to copy. Re-measure when the agent set changes.

  • Bound every node-level agent. An agent without a memory limit turns its own growth into the workload’s problem. With one, it becomes a restart on one host and an alert with the agent’s name on it.

  • Put the agents you need during an incident into Guaranteed QoS. A monitoring agent with no requests is BestEffort, which means it is first out of the door at exactly the moment you need it most.

  • Alert on Evicted events grouped by node. It costs nothing, it is a leading indicator, and grouping by node separates a node fault from a workload fault in one glance - the distinction this incident lacked for three weeks.

  • Garbage collect Evicted Pod objects. Left alone they consume quota and reappear as an unrelated ticket in a namespace that has done nothing wrong, which splits one incident into two investigations that never meet.