DeepConcepts

Kubernetes / resource management / cgroups / memory

Memory Limits, OOMKill and Exit Code 137

The misconception

That a memory limit behaves like a CPU limit — a ceiling the kernel enforces by slowing you down, or at worst an allocation failure the runtime can catch. Memory is not compressible, so there is nothing to throttle: the process is killed outright, with no stack trace and no chance to handle it. Two further beliefs follow from it and are just as wrong. That the pod moves to a bigger node — it does not; the container restarts in place, forever, as CrashLoopBackOff. And that the memory *request* buys you protection — it is written to no kernel file at all, and only changes an oom_score_adj number and where the scheduler puts you.

15 min

A CPU limit slows your container down. A memory limit kills it. There is no middle setting, because there is nothing to slow down: the kernel cannot give a process nine tenths of a page. When the container's memory charge reaches the limit and the kernel cannot free enough to continue, it picks a process inside that container and sends it SIGKILL. No exception, no stack trace, no chance to shed load. Exit code 137, and the container starts again from zero on the same node.

The Kubernetes documentation says this, in one sentence people read past: memory limits "are enforced by the kernel with out of memory (OOM) kills" and are "enforced reactively", while CPU limits are "a hard limit the kernel enforces". Those two clauses describe opposite mechanisms. A CPU limit becomes a CFS quota — a budget that refills every 100 milliseconds, and spending it early means being stopped until it refills. A memory limit becomes one number in one file, memory.max, and crossing it is terminal.

Below is a container running for five minutes against a 1 GiB limit. The application holds 320 MiB of live data — the objects it genuinely needs at any moment — and the JVM is started with -Xmx960m, which looks like a sensible 64 MiB of margin. Watch it die anyway, then drag limits.memory and see which failures the limit fixes and which it only relocates.

what is running in the container

Five minutes of wall clock at one-second steps. Each second the runtime allocates, garbage collects if it has a collector, and the kernel charges everything the container touches — anonymous pages plus page cache — to one cgroup. When the charge reaches memory.max the kernel reclaims what it can and then invokes the OOM killer inside the cgroup. Component sizes are a plausible model of a real runtime, not a measurement of one; the decisions — what is charged, what is reclaimable, who gets killed, what the container does next — follow the kernel's and the kubelet's actual rules.

container restarts
memory.events oom_kill
peak charge vs limit
headroom at peak
first kill at
QoS · oom_score_adj
memory.current against memory.max — one bar per second

Bar height is the cgroup's charge as a fraction of the limit; the top of the plot is memory.max. below the limit · the kernel is reclaiming to stay under it · reclaim failed, OOM killer ran · container not running, waiting out its restart back-off

What was charged at the worst second

Everything on this list is charged to the same cgroup. The heap is one line of it.

At the defaults the container is killed 51 seconds in, and again, and again: four restarts in five minutes, with the gaps growing 10, 20, 40, 80 seconds. The heap never overflowed. The JVM was given 960 MiB and its committed heap peaked at 833, so -Xmx was never reached and no OutOfMemoryError was thrown. What crossed the limit was the sum: 833 MiB of heap, plus 48 thread stacks at a megabyte each, plus 72 MiB of metaspace and code cache, plus 46 MiB of collector bookkeeping, plus 44 MiB of the VM's own native memory. That is 1043 MiB against a limit of 1024. The ledger prints the arithmetic. Nothing in -Xmx covers the lines below the first one.

The request is not a reservation

Drag resources.requests.memory across its entire range. Not one number in the simulation moves except the QoS readout. That is not a simplification — it is the whole behaviour of the field on a default cluster.

