oom_score, dmesg analysis, and targeted cgroup limits
When the Linux kernel comes under memory pressure, the OOM killer decides within a split second which process must die, often the database or PHP-FPM pool instead of the actual memory hog. This article explains the oom_score calculation, shows how to trace OOM kills in dmesg and journalctl, and how to protect critical services through oom_score_adj and cgroup limits.
Table of Contents
- 1. What the OOM killer actually does
- 2. How the kernel detects memory pressure
- 3. Picking the victim: oom_score and the badness calculation
- 4. Finding OOM kills in dmesg and journalctl
- 5. Tuning oom_score_adj for critical services
- 6. Systemd, cgroup v2, and systemd-oomd
- 7. Docker and Kubernetes: setting memory limits correctly
- 8. Prevention: swap, PSI monitoring, and alerting
- 9. OOM killer configuration compared
- 10. Summary
- 11. FAQ
1. What the OOM killer actually does
The OOM killer (Out-Of-Memory killer) is a kernel mechanism that forcibly terminates a process when the kernel can no longer satisfy a memory request, even after trying to free memory first. It is not a bug or a malfunction, but a deliberate design decision: a system that freezes completely because it cannot allocate any more memory is worse than a system that sacrifices a single process in order to keep running. Without this mechanism, a kernel panic would be imminent the moment the last free memory block is consumed.
The distinction from swapping matters here: as long as swap space is still available and the kernel can page memory out to disk, the OOM killer does not step in, even though the system becomes noticeably slower in the process. Only once reclaim attempts, swap-out, and memory compaction together fail to satisfy the requested page does the OOM killer get invoked directly from the page allocator in kernel context. The result is almost always a SIGKILL, the process gets no chance at a clean shutdown.
2. How the kernel detects memory pressure
Linux allows memory overcommit by default: processes can reserve more virtual memory than is physically present, because many allocations are never fully written to. This is controlled through /proc/sys/vm/overcommit_memory with three modes: 0 uses a heuristic estimate, 1 permits essentially unlimited overcommit, and 2 enforces strict accounting based on vm.overcommit_ratio plus swap size. Only once processes actually write to the reserved memory does it become physically used, and that is exactly where memory pressure can emerge.
Before the OOM killer intervenes, the kernel tries to free memory through kswapd running in the background and through direct reclaim in the allocation path itself: dropping unused page cache pages, writing anonymous pages out to swap, defragmenting memory. Under cgroup v2 there is an additional, self-contained reclaim and OOM mechanism per cgroup: once a service hits its memory.max limit, that triggers an OOM kill inside that cgroup, regardless of how much free memory is still available on the rest of the system. This separation is the key to controlled memory behavior in containerized environments.
3. Picking the victim: oom_score and the badness calculation
When the OOM killer becomes active, the kernel calculates a so-called badness score for every eligible process, visible under /proc/PID/oom_score. The basis of this calculation is roughly the share of actually used memory (RSS plus swap usage) relative to the total memory available, expressed in per-mille. A process consuming forty percent of system memory tends to receive a noticeably higher score than a small background service, even if both have been running for the same amount of time. The process with the highest score gets killed, not necessarily the process that triggered the current allocation.
This score can be influenced through /proc/PID/oom_score_adj, a value between -1000 and 1000. The value -1000 excludes a process from OOM selection entirely, the kernel itself uses this for init/PID 1 and typically for systemd, so the system remains functional at all after an OOM kill. Positive values increase the likelihood of being killed, negative values decrease it. The older oom_adj interface (range -17 to 15) is marked deprecated, should no longer be used in new scripts, and only exists for backward compatibility.
4. Finding OOM kills in dmesg and journalctl
The typical symptom of an OOM kill: a service dies seemingly without cause, with no error message in its own log, no core dump, often with exit code 137 (128 plus signal 9 for SIGKILL). The first place to look is always the kernel ring buffer: dmesg -T | grep -i "killed process" shows the kill line with timestamp, process name, and PID. On a system running systemd-journald, journalctl -k --since "-1h" delivers the same kernel messages persistently across reboots, which dmesg alone cannot do since its buffer is lost on reboot.
A complete OOM kill message contains far more than just the kill line: it starts with invoked oom-killer, then lists a table of all candidate processes along with their respective oom_score_adj and memory usage, and ends with the actual Killed process line including total-vm, anon-rss, and file-rss. This table is essential for diagnosis, because it shows why exactly this process, and no other, was selected. For containerized services, docker inspect <container> --format '{{.State.OOMKilled}}' adds the container context on top of the kernel view.
# Search the kernel ring buffer for OOM kills
dmesg -T | grep -i "killed process"
# [Sat Jul 11 03:14:22 2026] Killed process 18422 (mysqld) total-vm:4823112kB, anon-rss:3912456kB, file-rss:2048kB
# Persistent search via journald, survives a reboot
journalctl -k --since "-24h" | grep -iE "oom|killed process"
# Print the full OOM context around one event
journalctl -k -o short-precise | grep -A 40 "invoked oom-killer" | head -n 50
# Check the exit code of a systemd service (137 = SIGKILL after OOM)
systemctl status mysql.service | grep -i "code="
# Query the OOM status of a Docker container directly
docker inspect webapp --format '{{.State.OOMKilled}} exit={{.State.ExitCode}}'
5. Tuning oom_score_adj for critical services
To prevent the database or the central cache service from dying first while a buggy batch job allocates memory unchecked, oom_score_adj can be set explicitly per service. For systemd-managed services this is done declaratively via the OOMScoreAdjust= unit directive in the service file, or via an override created with systemctl edit mysql.service. For processes that are already running, the util-linux package ships the choom command, which lets you change the value at runtime without restarting the process.
The side effect matters here: oom_score_adj does not change the total memory of the system, it only shifts which process gets sacrificed in an emergency. If the database is fully shielded (-800 or lower), the next-worst candidate takes the hit instead, in the worst case an equally critical service such as the reverse proxy or even the SSH daemon. A staged priority scheme across several services, combined with real memory limits, makes more sense than granting a single process blanket immunity.
# Read the current badness score and adjustment of a running process
cat /proc/$(pgrep -f mysqld)/oom_score
cat /proc/$(pgrep -f mysqld)/oom_score_adj
# Set oom_score_adj at runtime, no restart needed (choom from util-linux)
choom -p "$(pgrep -f mysqld)" -n -500
# Configure the same behavior permanently via a systemd override
sudo systemctl edit mysql.service
# Paste into the override editor, see the next code block
sudo systemctl daemon-reload
sudo systemctl restart mysql.service
6. Systemd, cgroup v2, and systemd-oomd
Besides OOMScoreAdjust=, systemd exposes direct access to hard memory limits via cgroup v2: MemoryMax= sets the memory.max limit of a service's cgroup, MemoryHigh= sets a softer throttling threshold at which the kernel starts actively slowing the service down before the hard limit is reached. OOMPolicy= matters too: the default stop cleanly shuts down the service as soon as one of its processes is hit by the kernel OOM killer, while continue lets the service keep running despite an internal kill and kill terminates the entire cgroup immediately.
On top of that, modern distributions often run systemd-oomd, a userspace daemon that does not wait for the hard kernel OOM killer, but intervenes proactively based on Pressure Stall Information (PSI) from /proc/pressure/memory. PSI measures how much time processes spend blocked waiting for memory, and systemd-oomd can terminate a candidate as soon as pressure persists, long before the kernel itself reaches a critical state. This is configured globally in /etc/systemd/oomd.conf and per unit via ManagedOOMMemoryPressure= and ManagedOOMSwap=.
; /etc/systemd/system/mysql.service.d/override.conf
; Lower the OOM score for the database and set a hard memory limit
[Service]
OOMScoreAdjust=-500
MemoryMax=6G
MemoryHigh=5G
OOMPolicy=stop
; Disable systemd-oomd for this unit, the decision stays with
; the kernel OOM killer instead of the PSI-based daemon
ManagedOOMMemoryPressure=auto
ManagedOOMSwap=auto
7. Docker and Kubernetes: setting memory limits correctly
Containers get their memory limit through cgroups as well, in Docker via the --memory flag or deploy.resources.limits.memory in a compose file, in Kubernetes via resources.limits.memory in the pod manifest. When a container hits this limit, the OOM killer fires inside the container's cgroup, kills a process there, usually PID 1 of the container, and the container exits with code 137. This becomes visible via docker inspect in the OOMKilled field, or in Kubernetes via kubectl describe pod with the last state showing OOMKilled.
A common mistake in Kubernetes manifests: a limit without a matching request, which causes the scheduler to misjudge actual utilization and pods get unexpectedly evicted or OOM-killed under load. A more realistic requests.memory close to typical consumption combined with a limits.memory with a reasonable buffer for load spikes works better. Behavior similar to memory.high can currently only be approximated in Kubernetes via QoS classes and LimitRange objects, a native soft throttling threshold like the one systemd offers is still missing at the pod level.
# docker-compose.yml: memory limit for a PHP-FPM service
services:
php-fpm:
image: php:8.4-fpm
deploy:
resources:
limits:
memory: 768M
reservations:
memory: 384M
# Once 768M is reached, the cgroup OOM killer kills a process
# inside this container, exit code 137
restart: unless-stopped
8. Prevention: swap, PSI monitoring, and alerting
The most effective prevention is realistic capacity planning: enough RAM for the expected peak load, a moderate swap volume as a safety buffer rather than as primary storage, and a low vm.swappiness on servers where latency matters more than maximum cache utilization. Compressed swap via zram significantly reduces the I/O cost of paging out compared to classic file- or partition-based swap, and buys the system valuable additional reaction time during short load spikes before the OOM killer even becomes a consideration.
For proactive monitoring, it is worth regularly checking /proc/pressure/memory: if the avg60 value on the full line stays elevated above low single-digit percentages, memory pressure often announces itself minutes to hours before the actual OOM kill. The node_exporter for Prometheus exports these PSI metrics directly, so alerting rules can be defined that fire before the kernel even kills a process, instead of only reconstructing what happened from the logs afterward.
{
"_TRANSPORT": "kernel",
"PRIORITY": "0",
"SYSLOG_IDENTIFIER": "kernel",
"MESSAGE": "Out of memory: Killed process 18422 (mysqld) total-vm:4823112kB, anon-rss:3912456kB, file-rss:2048kB, shmem-rss:0kB, UID:112 pgtables:8420kB oom_score_adj:0",
"__REALTIME_TIMESTAMP": "1783825462123456"
}
9. OOM killer configuration compared
Whether an OOM event unfolds in a controlled, traceable way or hits the wrong service almost always comes down to a handful of configuration decisions. The following overview compares the most common insecure defaults against the recommended configuration.
| Area | Insecure / Default | Recommended Configuration | Benefit |
|---|---|---|---|
| Overcommit mode | overcommit_memory=1 unlimited | overcommit_memory=2 + overcommit_ratio | Memory commitments stay traceable |
| Critical services | no oom_score_adj set | OOMScoreAdjust=-500 for DB/cache | Database does not die first |
| Container limits | limits without requests | requests close to real usage + limits with buffer | Fewer unexpected evictions |
| Swap configuration | no swap, no buffer | zram + moderate swappiness | Time buffer during short spikes |
| Monitoring | reactive only, via dmesg after the fact | PSI metrics + alerting before the kill | Catch incidents before the kill |
Most incidents in practice do not come from a single wrong setting, but from the absence of any deliberate configuration at all: default overcommit, no memory limit, no oom_score_adj, no monitoring. Working through these five levers once per server or cluster substantially reduces the likelihood of a surprise production outage caused by the OOM killer, without having to buy any additional hardware.
Mironsoft
Server administration, memory tuning, and incident analysis for Linux infrastructure
Unexplained process deaths caused by the OOM killer?
We analyze your OOM kill logs, identify the actual root cause of the memory pressure, and configure oom_score_adj, cgroup limits, and monitoring so that critical services like your database and cache stay protected.
Incident analysis
Evaluating dmesg and journalctl to reconstruct the exact cause of an OOM event
Memory tuning
Configuring oom_score_adj, cgroup v2 limits, and systemd-oomd for critical services
Proactive monitoring
Setting up PSI-based alerting before the next OOM kill hits production
10. Summary
The OOM killer is not a random event, it follows a clear, traceable logic: it only steps in once reclaim and swap-out are no longer sufficient, and it picks the process with the highest badness score based on its share of total system memory. This selection can be influenced deliberately via oom_score_adj, without changing overall memory consumption. In dmesg and journalctl, every OOM kill leaves behind a complete diagnostic table showing exactly why that particular process was picked and how much memory its competitors were holding at that moment.
The problem is not solved sustainably by shielding a single process, but through a combination of realistic per-service cgroup limits, moderate swap as a time buffer, and PSI-based monitoring that detects memory pressure before the kernel ever has to kill a process. Establishing these building blocks consistently across production servers turns the OOM killer from a surprising source of failures into a predictable, manageable safety net.
Understanding the OOM Killer and Avoiding Memory Problems, the essentials at a glance
Trigger
Only once reclaim, swap, and compaction are no longer enough does the OOM killer get invoked from the page allocator.
Victim selection
The badness score in /proc/PID/oom_score is based on memory share, adjustable via oom_score_adj.
Diagnosis
dmesg -T | grep -i "killed process" and journalctl -k reveal the full kill table.
Prevention
cgroup limits, zram swap, and PSI monitoring via systemd-oomd prevent uncontrolled kills.