DevOps in Production · Containers Under Load
Restart Loop at Peak Traffic
Runtimes that don't respect the container's memory limit get OOMKilled under load. Size heap to the limit, set requests from data, and alert on restarts.
Scenario
Example team: a Java order service moves from VMs with 8 GB RAM to Kubernetes pods with limits.memory: 1Gi. The JVM flags were copied from the VM setup: -Xmx2g. Load tests passed with small data sets.
What you see in production
At the evening peak pods restart every few minutes. kubectl describe pod shows OOMKilled, exit code 137. Each restart drops in-flight orders and leaves fewer pods for the remaining traffic, so the others die faster. The JVM never logged an OutOfMemoryError: the kernel killed it first.
The naive code
containers:
- name: orders
image: registry.example.com/orders:9a1c3e2
env:
- name: JAVA_OPTS
value: "-Xmx2g" # heap alone may grow past the container limit
resources:
limits:
memory: 1Gi
Reproduce it
Container memory is heap plus everything else: thread stacks, metaspace, direct buffers, the JIT and native libraries. The kernel kills the container when the total crosses the limit, regardless of the runtime's own heap setting:
LIMIT_MB = 1024
NON_HEAP_MB = 300 # metaspace, thread stacks, code cache, direct buffers
def peak_usage(max_heap_mb, live_data_mb):
heap = min(max_heap_mb, live_data_mb * 2.2) # the GC lets heap grow well above live data before collecting
return heap + NON_HEAP_MB
for label, max_heap in [("-Xmx2g", 2048), ("MaxRAMPercentage=65", int(LIMIT_MB * 0.65))]:
for live in [150, 300, 450]:
used = peak_usage(max_heap, live)
state = "OOMKilled (137)" if used > LIMIT_MB else "ok"
print(f"{label:20} live data {live:3} MB -> container uses {used:6.0f} MB of {LIMIT_MB}: {state}")
Output
-Xmx2g live data 150 MB -> container uses 630 MB of 1024: ok -Xmx2g live data 300 MB -> container uses 960 MB of 1024: ok -Xmx2g live data 450 MB -> container uses 1290 MB of 1024: OOMKilled (137) MaxRAMPercentage=65 live data 150 MB -> container uses 630 MB of 1024: ok MaxRAMPercentage=65 live data 300 MB -> container uses 960 MB of 1024: ok MaxRAMPercentage=65 live data 450 MB -> container uses 965 MB of 1024: ok
Root cause
The JVM (and Node.js's V8, .NET, Go) size their heap from flags or from what memory they detect. A fixed -Xmx2g in a 1 GiB container lets the heap grow beyond the limit, and non-heap memory comes on top. The kernel's OOM killer then sends SIGKILL; there's no graceful shutdown and no Java exception. Restarts under load cascade, because the surviving pods receive more traffic.
The fix
- Size the runtime to the container: for the JVM use
-XX:MaxRAMPercentage=60to75(container-aware since Java 10), for Node.js--max-old-space-sizebelow the limit, for .NET theGCHeapHardLimitPercentsetting, and for GoGOMEMLIMIT. - Set requests and limits from measurements: observe real usage (p95/p99 of working set) under production-like load, set
requests.memorynear typical usage andlimits.memorywith headroom. Many teams set requests equal to limits for memory. - Load test with realistic data volumes, not just request rates.
- Add capacity first during an incident (more replicas or a higher limit), then fix the sizing.
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=65 -XX:+ExitOnOutOfMemoryError"
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { memory: 1Gi }
Trade-offs
- A smaller heap means more frequent garbage collection; tune with GC logs rather than guessing.
- Generous limits waste node capacity; tight ones risk kills. Vertical Pod Autoscaler recommendations help find the middle.
-XX:+ExitOnOutOfMemoryErrorturns a sick JVM into a clean restart, which is good only if you have enough replicas.
Detect it in production
- Alert on container restarts and
OOMKilledreasons (kube_pod_container_status_last_terminated_reason), not just on pod availability. - Track memory working set as a percentage of the limit per container.
- GC metrics (heap after collection) show whether live data is growing (a leak) or the heap is just sized wrong.
Key points
- The kernel enforces container memory limits with SIGKILL (exit 137), not a runtime exception.
- Configure runtimes relative to the container limit; remember non-heap memory.
- Set requests and limits from measured usage, and alert on restarts.
Practice questions
What does exit code 137 with reason OOMKilled mean?
Answer
The container exceeded its memory limit and the kernel's OOM killer sent SIGKILL (128 + 9 = 137). The process gets no chance to shut down or log a runtime error.
How do you size the JVM heap for a container?
Answer
Use container-aware percentage flags such as -XX:MaxRAMPercentage (for example 60-75%) instead of a fixed -Xmx, leaving room for metaspace, thread stacks and direct memory.
What's the equivalent for Node.js?
Answer
Set --max-old-space-size (in MiB) below the container limit, leaving room for buffers and native memory.
Open in the app to mark lessons complete, take notes and track your progress.