BlueByte
OOMKilled / exit code 137Fixed

Kubernetes: container terminated with OOMKilled (exit code 137)

By Haneul SeoUpdated September 17, 20266 min

Hi, it's BlueByte. A pod runs fine for twenty minutes, restarts, runs again, restarts again, and kubectl describe pod shows Reason: OOMKilled with Exit Code: 137. The application log ends mid-sentence, because nothing in the app chose to exit — the kernel killed it. We'll walk through what the status means, the three different things that end in the same 137, the commands that tell them apart, the fix for each, and how to confirm the pod stays up.

What OOMKilled and 137 are telling you

Exit code 137 is 128 + 9: the process died from signal 9, SIGKILL. The OOMKilled reason narrows that to the kernel's out-of-memory killer. It shows up in three shapes depending on when you look:

NAME            READY   STATUS      RESTARTS   AGE
memory-demo-2   0/1     OOMKilled   1          24s
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
    Ready:          False
    Restart Count:  5

The third shape is CrashLoopBackOff in kubectl get pods with the block above under Last State. All three are the same event. The Kubernetes docs put the mechanism in one sentence: "memory limits are enforced by the kernel with out of memory (OOM) kills", and add that the kill happens when the kernel detects pressure, so a container can sit above its limit briefly before it dies. Nothing is broken in Kubernetes here; it did what the limit asked.

Three different killers that end in the same status

Three causes, three fixes.

The container's own limit is too small. The limit is the memory cgroup's ceiling. A service whose real working set is 900 MiB under limits.memory: 512Mi is killed every time traffic pushes it over. Watch for the typo the docs call out: memory: 400m means 400 millibytes — 0.4 bytes — when the author meant 400Mi.

The runtime sized its heap without looking at the limit. A JVM started with -Xmx1g inside a 768Mi container dies as soon as the heap grows, because heap plus metaspace plus thread stacks exceed the cgroup. Node has the same shape with --max-old-space-size. A leak belongs here too: usage climbs for hours, then hits the ceiling.

The node ran out, not the container. With no limit, the container is only killed when the whole node is short. The kubelet sets oom_score_adj per QoS class — Guaranteed −997, BestEffort 1000, Burstable in between based on the request — so a pod with no requests is the first thing the kernel's oom_killer picks.

Find out which one you have before you change anything

Read the last termination and the resources in one go:

kubectl get pod api-7d9f -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.lastState.terminated.reason}{"\t"}{.lastState.terminated.exitCode}{"\t"}{.restartCount}{"\n"}{end}'
kubectl get pod api-7d9f -o jsonpath='{.spec.containers[*].resources}'
api	OOMKilled	137	5
{"limits":{"memory":"512Mi"},"requests":{"memory":"512Mi"}}

Then check live usage. kubectl top needs metrics-server; Metrics API not available means that add-on is missing:

kubectl top pod api-7d9f --containers
POD        NAME   CPU(cores)   MEMORY(bytes)
api-7d9f   api    120m         498Mi

Usage creeping toward the limit before each restart means cause one or two. No limit on the container, plus a Warning event of reason SystemOOM on the node — the kubelet records "System OOM encountered, victim process: java, pid: 1234" — means the node was the one out of memory:

kubectl get events -A --field-selector reason=SystemOOM

Fix 1: set the limit from the measured working set, with headroom

Watch kubectl top through a normal peak, set the limit about 25–30% above the highest value, and set the request to what the pod needs at rest. Requests decide scheduling; limits decide the kill line:

resources:
  requests:
    memory: "768Mi"
  limits:
    memory: "1Gi"

Two details from the docs: set only a limit and Kubernetes copies it into the request, so the scheduler reserves the full limit; and suffixes are case-sensitive — Mi mebibytes, M megabytes, m millibytes.

Fix 2: make the heap fit inside the limit

Modern JVMs are container-aware: the default maximum heap is 25% of the container's memory (-XX:MaxRAMPercentage defaults to 25), read from the cgroup ceiling, not the node's RAM. Raise the percentage rather than hard-coding -Xmx, and the heap follows the limit:

env:
  - name: JAVA_TOOL_OPTIONS
    value: "-XX:MaxRAMPercentage=75.0"

For Node, --max-old-space-size is in MiB and bounds only the old generation, so leave room below the limit:

env:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=768"
resources:
  limits:
    memory: "1Gi"

If usage still climbs without bound, you have a leak, and a larger limit only delays the kill — take a heap dump near the high-water mark and fix the app.

Fix 3: when the node was the one out of memory

Give the pod requests so it stops being BestEffort. Requests equal to limits make it Guaranteed, with oom_score_adj −997, at the back of the kernel's list. It still dies if it alone exceeds its limit — that is cause one — but it stops paying for a neighbour's leak.

kubectl get pod api-7d9f -o jsonpath='{.status.qosClass}'

Guaranteed is the value you want afterwards. If many pods on the node are BestEffort, a LimitRange with default requests fixes the whole namespace at once.

A real case: a Java API killed every forty minutes after a limit cut

A team cut limits.memory on an API from 2Gi to 1Gi to pack more pods per node. From then on it restarted about every forty minutes with OOMKilled. kubectl top pod --containers read 990Mi just before each restart, and the deployment still carried JAVA_TOOL_OPTIONS=-Xmx1536m from the 2Gi days — a heap allowed to grow past the new ceiling. They replaced the flag with -XX:MaxRAMPercentage=70.0, set the request to 768Mi, and kept the 1Gi limit. The next rollout held at about 740Mi under load and Restart Count stayed at 0 all week.

Check it end to end and keep it from coming back

Verify with the same commands you diagnosed with, a while after the fix:

