Один неудачный импорт в базе данных способен за десять минут отнять процессор и память у всех соседей по серверу, а замечают это обычно по тормозам сайта, а не по журналам. Механизм cgroups v2 решает проблему заранее: он делит процессор, память и дисковый ввод-вывод между службами и задает каждой свой потолок. По сравнению с первой версией здесь одна общая иерархия, предсказуемые файлы настроек и честная статистика давления на ресурсы. В статье разобран полный цикл: сборка группы для службы, расчет лимитов cpu.max и memory.high, ограничение диска через io.max и поиск тесноты по индикаторам давления. Все примеры выполняются на живом сервере с ядром Linux, где иерархия уже смонтирована в /sys/fs/cgroup, обычно это делает система инициализации systemd при загрузке.

Единая иерархия cgroups v2 и включение контроллеров через cgroup.subtree_control

Вторая версия механизма отказалась от нескольких независимых иерархий и держит все группы в одном дереве. Каталог /sys/fs/cgroup показывает дерево целиком, каждая папка в нем соответствует группе, каждый файл внутри папки управляет одним параметром. Контроллеры, доступные группе, перечисляет файл cgroup.controllers, а включает их запись в файл cgroup.subtree_control. Контроллер распределяет ресурс между непосредственными потомками группы, поэтому включение идет строго сверху вниз, от корня к листьям.

cat /sys/fs/cgroup/cgroup.controllers
echo "+cpu +memory +io" > /sys/fs/cgroup/cgroup.subtree_control

Первая команда на типичном сервере печатает строку cpuset cpu io memory hugetlb pids rdma misc, вторая включает три главных контроллера сразу. После записи файл cgroup.subtree_control у корня отвечает строкой cpu io memory.

Два структурных правила здесь стоят часов отладки. Первое: контроллер попадает в cgroup.controllers дочерней группы только после включения у родителя, настроить что-то снизу вверх не выйдет. Второе: группа, внутри которой живут процессы, не имеет права делить ресурс между детьми, процессы обязаны оставаться в листьях дерева. Если дистрибутив собран по гибридной схеме и часть контроллеров перехвачена первой версией, параметр ядра cgroup_no_v1=all в настройках загрузчика оставляет их второй версии.

Каркас группы для веб-службы и перенос процессов в отдельный каталог

Группу создает обычная команда mkdir, интерфейсные файлы ядро порождает само.

mkdir /sys/fs/cgroup/webapp
echo 8421 > /sys/fs/cgroup/webapp/cgroup.procs
cat /proc/8421/cgroup
0::/webapp

Запись PID в cgroup.procs переносит процесс вместе со всеми потоками, а путь вида 0::/webapp в /proc/PID/cgroup подтверждает переселение. Чтобы включить контроллеры внутри webapp, придется соблюсти правило про листья: корневую группу webapp держат пустой, а процессы размещают в подкаталогах webapp/app и webapp/worker, каждый со своим набором лимитов. Пустой каталог без детей удаляется командой rmdir, но сначала из него выводят процессы и выключают контроллеры.

На серверах с systemd всю рутину берет на себя менеджер: свойство Delegate=yes в юните службы заставляет его создать группу /sys/fs/cgroup/system.slice/webapp.service и не вмешиваться в содержимое. Изменение свойств проходит и без правки файлов юнитов, через systemctl set-property webapp.service CPUQuota=150% MemoryHigh=1500M, менеджер сам пересчитает квоту в значения cpu.max и запишет их в группу. Отдельный контроллер pids ограничивает число процессов файлом pids.max и полезен против неуправляемого размножения потоков, событие упора фиксируется в pids.events.

Потолок и доля процессора в cpu.max и cpu.weight с расчетом периодов

Контроллер cpu предлагает два независимых инструмента. Файл cpu.max держит жесткий потолок и принимает пару значений MAX PERIOD, оба числа в микросекундах. Заводская запись означает отсутствие лимита при базовом окне в 100 миллисекунд. Значение 200000 отдает группе два ядра: за каждое окно она успевает получить 200 миллисекунд процессорного времени на сумме всех ядер.

