Kubernetes · advanced
How to test Kubernetes memory behavior before OOMKilled
Memory pressure in Kubernetes is observable and testable before the kernel terminates a workload. This guide shows how to design a verification run that exercises the cgroup memory boundary, distinguishes OOM from throttling, and produces reproducible evidence of behavior under pressure.
The symptoms
- •Container exits with reason OOMKilled while node and pod metrics show available memory, suggesting the limit was reached before the workload visibly degraded.
- •memory.working_set_bytes plateaus near the configured limit while RSS in the container stays lower, indicating reclaimable cache or page cache pressure.
- •Latency rises and p99 spikes appear before exit, consistent with reclaim activity rather than allocation failure.
- •No node-level OOM event in dmesg despite container termination, pointing at the cgroup boundary rather than node pressure.
- •Restart count increments rapidly after a memory.boundary test workload is applied, even though requests were sized headroom.
Likely causes
- •Working set is larger than the limit because of cache, heap fragmentation, or per-thread arenas retained by the runtime.
- •Requests sized below working set, so the scheduler never reserves enough memory and reclaim begins immediately under load.
- •QoS class is BestEffort or Burstable without a limit matching the request, so the cgroup can grow until it is killed.
- •Sporadic allocations from background goroutines, JIT, or glibc malloc arenas that push past the steady state observed in normal traffic.
- •Sidecar or shared cgroup memory accounting that aggregates usage from more than the application container.
First ten minutes
- 01Confirm the failing pod and container names from the controller and capture the exit reason field from the most recent terminated state.
- 02Record the configured resources.limits.memory and resources.requests.memory for the container, then record the QoS class derived from those values.
- 03Pull the cgroup memory.events counters for the container's cgroup path to confirm a kill event tied to the limit rather than the node.
- 04Compare memory.working_set_bytes, memory.usage_bytes, and memory.rss_bytes from the kubelet metrics endpoint over the last hour.
- 05Note any throttling or reclaim signals in container_memory_failures_total and memory.stat's inactive_file and active_file fields before declaring OOM.
Evidence to collect
- •The terminated container's last exit status, reason, and finished-at timestamp from the pod's previous instance.
- •The container cgroup memory.events low and high counters, and memory.peak before the kill.
- •memory.working_set_bytes, memory.rss_bytes, memory.cache_bytes, and memory.failures.total over a window that spans the incident.
- •QoS class and the exact requests and limits for memory, including any LimitRange defaults applied by the namespace.
- •Node-level allocatable, available, and pressure stall information for the same window to rule out node pressure.
Where to look
- •The kubelet's read-only /metrics resource on the node, scoped to the container label set, for cgroup-backed memory metrics.
- •The container cgroup directory under the kubelet's cgroup root, specifically memory.events, memory.peak, memory.current, and memory.stat.
- •The pod status field .status.containerStatuses[*].lastState.terminated.reason, scoped to the namespace and pod name.
- •The namespace LimitRange and ResourceQuota objects, which can silently override the limits observed at runtime.
- •The events emitted on the pod and node for FailedKillPod, FailedCreate, or Evicted, which surface the boundary that fired.
Diagnostic steps
- 01Compare the observed peak usage in memory.peak against the configured limit to decide whether the boundary was hit cleanly or exceeded by a burst.
- 02Inspect memory.stat's total_cache and total_inactive_file to estimate reclaimable cache and separate working set from RSS.
- 03Cross-check the QoS class against requests and limits: Guaranteed requires requests equal limits, Burstable permits a higher limit, BestEffort sets neither.
- 04Read memory.events.high to confirm a cgroup high boundary trip versus memory.events.low for memory.low pressure reclaim signals.
- 05Reproduce by driving a controlled allocator up to a fraction of the limit and capture the same metric set, then compare the shape of the curve.
- 06Decide between a limit raise, a request raise, or a workload change based on whether the working set, the cache, or the burst caused the kill.
Common mistakes
- •Treating OOMKilled as a node-level event when memory.events.high is the real signal and the node has ample available memory.
- •Conflating memory.working_set_bytes with RSS, which hides cache and can lead to overprovisioning when cache is the culprit.
- •Raising the limit without verifying QoS, which can convert a BestEffort pod into a Burstable pod and change eviction behavior.
- •Ignoring LimitRange defaults that override the manifest, producing a smaller limit than what the deployment author intended.
- •Trusting average memory metrics over peak memory metrics, which hides the burst that crossed the boundary.
Safe fixes
- •Reproduce with a canary workload that allocates a fixed fraction of the limit and reports working set, peak, and RSS for comparison.
- •Set resources.requests.memory to a value at or above the observed working set, then re-measure to confirm reclaim no longer triggers at idle.
- •Tune the runtime allocator to release arenas or slabs during quiescent periods, then re-run the canary to confirm a lower steady-state RSS.
- •Isolate sidecars into their own containers with independent limits so the application cgroup reflects only the application.
- •Add a memory high watermark below the limit and observe memory.events.high to detect reclaim before a kill becomes inevitable.
Prove the fix
- 01A canary run sustains target load for the soak window with memory.peak remaining below the limit and memory.events.high stable across the run.
- 02The pod's terminated.reason field does not contain OOMKilled across at least N consecutive restarts under the same load profile.
- 03memory.working_set_bytes remains above the request and below the limit with a stable margin, and memory.stat's cache fraction does not spike before exit.
- 04A synthetic over-pressure run that exceeds the previous peak still exits with OOMKilled, demonstrating the boundary remains enforced rather than silently disabled.
- 05QoS class is documented for the canary and matches the operational expectation, so eviction and priority semantics are preserved after the change.
Prevention and next steps
- •Track memory.peak alongside memory.working_set_bytes in dashboards so the boundary is visible before any kill occurs.
- •Run a periodic canary that exercises a fraction of the limit and emits working set and peak to a shared metric stream.
- •Enforce a namespace LimitRange that matches the application's steady-state working set to prevent silent overcommit.
- •Alert on memory.events.high increments per container rather than only on OOMKilled, so reclaim pressure is visible early.
- •Document the chosen request, limit, and QoS class together so future sizing decisions preserve the verification contract.
Safe commands and checks
kubectl get pod <pod-name> -n <namespace> -o jsonpath='{.status.containerStatuses[*].lastState.terminated.reason}'
kubectl get pod <pod-name> -n <namespace> -o jsonpath='{.spec.containers[*].resources}'
kubectl describe limitrange -n <namespace> <limitrange-name>
cat /sys/fs/cgroup/system.slice/<cgroup-path>/memory.events
cat /sys/fs/cgroup/system.slice/<cgroup-path>/memory.peak
cat /sys/fs/cgroup/system.slice/<cgroup-path>/memory.stat | grep -E 'total_cache|total_rss|total_inactive_file'
kubectl get --raw /api/v1/namespaces/<namespace>/pods/<pod-name>/metrics
kubectl top pod <pod-name> -n <namespace> --containers