До появления cgroups управление ресурсами на Linux было грубым. Можно было установить приоритет процесса через nice, можно было убить его вручную, если он вышел из-под контроля. Но предотвратить ситуацию, когда один процесс забирает себе 95% CPU или всю доступную память и валит соседей, было практически невозможно без сторонних решений. Инженеры Google столкнулись с этим в 2006 году и написали то, что сначала назвали "контейнерами процессов", а затем переименовали в control groups. В 2008 году эта функциональность вошла в ядро 2.6.24. С тех пор каждый Docker-контейнер, каждый Kubernetes pod и каждый systemd-юнит работает именно на ней.

Что такое cgroup и как она устроена как файловая система

Cgroup представляет собой группу процессов, к которой применены правила использования ресурсов. Весь интерфейс управления cgroups реализован через псевдофайловую систему. Нет специальных системных вызовов для создания или изменения групп. Создать cgroup означает создать директорию. Установить лимит означает записать число в файл. Добавить процесс в группу означает записать его PID в файл cgroup.procs.

Проверить, какая версия активна на системе:

stat -fc %T /sys/fs/cgroup/
# cgroup2fs означает v2
# tmpfs означает v1

Посмотреть доступные контроллеры:

cat /sys/fs/cgroup/cgroup.controllers
# cpuset cpu io memory hugetlb pids rdma

Создать cgroup вручную и запустить в ней процесс:

# создать группу
mkdir /sys/fs/cgroup/myapp

# ограничить память до 256 МБ
echo "268435456" > /sys/fs/cgroup/myapp/memory.max

# ограничить CPU до 50% одного ядра
echo "50000 100000" > /sys/fs/cgroup/myapp/cpu.max

# добавить текущий шелл в группу
echo $$ > /sys/fs/cgroup/myapp/cgroup.procs

# убедиться что процесс в группе
cat /sys/fs/cgroup/myapp/cgroup.procs

После записи PID в cgroup.procs ядро немедленно начинает применять ограничения. Никакого перезапуска процесса не требуется.

Разница между v1 и v2 и почему архитектура первой версии была сломана

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

В v1 процесс мог одновременно находиться в разных позициях дерева для разных контроллеров. Например, в /sys/fs/cgroup/memory/groupA по памяти и в /sys/fs/cgroup/cpu/groupB по CPU. Это порождало противоречивые ситуации и делало поведение непредсказуемым. Пути в v1 выглядели так:

/sys/fs/cgroup/memory/docker/CONTAINER_ID/memory.limit_in_bytes
/sys/fs/cgroup/cpu/docker/CONTAINER_ID/cpu.cfs_quota_us

В v2 всё унифицировано в одном дереве:

/sys/fs/cgroup/system.slice/docker-CONTAINER_ID.scope/memory.max
/sys/fs/cgroup/system.slice/docker-CONTAINER_ID.scope/cpu.max

Включить нужные контроллеры для дочерних групп:

# посмотреть какие контроллеры активны в дочерних группах
cat /sys/fs/cgroup/cgroup.subtree_control

# включить cpu и memory для дочерних групп
echo "+cpu +memory" >> /sys/fs/cgroup/cgroup.subtree_control

# создать дочернюю группу и проверить наличие файлов контроллеров
mkdir /sys/fs/cgroup/myapp
ls /sys/fs/cgroup/myapp/
# теперь здесь есть cpu.max, memory.max, memory.high и другие файлы

CPU controller и разница между жёсткой квотой и относительным весом

CPU-контроллер в cgroups v2 управляет двумя разными механизмами. Первый называется cpu.max и задаёт жёсткую квоту. Второй называется cpu.weight и задаёт относительный вес при конкуренции.

cpu.max записывается в формате "quota period", оба значения в микросекундах:

# не более 50% одного CPU (50ms из каждых 100ms)
echo "50000 100000" > /sys/fs/cgroup/myapp/cpu.max

# не более 200% (два ядра)
echo "200000 100000" > /sys/fs/cgroup/myapp/cpu.max

# убрать ограничение
echo "max 100000" > /sys/fs/cgroup/myapp/cpu.max

cpu.weight задаёт приоритет при конкуренции. Значение по умолчанию 100. Если одна группа имеет вес 200, а другая 100, при нагрузке первая получит вдвое больше CPU-времени:

# повысить приоритет группы
echo "200" > /sys/fs/cgroup/myapp/cpu.weight

# посмотреть статистику троттлинга
cat /sys/fs/cgroup/myapp/cpu.stat
# nr_periods: сколько раз истекал период квоты
# nr_throttled: сколько раз группа была заторможена
# throttled_usec: суммарное время под троттлингом

Высокое значение nr_throttled при просмотре cpu.stat сигнализирует о том, что приложение регулярно упирается в лимит. В Kubernetes это проявляется как latency-спайки, не связанные с нагрузкой на само приложение. Решение зависит от ситуации: либо поднять лимит, либо оптимизировать код, либо перейти на cpu.weight вместо жёсткой квоты там, где небольшие всплески допустимы.

Memory controller и три уровня ограничений

Контроллер памяти в cgroups v2 предоставляет три уровня ограничений с разной семантикой. Понимание разницы между ними важно для правильной настройки контейнеров.

memory.max является жёстким потолком. При его достижении ядро запускает OOM killer внутри cgroup и убивает процессы группы, чтобы освободить память. Это аварийный предел:

# жёсткий лимит 512 МБ
echo "536870912" > /sys/fs/cgroup/myapp/memory.max

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

# мягкий предел 400 МБ
echo "419430400" > /sys/fs/cgroup/myapp/memory.high

memory.min гарантирует группе минимальный объём памяти. Ядро не будет свопировать страницы группы, пока она не превысит этот минимум, даже при нехватке памяти в системе:

# гарантировать минимум 128 МБ
echo "134217728" > /sys/fs/cgroup/myapp/memory.min

Посмотреть текущее потребление и статистику:

# текущее использование памяти
cat /sys/fs/cgroup/myapp/memory.current

# подробная статистика
cat /sys/fs/cgroup/myapp/memory.stat
# anon: анонимная память (heap, stack)
# file: кешированные файлы
# slab: память ядра для объектов
# oom_kill: сколько раз срабатывал OOM killer

Типичная ошибка при настройке контейнеров в Kubernetes состоит в том, что memory.max выставляется без memory.high. В результате приложение работает нормально вплоть до самой границы, а потом внезапно падает с OOM. Правильная практика предполагает выставлять memory.high примерно на 80-90% от memory.max, чтобы у приложения было время среагировать на сигнал давления памяти.

Как Docker и Kubernetes используют cgroups под капотом

Когда выполняется команда docker run с параметрами ограничения ресурсов, Docker создаёт cgroup и записывает в неё нужные значения:

# запустить контейнер с ограничениями
docker run -d \
    --name myapp \
    --memory 512m \
    --memory-reservation 256m \
    --cpus 1.5 \
    nginx

# найти cgroup контейнера
CONTAINER_ID=$(docker inspect myapp --format '{{.Id}}')
ls /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/

# посмотреть реальные значения которые Docker записал
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/cpu.max

Kubernetes управляет ресурсами через поля requests и limits в спецификации пода. Значение requests.memory отображается в memory.min (гарантия), а limits.memory в memory.max (потолок). Аналогично для CPU: requests.cpu задаёт cpu.weight, limits.cpu задаёт cpu.max.

Посмотреть cgroup конкретного пода в Kubernetes:

# найти cgroup пода
kubectl get pod mypod -o jsonpath='{.metadata.uid}'

# cgroup будет по пути вида
ls /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/
# kubepods-burstable-podUID.slice/

# проверить реальные лимиты
cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/\
kubepods-burstable-podUID.slice/memory.max

QoS-классы Kubernetes (Guaranteed, Burstable, BestEffort) напрямую влияют на то, в какой поддиректории cgroup-дерева окажется под и насколько агрессивно ядро будет отбирать у него ресурсы при нехватке.

IO controller и защита от голодания дисковой подсистемы

Контроллер io в cgroups v2 управляет дисковым вводом-выводом. Это особенно важно в многоарендных средах, где одно приложение с интенсивной записью способно заблокировать диск для всех остальных.

Посмотреть доступные устройства и их major:minor номера:

ls -la /dev/sda
# 8, 0 для /dev/sda

Установить лимиты на чтение и запись:

# ограничить до 10 МБ/с на чтение и запись для /dev/sda (8:0)
echo "8:0 rbps=10485760 wbps=10485760" > /sys/fs/cgroup/myapp/io.max

# ограничить IOPS
echo "8:0 riops=1000 wiops=500" > /sys/fs/cgroup/myapp/io.max

# посмотреть статистику io
cat /sys/fs/cgroup/myapp/io.stat
# 8:0 rbytes=... wbytes=... rios=... wios=... dbytes=... dios=...

io.weight задаёт относительный приоритет при конкуренции за диск, аналогично cpu.weight:

# повысить приоритет io для критичного сервиса
echo "default 200" > /sys/fs/cgroup/myapp/io.weight

# или для конкретного устройства
echo "8:0 300" > /sys/fs/cgroup/myapp/io.weight

systemd как фронтенд для cgroups и мониторинг в реальном времени

systemd с версии 232 использует cgroups v2 по умолчанию и предоставляет удобный интерфейс для управления ресурсами сервисов без прямой записи в /sys/fs/cgroup.

Ограничить ресурсы существующего сервиса без редактирования файлов:

# установить лимит памяти для сервиса
systemctl set-property myapp.service MemoryMax=512M

# установить лимит CPU
systemctl set-property myapp.service CPUQuota=150%

# установить вес CPU
systemctl set-property myapp.service CPUWeight=200

# изменения сохраняются в /etc/systemd/system/myapp.service.d/

Посмотреть потребление ресурсов всех юнитов в реальном времени:

systemd-cgtop

Эта команда показывает иерархию cgroups с текущим потреблением CPU, памяти и IO, обновляя данные каждую секунду. Это удобный способ быстро понять, какой сервис или контейнер потребляет больше всего ресурсов прямо сейчас.

Посмотреть полную иерархию cgroups с привязкой к systemd-юнитам:

systemd-cgls

Понять, почему cgroups стали фундаментом контейнеризации, несложно. Процессы внутри Docker-контейнера или Kubernetes-пода физически ничем не отличаются от любых других процессов на хосте. Изоляция по ресурсам достигается не за счёт виртуализации, а за счёт того, что ядро честно считает потребление каждой группы и применяет заданные пределы. Контейнер, который "видит" 2 ядра и 1 ГБ памяти, в реальности работает на том же железе, что и все остальные, но ядро следит за тем, чтобы он не вышел за выделенные ему границы. Cgroups и есть эти границы.