MAATRIX / Блог / Каждую ночь падал не тот процесс: разбираемся с oom_score_adj

Каждую ночь падал не тот процесс: разбираемся с oom_score_adj

MAATRIX

Каждую ночь в одно и то же время, во время пикового пакетного задания, на сервере падал сервис — и это был не тот процесс, который реально жрал память. Логика подсказывала: должен умереть виновник, а умирал сосед. После недели гаданий выяснилось, что дело не в случайности и не в багах ядра, а в одной забытой настройке — oom_score_adj, которая тихо перевернула приоритеты жертв с ног на голову. Разбираем инцидент по шагам, чтобы вы могли повторить расследование на своём сервере за час, а не за неделю.

Симптом: падает не тот процесс

Картина была стабильной ночь за ночью: в 3:00 запускалось пакетное задание — импорт данных, разворачивающееся в несколько параллельных воркеров, которые активно грузили память на время обработки. В момент пика нагрузки в dmesg появлялась строка Out of memory: Killed process, и убитым оказывался не сам импорт (он явно был тяжелее всех по RSS) и даже не один из его воркеров, а соседний долгоживущий сервис — условно назовём его сервисом кеша, который в это время вообще не делал ничего необычного.

Первая реакция дежурного инженера — перезапустить упавший сервис и списать всё на «разовый глюк». Но глюк повторился на следующую ночь, и на следующую после неё — с точностью до имени процесса. Три ночи подряд одна и та же жертва при, казалось бы, совершенно другом виновнике по объёму занятой памяти. Это уже не похоже на случайность: OOM killer работает по детерминированному алгоритму, и если он раз за разом выбирает одного и того же не самого тяжёлого кандидата, значит, у этого выбора есть конкретная причина, зашитая в состояние системы, а не в удачу.

