Skip to main content

PSI Metrics Reach GA in Kubernetes 1.36: What Your Node's CPU/Memory Graphs Have Been Hiding

8 min readDora NodaDora Noda
Share
On this page

A node can sit at 40% CPU and 55% memory on every dashboard you own while every pod on it is stalled. Request accounting tells you how much of a resource is reserved. It says nothing about whether tasks are actually waiting for that resource to free up — and on a bin-packed, multi-tenant box, waiting is exactly what kills your p99.

Kubernetes 1.36 graduates Pressure Stall Information (PSI) metrics to General Availability, finally exposing a per-cgroup contention signal the Linux kernel has tracked since version 4.20. That's the news. The part that actually changes how you operate a Cluster-API-managed fleet is less flattering: the kubelet's own eviction manager still doesn't read PSI, and there's a real, documented gotcha in naively wiring it up yourself. Below is what shipped, why request accounting misses real incidents that PSI would have caught, the specific CPU-PSI trap to avoid, and the wiring a self-hosted platform has to build to close the gap upstream hasn't closed yet.

What actually shipped in 1.36

KEP-4205 moved through alpha starting around 1.29, hit beta in 1.34 (September 2025), and graduated to stable in 1.36, released April 2026. The KubeletPSI feature gate is now locked to true — you can't turn it off, and there's nothing to opt into.

Two prerequisites still gate whether you actually get data: the node's kernel needs CONFIG_PSI=y (check with zgrep CONFIG_PSI /proc/config.gz, and some distros need a psi=1 boot parameter), and the node has to be on cgroup v2. Most current CAPI base images clear both bars by default, but it's worth verifying on any custom or older image in your fleet before you trust a blank PSI response.

Where beta and GA actually differ is the data shape. Kubernetes now reports PSI for CPU, memory, and I/O, each split into two states:

  • some — at least one task is stalled on this resource. Early warning.
  • fullevery non-idle task is stalled simultaneously. The node has stopped making progress on that resource, full stop.

Each state carries four fields: avg10, avg60, avg300 (percentage of wall-clock time stalled, as 10-second/60-second/5-minute moving averages) and total (a cumulative microsecond counter). A real response looks like this:

json
{
  "cpu":    { "some": { "avg10": 0.74, "avg60": 0.52, "avg300": 0.21, "total": 35232438 },
              "full":  { "avg10": 0,    "avg60": 0,    "avg300": 0,    "total": 0 } },
  "memory": { "some": { "avg10": 0.01, "avg60": 0.01, "avg300": 0,    "total": 658164 },
              "full":  { "avg10": 0,    "avg60": 0,    "avg300": 0,    "total": 539105 } },
  "io":     { "some": { "avg10": 0.52, "avg60": 0.45, "avg300": 0.12, "total": 40809937 },
              "full":  { "avg10": 0.31, "avg60": 0.22, "avg300": 0.05, "total": 33190987 } }
}

You can pull that from two places: the kubelet's Summary API (/stats/summary, at node/pod/container granularity), or as Prometheus counters on /metrics/cadvisorcontainer_pressure_cpu_waiting_seconds_total, container_pressure_memory_waiting_seconds_total, container_pressure_io_waiting_seconds_total. SIG Node's own benchmarks, run at 80-pod density on 4-core nodes, put kubelet-side collection overhead at roughly 0.1 cores (~2.5% of node capacity) — cheap enough that there's no real argument for scraping it selectively.

Why request accounting missed this, with a receipt

The claim that "healthy" utilization dashboards can hide a genuinely broken node isn't hypothetical — it's a documented incident. In "A Tale of Memory Pressure", a Kubernetes monitoring cluster's control-plane components started restarting at random. etcd's own logs showed apply requests — which should complete in under 50ms, with a 100ms warning threshold — taking nearly 5 seconds, and the API server's GuaranteedUpdate calls were timing out against a 5-second client budget.

The nodes weren't memory-starved by any conventional measure: free showed 7.3 GiB total with 2.3 GiB still available, and swap was off, as it is on every Kubernetes node. Nothing about that number trips a memory.available eviction threshold. But the OS was aggressively reclaiming file-backed page cache and writing it back to disk, and every time a reclaimed page was touched again, that was a major page fault. The system was logging 140–150 major page faults per second, disk iowait sat above 30%, and disk I/O utilization spiked past 90%. PSI told the real story directly: 35% full disk pressure — all tasks stalled on I/O nearly a third of the time — against only 2% full memory pressure, pinpointing disk contention as the actual bottleneck rather than the memory shortfall the raw numbers seemed to suggest.

That's a control-plane node, but the mechanism generalizes directly to a bin-packed worker: a CAPI-managed node running concurrent git clones, image pulls, and build layers from several tenants at once can page-cache-thrash the exact same way, at CPU/memory request levels that look completely unremarkable in kubectl top node. Request accounting measures what you reserved. It has no path to observing that reclaim, and cluster-autoscaler and the kubelet eviction manager both make decisions off request/allocatable accounting, not stall time.

