Skip to main content
RunBook Academy

← All runbooks in Linux

medium riskservice affecting~30 min

Runbook: Investigate an OOM kill

1 · Prerequisites

Confirm every item is in place before any state change.

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · Confirm an OOM kill actually happened - not every unexpected exit is one
  • · Capture the kernel log before it rotates: journalctl -k --since "1 hour ago" > /var/tmp/oom-<date>.log
  • · Establish whether the kill was global (host out of memory) or cgroup-scoped (a unit hit its own limit)
  • · Record the time of the kill and correlate with deploys, cron jobs, and traffic
  • · Confirm whether the host is a cluster node before restarting anything

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Find the kill record in the kernel log and read the full task table it prints
  2. 2Identify the killed process, the memory it was using, and its oom_score_adj
  3. 3Determine the scope: global OOM, cgroup/unit memory limit, or a container limit
  4. 4Establish whether the killed process was the cause or a bystander
  5. 5Check whether swap exists and whether it was exhausted
  6. 6Look for the real growth: a leak, an unbounded cache, a runaway query, or a batch job
  7. 7Apply the correct control: a unit MemoryMax, a fixed application limit, or a scheduling change
  8. 8Restore service and verify memory headroom under real load

4 · Verification

Confirm the procedure actually fixed the problem.

  • The killed service is running and answering its own health check
  • MemoryCurrent for the unit is well below MemoryMax under normal load
  • No further oom-kill records appear in the kernel log
  • Available memory (MemAvailable) has a documented margin, not just "it is up"
  • The monitoring alert cleared on its own

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • If a MemoryMax was set too low and caused new kills, raise or remove it and reload the unit
  • If swap was added, it can be removed with swapoff followed by removing the fstab entry
  • If an application tuning change made throughput worse, revert to the recorded previous value

6 · Escalation

When the runbook isn't enough, contact:

  • · Repeated OOM kills of the same process with a stable workload: escalate to the application team as a suspected leak, with the kill records attached
  • · The kernel, not a userspace process, is consuming the memory (large Slab or unaccounted): escalate to the platform team
  • · OOM on a database host: escalate to the data team before changing any memory setting
  • · OOM on a cluster node causing resource migration: follow the cluster incident runbook in parallel

An OOM kill is the kernel choosing a victim to save the host. The process that died is frequently not the process that caused it. This runbook reads the evidence the kernel left behind, rather than guessing from which service is missing.

Step 1: Confirm it was an OOM kill

Read-only / Safejournalctl -k
# The kill records themselves
sudo journalctl -k --since "2 hours ago" --no-pager \
| grep -iE 'out of memory|oom-kill|Killed process|oom_reaper'

# systemd's own view, per unit
journalctl -u myapp --no-pager | grep -i 'oom\|Main process exited'
systemctl show myapp -p Result -p ExecMainStatus

# Capture it before it rotates
sudo journalctl -k --since "2 hours ago" > /var/tmp/oom-$(date -u +%Y%m%dT%H%M%SZ).log

Result=oom-kill on the unit is the unambiguous signal. A process that simply exited non-zero is a different incident — use the failed-service runbook.

Step 2: Read the kill record properly

The kernel prints a full task table before it kills. That table is the whole diagnosis and it is almost always skipped.

Read-only / Safeoom task table
sudo journalctl -k --since "2 hours ago" --no-pager \
| grep -A80 'invoked oom-killer'

Read four things from it:

  • invoked oom-killer: gfp_mask=..., order=..., oom_score_adj=N — the process that triggered the allocation failure. Often not the victim.
  • The task tablepid, rss, oom_score_adj, name for every process. Sort by rss mentally: the largest consumers are the story.
  • Killed process <pid> (<name>) total-vm:..., anon-rss:..., file-rss:... — the victim and its actual resident size.
  • Memory cgroup out of memory — if this line is present, it was a cgroup OOM, not a host OOM. That is a completely different fix.

Step 3: Establish the scope

Read-only / Safecgroup memory
# Host-wide picture
free -h
grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree|Committed_AS' /proc/meminfo

# Per-unit accounting and limits
systemd-cgtop --order=memory -n 1
systemctl show myapp -p MemoryCurrent -p MemoryMax -p MemoryHigh -p MemorySwapMax

# The cgroup's own OOM counters - non-zero means it hit its ceiling
cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
cat /sys/fs/cgroup/system.slice/myapp.service/memory.max

