Пока в сервере есть свободные страницы, жизнь прекрасна. Потом одна служба наедает больше своей доли, ядро начинает вытеснять страницы, и случается худшее: под раздачу попадает самый ценный процесс, а не самый прожорливый. Механизм OOM выбирает жертву по собственным формулам, и угадать её заранее почти невозможно. Контроллер памяти cgroups v2, он же memcg, вместе с параметрами vm.* из sysctl позволяет забрать этот выбор себе. Ядру можно заранее сказать, какие группы процессов трогать нельзя, какие трогать лишь в крайнем случае, кого притормаживать при переполнении, а кого отпускать за границы. Ниже разобраны файлы лимитов в /sys/fs/cgroup, sysctl-ручки вытеснения, механизм принудительного освобождения memory.reclaim, измерители давления и автоматизация через systemd. С реальным синтаксисом, версиями ядра и числами, которые можно переносить в свой конфиг без переписывания.
Иерархия cgroups v2 от создания группы до записи PID в cgroup.procs
Контроллер памяти для единой иерархии cgroup v2 добрался в ядре 4.5, дальше интерфейс только расширялся, так что в современных дистрибутивах всё нужное уже на месте. Файловая система cgroup2 обычно смонтирована в /sys/fs/cgroup, а каждая группа процессов там это просто каталог. Каталог создаётся командой mkdir, контроллер включается записью в файл cgroup.subtree_control родительского каталога, а процесс переносится записью его PID в файл cgroup.procs.
mkdir /sys/fs/cgroup/web
echo "+memory" > /sys/fs/cgroup/cgroup.subtree_control
echo 4124 > /sys/fs/cgroup/web/cgroup.procs
cat /proc/4124/cgroup
Последняя команда должна показать строку вида 0::/web, которая подтверждает переезд. Нюанс, о который спотыкаются все: потоки процесса переезжают вместе, а память нет. Страница закреплена за той группой, в которой её заняли, и после переезда процесса продолжает числиться за старой. Ещё два правила иерархии. Контроллер включается только сверху вниз, у дочерней группы он появляется лишь при включённом у родителя. И в группе с живыми процессами нельзя включать контроллеры для детей, процессы сначала переводят в дочерние каталоги. Для защиты memory.min и memory.low на всю ветку сразу у монтирования есть опция memory_recursiveprot, иначе защиту приходится раскладывать по уровням вручную.
Четыре границы memory.min, memory.low, memory.high и memory.max
По сути, это четыре ступени давления, броня, мягкая защита, тормоз и потолок. Файл memory.min, добавленный в ядре 4.18, работает как жёсткая броня: пока группа укладывается в эффективную границу min, её память не вытесняется ни при каких условиях. Файл memory.low мягче, из незащищённых групп память забирают в первую очередь, и только когда брать больше нечего, очередь доходит до защищённых. Выше границы страницы уходят пропорционально перерасходу, поэтому небольшой перерасход создаёт слабое давление, а крупный жёсткое. Эффективная граница всегда ограничена значениями предков, и при перекоммите защиты внутри ветки лимиты распределяются между детьми пропорционально их использованию ниже границы. Ставить под защиту больше памяти, чем есть в машине, ядро прямо не рекомендует, это путь к постоянным OOM.
Файл memory.high это тормоз. При превышении процессы группы замедляются и сами тащат свои страницы наружу, а механизм OOM при этом не срабатывает никогда. В документации ядра memory.high прямо назван главным механизмом контроля, а перекоммит сумм высоких лимитов признан жизнеспособной стратегией. Файл memory.max это уже жёсткий потолок: если использование упёрлось и снижаться не хочет, внутри группы срабатывает OOM. Практичная связка для сервера на 16 ГиБ с СУБД, веб-приложением и ночными выгрузками выглядит так: базе memory.min=1G, memory.low=6G, memory.high=10G, memory.max=12G, а группе выгрузок memory.high=1500M и memory.max=2G. Наблюдение ведётся по файлу memory.events со счётчиками low, high, max, oom и oom_kill. Растущий счётчик high означает, что группу регулярно тормозят. Растущий low подсказывает, что защита перекоммичена и ядро вынуждено выгребать из защищённых зон.
Принудительное вытеснение через memory.reclaim и групповой swappiness
С ядра 5.19 у администратора есть собственная кнопка вытеснения, файл memory.reclaim. Запись в него просит ядро освободить запрошенный объём именно в этой группе, не дожидаясь нехватки памяти.
echo "1G" > memory.reclaim
echo "256M swappiness=150" > memory.reclaim
Ядро может вытеснить и больше, и меньше запрошенного, при недоборе запись вернёт ошибку -EAGAIN. Ключ swappiness задаёт режим конкретного захода, диапазон от 0 до 200, а значение max означает, что вытесняется только анонимная память. Смысл ручного вытеснения не в панике перед пиком нагрузки, а ровно в обратном. Прокатывающийся по группам лёгкий вытеснитель держит списки LRU отсортированными, оценка рабочего множества становится точнее, а поведение при оверкоммите предсказуемее. Это дополнение, а не замена фоновому потоку вытеснения kswapd и прямому вытеснению в контексте аллокаций. Объём такой работы виден по счётчику pgscan_proactive в memory.stat. Один нюанс стоит держать в голове: при таком вытеснении балансировка сетевой памяти не срабатывает, сетевой стек не подстраивается под вызванное вручную освобождение.
Swap под контролем memory.swap.max, memory.swap.high и vm.swappiness
Для свопа в memcg припасены собственные ручки. Файл memory.swap.max это жёсткий предел, при его достижении анонимные страницы группы перестают выгружаться. Понижение лимита ниже текущего использования, поддержка которого появилась в ядре 4.18, нагрузку не ломает: существующие записи свопа отыгрываются постепенно, и группа может долго оставаться выше лимита. Файл memory.swap.high, добавленный в ядре 5.8, работает иначе, это точка невозврата. Всё, что группа выгружает выше этой границы, замедляется, чтобы внешний управляющий процесс успел среагировать. Здоровая нагрузка до него доходить не должна. Счётчики high, max и fail в файле memory.swap.events показывают, как часто группа упиралась в эти границы.
Глобальный параметр vm.swappiness с ядра 5.8 принимает значения от 0 до 200. Значение 100 означает, что ядро считает стоимость выгрузки равной стоимости вытеснения файлового кеша, по умолчанию стоит 60. Если своп лежит на устройстве вдвое быстрее файловой системы, вычисление из документации ядра даёт 133, ведь x плюс 2x равно 200. Для zram и zswap, где выгрузка обходится почти даром, уместны значения заметно выше сотни. А при vm.swappiness=0 ядро вообще не начнёт свопить, пока свободных и файловых страниц в зоне не станет меньше верхнего водяного знака. Для группы с СУБД из примера выше разумно задать memory.swap.max=2G, а ночным выгрузкам выставить memory.swap.max=0, чтобы они не вытесняли своп приоритетов.
Водяные знаки ядра vm.min_free_kbytes, vm.watermark_scale_factor и vm.watermark_boost_factor
Глобальное планирование свободной памяти строится на трёх водяных знаках зоны, min, low и high. Ниже min аллокации идут в прямой вытеснение прямо в контексте приложения, и это чувствуется как рывки задержек. Достигнув low, ядро будит фоновый поток вытеснения kswapd, тот освобождает страницы и засыпает, когда свободной памяти набирается до high. За ширину зазоров между знаками отвечает vm.watermark_scale_factor. Единица измерения здесь доли от десяти тысяч, по умолчанию 10, то есть 0.1% памяти узла, потолок 3000 или 30%. На узле в 32 ГиБ зазор по умолчанию выходит около 33 МБ, а при значении 100 уже около 330 МБ. Диагноз по системе ведётся по счётчикам /proc/vmstat: рост allocstall и kswapd_low_wmark_hit_quickly говорит, что kswapd засыпает слишком рано и приложения регулярно влетают в прямой вытеснение, зазор пора расширять.
Файл vm.watermark_boost_factor отвечает за вытеснение при фрагментации. Значение по умолчанию 15000 означает, что при смешении страниц разной мобильности в блоках может быть вытеснено до 150% верхнего водяного знака. Задумка в том, чтобы у компакции оставалось меньше работы и успешнее шли крупные аллокации, от объектов SLUB до прозрачных огромных страниц. Значение 0 отключает механизм целиком. Параметр vm.min_free_kbytes задаёт основу для знака min. Меньше 1024 КиБ ставить нельзя, система станет хрупкой и склонной к зависаниям под нагрузкой, а завышенное значение приводит к OOM почти сразу после загрузки. Стартовое значение ядро вычисляет приблизительно как четыре корня из объёма памяти в килобайтах, для машины на 64 ГиБ это около 32768 КиБ, то есть 32 МБ. Постоянные значения удобно держать в файле /etc/sysctl.d/99-memory.conf и применять командой sysctl --system, а с ядра 5.8 те же параметры можно передавать строкой загрузки ядра, например sysctl.vm.swappiness=130.
Давление памяти в цифрах через memory.pressure и memory.stat
Объём свободной памяти плохой индикатор самочувствия, важнее знать, сколько времени задачи стоят в очереди за страницами. Эту метрику под названием PSI, Pressure Stall Information, ядро считает с релиза 4.20. В каждом каталоге cgroupfs лежат файлы memory.pressure, cpu.pressure и io.pressure, формат у них одинаковый.
some avg10=0.12 avg60=0.08 avg300=0.02 total=41234567
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
Строка some показывает долю времени, когда хотя бы часть задач стояла из-за нехватки ресурса. Строка full фиксирует состояние, когда стоят все незанятые задачи разом, процессор простаивает, а работа буксует, это и есть трэшинг в чистом виде. Окна 10, 60 и 300 секунд дают и всплески, и среднесрочные тренды. На файлах давления работают триггеры: в открытый файл /proc/pressure/memory записывается строка some 150000 1000000, и приложение получает уведомление, если простой превысил 150 мс в окне секунды, поддерживаемые окна от 500 мс до 10 секунд. Служба systemd-oomd, появившаяся в systemd 247, читает те же метрики и по директиве ManagedOOMMemoryPressure=kill способна завершить целый перегруженный cgroup раньше, чем пользователь заметит тормоза.
Углублённая картина собирается из файла memory.stat. Пары anon и file показывают, из чего состоит использование, slab и pagetables добавляют накладные расходы ядра. Счётчики pgscan_kswapd и pgscan_direct говорят, кто именно вытеснял, рост второго сигнализирует о работе на грани. Самая говорящая пара это workingset_refault_anon и workingset_refault_file: рост означает, что вытеснитель выбрасывает страницы, которые тут же запрашиваются обратно, то есть решает не ту задачу. Для свопов считаются pswpin и pswpout. Отдельно стоит memory.oom.group, переводящая группу в режим все или никто, и файл memory.peak, удобный для графиков пиковой нагрузки. Локальные события без наследования от детей видны в файле memory.events.local.
Практический порядок настройки через systemd и файлы sysctl
Ручное редактирование файлов в /sys/fs/cgroup отлично подходит для экспериментов, но после перезагрузки всё исчезает. Постоянство даёт systemd, и его директивы один в один отображаются на файлы memcg: MemoryHigh и MemoryMax появились в systemd 231, MemorySwapMax в 232, MemoryMin и MemoryLow в 240. Значения принимают суффиксы K, M, G и проценты от физической памяти машины. Рекомендация из документации systemd звучит жёстко: MemoryHigh основной регулятор, MemoryMax последняя линия обороны. Настройка через дроп-ин выглядит так.
systemctl edit postgresql.service
и в открывшемся редакторе:
[Service]
MemoryMin=1G
MemoryLow=6G
MemoryHigh=10G
MemoryMax=12G
MemorySwapMax=2G
Разовые изменения без рестарта службы делает команда systemctl set-property, например systemctl set-property backup.service MemoryMax=2G. Для типового сервера с одной приоритетной СУБД, веб-приложением и ночными выгрузками рабочий порядок выглядит так:
- разметить дерево групп и включить контроллер записью в cgroup.subtree_control;
- выставить приоритетам memory.min и memory.low, не превышая суммарно доступный объём;
- рабочим группам назначить memory.high с запасом на пики, а memory.max оставить страховкой;
- лимиты свопа расставить по memory.swap.max и memory.swap.high под тип хранилища;
- подобрать vm.swappiness, vm.watermark_scale_factor и vm.min_free_kbytes под характер нагрузки;
- закрепить наблюдение за memory.pressure и memory.events и корректировать границы по фактическим счётчикам.
Финальная проверка всегда одна и та же. После изменения границ стоит пару недель наблюдать за файлами давления и memory.events, затем сравнить workingset_refault до и после. Память из невидимого океана, в котором все барахтаются вперемешку, превращается в разложенный по группам ресурс с прозрачными правилами. И когда ночью очередной всплеск нагрузки придёт, ядро сделает ровно то, что ему заранее расписали, и никак иначе.