# два ядра: 2 x 100000, окно по умолчанию
echo "200000 100000" > /sys/fs/cgroup/webapp/cpu.max
# половина ядра для вспомогательного каталога
echo "50000 100000" > /sys/fs/cgroup/webapp/worker/cpu.max

Дробные доли выходят делением: 50000 микросекунд при стандартном окне дают половину ядра, 12500 дают одну восьмую. Ручной расчет простой: нужное число ядер умножается на длительность окна в микросекундах и записывается первым значением, период трогать обычно незачем.

Расход виден в cpu.stat, где счетчики nr_periods и nr_throttled показывают, сколько окон прошло и сколько раз группа уперлась в границу, а throttled_usec суммирует потерянное время.

nr_periods 1248
nr_throttled 312
throttled_usec 15873000

Здесь из 1248 окон торможение пришлось на 312, суммарно группа потеряла около 15,9 секунды. Растущий nr_throttled при большом throttled_usec означает, что службе тесно, и лимит пора поднимать.

Файл cpu.weight делит ресурс по весам в диапазоне от 1 до 10 тысяч при значении по умолчанию 100. Весам 800 и 100 соответствует пропорция 8 к 1, когда группы конкурируют за занятые ядра, а при простое никого не ограничивает. Восемь равных групп с весом 100 получат по одной восьмой суммарной емкости; стоит поднять вес критичной службы до 800, и она заберет львиную долю. Привычные значения nice переводит файл cpu.weight.nice с диапазоном от -20 до 19. Дополняющий файл cpu.max.burst разрешает группе накопить неиспользованное время и потратить его одним куском, предел задается первым значением cpu.max.

Мягкий лимит memory.high и жесткий memory.max с контролем подкачки

Контроль памяти строится на паре границ. Жесткая memory.max останавливает выделение: если вытеснить страницы не удалось, вступает OOM killer, ядерный механизм принудительного завершения процессов, а счетчик oom_kill в файле memory.events прибавляет единицу. Мягкая memory.high работает раньше: при превышении ядро тормозит выделения и отправляет группу в прямое вытеснение, надавливая на службу до возврата расхода под черту. Основным инструментом контроля считается именно memory.high, потому что он не доводит службу до аварийного порога и оставляет наблюдателю время на реакцию.

echo 1610612736 > /sys/fs/cgroup/webapp/memory.high
echo 2147483648 > /sys/fs/cgroup/webapp/memory.max

Числа задаются в байтах, но принимаются и суффиксы M и G, те же границы записываются как 1500M и 2G. Рабочая пропорция: мягкую границу ставят примерно на двадцать процентов ниже жесткой, тогда вытеснение успевает до аварийного порога. Файл memory.current показывает текущий расход, memory.stat раскладывает его на анонимные страницы, файловый кеш и память ядра, а memory.events подводит счетный итог по ключам low, high, max, oom и oom_kill. Максимум расхода с момента создания группы запоминает memory.peak, запись любого значения сбрасывает его для следующих чтений через тот же дескриптор.

low 4
high 142
max 3
oom 1
oom_kill 2

Сто сорок два торможения при трех касаниях жесткой границы говорят о здоровом балансе, а постоянный рост high с нулевым max означает, что мягкая граница давит слишком сильно и памяти стоит добавить. Границы memory.low и memory.min защищают группу от вытеснения соседями: первая объявляет предпочтение, вторая жесткий минимум, который ядро не отнимет. Вытеснение запускают и вручную записью в memory.reclaim, например echo 1G, а если вытеснить меньше запрошенного, ядро вернет ошибку EAGAIN. Подкачка учитывается отдельно: memory.swap.max ограничивает суммарный размер вытесненных страниц, memory.swap.high вводит порог торможения выделений, а memory.swap.events фиксирует события high, max и fail.

Лимиты диска io.max по номерам устройств и веса io.weight

Контроллер io оперирует номерами устройств вида MAJ:MIN, поэтому сначала выясняют, под какими номерами видны диски.

lsblk -o NAME,MAJ:MIN,SIZE /dev/sda
NAME MAJ:MIN SIZE
sda    8:0  100G
sda1   8:1  100G