memory.events has an oom and an oom_kill counter. A rising oom_kill with MemAvailable healthy on the host is conclusive: this is a limit problem, not a capacity problem.

Step 4: Victim or culprit?

The OOM killer scores by oom_score, which is driven mostly by resident memory and adjusted by oom_score_adj. It kills the biggest thing it is allowed to kill. That is often a database or a JVM sitting at its normal steady-state size, while the actual cause is a small process that suddenly allocated a lot.

Read-only / Safecurrent scores
# Biggest resident consumers right now
ps -eo pid,ppid,rss,vsz,comm,args --sort=-rss | head -15

# What the kernel would kill next
for p in $(ps -eo pid --no-headers | head -200); do
printf '%6s %5s %s\n' "$p" \
  "$(cat /proc/$p/oom_score 2>/dev/null)" \
  "$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null | cut -c1-60)"
done | sort -k2 -rn | head -10

Correlate the kill time with what changed: a deploy, a cron job, a report query, a traffic spike, a backup run. The task table’s rss column at the moment of the kill tells you which process was abnormal relative to its usual size.

Step 5: Swap, and why “just add swap” is not the answer

Read-only / Safeswap state
swapon --show
free -h
grep -E 'pgmajfault|pswpin|pswpout' /proc/vmstat
cat /proc/pressure/memory

/proc/pressure/memory is the honest signal. A host with memory free but high some avg10 is already thrashing; adding swap makes it thrash for longer before killing something, which is usually worse for a latency-sensitive service than a fast, clean kill.

Swap earns its place for cold anonymous pages on a general host. It does not rescue a leak, and on a low-latency database host it commonly makes the incident longer and less obvious.

Step 6: Apply the right control

Configuration changeunit memory limits
sudo systemctl edit myapp.service
# [Service]
# MemoryHigh=3G          # throttle and reclaim above this
# MemoryMax=4G           # hard ceiling; exceeding it triggers a cgroup OOM
# MemorySwapMax=0        # do not let this unit swap

sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl show myapp -p MemoryHigh -p MemoryMax -p MemoryCurrent

MemoryHigh is the one to reach for first: it applies reclaim pressure and slows the cgroup down instead of killing it, which turns a hard failure into a visible slowdown you can alert on.

Protect what must survive rather than only capping what must not grow:

Configuration changeoom protection
# Make the batch job the preferred victim (higher = more likely killed)
sudo systemctl edit batch-import.service
# [Service]
# OOMScoreAdjust=500

# Discourage killing the database (lower = less likely)
sudo systemctl edit postgresql.service
# [Service]
# OOMScoreAdjust=-500

Step 7: Restore and verify

Service impact possiblerestart and verify
sudo systemctl start myapp.service
systemctl is-active myapp.service
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost/healthz

# Headroom, not just "it is up"
systemctl show myapp -p MemoryCurrent -p MemoryMax
grep MemAvailable /proc/meminfo
cat /sys/fs/cgroup/system.slice/myapp.service/memory.events

The incident is closed when oom_kill in memory.events stops incrementing under representative load — not when the process comes back.

Common patterns

SymptomLikely causeResolution
Database killed, host has spare RAMcgroup/unit MemoryMax too lowRaise the unit limit; check memory.events
Largest healthy service killed repeatedlyA smaller process spikes and the killer picks the biggest victimFind the spiker; set OOMScoreAdjust on it
Kills only during a nightly windowBatch job or backup allocating unboundedBound the job with MemoryMax, or reschedule
Steady growth over days, then a killApplication leakEscalate with heap evidence; a restart is a workaround, not a fix
MemAvailable fine, high memory pressureCache thrash or unbounded page cache pinningRead /proc/pressure/memory; tune the workload
sshd or the agent killedNothing protected themSet negative OOMScoreAdjust on management units
Kills stopped after adding swap, latency now awfulSwap is masking a leakFix the leak; swap only delayed the kill

Knowledge check

Knowledge check · 4 questions

  1. Q1. PostgreSQL was OOM-killed. `free -h` shows 40 GB available on the host, and the kernel log contains "Memory cgroup out of memory". What do you change?

  2. Q2. Why is the OOM-killed process often not the process responsible for the incident?

  3. Q3. Setting OOMScoreAdjust=-1000 on a service that keeps getting killed is a reasonable fix.

  4. Q4. Which of these are evidence you should capture before restarting the killed service? Select all that apply.

Passing score: 75%. Answers are checked in this browser.

References

  1. proc(5) - /proc/meminfo and oom_score_adj
  2. systemd.resource-control(5)