Стандартная проверка через dmesg -T | grep -i "killed process" подтвердила закономерность документально: три ночи, три одинаковые строки с одним и тем же именем процесса-жертвы и разными PID (сервис перезапускался между инцидентами systemd'ом). Разбор общих подходов к чтению таких логов и поиску первопричины по обрывочным строкам — отдельная большая тема, здесь же интересен конкретно этот случай: почему ядро раз за разом обходит стороной настоящего пожирателя памяти.

Почему это не могло быть просто совпадением

Прежде чем копать глубже, стоило исключить очевидные версии. Может, у сервиса кеша реальная утечка памяти, и он на самом деле растёт к 3 часам ночи? Проверка через ps aux --sort=-rss и графики RSS в мониторинге за несколько дней это не подтвердила: потребление памяти сервиса кеша было ровным весь день, без роста к пиковым часам. Может, дело в том, что импорт данных использует память рывками, а мониторинг просто не успевает зафиксировать пик? Тоже нет — импорт стабильно занимал в разы больше памяти, чем весь сервис кеша, и это было видно даже на грубых пятиминутных снапшотах free -m, снятых по крону во время инцидента.

Оставался единственный разумный вывод: раз ядро систематически выбирает не самого тяжёлого по факту потребления кандидата, значит, при расчёте итогового «приговора» для каждого процесса участвует что-то ещё, помимо сырого RSS. И это что-то одинаково влияет на исход каждую ночь — то есть зашито в конфигурацию, а не появляется случайно в момент нехватки памяти. Здесь стоит вспомнить механику: ядро не убивает «самый большой процесс», оно убивает процесс с максимальным значением oom_score, а это число — результат формулы, где реальное потребление памяти лишь один из множителей. Подробно про сам алгоритм подсчёта score и про то, что происходит в момент убийства процесса, разобрано в статье как ядро выбирает жертву OOM killer — здесь же сфокусируемся именно на том, как это расследовать постфактум и как эта конкретная поломка обычно возникает.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Что проверять в первую очередь: oom_score и oom_score_adj

Ключевая улика лежит не в самих логах падения, а в текущем состоянии живых процессов. У каждого процесса в Linux есть два связанных, но разных числа в /proc:

cat /proc/<pid>/oom_score       # итоговый расчётный балл, 0..1000
cat /proc/<pid>/oom_score_adj   # ручная поправка к этому баллу, -1000..+1000

oom_score — это результат вычисления ядра на текущий момент: он меняется постоянно вместе с потреблением памяти процессом. А вот oom_score_adj — статичная настройка, которая либо стоит по умолчанию (обычно 0), либо явно выставлена кем-то или чем-то: администратором вручную, юнитом systemd, средой контейнеризации, супервизором процессов. Именно это второе число стоило проверить в первую очередь, потому что оно не зависит от текущей нагрузки и объясняет системную, повторяющуюся ошибку выбора жертвы.

Практический способ пройтись по всем интересующим процессам разом — небольшой скрипт вместо ручного разглядывания top:

for pid in $(pgrep -f "cache-service|import-worker"); do
  name=$(ps -p "$pid" -o comm=)
  score=$(cat /proc/$pid/oom_score 2>/dev/null)
  adj=$(cat /proc/$pid/oom_score_adj 2>/dev/null)
  echo "pid=$pid name=$name oom_score=$score oom_score_adj=$adj"
done

Именно этот вывод и стал ключом к разгадке. Оказалось, что у процесса импорта (тяжёлого по памяти, но по бизнес-логике — фонового и некритичного) oom_score_adj стоял -900, то есть был выставлен близко к максимальной защите. А у сервиса кеша (лёгкого по факту потребления, но критичного для работы всего продукта) значение оказалось +500 — то есть кто-то намеренно (или по ошибке) сделал его куда более вероятной жертвой, чем требовала логика бизнес-приоритетов. При таком раскладе итоговый oom_score кеша легко обгонял oom_score импорта, даже несмотря на то, что реальный RSS у импорта был в разы больше — поправка в сотни пунктов перевешивает разницу в потреблении памяти, которая на шкале 0-1000 распределяется куда более полого.

Как поправка вообще туда попала

Вопрос «кто это выставил» оказался интереснее самого симптома. Ни один инженер команды сознательно не трогал oom_score_adj этих двух сервисов — по крайней мере, никто в этом не признался, и в истории команд на сервере ручных вызовов echo ... > /proc/<pid>/oom_score_adj не нашлось. Разгадка обнаружилась в unit-файлах systemd.

Сервис импорта данных был скопирован полгода назад с шаблона другого, куда более тяжёлого пакетного джоба, у которого в своё время сознательно поставили OOMScoreAdjust=-900 — потому что тот процесс на старом сервере при аварии действительно нужно было защищать любой ценой. Файл скопировали, переименовали, поправили пути и команду запуска, а строку OOMScoreAdjust=-900 никто не убрал и не пересмотрел — она просто «доехала» вместе с остальным содержимым как часть шаблона:

# /etc/systemd/system/import-worker.service
[Service]
ExecStart=/usr/local/bin/import-worker
OOMScoreAdjust=-900
Restart=on-failure

А у сервиса кеша поправка +500 оказалась побочным эффектом того, что он запускался внутри Docker-контейнера с явным лимитом памяти через --memory. Docker-демон сам выставляет oom_score_adj контейнерам с ограничением памяти так, чтобы при прочих равных в первую очередь убивать процессы, которые ближе к собственному лимиту контейнера, — и в данном случае лимит был задан заметно ниже реального рабочего объёма кеша под нагрузкой, отчего демон посчитал его логичным кандидатом на выбывание. Подробнее о том, как cgroup-лимиты влияют на поведение OOM внутри контейнера и почему это работает независимо от общего состояния памяти хоста, разобрано в статье как cgroups ограничивают контейнер. Итог: два независимых источника — унаследованный шаблон systemd и умолчания докера — сложились в одну системную ошибку выбора жертвы, которая ничего общего с реальными бизнес-приоритетами не имела.

Исправление: приоритеты по факту, а не по наследству

Первый шаг — вернуть импорту нейтральную или даже положительную поправку, потому что по бизнес-логике это фоновый некритичный процесс, которым можно и нужно жертвовать в первую очередь:

# /etc/systemd/system/import-worker.service
[Service]
OOMScoreAdjust=300
OOMPolicy=stop

Второй шаг — защитить сервис кеша явно, а не полагаться на автоматику докера, которая ориентируется на лимит контейнера, а не на важность сервиса для бизнеса:

docker run -d \
  --name cache-service \
  --memory=2g \
  --oom-score-adj=-700 \
  cache-service:latest

Полезно свести реальные приоритеты в таблицу до того, как менять конфиги — это тот случай, когда сначала стоит договориться словами, а потом переводить в цифры:

СервисРольЦелевой oom_score_adj
Основная СУБДкритичный, единая точка отказа-800…-1000
Сервис кешакритичный для отклика, но данные восстановимы-500…-700
API-бэкендважный, но масштабируется горизонтально-200…0
Import-worker / batch-джобыфоновый, можно перезапустить0…+500
Разовые скрипты, отладочные утилитынекритичный+500…+1000

Отдельно стоит проверить не только два конкретных сервиса, а вообще все процессы на сервере — практика показывает, что унаследованные и «случайные» поправки редко бывают единичными. Быстрая ревизия:

for pid in $(ls /proc | grep -E '^[0-9]+$'); do
  adj=$(cat /proc/$pid/oom_score_adj 2>/dev/null)
  if [ -n "$adj" ] && [ "$adj" != "0" ]; then
    name=$(cat /proc/$pid/comm 2>/dev/null)
    echo "pid=$pid name=$name oom_score_adj=$adj"
  fi
done

Всё, что найдётся с ненулевым значением и без явного объяснения «кто и зачем это выставил», — кандидат на пересмотр. Особенно если объяснение сводится к «так было в шаблоне» или «докер сам так решил».

Почему это не должно быть основной стратегией защиты

Важно честно проговорить ограничение подхода: oom_score_adj — это управление тем, КОГО убить первым, когда памяти уже не хватает всем. Это ничего не делает с самим фактом нехватки. Если на сервере системно не хватает RAM под реальную нагрузку — рано или поздно OOM killer придёт и за защищённым процессом тоже, просто ему придётся сначала перебрать и убить всех менее защищённых соседей. Поправка не создаёт память из ниоткуда, она только переставляет местами очередь на казнь.

Поэтому правильный порядок действий — сначала разобраться, почему памяти не хватает регулярно именно в это время суток. В разобранном инциденте параллельно с настройкой приоритетов стоило (и в итоге было сделано) две вещи: во-первых, ограничить одновременное число воркеров импорта через семафор в самом джобе, чтобы пиковое потребление не приближалось так плотно к физическому потолку сервера; во-вторых, проверить, не растёт ли потребление памяти на сервере со временем само по себе — это отдельная и более коварная проблема, разобранная в статье про утечку памяти на сервере: если базовый уровень потребления памяти без всякого импорта потихоньку ползёт вверх месяцами, любая настройка oom_score_adj рано или поздно перестанет спасать, потому что запаса свободной памяти для пиковых нагрузок будет становиться всё меньше. Тонкая настройка приоритетов жертвы — это последний рубеж обороны и разумная гигиена конфигурации, а не замена работе над реальным балансом памяти и лимитов.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро проверить, была ли причина падения именно в oom_score_adj, а не в случайном совпадении?

Посмотрите dmesg -T | grep -i "killed process" — перед итоговой строкой ядро обычно печатает таблицу кандидатов с колонками вроде pid uid tgid total_vm rss pgtables_bytes oom_score_adj name. Если в этой таблице у жертвы объём памяти явно ниже, чем у других кандидатов, а oom_score_adj — заметно менее отрицательный (или более положительный), это прямое подтверждение, что поправка перевесила реальное потребление.

Можно ли посмотреть oom_score_adj процесса, который уже был убит, если он не сохранился в dmesg?

Нет, после завершения процесса запись в /proc/<pid>/ исчезает вместе с ним. Единственный источник данных о состоянии на момент убийства — сама таблица кандидатов в dmesg/journalctl -k, если ядро успело её записать. Поэтому первое, что стоит сделать после инцидента, — сохранить полный вывод dmesg в отдельный файл, пока его не затёрло кольцевым буфером.

Что делать, если несколько сервисов имеют одинаковый oom_score_adj и непонятно, кого убьёт ядро при следующей нехватке памяти?

При равной поправке решает реальное текущее потребление памяти — победит (в смысле выживания) тот, кто ест меньше. Если для вас критично, чтобы конкретный сервис выжил даже при равенстве с соседями по памяти, не полагайтесь на удачу — задайте ему отдельную, более отрицательную поправку явно.

Влияет ли oom_score_adj на процессы внутри разных контейнеров одинаково?

Нет. Если у контейнера задан собственный лимит памяти (cgroup memory.max), при достижении именно этого лимита сработает cgroup-версия OOM killer внутри контейнера — она смотрит на процессы этой конкретной cgroup, а поправка oom_score_adj действует уже поверх этого локального выбора. Общесистемный OOM killer хоста — отдельный сценарий, который наступает, только если не хватает памяти всему серверу целиком, а не одному контейнеру с лимитом. Подробнее о разнице лимитов и поведении можно свериться со статьёй про лимиты CPU и памяти в Docker.

Стоит ли выставлять oom_score_adj = -1000 всем важным сервисам сразу, на всякий случай?

Нет. Если защитить -1000 сразу несколько тяжёлых процессов, при реальной нехватке памяти ядро не сможет выбрать жертву среди них вовсе и будет вынуждено убивать оставшиеся, менее защищённые процессы — вплоть до системных служб, без которых сервер перестанет нормально функционировать. Защита должна быть точечной и обоснованной, а не блочной «на всякий случай».

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →