Skip to main content

Kubernetes v1.36 Ships PSI Metrics to GA: The Contention Signal Your Multi-Tenant Fleet Was Missing

10 min readDora NodaDora Noda
Share
On this page

Your node reports 62% CPU utilization. Everything looks fine — and yet one tenant's p99 latency just tripled while its rack-mate burns through a batch job. CPU percentage told you nothing was wrong because nothing about usage was wrong. Something about waiting was.

That distinction is exactly what graduated to stable in Kubernetes v1.36 "Haru": the kubelet now exports Linux Pressure Stall Information (PSI) — CPU, memory, and I/O stall time at node, pod, and container level — with the KubeletPSI feature gate locked on. It is the first built-in Kubernetes signal that measures contention instead of consumption. Here is what shipped, the four-item checklist your nodes must satisfy before you see a single number, a worked noisy-neighbor scenario showing what fires first, and one honest boundary: what GA does not do, because no kubelet flag turns on PSI-based eviction or scheduling in 1.36.

What shipped, in one paragraph: PSI has lived in the Linux kernel since 2018. Kubernetes picked it up as an alpha feature in v1.33, promoted it to beta in v1.34, and graduated the kubelet's PSI export to stable in v1.36 (released April 22, 2026). As of 1.36 you opt into nothing: the gate is locked to true and cannot be disabled. What GA is not: the scheduler and node-pressure eviction do not read PSI. There is no flag to "turn PSI-based eviction on." What you get is the signal itself — exported, per-cgroup, cheap — and the job of acting on it stays with your alerts, your autoscalers, and your controllers.

Your nodes qualify only if all four of these hold

Before the metrics, the gate. PSI is a kernel feature, and the kubelet can only report what the OS actually tracks. Every node that should emit PSI must satisfy all four:

  1. Linux kernel 4.20 or later. PSI entered the kernel in 4.20; anything older has no /proc/pressure to read.
  2. cgroup v2. Per-cgroup pressure files (cpu.pressure, memory.pressure, io.pressure) exist only on the unified hierarchy. A node still running cgroup v1 reports nothing.
  3. Kernel compiled with CONFIG_PSI=y. Most modern distributions enable it by default, but some compile it in and leave it off — in which case enable it at boot with the psi=1 kernel command-line parameter, and never boot with psi=0.
  4. Linux nodes only. PSI does not exist on Windows. A mixed cluster is fine: Windows kubelets simply omit the metrics.

One GA hardening item matters here. Before 1.36, a kubelet with the feature enabled on a kernel without PSI support emitted misleading zero-valued metrics — zeros that read as "no contention" instead of "no data" and could silence exactly the alerts built to catch it. In 1.36 the kubelet detects OS-level PSI support from the cgroup configuration before reporting, so pressure metrics appear only where they are real. If you adopted PSI during beta, re-check any dashboard panel that ever showed a suspicious flat zero: it may have been absence disguised as health.

Verify a node in seconds: cat /proc/pressure/cpu should print some and full lines with avg10/avg60/avg300 values, and stat -fc %T /sys/fs/cgroup should report cgroup2fs. If either fails, fix the node before writing a single alert rule.

Why utilization lies, in one example

Utilization answers "how much was used." PSI answers "how much work was blocked waiting." Those sound adjacent until they diverge — and on a multi-tenant node, they diverge exactly when it hurts.

The Kubernetes documentation's own example makes the reading concrete. A container reports cpu.some avg10=0.74, avg300=0.21. That means: over the last 10 seconds, at least one task in that container spent 0.74% of wall-clock time stalled waiting for CPU across that 10-second window. Because avg10 (0.74) towers over avg300 (0.21), this is a recent surge, not a chronic bottleneck; if you watch avg300 climb toward avg10, the surge is becoming the new normal. Meanwhile cpu.full sits at 0.00: some tasks waited, but the container as a whole kept making progress. A non-zero full would mean every non-idle task stalled at once — a qualitatively worse event, and the reason some and full are separate signals rather than one number.

The vocabulary generalizes across all three resources:

Resourcesome pressure meansfull pressure meansTenant symptom
CPUAt least one task waited for a runqueue slotAll tasks stalled simultaneously (rare, severe)p99 spikes while CPU% looks moderate
MemoryAt least one task stalled on reclaim/swapEverything stalled on memory at onceMounting tail latency ahead of OOM
I/OAt least one task waited on storageAll tasks blocked on I/OBuild/deploy steps hang, disks "fine"

A utilization dashboard collapses all of this into percentages that can read healthy until the node falls over. PSI preserves the shape of the contention: which resource, how recently, how widely shared.

What GA actually exports, and what it costs

Two surfaces, three granularities. The kubelet's Summary API carries .cpu.psi, .memory.psi, and .io.psi blocks at node, pod, and container level — per-tenant attribution without a sidecar:

text
kubectl get --raw "/api/v1/nodes/NODE_NAME/proxy/stats/summary"

For Prometheus pipelines, the kubelet's /metrics/cadvisor endpoint exposes the same data as counters:

  • container_pressure_cpu_waiting_seconds_total
  • container_pressure_memory_waiting_seconds_total
  • container_pressure_io_waiting_seconds_total