The kubelet writes exactly one memory number into the container's cgroup: the limit, as memory.max on cgroup v2 or memory.limit_in_bytes on v1. The request is written nowhere. There is no kernel file that says "this container is entitled to 512 MiB", and no mechanism that keeps those 512 MiB available for you once the node fills up. The request does three things, all of them outside the container:

  • It is what the scheduler adds up. A node is full when the sum of requests reaches allocatable, so the request decides which node you land on and nothing else about placement.
  • It sets oom_score_adj, which biases the node-level OOM killer, the one that runs when the machine itself is out of memory. The kubelet's formula is in pkg/kubelet/qos/policy.go: −997 if the pod is Guaranteed, 1000 if BestEffort, and otherwise 1000 − (1000 × containerMemoryRequest) / nodeMemoryCapacity, floored at 3. A 512 MiB request on a 16 GiB node gives 969.
  • It decides the QoS class together with the limit, which is what the eviction ranking uses. That is the subject of the next lesson, and it is a node-level mechanism, not a container-level one.

None of those help the container in the simulation, because the kill in the simulation is a cgroup OOM, not a node OOM. The kernel's oom_badness() makes the distinction explicit. For a memory cgroup OOM it sets totalpages to mem_cgroup_get_max() — your own limit — rather than the machine's memory, and every process in your container carries the same oom_score_adj. An identical constant added to every candidate changes no ranking. Your Guaranteed pod's −997 does not protect it from itself.

There is one way to make the request mean something to the kernel, and whether you already have it depends on your version. The MemoryQoS feature gate was alpha and off by default from v1.22 through v1.36; in pkg/features/kube_features.go on release-1.37 it gains a second entry, {Version: 1.37, Default: true, PreRelease: Beta}, so from v1.37 it is on unless you turn it off. Check yours rather than assuming — the behaviour below is the same either way, but which of your clusters is doing it is not. With the gate enabled the kubelet sets memory.high for Burstable containers at requests + 0.9 × (limits − requests), and with memoryReservationPolicy: TieredReservation it also sets memory.min to the request for Guaranteed pods and memory.low for Burstable ones. Tick MemoryQoS in the simulation at the defaults: the container is still killed, at the same second. memory.high throttles allocation and applies reclaim pressure; the kernel documentation says plainly that going over it "never invokes the OOM killer". It buys latency, not survival. It helps a container with reclaimable pages and does nothing for one whose growth is anonymous memory it is still using.

Your runtime does not know how big its container is, unless it was told

Select JVM, container default and leave everything else alone. The kills stop. The log fills instead with java.lang.OutOfMemoryError: Java heap space, and the container keeps running the whole five minutes. You changed nothing about the container, the limit or the data. You changed which of two completely different deaths you get.

Since JDK 10, backported to 8u191, the JVM reads the cgroup limit under -XX:+UseContainerSupport, which is on by default, and sizes the heap as a percentage of it. That percentage is MaxRAMPercentage, and its default in HotSpot is 25.0. So a 1 GiB limit means a 256 MiB maximum heap, and a live set of 320 MiB simply does not fit. You get a Java exception with a stack trace, thrown by a process that is alive and using a third of its container.

Now drag the limit to 2048 MiB. Everything is fine: the heap ceiling moves to 512 MiB, the live set fits, peak charge is 696 MiB. Note what just happened. The limit is an input to your program's memory use, not only a bound on it. Keep dragging — at 4096 MiB the same 320 MiB of data sits in a container whose peak charge is 1229 MiB, because the collector has been told it may let the heap reach a gigabyte before collecting. Raising a limit to stop OOM kills works, and it silently raises what every replica actually consumes.

Switch back to JVM, -Xmx set by hand, which is what most teams do precisely to avoid that. The default here is -Xmx960m against a 1024 MiB limit: 64 MiB of margin, which feels generous. The ledger shows where it goes.

  • 48 MiB of thread stacks. One megabyte per thread is the -Xss default on 64-bit Linux, and 48 threads is a small servlet pool. Set the limit to 1536 and drag threads / workers from 48 to 256: headroom falls from 359 MiB to 99. Of the 260 MiB that vanished, 208 is stack and the other 52 is per-thread native structures. Nobody sizing that pool thought they were spending a quarter of a gigabyte.
  • 72 MiB of metaspace and code cache. Class metadata and JIT-compiled code live outside the heap and are not bounded by -Xmx at all. Metaspace is unbounded by default; it is capped only if you set -XX:MaxMetaspaceSize.
  • 46 MiB of collector bookkeeping at that heap size — card tables, remembered sets, mark bitmaps — which grows with the heap you configured, not the heap you use.
  • Native allocation outside all of the above: the VM's own C heap, glibc malloc arenas, direct byte buffers from any NIO or Netty code path. Direct buffers are capped by -XX:MaxDirectMemorySize, whose default is the maximum heap size all over again.