kubectl get pod -l app=api -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[0].restartCount}{"\n"}{end}'
kubectl top pod -l app=api --containers

A restart count that stops climbing and usage that plateaus below the limit is the proof. To prevent a repeat: put a LimitRange in every namespace so no pod runs without requests, load-test before cutting a limit, alert on the kubelet's container_oom_events_total metric so you see the first kill rather than the fiftieth, and treat usage that grows for days as a leak to fix, not a limit to raise.

How this differs from Evicted and from a plain 137

Evicted is the kubelet, not the kernel: when the node's memory.available signal drops under an eviction threshold, the kubelet ends pods itself, the pod status reads Evicted, and the message says "The node was low on resource: memory". Which pods go first depends on requests and usage, not a cgroup ceiling, so the fix is cause three's — set requests — plus more room on the node. A 137 without OOMKilled (Reason: Error) is a SIGKILL from somewhere else, most often the kubelet force-stopping a container that ignored SIGTERM past its grace period after a failed liveness probe. Next time you see 137, read the reason first: OOMKilled sends you to memory, Error sends you to probes and shutdown handling.

Related questions

The container was killed while kubectl top showed usage under the limit. Why?

kubectl top shows the working set at the last metrics scrape, not a running maximum; a burst that crosses the limit between two scrapes still gets killed. Also check whether the node itself ran out — a SystemOOM event on the node means the kernel picked your container even though it never reached its own limit.

Does raising the limit fix a memory leak?

It delays the next kill. If usage grows steadily for hours and never plateaus, a bigger limit only buys time; capture a heap dump near the limit and fix the allocation that keeps growing.

Why is the JVM using far less than the limit and still getting killed?

The heap is only part of the JVM's footprint — metaspace, thread stacks, direct buffers, and the code cache all count against the cgroup. Leave headroom: a MaxRAMPercentage around 70–75 is safer than 90, and check for native memory with the -XX:NativeMemoryTracking flag if the gap is large.

Is OOMKilled the same as Evicted?

No. OOMKilled is the kernel killing one container that exceeded its cgroup limit, or one it chose during a node-wide OOM. Evicted is the kubelet ending pods when the node's memory.available signal crosses an eviction threshold; the pod status reads Evicted and the message says the node was low on resource: memory.

I set only limits.memory and the pod became Pending with Insufficient memory.

When a limit is set without a request, Kubernetes copies the limit into the request, so the scheduler reserves the full limit on a node. Set a smaller request explicitly if the pod normally idles well below its limit.

References

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

More in this category

detected dubious ownershipFixed

Git: fatal: detected dubious ownership in repository

Since the CVE-2022-24765 fix in Git 2.35.2, Git refuses to read a repository whose working tree or .git directory is owned by a different user than the one running the command. It shows up in containers, CI jobs, sudo sessions and shared drives. Fix the ownership if the repo should be yours, or add the exact path to safe.directory in your global config — never in the repo's own config, which Git ignores for this.

Git
failed calling webhookFixed

Kubernetes: Internal error occurred: failed calling webhook

An admission webhook sits in front of your write, the API server could not get an answer out of it, and failurePolicy: Fail turned that silence into a rejection. The tail of the message is the whole diagnosis: context deadline exceeded means the call went nowhere, no endpoints available means nothing is running, and an x509 line means the API server does not trust the webhook's certificate. Each has a different fix, and none of them is your manifest.

Kubernetes
1205Fixed

MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

A statement waited the full innodb_lock_wait_timeout for a row lock another transaction is still holding, and gave up. sys.innodb_lock_waits names the blocking session and hands you the KILL statement, and a blocking_query of NULL means the blocker is idle on an open transaction. The detail most retry loops get wrong: by default only the timed-out statement is rolled back, so your transaction is still open and still holds every lock it took earlier.

MySQL
MISCONFFixed

Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk

Reads keep working and every write is rejected, because the last background save failed and stop-writes-on-bgsave-error defaults to yes. The log names the real cause — no space, a dir the redis user can't write, a read-only mount at rename time, or fork failing with Cannot allocate memory. Fix the cause, run one BGSAVE, and writes come back on their own with no restart: rdb_last_bgsave_status flips from err to ok. Setting stop-writes-on-bgsave-error no restores writes instantly but leaves the snapshot broken, so treat it as a deliberate trade, not the fix.

Redis
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryFixed

Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (exit 134)

The V8 heap has its own ceiling, derived from system memory and the Node release, and it is often far below the RAM you have; when a build or server reaches it, V8 aborts with FATAL ERROR: Reached heap limit and exit code 134. Read the real limit with v8.getHeapStatistics().heap_size_limit, then raise it with --max-old-space-size (in MiB) or NODE_OPTIONS for a large workload, size it below the cgroup limit inside containers, and use --heapsnapshot-near-heap-limit to catch a leak in a long-running process. Exit 137 with no FATAL ERROR line is a container kill, not this.

Node.js
exec /docker-entrypoint.sh: exec format errorFixed

Docker: "exec format error" when the container starts — wrong-platform image, no emulator, or a script with no shebang

The container exits on its first instruction with exec format error — the kernel's ENOEXEC, meaning the file exists but cannot be executed here. In practice that is an image built on one CPU architecture (an Apple-silicon Mac produces linux/arm64) and run on another (an x86_64 server) with no QEMU handler registered in binfmt_misc, or an entrypoint script whose first line is not a shebang. uname -m, docker image inspect and ls /proc/sys/fs/binfmt_misc tell the causes apart; the fix is an explicit docker buildx build --platform (or a manifest list for both), QEMU registration or --platform when you mean to emulate, and a #!/bin/sh line for the script.

Docker