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.
- 1Find the kill record in the kernel log and read the full task table it prints
- 2Identify the killed process, the memory it was using, and its oom_score_adj
- 3Determine the scope: global OOM, cgroup/unit memory limit, or a container limit
- 4Establish whether the killed process was the cause or a bystander
- 5Check whether swap exists and whether it was exhausted
- 6Look for the real growth: a leak, an unbounded cache, a runaway query, or a batch job
- 7Apply the correct control: a unit MemoryMax, a fixed application limit, or a scheduling change
- 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
# 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).logResult=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.
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 table —
pid,rss,oom_score_adj,namefor every process. Sort byrssmentally: 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
# 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.maxmemory.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.
# 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 -10Correlate 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
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
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 MemoryCurrentMemoryHigh 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:
# 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=-500Step 7: Restore 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.eventsThe incident is closed when oom_kill in memory.events
stops incrementing under representative load — not when the
process comes back.
Common patterns
| Symptom | Likely cause | Resolution |
|---|---|---|
| Database killed, host has spare RAM | cgroup/unit MemoryMax too low | Raise the unit limit; check memory.events |
| Largest healthy service killed repeatedly | A smaller process spikes and the killer picks the biggest victim | Find the spiker; set OOMScoreAdjust on it |
| Kills only during a nightly window | Batch job or backup allocating unbounded | Bound the job with MemoryMax, or reschedule |
| Steady growth over days, then a kill | Application leak | Escalate with heap evidence; a restart is a workaround, not a fix |
MemAvailable fine, high memory pressure | Cache thrash or unbounded page cache pinning | Read /proc/pressure/memory; tune the workload |
| sshd or the agent killed | Nothing protected them | Set negative OOMScoreAdjust on management units |
| Kills stopped after adding swap, latency now awful | Swap is masking a leak | Fix the leak; swap only delayed the kill |
Knowledge check
Knowledge check · 4 questions
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?
Q2. Why is the OOM-killed process often not the process responsible for the incident?
Q3. Setting OOMScoreAdjust=-1000 on a service that keeps getting killed is a reasonable fix.
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.