That is the arithmetic behind the most-upvoted version of this question on Stack Overflow, "Java using much more memory than heap size", at 115,000 views. The rule that falls out of it: -Xmx is not a container budget. It is one line of a container budget, and on a service with a large thread pool it can be well under half of it. The same accounting problem shows up one layer up in a Spark executor, where the overhead fraction exists exactly because someone has to pay for these lines.

Select Go and drag threads / workers from 2 to 256. The total barely moves — a goroutine starts with an 8 KiB stack, so 256 of them cost two megabytes where 256 Java threads cost 256. Go's exposure is the other direction: with the default GOGC=100 the collector runs when the heap reaches twice the live set, so a container holding 320 MiB of live data needs 640 MiB of heap plus overhead before a collection is even attempted. The Go runtime does not read your cgroup limit for this.

GOMEMLIMIT exists to tell it, and the slider labelled -Xmx / GOMEMLIMIT is that variable while Go is selected. Push it to its maximum, 4096, so that it is far above the container limit and therefore not binding — that is the same as not setting it at all — and raise live data to 600 MiB. The container is killed at 0:43 and four times over. Now bring GOMEMLIMIT back to 960: no kills, 56 MiB of headroom, same data, same limit. The collector simply ran more often.

Two cautions on that. The garbage collector guide is explicit that the limit is soft: "the Go runtime makes no guarantees that it will maintain this memory limit under all circumstances; it only promises some reasonable amount of effort." And its own recommendation is to leave 5–10% of headroom below the container limit, because GOMEMLIMIT governs memory the runtime knows about and there is always some it does not.

Note the asymmetry with CPU. Go 1.25 made GOMAXPROCS default to the cgroup CPU limit, and the JVM has read the CPU quota since JDK 10. For memory, only the JVM reads the limit, only for the heap, and only to a quarter of it.

Who dies, and what survives

Select Python, gunicorn, set threads / workers to 96 — which is 16 gunicorn workers — and untick memory.oom.group. The kernel kills a process 26 times over the five minutes and RESTARTS stays at zero. This is the failure that wastes the most time, because every dashboard a team normally looks at says the pod is healthy.

The kernel's OOM killer picks a process, not a container. Its ranking function oom_badness() is resident pages plus page tables plus swap, with oom_score_adj scaled by totalpages/1000 added on. In a gunicorn container the master process holds almost nothing and each worker holds its own copy of its share of the data, so the largest worker always outranks the master. The kernel kills a worker. The master notices a dead child and forks a replacement. PID 1 never died, so the container never exited, so the kubelet has nothing to report — the pod's restart count does not move and there is no OOMKilled anywhere in kubectl describe. The only evidence is a counter in memory.events and a line in the node's kernel log. This is the practical content of Kubernetes issue 69676, "Log something about OOMKilled containers", open since 2018 with 123 reactions.

Tick memory.oom.group back on and the same run reports one restart and 17 kills — one group kill, in which all sixteen workers and the master died in the same second, and the container therefore exited and was reported. memory.oom.group is a cgroup v2 file whose kernel documentation says the cgroup is "treated as an indivisible workload by the OOM killer" and that all its tasks "are killed together or not at all". Kubernetes 1.32 made the kubelet set it on container cgroups by default; the escape hatch is the kubelet option singleProcessOOMKill: true, which is the only permitted value on cgroup v1 because v1 has no group kill. So the answer to "why did my multi-process container get partially killed" is very often "because the node is still on cgroup v1, or on a kubelet older than 1.32".