Which raises the obvious question: now that PSI is GA, does the kubelet at least evict on it? No. The kubelet's node-pressure eviction manager watches exactly these signals, unchanged by the PSI graduation: memory.available, nodefs.available / nodefs.inodesFree, imagefs.available / imagefs.inodesFree, containerfs.available / containerfs.inodesFree, and pid.available. Not one of them is PSI-derived. GA means the kernel signal is finally exposed as structured data — it does not mean anything in the scheduler or eviction manager consumes it yet. That gap is the platform's to close.

The gotcha: don't taint on node-aggregate CPU PSI

Before you wire a "cordon the node when pressure crosses X" rule, know the failure mode that's already been demonstrated in the KEP-4205 enhancement discussion itself. A documented reproduction ran a single pod with 8 CPU stressor processes but capped it at 20 millicores via its resource limit. That pod, entirely by design, hit ~99% self-pressure — it's supposed to be throttled, that's what the limit is for.

The problem is where that pressure propagated: the kubelet's own cgroup slice read 88.89% CPU pressure (some), and the node level read 85.02%. A single intentionally-throttled pod dragged the node-aggregate CPU PSI reading to "critical" — even though every other tenant on that node had all the CPU it needed. If you'd wired a MachineHealthCheck or autoscaler rule off node-level cpu.some, you'd have cordoned or scaled around a phantom problem caused by one workload's own limits.

The reason this is specific to CPU: a CPU limit is enforced via CFS bandwidth throttling, which is mechanically a stall — the kernel is deliberately withholding CPU time from that cgroup, and PSI faithfully reports it as pressure. Memory and I/O limits don't have an equivalent "your quota says wait" mechanism baked into the kernel's accounting the same way; memory.full and io.full pressure at the node level generally reflects actual contended demand for a genuinely scarce resource, not one tenant's own throttle setting bleeding into the aggregate. The rule that falls out of this: treat node-aggregate memory and io PSI as real contention signals. Treat node-aggregate cpu PSI with suspicion, and if you need CPU pressure as a signal at all, scope it to per-container readings from the Summary API rather than the node-level rollup.

Wiring PSI into a CAPI-managed fleet

None of this is available out of the box on a self-hosted, CAPI-managed cluster — no managed-Kubernetes control plane is going to build this for you either, since it's fleet-specific tuning, not a platform primitive. Four concrete steps:

  1. Verify the prerequisites per node image. CONFIG_PSI=y and cgroup v2 on every base image in the fleet, not just the newest one — mixed-generation node pools are common in a self-hosted setup that's been running for a while.
  2. Scrape both exposure points, not just one. Point Prometheus at /metrics/cadvisor for the container_pressure_*_waiting_seconds_total counters for long-term trending and alerting rules; use the Summary API directly from any custom node-health agent that needs point-in-time reads without a scrape-interval delay.
  3. Build a decision rule that a stock kubelet won't apply for you. A small DaemonSet or node-agent that cordons a node — or emits a custom metric the cluster-autoscaler can scale on — when io.some avg60 or memory.full avg10 stays elevated for a sustained window (say, several consecutive minutes, not a single spike). Deliberately exclude node-aggregate cpu.some/cpu.full from this rule for the reason above; if CPU pressure matters to you, alert on it per-container instead.
  4. Feed the signal into CAPI's health machinery, not around it. A MachineHealthCheck can watch a node condition your agent sets; cluster-autoscaler can scale on a custom metric. Either way, PSI becomes an input to decisions Cluster API already makes, instead of a second, disconnected monitoring system nobody acts on.

This is exactly the kind of operational wiring a self-hosted platform ends up owning that a hyperscaler abstracts away for you — Bex.co is the open-source, AI-native Render alternative built for that reality: push a git repo, get a running HTTPS service on machines you own, with a Render-compatible API and Cluster-API-managed nodes under it. Star the repo or deploy your first app to see the fleet-health story firsthand.

Where this goes next

PSI reaching GA is a real, useful shift — three years of alpha-to-stable work landed a kernel-native contention signal that request-based accounting structurally cannot produce. But GA describes exposure, not enforcement. SIG Node hasn't wired the eviction manager or the default scheduler off PSI, and the CPU-PSI ambiguity documented in KEP-4205's own tracker is a real part of why: node-aggregate CPU pressure isn't safe to act on without more nuance than a threshold check, and nobody's shipped the answer to that upstream yet.

Until that lands, the gap between "the kernel has always known when a node is stalling" and "the scheduler acts on it" is a gap self-hosted operators have to close themselves — which, for a platform built around agents that already read structured infrastructure state and act on it, is less of a burden than it sounds. The signal is sitting there in GA. Somebody just has to wire it up.

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