Kubernetes OOMKilled (exit code 137): causes and fixes
Explore with AI
OOMKilled OOMKilled means the Linux kernel killed a process in the container because the container went over its memory limit, and 137 is the exit code for that SIGKILL (128 + 9). Confirm it with kubectl describe pod, which shows Reason: OOMKilled and Exit Code: 137 under Last State. Then either raise the memory limit to cover the real working set, or bring usage down by fixing a leak or sizing the runtime's heap to fit inside the limit.
OOMKilled shows up in the STATUS column of kubectl get pods for a moment after a container dies, then the pod goes back to Running with one more restart. If it keeps happening, the pod settles into CrashLoopBackOff. In kubectl describe pod the evidence is in the container’s last state:
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
The message is precise. A process in this container asked for more memory than the container’s limit allows, and the kernel killed it. What it can’t tell you is whether the limit is too low, the app is leaking, or the runtime inside the container doesn’t know about the limit at all. This page covers how to tell those apart, and the fix for each.
What OOMKilled and exit code 137 mean
When the kubelet starts a container, it passes the memory limit to the container runtime, which writes it into the container’s Linux cgroup. The resource management docs describe what happens next: if the container tries to allocate more than the limit, the kernel’s out-of-memory subsystem activates and usually stops one of the processes in the container that tried to allocate. If that process is PID 1 and the container can be restarted, Kubernetes restarts it. The container’s last state then shows the reason OOMKilled, and kubectl describe nodes records the kill too, with a line like Warning OOMKilling Memory cgroup out of memory: Kill process 4481 (stress).
The exit code comes from the signal. The OOM killer sends SIGKILL, which is signal 9 and can’t be caught or blocked. The Bash manual gives the convention: a process killed by signal N exits with 128 + N. So 128 + 9 = 137. Because SIGKILL can’t be handled, the app writes no final log line, which is why the previous logs often stop mid-sentence.
Two details of the kill depend on the node:
- cgroup v2 kills the whole container. Since Kubernetes v1.28, on cgroup v2 nodes the kubelet sets
memory.oom.groupon container cgroups, so the changelog says all processes in the cgroup are killed together. A worker process going over the limit takes the main process down with it. - v1.32 added a way back. The kubelet setting
singleProcessOOMKillstops the kubelet from settingmemory.oom.group, so processes are killed one at a time, as on cgroup v1. On cgroup v2 it defaults tofalse.
The memory request plays no part in the kill. The memory task docs say a container can go over its request when the node has memory free, but can never go over its limit.
Reading exit code 137 correctly
Exit code 137 alone doesn’t prove an OOM kill. It means SIGKILL, and three different things send one. Check which you have before you touch a limit.
kubectl describe pod <pod>
kubectl get pod <pod> -o jsonpath='{range .status.containerStatuses[*]}{.name}{" "}{.lastState.terminated.reason}{" "}{.lastState.terminated.exitCode}{"\n"}{end}'
- Reason
OOMKilled, exit code 137. The container hit its own limit. The rest of this page is about this case. - Reason
Error, exit code 137. Something sent SIGKILL without an OOM. When a pod stops, the Pod Lifecycle docs say the kubelet sends the stop signal, waits for the grace period (30 seconds by default), and then the runtime sends SIGKILL to anything still running. A failed liveness probe also kills the container. Here the fix is in graceful shutdown or the probe, and memory limits won’t help. The CrashLoopBackOff page covers the probe case. - Pod status
Failed, reasonEvicted. This is a node problem, covered below.
In a pod with several containers, read the reason for each one. A sidecar such as a log shipper or a proxy can be the container that hits its limit, and its restarts can take the pod’s readiness down with it. The memory task docs describe the pod’s request and limit as the sums of its containers’ values, but each limit is enforced on its own container, so one small sidecar limit is enough to cause OOM kills while the main app has room to spare.
Why the container goes over its memory limit
Cause 1: the limit is below what the app really uses
How to tell it’s yours: the kills happen under normal load, usage climbs to a plateau close to the limit, and the plateau is the same after each restart.
kubectl top pod <pod> --containers
kubectl top needs the metrics-server or another resource metrics API in the cluster, and the docs warn that metrics may be missing for a few minutes after a pod starts. It samples, so a short spike can kill the container without ever showing in top.
Fix:
- Find the real peak. Watch
kubectl top pod --containersunder production load, or read the container memory metric in your monitoring tool over a few days, including the busiest hour. - Set the request to normal usage and the limit above the peak. For a quick change:
Then put the same values in the manifest so the next deploy doesn’t undo them:kubectl set resources deployment/api -c=api --requests=memory=512Mi --limits=memory=1Gicontainers: - name: api image: registry.example.com/api:1.4.2 resources: requests: memory: "512Mi" limits: memory: "1Gi" - Count memory-backed volumes. The kubelet tracks a tmpfs
emptyDiras container memory, so files written there count toward the limit. Give the volume asizeLimitand include it in your sizing. - Check a node can fit it. Pods are scheduled by requests, so a request bigger than any node can offer leaves the pod
Pending.
Cause 2: a memory leak
How to tell it’s yours: usage grows steadily from each start and never levels off, and the time between kills is roughly the same. Raising the limit only stretches that time.
Fix:
- Confirm the shape. Plot the container’s memory since the last restart. A straight line or staircase up to the limit is a leak. A plateau is cause 1.
- Take heap snapshots at two points in the climb and compare them. Node.js can write one as it nears the heap limit with
--heapsnapshot-near-heap-limit; the JVM has-XX:+HeapDumpOnOutOfMemoryError. Point the output at a volume that outlives the container. - Look first at what grows with traffic: unbounded in-memory caches, maps keyed by user or request, listeners that are added and never removed, and buffers held for retries.
- While you fix the code, raise the limit enough to cut how often it restarts, and note the date so the extra headroom gets removed once the leak is gone.
- Ship the fix and compare the memory curve over the same number of hours as before. A curve that now flattens confirms the leak is gone; one that climbs more slowly means there is a second one.
Leaks often arrive with a deploy. If the kills started right after a release, compare that release’s diff with the previous one before you open a profiler: a new cache, a new dependency or a change to how requests are buffered is a good first suspect. kubectl rollout undo deployment/<name> buys you time while you look.
Cause 3: the runtime’s heap isn’t sized to the limit
How to tell it’s yours: the app runs on Node.js, the JVM or another runtime with its own heap limit, and the kill comes with no out-of-memory error from the runtime itself. The runtime thought it had room left; the kernel disagreed.
A runtime that sizes its heap from the machine, or from a fixed default, can grow past the container limit before its garbage collector works hard. Set the heap explicitly and leave room under the limit for everything that isn’t heap: thread stacks, native buffers, code and the runtime itself.
Fix for Node.js:
- Set
--max-old-space-sizein MiB. The Node.js docs say that as usage approaches it, V8 spends more time on garbage collection, and give the example of 1536 on a machine with 2 GiB to leave memory for other uses. - Pass it through
NODE_OPTIONSso it applies whatever the start command is:containers: - name: api image: registry.example.com/api:1.4.2 env: - name: NODE_OPTIONS value: "--max-old-space-size=1536" resources: limits: memory: "2Gi"
Fix for the JVM:
- Use
-XX:MaxRAMPercentage, which sets the heap as a percentage of the memory the JVM sees. The java command docs give a default of 25 percent and an example of 75. - Set it with
JAVA_TOOL_OPTIONS:env: - name: JAVA_TOOL_OPTIONS value: "-XX:MaxRAMPercentage=75" - Keep the percentage well below 100. The JVM also uses memory outside the heap, and all of it counts toward the container limit.
Cause 4: the node ran out of memory
This one looks different, and it needs a different fix. When the node itself runs low, the kubelet evicts whole pods to protect it. The node-pressure eviction docs give the default hard threshold on Linux as memory.available<100Mi. An evicted pod’s phase is set to Failed, its reason is Evicted, and the message comes from the kubelet’s eviction code:
Status: Failed
Reason: Evicted
Message: The node was low on resource: memory. Container api was using 1843Mi, request is 512Mi, has larger consumption of memory.
How to tell it’s yours: the pod status says Evicted, other pods on the same node may fail at the same time, and kubectl describe node <node> shows the MemoryPressure condition.
The kubelet evicts pods whose usage is over their request first, ordered by priority and then by how far over they are. With hard thresholds, it uses a 0 second grace period. If memory drops too fast for the kubelet to act, the kernel’s OOM killer acts on the node. It kills the container with the highest score after the kubelet’s oom_score_adj: -997 for Guaranteed pods, 1000 for BestEffort, and a value in between for Burstable pods based on their request. Containers in low QoS pods that use a lot of memory compared with their requests go first.
Fix:
- Set memory requests on every container so the scheduler doesn’t pack the node past its real capacity.
- For critical workloads, set requests equal to limits on every container. That gives the Guaranteed QoS class, and the docs say those pods are never evicted because of another pod’s usage.
- Reserve memory for the system with the kubelet’s
--kube-reservedand--system-reservedflags, as the docs suggest.
Confirming the OOM kills have stopped
- Watch the restart count:
It should stay flat through your busiest period.kubectl get pod <pod> -w - Watch memory under load:
Usage should level off with clear room below the limit. A line that keeps rising means the leak is still there.kubectl top pod <pod> --containers - Check the last state again.
kubectl describe pod <pod>should show the sameLast Statetimestamp as before your change, or none at all for a new pod.
Keeping OOMKilled from coming back
- Size from data, on a schedule. The Vertical Pod Autoscaler is installed separately. With
updateMode: "Off"it only writes recommendations to the VPA object’s.status, so you can read them and change requests yourself:
Then read them withapiVersion: autoscaling.k8s.io/v1 kind: VerticalPodAutoscaler metadata: name: api spec: targetRef: apiVersion: apps/v1 kind: Deployment name: api updatePolicy: updateMode: "Off"kubectl describe vpa api. - Alert before the kill. Alert when a container’s memory stays above a set share of its limit, and on any restart whose reason is
OOMKilled. - Tie heap flags to limits. When someone changes
limits.memory, the Node or JVM heap setting should change in the same pull request. - Test with production-sized data. Many leaks only show with real traffic and real payload sizes.
Outside Kubernetes the same kill happens in plain Docker. docker run -m sets the limit, with a minimum of 6m, and docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container> prints true 137 after a kill.
Finding the cause of an OOM kill with Polylane
Polylane connects a cluster through an agent you install with Helm, with read-only RBAC limited to get, list and watch. Its agents inspect pods, deployments and nodes and read logs and events, and OOM kills are among the failures it investigates and writes up, starting from the rollout that came before them.
Running on Kubernetes? See how Polylane monitors Kubernetes in production.
Common questions.
Why is the exit code 137?
When a process dies from a signal, the shell convention is 128 plus the signal number. SIGKILL is signal 9, so 128 + 9 = 137. The kernel's OOM killer uses SIGKILL, which the process can't catch, so it gets no chance to log or clean up.
Does exit code 137 always mean out of memory?
No. Any SIGKILL gives 137. Kubernetes also sends SIGKILL when a container is still running after its termination grace period, which defaults to 30 seconds. Only the reason OOMKilled in the container's Last State tells you memory was the cause.
What is the difference between OOMKilled and Evicted?
OOMKilled is the kernel enforcing one container's memory limit, and the kubelet restarts the container in the same pod. Evicted is the kubelet removing a whole pod because the node is short of memory, for example below the default hard threshold of memory.available<100Mi on Linux. An evicted pod is set to Failed and its controller creates a new one.
Does the memory request matter if only the limit triggers the kill?
Yes. The scheduler places pods by their requests, and under node pressure the kubelet evicts pods whose usage is over their request first. Setting requests equal to limits for every container gives the pod the Guaranteed QoS class, which gets an oom_score_adj of -997.
Why does kubectl top show less memory than the limit right before the kill?
kubectl top shows a recent reading from the resource metrics API, so a fast spike between readings may never appear. The docs note the metrics can be missing for a few minutes after a pod starts. Treat top as the steady state and the OOMKilled reason as proof of the peak.
Do all processes in the container die on an OOM kill?
On cgroup v2 nodes, since Kubernetes v1.28, the kubelet sets memory.oom.group, so the kernel kills every process in the container together. From v1.32 the kubelet setting singleProcessOOMKill: true turns that off, so only the chosen process dies, as it did on cgroup v1. On cgroup v2 it defaults to false.
Can a memory-backed emptyDir cause OOMKilled?
Yes. The kubelet counts files in a tmpfs emptyDir (medium: Memory) as container memory, so writing large files there adds to the usage that the limit applies to. Set a sizeLimit on the volume and count it when you size the limit.
How do I see OOM kills in plain Docker?
Run docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container>. It prints true 137 when the kernel killed the container for exceeding the limit set with docker run -m. Docker's minimum for -m is 6m.
Sources
- Assign Memory Resources to Containers and Pods (Kubernetes docs)
- Resource Management for Pods and Containers (Kubernetes docs)
- Pod Lifecycle (Kubernetes docs)
- Node-pressure Eviction (Kubernetes docs)
- Kubelet Configuration (v1beta1) (Kubernetes docs)
- Kubernetes v1.28 changelog
- Kubernetes v1.32 changelog
- eviction helpers.go (Kubernetes source)
- kubectl top pod (Kubernetes docs)
- kubectl set resources (Kubernetes docs)
- Deployments (Kubernetes docs)
- Vertical Pod Autoscaling (Kubernetes docs)
- Command-line API, --max-old-space-size (Node.js docs)
- The java command, -XX:MaxRAMPercentage (Oracle Java SE 21 docs)
- Exit Status (GNU Bash manual)
- signal(7), Linux manual page
- Resource constraints (Docker docs)
- Container state type, OOMKilled (Moby source)
- Kubernetes integration (Polylane docs)
- Polylane documentation
- Polylane full content
Boris Tane is the founder of Polylane. He previously founded Baselime, observability for the future of the cloud, which Cloudflare acquired. At Cloudflare he built and led the Workers observability team.
Related
- Kubernetes CrashLoopBackOff: causes and fixes
What CrashLoopBackOff means in Kubernetes, how to read the exit code and events behind it, and the fix for each common cause, from bad config to failing probes.
- Fix “context deadline exceeded” in Docker and fly deploy
What “context deadline exceeded” means in Docker and fly deploy, how to find the call that timed out, and the fixes for WireGuard, builders and daemons.
- Is It Safe to Give an AI SRE Read Access to Production?
Read access for an AI SRE is a sound first step if you scope it. The risks are secrets, customer data and exfiltration. Here are the controls and a checklist.
- Fix “Durable Object reset because its code was updated”
Why a deploy makes Cloudflare Durable Objects throw “reset because its code was updated”, and how to retry safely, keep clients connected and lose no state.
- Fix “remaining connection slots are reserved” in PostgreSQL
Why Postgres refuses new logins with “remaining connection slots are reserved”, how to find who holds the connections, and how to pool and cap them for good.