
В сентябре 2004 года разработчик Томас Хабетс вернулся к рабочей станции и обнаружил, что его экран разблокирован. Память закончилась, OOM Killer выбрал жертву и завершил xlock... Для того, чтобы больше такого не произошло, Хабетс предложил список процессов, которые Linux запрещено убивать даже в такой ситуации. Его патч в ядро не вошёл, но позже разработчики добавили настройку приоритетов, поддержку cgroups и отдельный поток для быстрого освобождения памяти.
Несмотря на это, OOM Killer всё равно может снести важный процесс, ведь ядро видит только объём памяти и заданные администратором правила. Если правил нет, то Linux сам решает, кого «убить». Почему и что делать, чтобы этого не произошло — в статье.
Память обещали, но не выдали
Linux разрешает программам запрашивать больше виртуальной памяти, чем система способна обеспечить физически. Процесс вызывает malloc() и получает адрес, но это ещё не значит, что под весь диапазон уже выделены страницы RAM. Реальная память нужна позже, когда процесс начинает читать или записывать эти страницы.
Такой оверкоммит нужен, потому что многие программы резервируют адресное пространство с запасом и используют лишь его часть. После fork() родитель и потомок сначала разделяют одни и те же физические страницы благодаря механизму COW, и если бы ядро заранее требовало отдельное пространство для каждого байта, память бы расходовалась быстрее.
Однако обещания иногда приходится выполнять — тут и начинаются проблемы. Если свободных страниц недостаточно, ядро запускает reclaim (для новичков: это механизм освобождения памяти). Оно пытается вытеснить пригодные для повторного чтения файловые страницы из страничного кэша, записать грязные страницы на диск, освободить часть кэшей ядра и, если настроен swap, выгрузить туда анонимную память. Если reclaim не дает нужного результата, Linux разрешает «убийство» какого-нибудь процесса.
К слову, OOM Killer не ждёт, пока память кончится до нуля. Он срабатывает, когда аллокатор уже не может удовлетворить запрос на выделение, то есть система находится в глубоком кризисе. К этому моменту машина уже тормозит, и SSH-сессия отвечает на клавиши с задержкой в несколько секунд.
Поведение при выделении памяти задаёт параметр vm.overcommit_memory (документацию можно поглядеть тут):
-
0 — эвристика (значение по умолчанию). Оверкоммит разрешён, но ядро отклоняет «очевидно безумные» запросы.
-
1 — ядро разрешает оверкоммит без проверки доступного резерва. Запрос не будет отклонён из-за CommitLimit, но malloc() всё равно может завершиться ошибкой по другим причинам.
-
2 — строгий учёт. Лимит считается как CommitLimit = (RAM − hugepages) × overcommit_ratio/100 + swap, и malloc() вернёт NULL, когда лимит исчерпан.
Посмотреть текущий режим и переключить его можно так:
cat /proc/sys/vm/overcommit_memory
sudo sysctl vm.overcommit_memory=2
sudo sysctl vm.overcommit_ratio=90
Для серверов PostgreSQL можно рассмотреть vm.overcommit_memory=2. Такой режим снижает вероятность глобального OOM, но требует расчёта CommitLimit с учётом RAM, huge pages, swap и выбранного overcommit_ratio, а также проверки поведения реальной нагрузки. Сама PostgreSQL рекомендует сочетать эту настройку с разумными значениями shared_buffers, work_mem, hash_mem_multiplier и max_connections.
Как ядро решает, кого убить
За выбор отвечает функция oom_badness() в mm/oom_kill.c.
До октября 2010 года, когда вышло ядро 2.6.36, эвристика была многофакторной — в зачёт шли объём виртуальной памяти процесса, потраченное CPU time и uptime (чем дольше процесс живёт и работает, тем меньше шансов на расстрел), число дочерних процессов, nice value, привилегии root и прямой доступ к железу. Всё это перемножалось и делилось под квадратными корнями, а на выходе получалось число, которое мало что говорило. OOM Killer мог прихлопнуть sshd, X-сервер или базу данных, и угадать заранее, кого именно он затронет, было невозможно.
В 2010 году Дэвид Риентджес из Google полностью переписал эвристику. С тех пор score считается почти исключительно от памяти:
points = get_mm_rss_sum(p->mm)
+ get_mm_counter_sum(p->mm, MM_SWAPENTS)
+ mm_pgtables_bytes(p->mm) / PAGE_SIZE;
adj *= totalpages / 1000;
points += adj;
В целом, складываются RSS, то есть физическая память процесса, страницы, вытесненные в swap, и структуры ядра для управления виртуальной памятью (таблицы страниц PTE и PMD). Потом это нормализуется относительно доступной памяти.
Дальше, score считается относительно доступной памяти, а она не всегда равна всей RAM. Если OOM случился внутри memory cgroup, «доступная память» — это лимит группы. Процесс, занявший 100 МБ из лимита в 128 МБ, получит score ближе к 1000, даже если на хосте свободны гигабайты. Посмотреть, кто сейчас главный кандидат на убийство, можно в /proc:
cat /proc/1234/oom_score
cat /proc/1234/oom_score_adj
А топ-20 процессов по score:
printf "%-8s %-8s %-8s %-20sn" "PID" "SCORE" "ADJ" "COMMAND"
for pid in /proc/[0-9]*/; do
pid=${pid#/proc/}
pid=${pid%/}
if [ -r /proc/$pid/oom_score ]; then
score=$(cat /proc/$pid/oom_score 2>/dev/null)
adj=$(cat /proc/$pid/oom_score_adj 2>/dev/null)
comm=$(cat /proc/$pid/comm 2>/dev/null)
printf "%-8s %-8s %-8s %-20sn" "$pid" "$score" "$adj" "$comm"
fi
done | sort -k2 -rn | head -20
Обычно наверху списка оказывается база данных, Java-приложение или аналитический скрипт, съевший половину RAM.
Почему выбор кажется неправильным
OOM Killer не знает устройство вашего бизнеса. Для него база данных важна ровно настолько, насколько это отражено в oom_score_adj, cgroup и доступной памяти. Если PostgreSQL занял 12 ГБ, а sshd всего несколько мегабайт, убийство базы выглядит логичнее.
Кроме того, несколько процессов могут использовать одно адресное пространство или общую память, а завершение одного из них не всегда возвращает весь объем, который виден в статистике. Тогда ядру приходится выбирать следующую жертву.
В контейнерах действуют два разных сценария:
-
При достижении лимита memory cgroup возникает локальный memcg OOM, и кандидаты ищутся внутри ограниченной группы — такой механизм существовал и в cgroup v1. Cgroup v2 добавил более удобные memory.high, memory.events, групповое завершение и единую иерархию.
-
При нехватке памяти на всей ноде запускается глобальный OOM Killer. В Kubernetes на его выбор также влияют класс QoS и oom_score_adj, который выставляет kubelet. Guaranteed-контейнеры получают более сильную защиту, а BestEffort становятся первыми кандидатами.
Наконец, свободная память может существовать не в том домене, где произошёл сбой. В этом случае процесс ограничен NUMA policy, cpuset или memory cgroup, а ядро не имеет права взять страницы за их пределами.
Что изменилось после истории с xlock
До появления PSI разработчики решили ещё одну проблему. После выбора жертвы OOM Killer отправляет ей SIGKILL, но процесс может завершиться не сразу, например, из-за блокировки внутри ядра.
В Linux 4.6 появился oom_reaper, отдельный поток ядра, который пытается освободить подходящие анонимные страницы жертвы, не дожидаясь её полного завершения. Всю память он забрать не может, так как shared memory и некоторые специальные отображения остаются до окончательного выхода процесса.
В Linux 4.20 добавили Pressure Stall Information (PSI). Этот интерфейс показывает не объём занятой RAM, а время, потерянное из-за нехватки ресурса. Вместо того чтобы ждать отказа page allocator, PSI измеряет, сколько времени процессы проводят в ожидании памяти (а также CPU и I/O):
cat /proc/pressure/memory
some avg10=70.24 avg60=68.52 avg300=69.91 total=3559632828
full avg10=57.59 avg60=58.06 avg300=60.38 total=3300487258
Some показывает долю времени, когда из-за нехватки памяти ожидала хотя бы одна активная задача, а full — время, когда одновременно ожидали все активные, то есть non-idle, задачи в наблюдаемой системе или cgroup.
Однократный скачок ещё ни о чём не говорит, поэтому смотрите на avg10, avg60 и avg300. Если full стабильно держится в десятках процентов, система занимается reclaim и подкачкой вместо полезной работы. Это признак пробуксовки и высокого риска OOM.
Как понять, где произошёл OOM
Первый источник информации находится в журнале ядра:
journalctl -k -g 'oom|Out of memory|Killed process'
# Для систем без journald
dmesg -T | grep -Ei 'oom|out of memory|killed process'
Чаще всего логика такая:
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,
global_oom,task_memcg=/system.slice/postgresql.service,
task=postmaster,pid=2575533,uid=26
Out of memory: Killed process 2575533 (postmaster)
total-vm:9716200kB anon-rss:5113080kB file-rss:0kB
shmem-rss:16828kB pgtables:10256kB oom_score_adj:0
oom_reaper: reaped process 2575533 (postmaster),
now anon-rss:0kB file-rss:0kB shmem-rss:16828kB
Здесь важны несколько полей: global_oom и CONSTRAINT_NONE говорят о глобальном событии. Строка task_memcg=/system.slice/postgresql.service показывает, в какой cgroup находилась жертва, но сама по себе не доказывает, что сервис упёрся в лимит.
При локальном событии в журнале обычно появляется Memory cgroup out of memory, а причину дополнительно подтверждают счётчики memory.events.
Как защитить важные процессы
Способ первый и основной — oom_score_adj. Постоянную настройку проще оформить через drop-in для systemd. Сначала нужно проверить название SSH-юнита, поскольку в Debian и Ubuntu он часто называется ssh.service, а в других дистрибутивах — sshd.service:
systemctl status ssh.service sshd.service
Допустим, в системе используется sshd.service. Создадим для него drop-in:
sudo systemctl edit sshd.service
В открывшийся файл нужно добавить:
[Service]
OOMScoreAdjust=-500
Затем проверить конфигурацию SSH и перезапустить сервис:
sudo sshd -t
sudo systemctl restart sshd.service
Если юнит называется ssh.service, это имя нужно использовать в командах systemctl edit и systemctl restart. На удалённом сервере не закрывайте действующую SSH-сессию, пока не убедитесь, что новое подключение работает.
Значение -500 снижает вероятность завершения процесса, но не делает его полностью неприкосновенным. Значение -1000 исключает процесс из списка кандидатов OOM Killer, поэтому использовать его стоит только для небольших критических сервисов и после проверки поведения системы при нехватке памяти.
Отрицательные значения лучше назначать небольшим критическим сервисам, которые нужны для диагностики и восстановления. Положительные значения — воркерам, пакетным задачам и кэшам, которые можно безопасно перезапустить. Конкретные числа выбирайте после сравнения текущих oom_score под реальной нагрузкой.
Для баз данных важнее задать границы через MemoryHigh=, MemoryMax=, настроить потребление памяти и обеспечить нормальный рекавери.
Без systemd значение можно записать напрямую в /proc. Сначала нужно получить PID одного конкретного процесса. Команда pidof sshd для этого не подходит, поскольку SSH-демон обычно запускает несколько процессов и команда вернёт сразу несколько PID.
# Найти основной процесс sshd
pid=$(pgrep -xo sshd)
# Уменьшить вероятность его завершения
echo -500 | sudo tee "/proc/$pid/oom_score_adj"
# Проверить текущие значения
cat "/proc/$pid/oom_score"
cat "/proc/$pid/oom_score_adj"
Такая настройка действует только до завершения процесса. После перезапуска сервиса её придётся применять заново, поэтому постоянные правила лучше задавать через systemd.
Чем выше oom_score, тем привлекательнее процесс для OOM Killer. Нулевое значение не даёт полной защиты, а 1000 не означает, что процесс гарантированно будет убит первым. Из списка кандидатов процесс исключает только oom_score_adj=-1000. Использовать это значение нужно осторожно, потому что если защитить основных потребителей памяти, ядру придётся завершать множество небольших процессов, а систему это может не спасти.
Убивать до того, как поздно
Kernel OOM Killer срабатывает, когда система уже в thrashing. Поэтому сейчас появились userspace-демоны, которые делают «грязную» работу, пока машина ещё отвечает:
1. Earlyoom. Написан на C, имеет минимум зависимостей и работает даже на старых ядрах. Проверяет /proc/meminfo до десяти раз в секунду, и когда доступная память и свободный swap падают ниже порога (по умолчанию 10%), шлёт SIGTERM процессу с наибольшим oom_score. Если стало совсем плохо (по умолчанию 5%), в ход идёт SIGKILL.
sudo apt install earlyoom
sudo systemctl enable --now earlyoom
journalctl -u earlyoom -f
Конфиг для сервера:
# /etc/default/earlyoom
EARLYOOM_ARGS="-m 5 -s 10 --avoid '(^|/)(init|systemd|sshd|dbus-daemon|rsyslogd|cron|agetty|postgres)$'"
В Fedora 32 и 33 earlyoom шёл по умолчанию на десктопных сборках, и конфиг там щадил графическое окружение, зато в первую очередь жертвовал браузерами.
Из минусов — демон смотрит на абсолютные проценты, а не на pressure, поэтому на машине со 128 ГБ десять процентов это очень ранняя стрельба, а на с гигабайтом может оказаться поздновато.
2. Nohang. Написан на Python, требует ядро 3.14+ из-за MemAvailable, но умеет намного больше earlyoom. При нехватке памяти сначала отправляет процессу SIGTERM и даёт ему время на корректное завершение. Если память продолжает заканчиваться, а процесс не отвечает, следует SIGKILL.
Демон умеет работать с PSI и zram, показывать GUI-уведомления и выбирать жертву по имени или cgroup с помощью регулярных выражений. Вместо завершения процесса можно выполнить другую команду, например, перезапустить сервис через systemctl restart. Раньше nohang устанавливался по умолчанию в Garuda Linux.
Из минусов — зависимость от Python и довольно объёмная конфигурация.
3. Systemd-oomd. Этот демон использует PSI и cgroup v2, поэтому реагирует на длительное давление на память, а не только на остаток свободной RAM. Когда заданный порог превышен, systemd-oomd выбирает одну из дочерних cgroups и отправляет SIGKILL всем процессам внутри неё.
В Fedora 34 systemd-oomd заменил earlyoom и был включен по умолчанию. В Ubuntu 22.04 он входит в состав systemd, но активная политика зависит от редакции и настроек системы.
Политику memory pressure лучше задавать для slice, внутри которого находятся сервисы:
Политика для slice:
# /etc/systemd/system/batch.slice.d/oomd.conf
[Slice]
ManagedOOMMemoryPressure=kill
ManagedOOMMemoryPressureLimit=60%
Подключение сервиса к этому slice:
# /etc/systemd/system/myapp.service.d/resources.conf
[Service]
Slice=batch.slice
Назначать ManagedOOMMemoryPressure=kill обычному leaf-сервису бессмысленно, если внутри его cgroup нет дочерних групп. systemd-oomd наблюдает за указанной группой, но жертву ищет среди её потомков.
Если задача состоит в том, чтобы при kernel OOM завершался весь сервис, а не один его воркер, можно использовать это:
# /etc/systemd/system/myapp.service.d/oom.conf
[Service]
OOMPolicy=kill
Restart=on-failure
OOMPolicy=kill включает групповое завершение процессов сервиса через memory.oom.group. После изменения конфигурации нужно перечитать unit-файлы:
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
Проверить работу systemd-oomd можно так:
systemctl status systemd-oomd
oomctl
journalctl -u systemd-oomd -f
Из минусов — нужны cgroup v2 и свеженький systemd, а на десктопах oomd прославился тем, что убивает долгие сборки в терминале.
Что где ставить:
-
старый сервер или embedded — только earlyoom,
-
современный сервер со systemd и cgroup v2 — systemd-oomd, но с осмысленными порогами,
-
десктоп — nohang или earlyoom,
-
сервер с PostgreSQL — подумайте про overcommit_memory=2 в связке с защитным OOMScoreAdjust.
Тренировка на кошках
Проверять OOM-настройки лучше в отдельной виртуальной машине. Обычный запуск stress-ng с потреблением большей части RAM может подвесить весь хост, а принудительный вызов глобального OOM Killer через SysRq способен завершить рабочий сервис.
На системе с systemd и cgroup v2 безопаснее создать временный slice с отдельным лимитом:
sudo apt install stress-ng
sudo systemctl set-property --runtime oom-test.slice
MemoryMax=512M
MemorySwapMax=0
Теперь запустим внутри него сервис, который попытается занять 1 ГБ:
sudo systemd-run
--unit=oom-test.service
--slice=oom-test.slice
--property=OOMPolicy=kill
stress-ng --vm 1 --vm-bytes 1G --vm-keep --timeout 30s
Процесс запросит больше памяти, чем разрешено его slice. В результате возникнет локальный memcg OOM, который не должен затронуть остальные сервисы хоста.
Результат можно посмотреть так:
systemctl status oom-test.service
journalctl -u oom-test.service
journalctl -k -g 'oom|Out of memory|Killed process'
Счётчики останутся в cgroup родительского slice:
cat /sys/fs/cgroup/oom-test.slice/memory.events
Рост oom и oom_kill подтвердит, что сработало ограничение тестовой cgroup, а не глобальный OOM Killer. После проверки временные настройки нужно удалить:
sudo systemctl stop oom-test.service oom-test.slice
sudo systemctl revert oom-test.slice
Даже локальный тест лучше проводить вне прода. Ну и наконец, чеклист:
-
Проверьте vm.overcommit_memory, CommitLimit и Committed_AS. Режим 1 не всегда ошибочен, а режим 2 не всегда является универсально безопасным. Выбор зависит от нагрузки.
-
Посмотрите текущие oom_score и oom_score_adj критических сервисов. Не назначайте -1000 автоматически — это значение полностью исключает процесс из списка жертв.
-
Ограничьте потребление памяти через MemoryHigh и MemoryMax. Для базы данных настройте собственные параметры памяти и заранее проверьте процедуру восстановления после OOM.
-
Следите за /proc/pressure/memory. Устойчивый рост some указывает на задержки, а высокий full означает, что давление одновременно затронуло все активные задачи.
-
Проверьте vm.oom_dump_tasks. Обычно параметр уже включен, но если он равен нулю, включить вывод можно так: sudo sysctl vm.oom_dump_tasks=1.
-
На обычном сервере или десктопе рассмотрите earlyoom, nohang либо systemd-oomd. Выбор зависит от наличия PSI, cgroup v2 и нужной области завершения.
-
В Kubernetes сначала настройте requests, limits, QoS и eviction thresholds. Убедитесь, что JVM, Node.js и другие рантаймы видят лимиты cgroup. Поведение memory.oom.group должно быть согласовано с runtime и kubelet.
Подытожу, OOM Killer выбирает тот процесс, который выглядит «лучшей жертвой» при заданных правилах. Если этим процессом оказывается база данных, то вопросы задавайте сами себе.
А кого ваш OOM Killer выбрал в последний раз? Делитесь историями в комментариях.
Р. S. Полную историю обсуждения из лида можно прочитать на LWN.
© 2026 ООО «МТ ФИНАНС»
Автор: SrvTrantor
