General

The Kubernetes OOMKilled Lie: Why cgroup v2 Memory Limits Murder Healthy Pods

Oct 13, 2026/8 min read

calricon / field note

Site Reliability Engineers spend endless hours debugging container exit code 137, bewildered to find application runtime heaps sitting comfortably below 40% utilization. The brutal truth is that default Kubernetes memory limits enforce a hard ceiling on Linux page cache, turning file I/O, buffered logging, and streaming into fatal traps. Discover how to transition from brittle memory.max hard limits to resilient cgroup v2 Memory QoS.

Oct 13, 2026/General
143
Advertisement

The Kubernetes OOMKilled Lie: Why cgroup v2 Memory Limits Murder Healthy Pods

When a Kubernetes pod suddenly dies with exit code 137 (OOMKilled), developers invariably inspect their application performance monitoring dashboards, discover their JVM, Go, or Node.js heap was only hovering at 35% utilization, and declare the failure a phantom glitch. It is not a glitch; it is a fundamental misunderstanding of the Linux kernel's memory management subsystem. Setting resources.limits.memory in Kubernetes does not merely restrict anonymous application heap memory (RSS); it configures memory.max in Linux cgroup v2, an unyielding ceiling that counts both resident memory and the kernel page cache (file-backed buffers). Under heavy disk I/O, unbuffered logging to stdout, or large file transfers, the kernel page cache rapidly expands to fill the container's allocation. If dirty pages cannot be reclaimed by the kernel faster than incoming memory allocations, the OOM Killer brutally executes the container's main process, regardless of whether the application's actual runtime heap was starving or healthy. The solution is abandoning naive hard memory limits in favor of cgroup v2 Memory Quality of Service (memory.high) and memory requests with node-level eviction.

"Setting resources.limits.memory does not merely restrict application heap memory; it configures an unyielding ceiling on the kernel page cache. When I/O bursts hit, the OOM Killer strikes regardless of whether your heap was healthy."

The Anatomy of Exit Code 137: Heap vs. Page Cache

In Linux memory accounting, not all bytes are created equal. The operating system divides memory usage into two primary categories:

  1. Anonymous Memory (RSS): Memory allocated dynamically by application processes on the heap and stack (e.g., objects created by your Java, Python, or Go code). This memory cannot be discarded without writing to swap.
  2. File-Backed Memory (Page Cache): Memory used by the kernel to cache files read from or written to disk. Because memory that sits idle is considered wasted by Linux, the kernel aggressively caches all filesystem operations in RAM to make subsequent reads instantaneous.

Under Linux cgroups (both v1 and v2), the container's memory accounting counter tracks both Anonymous Memory and Page Cache together:

memory.current = anon + file

When Prometheus measures container_memory_working_set_bytes—the metric Kubernetes uses to trigger OOM kills—it calculates:

Working Set = memory.current - inactive_file

Notice what is missing: active file page cache is explicitly included in the working set. If your application processes files, reads from local storage, or generates high-throughput stdout logs, the kernel marks those memory pages as "active file cache." When total usage touches memory.max, the Linux kernel triggers synchronous page reclaim. If your application attempts an allocation faster than the kernel can write dirty pages back to disk, the reclaim stalls, and the kernel invokes the Out-Of-Memory (OOM) Killer. The killer targets the process with the highest OOM badness score—which is almost always your primary application binary.

The Three Triggers That Turn Healthy Pods into OOM Victims

1. High-Throughput Streaming and Disk Buffering

Applications that handle file uploads, stream large objects from AWS S3, or write temporary artifacts to an ephemeral volume (emptyDir) frequently fall victim to page cache inflation. When an application downloads a 2GB CSV file, processes it, and deletes it, the application heap might only hold 50MB of memory at any given second. However, the Linux kernel retains the entire 2GB file in the container's page cache. If your pod has a memory limit of 1.5GB, the moment total cached data plus heap exceeds 1.5GB, the kernel kills the pod, even though the application finished processing the file and discarded its heap references.

2. Unbuffered Stdout Logging

In cloud-native 12-factor application architecture, services write logs directly to stdout and stderr. What engineering teams forget is that standard out in a container is a pipe or pseudo-terminal handled by the container runtime (containerd or CRI-O). When an application encounters a traffic burst and spits out thousands of debug or trace logs per second, the Linux kernel buffers those log writes in the container's page cache before containerd flushes them to /var/log/pods. Under intense logging pressure, the page cache swells rapidly, pushing a healthy service over its hard memory limit and triggering an instant termination.