The natural objection is overhead: per-cgroup stall accounting on every node sounds like a tax on the fleet. The SIG Node performance work published with the GA announcement measured it directly at 80-pod density on 4-core machines, isolating the two layers. Kernel-side bookkeeping cost 0.037 to 0.125 cores — roughly 1% to 3% of node capacity — with a single controlled spike to 0.225 cores under heavy I/O and CPU load. The kubelet's collection sweeps were lighter still: its CPU usage stayed interchangeable with the feature-off baseline, never exceeding 0.25 cores for longer than a second, blending into standard housekeeping cycles. At fleet scale that is noise — far below the cost of any per-workload agent you would run to get the same per-container attribution.

The noisy-neighbor scenario, worked end to end

Put it together on a node shared by two tenants. Tenant A launches a batch transcode that saturates the runqueue in bursts. Tenant B serves web traffic with a strict p99 budget. Node CPU utilization settles around 60% — no threshold crossed, no page fired. But Tenant B's p99 triples, because average CPU headroom says nothing about whether B's tasks get a core when they wake up.

Here is what each signal shows, in order:

  1. Tenant B's cpu.some avg10 lifts first — from near zero to single digits — while node CPU% barely moves. This is the earliest warning the kubelet itself can give you: tasks are waiting, not consuming.
  2. The avg10/avg300 spread confirms it is real. A lone spike that decays is a burst; avg60 and avg300 climbing behind avg10 is sustained contention — the signature of a neighbor, not a blip.
  3. Memory or I/O PSI implicates the resource. If memory.some climbs on Tenant B's cgroup while the node has free pages, the stall is reclaim pressure from A's allocations churning the page cache — invisible to every utilization chart on the dashboard.
  4. Node-level PSI corroborates before you act. Per-container PSI says who hurts; node-level PSI says the machine agrees. When both point at CPU, the remediation (cap A's CPU shares, move A to a batch pool, deschedule B) rests on two independent readings instead of one tenant's complaint.

The contrast table is the whole argument in miniature:

MomentNode CPU%Tenant B cpu.some avg10Operator conclusion
A starts batch job45% → 60%0.1 → 3.5Contention beginning; utilization calm
B's p99 triples62%, flat8+ and rising, avg60 followingNoisy neighbor confirmed; act now
A finishes60% → 30%Decays to ~0Health restored; utilization never paged

No CPU-percentage alert fires at any point in that incident. The PSI alert fires at row one.

A starter runbook: four alerts and two commands

Translate the scenario into monitoring with one rule per failure shape. Treat every threshold below as a starting point to tune against your own fleet's baseline — the signal semantics are stable, the numbers that constitute "abnormal" depend on your workloads:

  • CPU contention onset: rate(container_pressure_cpu_waiting_seconds_total[2m]) rising on a container whose CPU usage is flat. Alert when short-window stall rate exceeds its own 1-hour baseline by an order of magnitude — that is the avg10-vs-avg300 spread expressed in PromQL.
  • Sustained CPU starvation: elevated cpu.some persisting across the 5-minute window. Short spikes are bursts; a 5-minute plateau is a neighbor.
  • Memory pressure ahead of OOM: any sustained non-zero memory.full. Full-line memory stall means no task in the cgroup could make progress — treat it as a paging incident already in progress, more urgent than any memory.some reading.
  • I/O saturation on build/storage nodes: rising io.some on nodes backing image builds, artifact storage, or database volumes, correlated with io.full before declaring the device — not the tenant — the bottleneck.

Two commands round out the kit. The Summary API one-liner from the official docs pulls per-container PSI for a named container without any monitoring stack at all — useful from a jump host during an incident. And the qualification checks from the first section (/proc/pressure/cpu, cgroup filesystem type) belong in your node-image CI: a node that silently stops emitting PSI after an OS upgrade should fail provisioning, not silently disarm every alert above it.

The honest boundary: the scheduler is not listening yet

Say it plainly so nobody misconfigures around it: in 1.36, nothing inside Kubernetes itself acts on PSI. The default scheduler still scores nodes on requested resources, not stall time. Node-pressure eviction still keys off memory.available, nodefs, and image-filesystem thresholds — a node can be PSI-screaming and evict nothing, or evict while PSI is calm. "Switching eviction and scheduling heuristics from utilization-based to PSI-based" is roadmap language, not a 1.36 feature, and any post that implies a flag flip delivers it is selling a future as a present.

What GA does unlock is everything one layer out: PSI-fed custom deschedulers that move victims off contended nodes, cluster-autoscaler pressure signals that scale on stall instead of on 80%-CPU lag, and VPA work already underway to use PSI plus memory events for reactive in-place resize — raising a pod's limits without a restart when stall data, not a usage forecast, says it is starving. The signal had to go stable before any of that could be built on without a beta disclaimer. That is the actual shape of this release: not autonomy, but the trustworthy substrate autonomy will need.

If you run your own fleet, that substrate lands directly on your to-do list: qualify the nodes, scrape the endpoint, write the four alerts, and your next noisy-neighbor incident starts with a page at row one of the table instead of a tenant ticket at row three.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex