Приложение запускает дочерние процессы и хочет, чтобы ни один из них не съел всю память или не забил все ядра, оставив остальных голодать. Без контроля прожорливый потомок способен утянуть за собой всю систему: разрастётся в памяти до отказа, упрётся в нехватку и потянет за собой убийцу нехватки памяти, который начнёт отстреливать процессы наугад. Нужен механизм, который заранее очертит каждому потомку рамки потребления и не даст их превысить. Этот механизм в Linux называется контрольные группы, или cgroups, а удобную обвязку для работы с ними даёт библиотека libcgroup.

Контрольные группы это возможность ядра объединять процессы в иерархические группы и ограничивать, учитывать и контролировать потребление ими ресурсов: процессорного времени, памяти, дискового ввода-вывода и прочего. Группы организованы иерархически, подобно процессам, и дочерняя группа наследует ограничения родительской. Приложение, раскладывающее своих потомков по таким группам с заданными лимитами, получает над их аппетитами полную власть, превращая хаотичную конкуренцию за ресурсы в управляемое распределение.

Чем единая иерархия второй версии отличается от россыпи первой

Контрольные группы пережили крупную переделку, и понимать разницу версий важно. Первая версия позволяла каждому виду ресурса иметь свою отдельную иерархию: память жила в одном дереве, процессор в другом, и процесс мог оказаться в разных местах разных деревьев. Это давало гибкость ценой хаоса и трудноуловимых противоречий. Вторая версия, появившаяся в ядре 4.5 в 2016 году, всё перестроила: теперь иерархия процессов единая, одна на все ресурсы, а различает она процессы, а не отдельные потоки.

Эта унификация принесла два важных правила, оберегающих от путаницы. Первое: если у группы есть дочерние группы, она сама не может содержать процессы, за единственным исключением корневой группы. Процессы живут только в листьях дерева, а внутренние узлы лишь распределяют ресурсы между потомками. Второе правило единственного писателя: каждой группой в один момент должен управлять только один распорядитель, чтобы несколько программ не дрались за одни и те же настройки наперегонки. Оба правила введены намеренно, чтобы сократить беспорядок, которым грешила первая версия. Современные системы в основном перешли на вторую версию, и новый код стоит писать под неё.

Есть и тонкость с включением контроллеров вниз по дереву. Чтобы дочерняя группа могла распоряжаться, скажем, памятью и процессором, родитель обязан явно разрешить эти контроллеры для своих потомков через особый управляющий файл. Контроллер, не включённый у родителя, недоступен и детям, как бы они его ни просили. Это каскадное включение выстраивает строгую вертикаль: ресурс спускается от корня вниз, и на каждом уровне его можно лишь сузить, но не получить из ниоткуда. Понимание этой механики избавляет от недоумения, почему свежесозданная дочерняя группа вдруг не видит нужного контроллера, хотя в корне он явно присутствует.

Как создать группу, выставить лимиты и запустить в ней процесс

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

Самый прямой путь это ручная работа с файловой системой групп. Создаём каталог группы, пишем в файл лимита памяти максимально допустимое число байт, в файл лимита процессора пропорцию времени, а затем вписываем идентификатор процесса в файл состава группы. С этого мгновения процесс и все его потомки подчиняются выставленным рамкам. Выглядит это так:

# создаём группу
mkdir /sys/fs/cgroup/myapp_pen

# лимит памяти 100 мегабайт
echo 100M > /sys/fs/cgroup/myapp_pen/memory.max

# лимит процессора: 50000 микросекунд из каждых 100000, то есть половина ядра
echo "50000 100000" > /sys/fs/cgroup/myapp_pen/cpu.max

# помещаем процесс в группу по его идентификатору
echo $PID > /sys/fs/cgroup/myapp_pen/cgroup.procs

Библиотека libcgroup предлагает поверх этой механики набор удобных команд. Одна создаёт группу с указанием контроллеров и имени, другая позволяет задать значение конкретного параметра, третья запускает новый процесс сразу внутри группы. Последнее принципиально: в отличие от перемещения уже запущенного процесса, запуск нового прямо в группе означает, что ограничения действуют с самого первого мгновения его жизни, включая всех потомков, которых он породит. Создание группы и запуск в ней нагрузочного процесса через утилиты выглядят компактно:

# создать группу с контроллерами памяти и процессора
sudo cgcreate -g memory,cpu:myapp_pen

# выставить лимиты
sudo cgset -r memory.max=100M myapp_pen
sudo cgset -r cpu.max="50000 100000" myapp_pen

# запустить процесс сразу внутри группы
sudo cgexec -g memory,cpu:myapp_pen ./my_worker

Опция удержания позволяет не дать особому демону переклассификации растащить процесс и его потомков обратно по другим группам, что важно, когда в системе работает автоматическое распределение по правилам.

Особого внимания заслуживает то, как процесс попадает в группу и что происходит с его потомками. Когда процесс уже запущен, его можно переместить в группу, вписав идентификатор в файл состава, но это перемещение разовое: оно затрагивает именно этот процесс, а вот его будущие потомки наследуют группу автоматически, оставаясь в ней с момента рождения. Это ключевое свойство для контроля над целым деревом процессов: достаточно поместить в группу корневой процесс, и все порождённые им потомки окажутся под теми же рамками без отдельных действий. Именно поэтому запуск процесса сразу внутри группы предпочтительнее перемещения уже работающего: при перемещении остаётся короткое окно, когда процесс уже стартовал, но ещё не в группе, и за это время он успевает что-то натворить вне рамок, тогда как запуск внутри группы закрывает это окно начисто.

