DEV Community

Cover image for Debugging Kubernetes OOMKilled: A Step-by-Step Guide
Samson Tanimawo
Samson Tanimawo

Posted on

Debugging Kubernetes OOMKilled: A Step-by-Step Guide

The Dreaded OOMKilled

$ kubectl describe pod api-service-7f8d9c-abc12
...
State:          Terminated
Reason:         OOMKilled
Exit Code:      137
Enter fullscreen mode Exit fullscreen mode

Your container got killed because it used more memory than its limit. Sounds simple. Debugging it is not.

Step 1: Confirm the OOM

# Check pod events
kubectl get events --field-selector involvedObject.name=api-service-7f8d9c-abc12

# Check container status
kubectl get pod api-service-7f8d9c-abc12 -o jsonpath='{.status.containerStatuses[0].lastState}'

# Check node-level OOM events
kubectl describe node <node-name> | grep -A5 'OOMKilling'
Enter fullscreen mode Exit fullscreen mode

Important distinction:

  • Container OOM: Container exceeded its memory limit → K8s kills it
  • Node OOM: Node ran out of memory → kernel OOM killer picks a victim

Step 2: Understand Your Memory Usage

# Current memory usage vs limits
kubectl top pod api-service-7f8d9c-abc12

# Historical memory usage (Prometheus)
curl -s 'prometheus:9090/api/v1/query_range' \
  --data-urlencode 'query=container_memory_working_set_bytes{pod="api-service-7f8d9c-abc12"}' \
  --data-urlencode 'start=2024-03-15T00:00:00Z' \
  --data-urlencode 'end=2024-03-15T12:00:00Z' \
  --data-urlencode 'step=60s'
Enter fullscreen mode Exit fullscreen mode

Key memory metrics:

container_memory_working_set_bytes:  # What K8s uses for OOM decisions
container_memory_rss:                 # Resident Set Size (actual RAM)
container_memory_cache:               # File system cache (reclaimable)
container_memory_usage_bytes:         # Total (includes cache — misleading!)
Enter fullscreen mode Exit fullscreen mode

Always look at working_set_bytes, not usage_bytes. The latter includes cache that the kernel can reclaim.

Step 3: Find the Memory Leak

For Node.js:

// Add heap snapshot endpoint
const v8 = require('v8');
const fs = require('fs');

app.get('/debug/heap', (req, res) => {
  const snapshotStream = v8.writeHeapSnapshot();
  res.json({ snapshot: snapshotStream });
});

// Track memory over time
setInterval(() => {
  const used = process.memoryUsage();
  console.log(JSON.stringify({
    rss: Math.round(used.rss / 1024 / 1024) + 'MB',
    heapUsed: Math.round(used.heapUsed / 1024 / 1024) + 'MB',
    heapTotal: Math.round(used.heapTotal / 1024 / 1024) + 'MB',
    external: Math.round(used.external / 1024 / 1024) + 'MB'
  }));
}, 30000);  // Every 30 seconds
Enter fullscreen mode Exit fullscreen mode

For Python:

import tracemalloc
import linecache

tracemalloc.start(25)  # Keep 25 frames

@app.route('/debug/memory')
def memory_snapshot():
    snapshot = tracemalloc.take_snapshot()
    top_stats = snapshot.statistics('lineno')[:20]
    return jsonify([
        {
            'file': str(stat.traceback),
            'size_mb': round(stat.size / 1024 / 1024, 2),
            'count': stat.count
        }
        for stat in top_stats
    ])
Enter fullscreen mode Exit fullscreen mode

For Go:

import (
    "net/http"
    _ "net/http/pprof"  // Enable profiling endpoints
)

// In main():
go func() {
    http.ListenAndServe(":6060", nil)
}()

// Then: go tool pprof http://localhost:6060/debug/pprof/heap
Enter fullscreen mode Exit fullscreen mode

Step 4: Common Causes and Fixes

1. Memory Limit Too Low

# Check actual usage pattern first
# If peak usage is 450MB and limit is 512MB, that's too tight
resources:
  requests:
    memory: 256Mi  # Based on average usage
  limits:
    memory: 768Mi  # 1.5x peak usage for headroom
Enter fullscreen mode Exit fullscreen mode

2. Unbounded Caches

# Bad: Cache grows forever
cache = {}
def get_user(user_id):
    if user_id not in cache:
        cache[user_id] = db.query(user_id)  # Never evicted!
    return cache[user_id]

# Good: Bounded cache with LRU eviction
from functools import lru_cache

@lru_cache(maxsize=10000)  # Bounded!
def get_user(user_id):
    return db.query(user_id)
Enter fullscreen mode Exit fullscreen mode

3. Connection Accumulation

# Bad: Connections never closed
def process_request():
    conn = create_db_connection()  # Opens connection
    result = conn.query('SELECT ...')
    return result  # Connection leaked!

# Good: Always close connections
def process_request():
    with get_db_connection() as conn:  # Auto-closes
        return conn.query('SELECT ...')
Enter fullscreen mode Exit fullscreen mode

Step 5: Set Up Proactive Alerts

- alert: MemoryApproachingLimit
  expr: |
    container_memory_working_set_bytes 
    / container_spec_memory_limit_bytes > 0.85
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "{{ $labels.pod }} using {{ $value | humanizePercentage }} of memory limit"
Enter fullscreen mode Exit fullscreen mode

Catch it at 85% instead of discovering it at OOMKilled.

If you want AI-powered memory analysis that finds leaks before they crash your pods, check out what we're building at Nova AI Ops.


Written by Dr. Samson Tanimawo
BSc · MSc · MBA · PhD
Founder & CEO, Nova AI Ops. https://novaaiops.com

Top comments (0)