Symptom
Pods restart repeatedly, and kubectl get pods shows a rising RESTARTS count with CrashLoopBackOff or OOMKilled. Raising the memory limit helped for a day, then it came back. Before you raise it again, find out which of these three problems you have.
| Evidence | Problem | Fix direction |
|---|---|---|
Reason: OOMKilled, usage near the container limit | The container reached its own limit | Tune the runtime or raise the limit |
Evicted pods, MemoryPressure on the node | The node ran short of memory | Fix requests and overcommit |
Exit 137 without OOMKilled | SIGKILL from elsewhere, such as a grace period timeout | Fix probes and shutdown |
Step 1: read the termination record
kubectl describe pod <pod> -n <ns> | grep -A8 "Last State"
kubectl get pod <pod> -n <ns> -o jsonpath=\
'{range .status.containerStatuses[*]}{.name}{" "}{.restartCount}{" "}{.lastState.terminated.reason}{" "}{.lastState.terminated.exitCode}{"\n"}{end}'
Exit code 137 means 128 + 9: the process received SIGKILL. Only the reason field tells you it was the kernel OOM killer. In a multi-container pod, check which container died; it's often a sidecar.
Step 2: check for node-level pressure
kubectl get events -A --field-selector reason=Evicted --sort-by=.lastTimestamp | tail -20
kubectl describe node <node> | sed -n '/Conditions:/,/Addresses:/p'
kubectl describe node <node> | sed -n '/Allocated resources:/,/Events:/p'
kubectl top node
If memory limits add up to well over 100% of allocatable while requests look modest, the node is overcommitted. Several pods can grow at once, and pods using more than their request are evicted first. Unexpected kernel kills also appear as node events with reason OOMKilling.
With Container Insights:
KubeEvents
| where TimeGenerated > ago(24h)
| where Reason in ("OOMKilling", "Evicted", "BackOff")
| summarize Count = count() by Reason, Namespace, Name, Computer
| order by Count desc
Step 3: look at memory from inside the container
On cgroup v2 nodes (current AKS Linux images), these files show what the kernel enforces:
kubectl exec -n <ns> <pod> -c <container> -- sh -c '
echo "limit: $(cat /sys/fs/cgroup/memory.max)"
echo "current: $(cat /sys/fs/cgroup/memory.current)"
cat /sys/fs/cgroup/memory.events
grep -E "^(anon|file|shmem) " /sys/fs/cgroup/memory.stat'
oom_killinmemory.eventscounts kills in this cgroup.- A high
anonvalue means heap, stacks and native allocations. This is real application memory. - A high
filevalue is mostly page cache, which can usually be reclaimed. A bigshmemvalue isn't reclaimable, and it includesemptyDirvolumes withmedium: Memory.
Kubernetes evaluates eviction on working set, so kubectl top pod --containers and the graph below show the number that matters:
Perf
| where TimeGenerated > ago(7d)
| where ObjectName == "K8SContainer" and CounterName == "memoryWorkingSetBytes"
| where InstanceName has "<workload-name>"
| summarize P50_MB = percentile(CounterValue, 50) / 1048576,
P99_MB = percentile(CounterValue, 99) / 1048576,
Max_MB = max(CounterValue) / 1048576
by bin(TimeGenerated, 1h), InstanceName
| order by TimeGenerated asc
A steady climb with sawtooth drops at each restart points to a leak. A flat line with sudden spikes points to a large request, batch job or startup burst.
Step 4: check that the runtime knows its limit
Older runtimes don't read cgroup v2 limits correctly and size their heap from node memory. That's a frequent cause after a node image upgrade moves the cluster to cgroup v2.
| Runtime | Check | Setting |
|---|---|---|
| Java | Use a build that understands cgroup v2 (Java 15+, or 11.0.16+ / 8u372+) | -XX:MaxRAMPercentage=70 |
| .NET | Use .NET 5 or later | DOTNET_GCHeapHardLimitPercent (hex value) |
| Node.js | The V8 heap may not follow the cgroup limit on older versions | --max-old-space-size=<MB> |
| Python | No heap limit exists | Use tracemalloc, and watch worker counts |
Verify what the JVM sees:
kubectl exec -n <ns> <pod> -- java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|UseContainerSupport"
Leave room for non-heap memory. Metaspace, threads, direct buffers and JNI allocations all count toward the limit, so a heap of 100% of the limit will always be killed.
Step 5: exit 137 without OOMKilled
The kubelet sends SIGKILL if a container ignores SIGTERM for longer than terminationGracePeriodSeconds. Failing liveness probes restart the container in the same way. Check the events:
kubectl get events -n <ns> --field-selector involvedObject.name=<pod> --sort-by=.lastTimestamp
Look for Unhealthy and Killing. A memory-starved JVM stuck in garbage collection often fails liveness probes first, so one problem can look like the other.
The fix
Set the memory request equal to the limit for important services. That removes overcommit for this container and makes it a much later eviction target:
resources:
requests:
cpu: "500m"
memory: "2Gi"
limits:
memory: "2Gi"
Size it from the P99 working set plus a margin, not from a guess. To get a recommendation without automatic changes, enable the Vertical Pod Autoscaler in recommendation mode:
az aks update --name <cluster> --resource-group <rg> --enable-vpa
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: api-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: api
updatePolicy:
updateMode: "Off"
Read the result with kubectl describe vpa api-vpa.
Warning
Raising a limit on an overcommitted node moves the problem from one pod to the whole node. Check
Allocated resourcesand add capacity if the sum of requests no longer fits.
Prevent it next time
- Alert on restarts with reason
OOMKilledand on working set above 85% of the limit. - Use a
LimitRangeso namespaces can't deploy containers without memory requests. - Capture a heap dump on OOM (
-XX:+HeapDumpOnOutOfMemoryErrorfor Java) to a mounted volume. - Related: node disk and eviction pressure and the AKS day-2 cheat sheet.