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:

MetricMeaningcgroup fieldReclaimable?
container_memory_usage_bytesTotal container memory (RSS + page cache)memory.usage_in_bytesContains reclaimable parts
container_memory_working_set_bytesWorking set (usage - inactive_file)usage - inactive_filePartially reclaimable
container_memory_rssProcess resident memorytotal_rssNot reclaimable
container_memory_inactive_fileIdle file cacheinactive_fileReclaimable

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_bytes is every byte of memory the container consumes, including reclaimable page cache
  • working_set_bytes is the portion the container truly cannot release after deducting reclaimable cache
  • rss is the physical memory occupied by processes, with no cache included
  • inactive_file is 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:

  1. The kernel tries to reclaim inactive_file, succeeding on 20MiB (not all 40MiB—some pages are transitioning to active and reclaim takes time)
  2. Actual usage = 990 + 60 - 20 = 1030 MiB
  3. 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:

Dimensioncgroup OOMNode OOM
Triggered byLinux cgroup subsystemLinux global OOM Killer
Trigger conditioncontainer usage > container limitnode available memory below threshold
Victimthe container process over its limitoom_score-based victim, could be any process
Visible in kubectlOOMKilled, Exit Code 137Evicted, Phase=Failed
Prometheus metrickube_pod_container_status_terminated_reasonkube_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 strategyFalse pos.MissedNotes
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:

TierConditionDurationAction
P3 (Watch)working_set / limit > 0.7510minlog trend, no alert
P2 (Warning)working_set / limit > 0.85 AND inactive_file < 50MB3minnotify on-call group
P1 (Critical)working_set / limit > 0.90 AND inactive_file < 30MB1minphone 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:

  1. Uncleaned ThreadLocal instances—created per request, never removed
  2. Database connection pool leak—connections acquired but never returned
  3. Logging framework cache bloat—DEBUG-level logs buffered in memory
  4. 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:

Dimensioncgroup v1cgroup v2
Total usagememory.usage_in_bytesmemory.current
Limitmemory.limit_in_bytesmemory.max
Breakdownmemory.stat (multi-line)memory.stat (multi-line, different field names)
OOM eventsmemory.oom_controlmemory.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

ParameterInitialTune directionImpact
working_set threshold0.85lower → more sensitivemore false pos., fewer missed
inactive_file threshold50MBlower → more precisefewer false pos., more missed
Duration3minlonger → more stablefewer false pos., more latency
Trend window5minlonger → more conservativeslower but more accurate
Trend duration30minshorter → fastermore 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 typerequests/limits ratio-Xmx as % of limitKey notes
Spring Boot API1:1 (Guaranteed)60%leave 40% for Metaspace, Direct Buffer, GC
Spring Boot + Netty1:150%heavy Direct Buffers, leave more room
Go microservice1:1N/Ano JVM, but set GOMEMLIMIT
Python/FastAPI1:1N/AGC can lag; be conservative with limits
Cron Job1:2 (Burstable)60%restarts are tolerable; Burstable saves resources
Database (MySQL/PG)1:1N/Adedicated node, no co-tenancy

A few principled suggestions:

  1. Critical services use Guaranteed QoS (requests = limits). When node memory is tight, Guaranteed pods have the highest priority and won’t be evicted easily.
  2. Go services use the GOMEMLIMIT soft limit. Go 1.19+ supports the GOMEMLIMIT env var, making GC reclaim more aggressively near the limit. Set it to 90% of the container limit.
  3. 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.
  4. 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:

  1. 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.

  2. Compound conditions are 3x more accurate: working_set > 85% AND inactive_file < 50MB cuts false positives 3x versus plain working_set > 90%, by adding the “cushion thickness” constraint that filters out “high cache but safe” scenarios.

  3. 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.

  4. 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.

  5. 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:

  1. Troubleshoot OOMKilled in AKS clusters — Microsoft Azure official documentation, detailing the difference between OOMKilled and Eviction and how to troubleshoot
  2. Kubernetes OOM Explained: Memory Composition, Metric Formulas and Intuitive Cases — LizeLiong, deep dive into container memory metric formulas and OOM trigger mechanics
  3. Key Metrics and Best Practices for K8s Container Monitoring — Alibaba Cloud Developer Community, metric classification and alert threshold recommendations
  4. A Kubernetes Incident Review Caused by Improper requests/limits Configuration — Tencent Cloud Developer Community, real incident review showing the CPU throttling and OOM correlation
  5. The Ultimate Guide to OOM Troubleshooting in K8s Production — Leo_Yide, practical Prometheus rules and Grafana queries