When PID 1 does die, watch what the kubelet does, because it is the opposite of what the situation seems to call for. The container is restarted in place: same pod, same node, same cgroup, same limit, empty heap. Nothing reschedules. Nothing consults the node's free memory, which may be enormous — this is the "OOMKilled with apparently plenty of memory left in node" question, at 39,000 views, and the answer is that the limit is enforced per-cgroup and the node was never involved.

So the loop is deterministic. The restart resets the warm-up, the application reloads the same working set, and it crosses the same threshold at roughly the same age. The gaps between attempts are the kubelet's crash loop back-off, which starts at 10 seconds and doubles to a ceiling of 300; the constants are initialCrashLoopBackOff = 10s and MaxCrashLoopBackOff in pkg/kubelet/kubelet.go, and the counter is forgotten after 600 seconds without a restart. An alpha gate, ReduceDefaultCrashLoopBackOffDecay, lowers those to 1 second and 60. Count the restarts in the simulation at the defaults: four in five minutes, killed at 0:51, 1:52, 2:58 and 4:29, restarted at 1:01, 2:12 and 3:38, and the fifth attempt would not begin until 80 seconds after the last one. What looks like a system slowly recovering is a system taking longer and longer to fail the same way.

Contrast that with eviction, which is the other way a pod loses to memory and behaves in the way people expect an OOMKill to. An eviction is the kubelet's decision, not the kernel's; it fires when the node is short of memory, it terminates every container in the pod, it sets the pod's phase to Failed, and the replacement pod is usually created somewhere else. Two mechanisms, two verdicts, and the word "OOM" is used for both. If kubectl describe pod says OOMKilled with exit code 137, that is the kernel and your limit. If it says Evicted with a message about memory.available, that is the kubelet and your node.

Where each fix stops working

Raise the limit. At the defaults, 1216 MiB is enough and the kills stop. This works and is usually right, and it has two boundaries. The first is the one above: if the runtime sizes itself from the limit, the limit is not headroom, and raising it raises usage with it. The second is that you have moved the failure from your container to the node — memory you are permitted to use but did not request is memory that is not reserved for you anywhere, so under node pressure you become an eviction candidate the moment you exceed your request.

Set requests equal to limits. Drag requests up to 1024 with the limit at 1024: the QoS readout flips to Guaranteed and oom_score_adj to −997. The kill happens at exactly the same second. Guaranteed protects you from other pods and from the node; it does not protect you from your own arithmetic, because the constant is applied equally to every process in your own cgroup and cancels out of the ranking.

Read files and blame the cache. Leave the limit at 1024, select JVM, container default, and set file reads to 40 MiB/s. From 0:20 onward the charge sits at exactly 100% of the limit, the bars stay amber for the rest of the run, and nothing is ever killed — the log shows the kernel reclaiming a few tens of megabytes of page cache every second and the allocation succeeding every time. Page cache is charged to your cgroup — that is why memory.current and every "container memory usage" graph run hot on any container that touches a file — but clean page cache is reclaimable, so the kernel drops it and the allocation proceeds. Your dashboard shows 100% of the limit and your container is fine.

That is also why an alert on memory usage is close to useless and an alert on the kill is exact. It is worth knowing where the exception is: page cache that cannot be dropped — dirty pages waiting on a slow disk, pages in a tmpfs or an emptyDir: {medium: Memory} volume, or a file the container has locked — is charged like anonymous memory and will kill you. An emptyDir memory volume in particular is a RAM disk billed to your container's limit, and it is the most common surprising line on this list.

The node-level version of the same confusion is Kubernetes issue 43916, "kubelet counts active page cache against memory.available", the most-reacted memory issue in the repository at 223 reactions and 144 comments, open since 2017. The eviction signal subtracts inactive_file from the node's usage but not active_file, so file pages that are recently touched and perfectly droppable are counted as used, and the kubelet can start evicting pods to reclaim memory the kernel would have handed back for free.