Файл io.max принимает четыре ключа: rbps и wbps ограничивают чтение и запись в байтах в секунду, riops и wiops считают операции в секунду. Значение 52428800 соответствует 50 МБ в секунду, а запись со словом max снимает ограничение, например 8:0 rbps=max возвращает группе полную скорость чтения.

echo "8:0 rbps=52428800 wbps=20971520 riops=200 wiops=100" > /sys/fs/cgroup/webapp/io.max

Фактический расход раскрывает io.stat с ключами rbytes, wbytes, rios и wios по каждому устройству.

8:0 rbytes=90430464 wbytes=299008000 rios=8950 wios=1252 dbytes=50331648 dios=3021

Здесь прочитано около 90 МБ, записано около 299 МБ, а ключи dbytes и dios считают сброшенные области. Пропорциональное деление без абсолютных потолков дает io.weight, в его основе лежит контроллер стоимости ввода-вывода iocost. На корне дерева его настраивают через io.cost.qos: строка 8:16 enable=1 ctrl=auto rpct=95.00 rlat=75000 wpct=95.00 wlat=150000 min=50.00 max=150.0 означает, что устройство считается насыщенным при 95 процентиле задержки чтения выше 75 миллисекунд или записи выше 150, после чего общий темп плавает между 50 и 150 процентами расчетной скорости. Дальше группы делят полосу по io.weight, большему весу достается большая доля.

Диагностика давления через memory.pressure, cpu.stat и memory.events

Индикатор давления PSI отвечает на главный вопрос настройки: ресурса действительно не хватает или просто страшно. Файл memory.pressure печатает две строки.

some avg10=0.85 avg60=0.41 avg300=0.12 total=92535041
full avg10=0.00 avg60=0.00 avg300=0.00 total=0

Строка some считает долю времени, когда ждать памяти приходилось хотя бы одной задаче, строка full, когда ждали все непрерывно. Ключи avg10, avg60 и avg300 усредняют картину за 10, 60 и 300 секунд, а поле total копит суммарные микросекунды ожидания. Постоянные значения около 0.8 в avg10 при нулевом full означают тесноту, которую служба пока выравнивает, а ненулевой full сигналит, что тормозит вся группа целиком. Парные файлы cpu.pressure и io.pressure читаются так же и закрывают процессор с диском.

Наблюдение удобно поставить на минутный цикл, чтобы видеть динамику событий, а не разовые всплески.

while true; do grep -H . /sys/fs/cgroup/webapp/memory.events /sys/fs/cgroup/webapp/cpu.stat; sleep 60; done

Файл cgroup.events с полем populated показывает, есть ли живые процессы в группе, и меняется вместе с их появлением и завершением. Сочетание растущего oom_kill, ненулевого full в memory.pressure и стабильного nr_throttled дает полную картину: по какому ресурсу служба упирается и насколько сильно.

Порядок внедрения и типовые ошибки настройки лимитов

Первые эксперименты разумно вести на некритичной службе, потому что завышенный memory.max при нулевом memory.high создает ложное спокойствие: группа молча копит страницы до предела и обрушивается сразу жестко. Проверенный маршрут внедрения выглядит так:

  1. включить контроллеры cpu, memory и io в cgroup.subtree_control корня иерархии;
  2. создать каталог группы и перенести туда PID родительского процесса приложения;
  3. записать cpu.max с расчетом от базового периода в 100 миллисекунд;
  4. поставить memory.high примерно на двадцать процентов ниже memory.max и проверить memory.swap.max;
  5. снять номера MAJ:MIN через lsblk и записать лимиты в io.max для каждого занятого устройства;
  6. неделю наблюдать memory.events, cpu.stat и memory.pressure, прежде чем ужесточать границы.

Ошибок немного, но каждая стоит часов. Запись в cgroup.subtree_control дочерней группы до включения контроллера у родителя завершится отказом, ядро просто не примет строку. Лимит только на чтение без записи часто бесполезен для баз данных, которые нагружают диск именно записью. Вес без потолка не спасает от очередей ввода-вывода, пока iocost не включен на устройстве. Менять границы можно на ходу: достаточно повторной записи в тот же файл, перезапускать службу или переносить процессы не нужно. И последнее, лимиты не заменяют профилирование приложения, они лишь удерживают ущерб в пределах одной группы, когда что-то идет не по плану.