Overview
3 AM. The alert group exploded.
Five pods were OOMKilled and restarted within 10 minutes. The on-call engineer opened Grafana, and every pod’s working_set was still 200MB below its limit—80% utilization. Monitoring looked fine. The pods died anyway.
This is not an isolated case. When I was doing a Kubernetes migration for a new-energy logistics platform, I dealt with this exact pattern—“monitoring is green but pods die”—more than 10 times. The root cause was always the same: the ops team watched exactly one metric, and that metric lies.
Container memory is not a single number. cAdvisor exposes four dimensions of memory metrics, yet most teams alert on container_memory_working_set_bytes alone. The problem: when page cache is already small, working_set at 80% can mean only 5% of truly allocatable memory remains—the 20% headroom you see doesn’t exist.
This article breaks down the four-layer metric blind spots of container memory monitoring and gives 5 production-grade alerting decisions, including complete PromQL recording rules, alert rules, and Grafana queries. This setup ran for 6 months on a 120+ microservice cluster at a new-energy logistics platform, raising OOM alert accuracy from 60% to 95% while cutting false positives from 40% to 5% (related: Alerting Strategy Design: From Noise to Signal).
The Four-Layer Fog of Container Memory Metrics
What cAdvisor exposes
cAdvisor (Container Advisor) is the built-in container monitoring component inside kubelet on each K8s node, exposing Prometheus-format metrics via the /metrics endpoint. On the memory dimension, it exposes four key metrics that map to different cgroup fields:
| Metric | Meaning | cgroup field | Reclaimable? |
|---|---|---|---|
container_memory_usage_bytes | Total container memory (RSS + page cache) | memory.usage_in_bytes | Contains reclaimable parts |
container_memory_working_set_bytes | Working set (usage - inactive_file) | usage - inactive_file | Partially reclaimable |
container_memory_rss | Process resident memory | total_rss | Not reclaimable |
container_memory_inactive_file | Idle file cache | inactive_file | Reclaimable |
The relationship between the four metrics:
container_memory_usage_bytes = container_memory_rss
+ active_file
+ container_memory_inactive_file
+ inactive_anon
+ active_anon
container_memory_working_set_bytes = container_memory_usage_bytes
- container_memory_inactive_file
In plain words:
usage_bytesis every byte of memory the container consumes, including reclaimable page cacheworking_set_bytesis the portion the container truly cannot release after deducting reclaimable cacherssis the physical memory occupied by processes, with no cache includedinactive_fileis the file-cache pages “not accessed recently,” which the kernel can reclaim when memory gets tight
Which metric should be the alert baseline
Most Prometheus community tutorials recommend container_memory_working_set_bytes as the memory alert baseline. The reasoning: kubelet uses working_set when deciding whether to evict a pod.
That recommendation is correct for node-level eviction. Kubelet evicts pods based on node-level memory.available, which is directly tied to container working set.
But for container-level OOM prediction, working_set has a fatal blind spot—it makes you misjudge the remaining headroom. Let me walk through a real case.
Why working_set Shows 80% but OOM Still Happens
The page cache reclaim time gap
Consider a real scenario. A pod has a memory limit of 1000MiB. At some moment cAdvisor collects:
container_memory_usage_bytes = 990 MiB
container_memory_working_set_bytes = 950 MiB
container_memory_inactive_file = 40 MiB
container_memory_rss = 880 MiB
Grafana shows memory utilization = working_set / limit = 950 / 1000 = 95%. Looks like 50MiB of headroom. The dashboard is green.
But the actual allocatable headroom = limit - usage_bytes = 1000 - 990 = 10 MiB.
What about those 40MiB of inactive_file? The kernel can reclaim them, but reclaim isn’t instant. The next second, a 60MiB memory allocation arrives:
- The kernel tries to reclaim inactive_file, succeeding on 20MiB (not all 40MiB—some pages are transitioning to active and reclaim takes time)
- Actual usage =
990 + 60 - 20= 1030 MiB 1030 > 1000→ cgroup OOM Kill
Monitoring shows 95%, but only 10MiB of real headroom remains. That’s the root cause of “monitoring shows 5% left and still OOM”—the inactive_file deducted from working_set looks like a safety cushion, but that cushion can’t deploy fast enough under a sudden allocation burst.
If you only watch working_set, you can never compute how far OOM actually is.
The fatal sampling interval blind spot
Prometheus scrapes every 15 seconds by default; many clusters use 30 or even 60 seconds. OOM can happen in milliseconds.
A real production timeline looks like this:
Timeline (30s scrape interval):
T+0s: working_set = 800 MiB (80%) ← sample 1
T+30s: working_set = 820 MiB (82%) ← sample 2
T+60s: working_set = 950 MiB (95%) ← sample 3, last one
T+61s: [OOMKilled] ← happens between scrapes
T+90s: working_set = 200 MiB (20%) ← pod restarted, back to zero
The curve in Grafana looks like “jumped from 82% straight to 20%"—the 95% point was missed or averaged away by minute-level aggregation. You think OOM “happened suddenly,” but memory had been growing for a while; the scrape interval was just too coarse to see it.
This is why I recommend deriv(working_set[5m]) for trend detection—even with sparse samples, the trend tells you “memory is growing,” which is far more reliable than absolute values.
cgroup OOM vs node OOM
There’s an even sneakier case: your pod gets killed by the node-level OOM Killer while its own limit is nowhere near.
cgroup OOM (container level) and node OOM (host level) are two independent mechanisms with completely different triggers and victim selection:
| Dimension | cgroup OOM | Node OOM |
|---|---|---|
| Triggered by | Linux cgroup subsystem | Linux global OOM Killer |
| Trigger condition | container usage > container limit | node available memory below threshold |
| Victim | the container process over its limit | oom_score-based victim, could be any process |
| Visible in kubectl | OOMKilled, Exit Code 137 | Evicted, Phase=Failed |
| Prometheus metric | kube_pod_container_status_terminated_reason | kube_pod_status_phase |
Pods without resources.limits.memory, or with limits far beyond node capacity (overcommit), get picked as victims by the node OOM Killer based on oom_score. In that case your container monitoring never fires—the container’s working_set is far from its limit, but the node is already drowning.
To check: kubectl describe node <node-name> and compare Allocatable against the sum of all pods’ requests. If requests sum is close to node Allocatable, add a couple of Burstable pods and node-level OOM is on its way.
Three-axe OOM investigation routine
After a pod is OOM killed, use these three steps to pin down the root cause quickly.
Step 1: Confirm the OOM type
# Check pod status and exit code
kubectl get pod <pod-name> -o jsonpath='{.status.containerStatuses[0].lastState}'
# Example output:
# {"terminated":{"exitCode":137,"reason":"OOMKilled","message":"..."}}
Exit Code 137 = 128 + 9 (SIGKILL). If reason is OOMKilled, it’s container-level cgroup OOM; if Evicted, it’s node-level eviction.
Step 2: Check node kernel logs
# SSH to the node hosting the pod, check dmesg
dmesg -T | grep -i "killed process\|oom"
# Example output:
# [Mon Oct 8 03:01:22 2026] Memory cgroup out of memory: Kill process 12345 (java) score 956
score 956 is the oom_score; the higher it is, the more likely the process gets killed. Note which process got killed—if it’s your business process, it’s container-internal OOM; if another process on the node died, node-level OOM spilled over to your pod.
Step 3: Check pod events and historical metrics
# Pod events (last 1 hour)
kubectl get events -n <namespace> --field-selector involvedObject.name=<pod-name> --sort-by='.lastTimestamp'
# Kubelet-recorded OOM events
kubectl describe pod <pod-name> | grep -A 5 -i "OOM\|Killed"
Combine that with Prometheus history (container_memory_working_set_bytes, container_memory_inactive_file_bytes, container_cpu_cfs_throttled_seconds_total) and replay the 30 minutes before the incident—you can usually identify the direct cause of the OOM.
Practical tip: embed the Prometheus query link directly in alert notifications. We use Alertmanager annotation templates to turn
{{ $labels.pod }}into a Grafana deep link, so the on-call engineer can click and see the pod’s memory curve for the hour before the incident. This small change cut diagnosis time from 30 minutes to 8.
5 Production Decisions for OOM Prediction
Decision 1: Replace the single working_set with a compound condition
My recommendation: don’t just watch working_set / limit. OOM prediction should use a compound condition:
Alert condition = working_set / limit > 0.85
AND
inactive_file < 50MB
Why add the inactive_file < 50MB constraint?
When inactive_file is large (say 500MB), even at 85% working_set the kernel still has plenty of page cache to reclaim, so OOM isn’t imminent. Your pod could run in that state for 24 hours without issue.
But when inactive_file is small (< 50MB), the kernel has almost no reclaimable headroom. Working_set at 85% is genuinely dangerous—the next allocation may directly trigger OOM.
This compound condition kills the biggest source of false positives: misjudging “high cache share but safe” scenarios as “about to OOM.”
In practice at the new-energy logistics platform, adding the inactive_file constraint cut the false positive rate from 40% to 12%.
Measured comparison (120+ microservice cluster, 6 months of data):
Alert strategy False pos. Missed Notes working_set / limit > 0.940% 15% classic strategy, noisy working_set / limit > 0.85 AND inactive_file < 50MB12% 8% added cache constraint usage_bytes / limit > 0.9225% 5% total usage is better but still noisy Compound + trend (final) 5% 3% added 5min slope detection
Decision 2: Thresholds aren’t a single 90%—they’re layered
Most teams set one working_set / limit > 0.9 rule and call it done. Too coarse for production.
I recommend three tiers:
| Tier | Condition | Duration | Action |
|---|---|---|---|
| P3 (Watch) | working_set / limit > 0.75 | 10min | log trend, no alert |
| P2 (Warning) | working_set / limit > 0.85 AND inactive_file < 50MB | 3min | notify on-call group |
| P1 (Critical) | working_set / limit > 0.90 AND inactive_file < 30MB | 1min | phone call |
Why doesn’t P3 alert?
75% working_set is perfectly normal when cache is abundant. A Java app starts with RSS at 60%, add 20% page cache and working_set hits 80%. Alert at that stage and you’ll drive the on-call engineer insane.
P2 adds the inactive_file constraint plus a 3-minute for duration. Three minutes is enough to filter out GC-induced memory jitter—a Java Full GC can push working_set up 10-15% temporarily, but it falls back within 1-2 minutes. With for: 0m, every Full GC triggers an alert.
P1 is real danger. inactive_file below 30MB means the kernel has almost no reclaim headroom; working_set at 90% is the eve of OOM. This tier should page someone directly.
Decision 3: Pre-aggregate with recording rules, don’t let Prometheus compute on the fly
With many containers (120+ microservices × 3-5 containers each = 400+ containers), computing working_set / limit in real time makes Prometheus choke. Every Grafana refresh recomputes the whole thing; CPU spikes and queries time out.
Pre-compute with recording rules so Grafana and alert rules query pre-calculated results:
groups:
- name: container_memory_recording
interval: 30s
rules:
# memory utilization (working_set / limit)
- record: container:memory_utilization:ratio
expr: |
sum by(namespace, pod, container) (
container_memory_working_set_bytes{container!="POD",container!=""}
)
/
sum by(namespace, pod, container) (
container_spec_memory_limit_bytes{container!="POD",container!=""} > 0
)
# total utilization (usage_bytes / limit, includes cache)
- record: container:memory_total_utilization:ratio
expr: |
sum by(namespace, pod, container) (
container_memory_usage_bytes{container!="POD",container!=""}
)
/
sum by(namespace, pod, container) (
container_spec_memory_limit_bytes{container!="POD",container!=""} > 0
)
# inactive_file absolute value
- record: container:inactive_file:bytes
expr: |
sum by(namespace, pod, container) (
container_memory_inactive_file_bytes{container!="POD",container!=""}
)
# memory growth slope (bytes/min)
- record: container:memory_growth_rate:5m
expr: |
deriv(
container_memory_working_set_bytes{container!="POD",container!=""}[5m]
) * 60
These recording rules cut Prometheus query latency from 8 seconds to 200 milliseconds on our cluster. The cost: 400 extra time series every 30 seconds, roughly 2GB of extra storage at a 15-day retention. Compared to the query performance win, that’s a bargain.
Note the critical container_spec_memory_limit_bytes > 0 filter—containers without a limit have this value at 0, and dividing by 0 produces NaN that pollutes the aggregation.
Decision 4: Correlate CPU throttling with memory pressure
This was the biggest pit I ever stepped into.
One 3 AM incident: 3 pods OOMKilled simultaneously. Memory curves showed usage climbing from 60% to 95% in the 5 minutes before. CPU curves showed throttling jumping from 0 to 30% over the same window.
Root cause: upstream traffic spike → CPU limit exceeded → CFS (Completely Fair Scheduler) throttling → requests processed slower → queued requests pile up in memory → memory explodes → OOM.
The CPU-throttle-causes-OOM causal chain is ignored in 90% of monitoring setups. CPU and memory are two independent alert rule sets; the on-call engineer chases the CPU alert, then the memory alert, and almost nobody correlates them.
How CFS works, simply: once a container burns through its CPU quota (replenished every 100ms), the processes get paused until the next 100ms period. While paused, requests that already arrived don’t vanish—they stack up in the application’s memory (request objects, connection buffers, serialization data). Past a point, memory blows up.
The correlation alert rule:
- name: container_combined_alerts
rules:
- alert: CPUThrottleCausingMemoryPressure
expr: |
rate(container_cpu_cfs_throttled_seconds_total{container!="POD",container!=""}[1m]) > 0.1
and on(namespace, pod, container)
container:memory_utilization:ratio > 0.75
for: 5m
labels:
severity: warning
category: combined
annotations:
summary: "CPU throttling + memory pressure: {{ $labels.pod }}"
description: |
Container {{ $labels.container }} in pod {{ $labels.pod }}
is experiencing CPU throttling (>0.1s/s) while memory
utilization exceeds 75%. This combination often leads to
OOM within 10-15 minutes. Check for traffic spikes causing
request backlog.
After this rule went live on the new-energy logistics platform, it caught three “CPU throttle → memory OOM” chain failures 10-15 minutes in advance. The on-call engineer had time to scale out manually or rate-limit, avoiding the OOM.
For resource management settings, see Kubernetes Resource Management: Requests and Limits—if you constantly hit CPU throttling, your limits are probably set too tight.
Decision 5: Trend detection beats absolute thresholds
Absolute thresholds (“alert when memory > 90%”) can’t catch memory leaks. A leak grows 2-3% per day and OOMs after 30 days. Set a 90% threshold and it fires on day 28, leaving 2 days before the kill.
Detect growth trends with a 5-minute slope:
- alert: ContainerMemoryLeakDetected
expr: |
container:memory_growth_rate:5m > 0
and on(namespace, pod, container)
container:memory_utilization:ratio > 0.5
for: 30m
labels:
severity: info
category: trend
annotations:
summary: "Possible memory leak: {{ $labels.pod }}/{{ $labels.container }}"
description: |
Container has shown continuous memory growth for 30+ minutes.
Current utilization exceeds 50%. Investigate for memory leaks
before OOM occurs.
This rule doesn’t need memory at 90% to fire. If memory keeps growing for 30 minutes and utilization exceeds 50%, it alerts. That gives ops a 2-3 day buffer to hunt the leak instead of firefighting after the OOM.
In my practice, this rule caught 4 Java memory leaks 3-5 days early:
- Uncleaned ThreadLocal instances—created per request, never removed
- Database connection pool leak—connections acquired but never returned
- Logging framework cache bloat—DEBUG-level logs buffered in memory
- Kafka consumer backlog causing heap object pile-up—consumption slower than production
Without trend detection, all four leaks end up as 3 AM OOMKilled alerts.
Complete Production Alert Rule Configuration
Combine all 5 decisions into one complete Prometheus alert rule file, deployable with kubectl apply:
groups:
- name: container_oom_prediction
interval: 30s
rules:
# ============ Recording Rules ============
- record: container:memory_utilization:ratio
expr: |
sum by(namespace, pod, container) (
container_memory_working_set_bytes{container!="POD",container!=""}
)
/
sum by(namespace, pod, container) (
container_spec_memory_limit_bytes{container!="POD",container!=""} > 0
)
- record: container:memory_total_utilization:ratio
expr: |
sum by(namespace, pod, container) (
container_memory_usage_bytes{container!="POD",container!=""}
)
/
sum by(namespace, pod, container) (
container_spec_memory_limit_bytes{container!="POD",container!=""} > 0
)
- record: container:inactive_file:bytes
expr: |
sum by(namespace, pod, container) (
container_memory_inactive_file_bytes{container!="POD",container!=""}
)
- record: container:memory_growth_rate:5m
expr: |
deriv(
container_memory_working_set_bytes{container!="POD",container!=""}[5m]
) * 60
# ============ Alert Rules ============
# P2: memory close to OOM (compound condition)
- alert: ContainerMemoryNearOOM
expr: |
container:memory_utilization:ratio > 0.85
and on(namespace, pod, container)
container:inactive_file:bytes < 52428800
for: 3m
labels:
severity: warning
annotations:
summary: "Memory near OOM: {{ $labels.namespace }}/{{ $labels.pod }}/{{ $labels.container }}"
description: |
Working set exceeds 85% of limit with low inactive file cache (<50MB).
OOM risk is high. Check for memory leaks or traffic spikes.
# P1: memory extremely close to OOM (critical)
- alert: ContainerMemoryCritical
expr: |
container:memory_utilization:ratio > 0.90
and on(namespace, pod, container)
container:inactive_file:bytes < 31457280
for: 1m
labels:
severity: critical
annotations:
summary: "Memory critical - OOM imminent: {{ $labels.namespace }}/{{ $labels.pod }}"
description: |
Working set exceeds 90% of limit with very low inactive file cache (<30MB).
OOM expected within minutes. Immediate action required.
# CPU throttling + memory pressure correlation
- alert: CPUThrottleMemoryPressure
expr: |
rate(container_cpu_cfs_throttled_seconds_total{container!="POD",container!=""}[1m]) > 0.1
and on(namespace, pod, container)
container:memory_utilization:ratio > 0.75
for: 5m
labels:
severity: warning
category: combined
annotations:
summary: "CPU throttle + memory pressure: {{ $labels.pod }}"
description: |
CPU throttling combined with high memory usage (>75%).
OOM likely within 10-15 min if traffic continues.
# Memory leak trend detection
- alert: ContainerMemoryLeak
expr: |
container:memory_growth_rate:5m > 0
and on(namespace, pod, container)
container:memory_utilization:ratio > 0.50
for: 30m
labels:
severity: info
category: trend
annotations:
summary: "Possible memory leak: {{ $labels.pod }}/{{ $labels.container }}"
description: |
Continuous memory growth for 30+ min with utilization >50%.
Investigate for memory leaks before OOM occurs.
Alertmanager routing configuration
The four alert levels (Critical, Warning, Combined, Trend) need Alertmanager routing so each level goes to the right channel:
# alertmanager.yml routing config
route:
receiver: default
group_by: ['namespace', 'pod']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
# P1 Critical → phone + WeCom
- matchers: ['severity="critical"']
receiver: phone-call
group_wait: 0s
repeat_interval: 30m
# P2 Warning → WeCom on-call group
- matchers: ['severity="warning"']
receiver: wechat-ops
group_wait: 30s
# Trend → email, non-intrusive
- matchers: ['severity="info"']
receiver: email
group_wait: 5m
repeat_interval: 24h
Key design: Critical uses group_wait: 0s to send immediately without aggregation; Trend’s repeat_interval is 24 hours because memory leaks are chronic—no need for hourly reminders. group_by: ['namespace', 'pod'] collapses multiple alerts for the same pod into one, avoiding alert storms.
Core Grafana queries
The core PromQL queries for the OOM warning dashboard:
# 1. memory utilization (working_set / limit)
container:memory_utilization:ratio
# 2. total utilization (includes cache, closer to real OOM distance)
container:memory_total_utilization:ratio
# 3. inactive_file trend (safe cushion thickness)
container:inactive_file:bytes
# 4. memory growth rate (positive = leak risk)
container:memory_growth_rate:5m
# 5. top 10 containers by memory
topk(10, container:memory_utilization:ratio)
# 6. OOM risk marker (compound condition visualization)
container:memory_utilization:ratio > 0.85
and on(namespace, pod, container)
container:inactive_file:bytes < 52428800
Set two threshold lines on the Grafana panel: 0.85 (Warning) and 0.90 (Critical), viewed alongside the inactive_file panel. When utilization crosses 0.85 while inactive_file drops below 50MB, that’s the OOM prelude. Reading the two together is far more accurate than either alone.
Pitfall Log: Root Causes of False Positives and Missed Alerts
Pit 1: Java JVM heap vs container memory limit
Java apps are the most OOM-prone because the JVM memory model and container memory limits are two different things.
JVM -Xmx sets the heap cap, but the container limit also covers:
- Off-heap memory (Metaspace, Direct Buffer, Thread Stack)
- GC workspace (Full GC needs extra memory for marking and copying)
- JNI-allocated native memory
- Code Cache (JIT-compiled machine code cache)
Recommended: -Xmx = container limit × 0.6. Leave 40% for non-heap. This ratio worked well for Spring Boot apps in our practice. If your app uses heavy Direct Buffers (Netty, gRPC), consider 0.5 or lower.
One incident: a Java app set -Xmx 1800m with a container limit of 2000m. Looked like 200MB of headroom. But Full GC alone needs 200-300MB of working memory, plus Metaspace at 200MB and Direct Buffer at 100MB—total blew past the limit. OOM isn’t the heap filling up; it’s heap + non-heap + GC overhead collectively exceeding the container limit.
Pit 2: Huge requests/limits gap downgrades QoS
# anti-pattern
resources:
requests:
memory: "256Mi"
limits:
memory: "2Gi"
An 8x gap between requests and limits downgrades the pod’s QoS class to Burstable. When node memory is tight, Burstable pods get evicted before Guaranteed ones. And kubelet’s --eviction-hard default (memory.available<100Mi) is node-level—it never considers an individual container’s limit.
During the new-energy logistics platform migration, we mandated Guaranteed QoS (requests = limits) for critical production services; non-critical services could stay Burstable but with requests at least 50% of limits. After that constraint, unexpected evictions from node memory pressure dropped 80%.
Pit 3: HPA scaling speed can’t keep up with memory growth
HPA (Horizontal Pod Autoscaler) scales on CPU, but during a memory leak CPU often doesn’t rise. Your pod’s memory goes from 60% to 95% while HPA stays still—CPU utilization is at 30%.
stabilizationWindowSeconds defaulting to 300 is another trap. Even when scaling triggers, the 5-minute stabilization window means a new pod doesn’t join load balancing until 5 minutes later. In a leak scenario, 5 minutes is enough for the old pod to OOM three times.
My recommendation: memory-sensitive services shouldn’t rely on CPU-based HPA—drive scaling with custom metrics. Use Prometheus Adapter to expose container:memory_utilization:ratio as a custom metric and scale HPA on it. Set the scale threshold at 0.75 instead of 0.85—leave 10% buffer for new pods to start (related: Kubernetes Autoscaling Deep Dive: HPA/VPA/CA).
Pit 4: cgroup v2 metric differences
More clusters are migrating to cgroup v2 (default since K8s 1.25). cgroup v2’s memory statistics granularity and field names differ from v1; with older cAdvisor (< 0.35), some metrics may not be collected.
Key differences:
| Dimension | cgroup v1 | cgroup v2 |
|---|---|---|
| Total usage | memory.usage_in_bytes | memory.current |
| Limit | memory.limit_in_bytes | memory.max |
| Breakdown | memory.stat (multi-line) | memory.stat (multi-line, different field names) |
| OOM events | memory.oom_control | memory.events (includes oom_kill counter) |
cAdvisor 0.37+ handles the adaptation and keeps the same Prometheus metric names (still container_memory_working_set_bytes etc.), but if you read cgroup files directly via node-exporter, watch for the field name mapping changes.
My advice: during a v1 → v2 migration window, use cAdvisor as the collection layer rather than reading cgroup files directly. cAdvisor hides the underneath differences; you only care about its exposed Prometheus metric names. But upgrade cAdvisor to 0.37+, or memory metrics may return 0 on cgroup v2 nodes.
Validation and Tuning
How to validate alert effectiveness
After deploying new alert rules, validate with this flow:
# 1. List OOM events from the past 7 days
kubectl get events -A --field-selector reason=OOMKilled \
--sort-by='.lastTimestamp' | tail -20
# 2. Replay the alert condition in Prometheus
# How many times would NearOOM have fired in the past 7 days?
count_over_time(
(container:memory_utilization:ratio > 0.85
and on(namespace, pod, container)
container:inactive_file:bytes < 52428800)[7d:]
)
# 3. Compare actual OOM count vs alert count
# alerts should be > OOMs (early warning)
# but no more than 5x OOMs (otherwise too noisy)
Tuning parameters
| Parameter | Initial | Tune direction | Impact |
|---|---|---|---|
| working_set threshold | 0.85 | lower → more sensitive | more false pos., fewer missed |
| inactive_file threshold | 50MB | lower → more precise | fewer false pos., more missed |
| Duration | 3min | longer → more stable | fewer false pos., more latency |
| Trend window | 5min | longer → more conservative | slower but more accurate |
| Trend duration | 30min | shorter → faster | more false pos., faster response |
Tuning cadence: observe for 2 weeks after launch, then adjust based on how alerts match actual OOMs. Change one parameter at a time; observe a week before the next change. Change several at once and you’ll never know which one mattered.
Memory limit configuration reference
Different app types need very different memory limit strategies. These are our empirical values from the 120+ microservice cluster:
| App type | requests/limits ratio | -Xmx as % of limit | Key notes |
|---|---|---|---|
| Spring Boot API | 1:1 (Guaranteed) | 60% | leave 40% for Metaspace, Direct Buffer, GC |
| Spring Boot + Netty | 1:1 | 50% | heavy Direct Buffers, leave more room |
| Go microservice | 1:1 | N/A | no JVM, but set GOMEMLIMIT |
| Python/FastAPI | 1:1 | N/A | GC can lag; be conservative with limits |
| Cron Job | 1:2 (Burstable) | 60% | restarts are tolerable; Burstable saves resources |
| Database (MySQL/PG) | 1:1 | N/A | dedicated node, no co-tenancy |
A few principled suggestions:
- Critical services use Guaranteed QoS (requests = limits). When node memory is tight, Guaranteed pods have the highest priority and won’t be evicted easily.
- Go services use the
GOMEMLIMITsoft limit. Go 1.19+ supports theGOMEMLIMITenv var, making GC reclaim more aggressively near the limit. Set it to 90% of the container limit. - Don’t give non-critical services huge limits. A cron job pod with an 8Gi limit, when leaking, can eat the whole node’s memory and take critical services down with it.
- Do memory capacity planning—don’t look at a single pod’s limit; look at the sum of all limits on a node. If limit sum > node memory × 1.5, OOM risk is accumulating. This PromQL computes it fast:
# node overcommit ratio (sum of all pod limits / node allocatable memory)
sum by(node) (
container_spec_memory_limit_bytes{container!="POD",container!=""}
)
/
sum by(node) (
kube_node_status_allocatable_memory_bytes
)
Above 1.0 means overcommit. Occasional 1.2-1.5 is acceptable (not all pods max out at once); above 2.0 is dangerous—when traffic spikes and multiple pods approach their limits simultaneously, node-level OOM is on its way.
Summary
Container memory monitoring is not “watch one working_set metric and call it done.” Core takeaways:
Read the four metrics together: working_set only reflects the “non-reclaimable” part, but OOM triggers on “total usage over limit.” The size of inactive_file determines the cushion thickness between working_set and OOM. Read them together—far more accurate than either alone.
Compound conditions are 3x more accurate:
working_set > 85% AND inactive_file < 50MBcuts false positives 3x versus plainworking_set > 90%, by adding the “cushion thickness” constraint that filters out “high cache but safe” scenarios.CPU throttling indirectly causes OOM: CPU starvation → request pile-up → memory blowup → OOM. This causal chain must be captured by correlated alerts, or you’ll always be “finding the cause” after the OOM.
Trend detection warns 3-5 days early about leaks: don’t wait for 90%. Memory growing for 30 minutes at over 50% utilization deserves attention.
deriv()is the tool for chronic leaks.Recording rules are the precondition for scale: computing ratios for 400+ containers in real time chokes Prometheus. Pre-computation drops query latency from 8 seconds to 200ms at the cost of 2GB storage—a trade worth making.
This system ran for 6 months on a 120+ microservice cluster at a new-energy logistics platform. Before it, we averaged 4-5 “no alert but pods OOM” incidents per month. After, 0-1. Pushing availability from 99.5% to 99.9% was partly built on accurate memory monitoring—an unreliable alerting system makes SLI/SLO talk meaningless.
The ROI of the alerting system says it all in the data. There’s more room to explore in alert automation and self-healing—auto-triggering VPA to adjust limits, chaos-engineering OOM drills, and so on (related: Alert Automation: From Storm to Self-Healing Systems). But get monitoring accurate first; automation after. The order matters.
References & Acknowledgments
This article draws on the following sources during writing. Thanks to the original authors:
- Troubleshoot OOMKilled in AKS clusters — Microsoft Azure official documentation, detailing the difference between OOMKilled and Eviction and how to troubleshoot
- Kubernetes OOM Explained: Memory Composition, Metric Formulas and Intuitive Cases — LizeLiong, deep dive into container memory metric formulas and OOM trigger mechanics
- Key Metrics and Best Practices for K8s Container Monitoring — Alibaba Cloud Developer Community, metric classification and alert threshold recommendations
- A Kubernetes Incident Review Caused by Improper requests/limits Configuration — Tencent Cloud Developer Community, real incident review showing the CPU throttling and OOM correlation
- The Ultimate Guide to OOM Troubleshooting in K8s Production — Leo_Yide, practical Prometheus rules and Grafana queries