3. Runtime GCs Blind to Kernel Cache Pressure

Modern application runtimes—most notably the Java Virtual Machine (JVM) and the Go runtime—have added container awareness to automatically configure heap limits (such as Java's -XX:MaxRAMPercentage=75.0). However, these runtimes inspect only total container memory limits, not the instantaneous balance between heap and page cache. The JVM believes it has 75% of the container limit available for its heap. But if the host kernel is simultaneously using 35% of the container's limit for file page cache, the combined footprint exceeds 100%, and the pod is executed by the kernel before the JVM Garbage Collector even decides to trigger a collection cycle.

Why cgroup v1 "Solutions" Fail on Modern cgroup v2 Nodes

On legacy cgroup v1 systems, memory limits were split across disjoint controllers with poor visibility into IO pressure. In modern cgroup v2 (the default across all modern Linux distributions and managed Kubernetes services like EKS and GKE), memory pressure is tracked holistically using Pressure Stall Information (PSI). Because cgroup v2 strictly enforces unified hierarchies, attempting to solve memory bloat by tweaking swapiness or running arbitrary drop_caches commands inside containers is prohibited. The container lacks root privileges, and the kernel manages memory eviction globally.

The Calricon Production Playbook: Bulletproofing Kubernetes Memory

Step 1: Adopt cgroup v2 Memory QoS (memory.high Throttling)

In modern Kubernetes (v1.27+ alpha/beta, standard in v1.36+), enable the MemoryQoS feature gate. Instead of configuring only the catastrophic memory.max hard limit, Kubernetes programs the cgroup v2 memory.high throttle limit:

  • When memory usage crosses memory.high, the kernel does NOT kill the process.
  • Instead, the kernel throttles the container's processes and aggressively forces background page cache reclaim, slowing down the allocating threads while keeping the service alive and serving traffic.
  • This transforms fatal exit code 137 crashes into transient, graceful latency bumps that resolve automatically once the I/O burst concludes.

Step 2: Stop Setting Hard Memory Limits on Burstable Microservices

For stateless microservices, setting resources.limits.memory equal to resources.requests.memory is an anti-pattern that creates artificial, brittle fragility:

  • Configure resources.requests.memory accurately based on your application's true peak RSS heap requirements under load.
  • Omit resources.limits.memory entirely (or set it to 2x-3x the request limit to provide generous headroom for transient page caching).
  • Rely on the Kubernetes Kubelet eviction threshold (memory.available < 100Mi) to evict pods gracefully if a node experiences genuine hardware memory exhaustion, rather than allowing isolated page cache spikes to kill pods inside unconstrained nodes.

Step 3: Implement Direct I/O and POSIX Cache Advisories

If your application inherently reads or writes massive files (such as database engines, backup tools, or ETL pipelines), stop allowing the Linux kernel to blindly buffer everything in page cache:

  • Use Direct I/O (O_DIRECT): Bypass the kernel page cache entirely when reading or writing large blobs directly to storage volumes.
  • Call posix_fadvise: In languages like C, Go, or Python, inform the kernel after streaming a file chunk: fadvise(fd, offset, len, POSIX_FADV_DONTNEED). This instructs the kernel to immediately evict the read pages from the cache, preventing page cache accumulation from ever threatening container limits.

Step 4: Enable Container Swap on cgroup v2 Nodes

In early Kubernetes, enabling swap was strictly forbidden. In modern Kubernetes running on cgroup v2, swap support is mature and configurable per pod. Configure limited swap allocations on worker nodes so that when an application experiences a temporary spike in memory allocations, the kernel can swap out truly idle anonymous pages to disk, providing a vital buffer that prevents sudden OOM kills while memory reclaim runs.

The Architectural Verdict: Memory is a Spectrum, Not a Concrete Box

Treating container memory as a rigid glass box that shatters the microsecond a byte overflows is a legacy operational mindset. Memory is a dynamic, fluid hierarchy spanning application heap, active buffers, kernel caches, and persistent backing stores. Understand your page cache footprint, embrace cgroup v2 Memory QoS, and stop letting the Linux kernel murder healthy workloads.

Advertisement
Talk to Calricon