Give the runtime the right number. This is the fix that addresses the mechanism. Put the limit back to 1024, keep -Xmx set by hand, and drag -Xmx from 960 down. At 832 it still dies. At 768 the container survives all five minutes with 50 MiB of headroom, and at 704 with 117. You did not give the container more memory. You stopped promising the heap memory that the stacks, the metaspace and the collector had already spent.

Its boundary is visible three drags away. Keep pulling -Xmx down, to 320: now the heap is smaller than the live set and you are back to java.lang.OutOfMemoryError. Between "the container dies with no stack trace" and "the application dies with one" there is a window, and the width of that window is the limit minus everything that is not heap. If that window is negative — a large thread pool against a small limit — no value of -Xmx works, and the honest answers are fewer threads, a smaller -Xss, or a bigger limit.

Checking it on a real pod

Start with the verdict, because it is unambiguous and takes one command:

kubectl describe pod api-7d9f -n prod | grep -A5 "Last State"
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Thu, 13 Aug 2026 09:14:02 +0000
      Finished:     Thu, 13 Aug 2026 09:14:53 +0000

Reason: OOMKilled with exit code 137 means the kernel SIGKILLed a process in that container's cgroup. Subtract Started from Finished: if it is tens of seconds every time, you have a working set that does not fit and no amount of restarting will change it. If it is hours or days, you have a leak, and the limit is a detector rather than a cause.

Then get the numbers the kubelet does not report, from inside the container on cgroup v2:

  • cat /sys/fs/cgroup/memory.max → the limit in bytes, or max. On v1 it is /sys/fs/cgroup/memory/memory.limit_in_bytes.
  • cat /sys/fs/cgroup/memory.current → what is charged right now, page cache included.
  • cat /sys/fs/cgroup/memory.stat → the breakdown that makes the ledger above real. anon is the part that can kill you, file is mostly not, slab and kernel_stack are charged to you as well and appear in no language runtime's accounting.
  • cat /sys/fs/cgroup/memory.eventsoom counts the times an allocation was about to fail; oom_kill counts processes actually killed; oom_group_kill counts group kills. A rising oom_kill with a flat pod restart count is the silent-worker-death case exactly.
  • cat /proc/1/oom_score_adj → confirms which QoS class the kubelet actually assigned, without trusting the YAML you think you applied.

In Prometheus the useful signal is the kill, not the usage:

increase(kube_pod_container_status_restarts_total[1h]) joined against kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}

For sizing, the number to compare against the limit is container_memory_working_set_bytes, which is cAdvisor's usage minus inactive_file — that is, with the obviously-droppable page cache removed. Do not use container_memory_usage_bytes, which includes all page cache and will tell you that every container reading a file is nearly full. Take the maximum, not the average:

max_over_time(container_memory_working_set_bytes{container!=""}[7d]) / on(pod,container) kube_pod_container_resource_limits{resource="memory"}

A ratio consistently above roughly 0.9 is a pod that will be killed by any slightly unusual day. A ratio below 0.3 is a pod paying for memory it never touches — and if it is Guaranteed, paying for it out of the scheduler's allocatable pool as well.

Finally, read the node. The kernel writes a full report on every cgroup OOM and it names the victim, which is the one thing kubectl will not tell you:

Memory cgroup out of memory: Killed process 4213 (java) total-vm:4192384kB,
  anon-rss:1180416kB, file-rss:23044kB, shmem-rss:0kB, UID:1000
  pgtables:2604kB oom_score_adj:969
memory: usage 1048576kB, limit 1048576kB, failcnt 812

anon-rss is the process's own anonymous memory, and comparing it to the limit on the next line tells you immediately whether one process was the whole story or whether the container died of the sum of its parts.

A Java service has requests.memory: 2Gi, limits.memory: 2Gi and -Xmx1800m. It is OOMKilled every few hours. Heap dumps taken just before death show 900 MiB of live objects and no leak. A colleague raises both requests and limits to 4Gi. What happens?

Next: what Guaranteed actually guarantees, and why the class that decides eviction order is not an input to the eviction decision — QoS classes.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.