Что происходит, когда процесс упирается в выставленный лимит

Самое интересное начинается при достижении границы, и память с процессором ведут себя по-разному. Когда процесс в группе пытается занять памяти больше выставленного потолка, ядро не позволяет ему перешагнуть черту. Если высвободить память не удаётся, в дело вступает убийца нехватки памяти, но действует он теперь прицельно: отстреливает процесс внутри переполнившейся группы, а не наугад по всей системе. Это и есть главная защита: прожорливый потомок убивает сам себя своим аппетитом, не утягивая за собой остальных и не обрушивая всю машину.

Процессорный лимит работает мягче, через торможение. Группа с ограничением процессорного времени не убивается за превышение, ведь время нельзя занять впрок, его можно лишь использовать. Вместо этого планировщик попросту притормаживает процессы группы, не давая им суммарно использовать больше отведённой доли. Процесс, которому выделена половина ядра, при попытке загрузить его полностью будет получать процессор лишь половину времени, а остальное простаивать в ожидании. Это видно в инструментах наблюдения: нагрузочный процесс в группе с десятипроцентным лимитом честно показывает около десяти процентов использования, сколько бы он ни рвался в бой.

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

Стоит различать жёсткий и мягкий потолок памяти, потому что у группы их два. Жёсткий потолок это абсолютная черта, переступить которую процесс не может: упёршись в неё и не сумев высвободить память, он нарывается на прицельный отстрел. Мягкий потолок работает иначе, это уровень, выше которого ядро начинает активно давить на группу, отбирая у неё память под давлением, но не убивая сразу. Группа может временно превышать мягкий потолок, когда в системе есть свободная память, но при общем дефиците её прижмут к нему в первую очередь. Сочетание двух уровней даёт гибкую политику: мягкий потолок задаёт желаемый рабочий объём, а жёсткий ставит непреодолимый предел на случай выхода из-под контроля. Грамотный профиль часто выставляет оба, оставляя зазор между ними как буфер для всплесков нагрузки.

Полезно также учитывать память подкачки отдельно. У группы есть свой контроль над тем, сколько она может вытеснить в подкачку, и без его настройки лимит основной памяти можно невольно обойти, спихнув данные в своп. Поэтому при серьёзном ограничении памяти оба предела, на основную память и на подкачку, задают согласованно, иначе картина потребления окажется не такой, как задумано.

Какие подводные камни всплывают при работе с контрольными группами

Первая ловушка касается взаимодействия с менеджером системы. На современных дистрибутивах менеджер служб сам активно пользуется контрольными группами и владеет всей иерархией. Прямое вмешательство сторонней библиотеки в это дерево способно вступить в конфликт со штатной иерархией менеджера, из-за чего на некоторых системах классический пакет библиотеки объявили устаревшим именно из-за риска таких столкновений. Правило тут простое: не смешивать в одной системе самодеятельное управление через библиотеку и управление через менеджер служб, выбирать что-то одно, иначе два распорядителя начнут спорить за одни настройки в нарушение правила единственного писателя.

Вторая тонкость это делегирование. Чтобы непривилегированное приложение могло само создавать и настраивать группы под своими потомками, ему нужно делегировать поддерево иерархии, передав права на управление определённой ветвью. Без делегирования работа с группами требует прав суперпользователя, что для обычного приложения неудобно и небезопасно. Правильно настроенное делегирование позволяет приложению хозяйничать в своём поддереве, не трогая остальную систему и не запрашивая лишних прав.

Третья засада связана с различиями версий и дистрибутивов. Имена и формат файлов параметров между первой и второй версией отличаются: то, что в одной версии задаётся одним набором файлов, в другой выглядит иначе, а пути в файловой системе тоже разнятся. Код, жёстко завязанный на конкретную раскладку, ломается при переносе на систему с другой версией. Поэтому перед работой стоит определить действующую версию и подстроить под неё имена параметров, а не полагаться на единый формат. Узнать, какие контроллеры вообще скомпилированы в текущее ядро, можно через особый служебный файл, перечисляющий доступные подсистемы, и опираться на этот список, а не на предположения.

Сведём всё воедино. Контрольные группы дают приложению власть над потреблением ресурсов его дочерними процессами: память, процессорное время, число процессов, дисковый ввод-вывод раскладываются по группам с лимитами, и потомок уже не способен обрушить систему своей прожорливостью. Создать группу, выставить лимиты и запустить в ней процесс можно как прямой работой с файловой системой групп, так и удобными утилитами библиотеки, причём запуск процесса сразу внутри группы накрывает его рамками с первого мгновения. При переполнении памяти ядро прицельно убивает виновника, а превышение процессора гасится торможением. Помнить нужно про конфликт с менеджером служб, про необходимость делегирования для непривилегированной работы и про различия версий. Тогда дочерние процессы живут в очерченных рамках, а система остаётся отзывчивой, сколько бы ни старался разойтись отдельный прожорливый потомок.