Зачем вам cgroups v2, если есть Docker
В production лимиты ресурсов важны не потому, что «так правильно», а потому что без них один неудачный контейнер способен уронить весь узел: забить память до OOM, выжечь CPU на 100%, устроить I/O-шторм и сделать сервисы «живыми, но бесполезными». В Linux за изоляцию ресурсов отвечает механизм control groups — cgroups.
Docker умеет ставить лимиты контейнерам, но на современных дистрибутивах всё чаще используется cgroups v2, а роль «дирижёра» берёт на себя systemd resource control. Связка systemd + cgroups v2 позволяет вводить production limits не только для контейнеров, но и для демона Docker/containerd, групп сервисов, отдельных systemd-юнитов и целых «слайсов».
Дальше — практичные шаги: как понять, что у вас cgroups v2, как посмотреть текущие лимиты, и как правильно ограничивать Docker-хост через systemd (CPU/RAM/IO) так, чтобы изменения были предсказуемыми и сохранялись после перезагрузки.
Как понять, что включён cgroups v2
Главный признак: смонтирован единый cgroup2 и systemd активно им управляет.
stat -fc %T /sys/fs/cgroup
Если команда вернёт cgroup2fs — это v2.
Ещё одна быстрая проверка:
mount | grep cgroup
В режиме v2 вы увидите строку с type cgroup2 для /sys/fs/cgroup.
И полезно сразу посмотреть, где systemd разместил Docker в дереве и разрешил ли делегирование:
systemctl show docker.service --property=ControlGroup --property=Delegate --property=Slice
Поле ControlGroup покажет путь, а Delegate=yes важно для контейнерных рантаймов (почему — разберём ниже).
Чтобы изолировать «шумные» сборки/воркеры от остальной инфраструктуры, часто помогает вынести контейнерные задачи на отдельный VDS и уже там жёстко зафиксировать лимиты по CPU/RAM/IO.
После внедрения лимитов обязательно прогоните нагрузочные сценарии (сборки, бэкапы, пики трафика) и проверьте, что хост остаётся отзывчивым для SSH и системных демонов.

Базовая модель: кто кому ставит лимиты
С точки зрения ядра, лимиты применяются к cgroup. С точки зрения администратора, чаще всего вы применяете их:
- к системному сервису (например,
docker.serviceилиcontainerd.service); - к «слайсу» (группе сервисов, например,
system.sliceили кастомномуdocker.slice); - к отдельному контейнеру (через Docker flags / Compose), что тоже превращается в параметры cgroup.
Ключевой момент: лимит, заданный выше по дереву (на уровне сервиса/слайса), ограничивает сумму потребления всех дочерних процессов. Поэтому для production обычно работает двухуровневая стратегия:
- «Потолок» на уровне Docker/контейнерного пула (systemd-юнит или slice), чтобы узел не умер полностью.
- Более строгие лимиты на уровне отдельных контейнеров/сервисов, чтобы они не мешали друг другу.
Проверяем текущие ограничения: systemd, cgroup, Docker
1) Посмотреть лимиты systemd у юнита
systemctl show docker.service -p MemoryMax -p MemoryHigh -p CPUQuota -p CPUWeight -p IOWeight -p TasksMax
Если значения пустые или infinity — systemd не ограничивает этот юнит.
2) Посмотреть потребление ресурсов (быстро и наглядно)
systemd-cgtop
Удобный способ увидеть, какие cgroup реально «едят» CPU/память прямо сейчас.
3) Проверить, что Docker реально работает поверх cgroups v2
docker info | grep -i cgroup
Обычно вас интересует строка Cgroup Version. Если там 2 — хорошо. Если 1 — на узле могут быть включены legacy-настройки ядра или вы на старом дистрибутиве.
Важно: «включён cgroups v2» и «Docker использует v2» — не всегда одно и то же на нестандартных конфигурациях. В production лучше один раз зафиксировать это проверками выше и задокументировать для команды.
Если вы дополнительно раскладываете сервисы по слайсам (например, выносите пул PHP-FPM/веба отдельно), пригодится разбор подхода со слайсами и иерархией: лимиты через systemd slices и иерархию cgroups.
Лимиты памяти: MemoryMax, MemoryHigh и OOM-поведение
Память — главный источник внезапных аварий. В cgroups v2 есть несколько уровней контроля. На практике чаще всего используют:
MemoryMax— жёсткий предел; при превышении начнутся OOM-киллы в рамках cgroup.MemoryHigh— «мягкий» порог; ядро начинает давить (reclaim), но не убивает сразу.MemorySwapMax— ограничение swap (если swap включён).
Почему MemoryHigh полезен: он даёт системе шанс «притормозить» нарушителя без мгновенного убийства. Для сервисов с кэшами (reverse proxy, приложения с JIT/кэшированием) это часто более предсказуемо, чем жизнь на грани OOM.
Временная установка лимита (без правки файлов)
sudo systemctl set-property docker.service MemoryHigh=6G MemoryMax=8G
Проверяем:
systemctl show docker.service -p MemoryHigh -p MemoryMax
На практике это удобно, когда вы вводите лимиты «аккуратно»: ставите пороги, наблюдаете метрики/логи, и только потом фиксируете конфигурацию на диске.
Постоянная установка лимита через drop-in
sudo systemctl edit docker.service
Добавьте:
[Service]
MemoryHigh=6G
MemoryMax=8G
Применяем изменения:
sudo systemctl daemon-reload
sudo systemctl restart docker.service
Если на узле крутятся критичные контейнеры, перезапуск Docker может быть нежелателен: он обычно остановит контейнеры. В этом случае используйте set-property на живой системе, а «окно» на перезапуск планируйте отдельно, чтобы зафиксировать конфигурацию.
Частая ошибка: лимит поставили, а «память всё равно кончается»
Проверьте три вещи:
- Лимит стоит именно на том юните/слайсе, где реально находятся процессы контейнеров (сверяйте
systemctl show -p ControlGroupи/proc/PID/cgroup). - Вы не путаете память cgroup и «общесистемные» вещи (например, сильный файловый кэш или память, которую съедают процессы вне ограниченной cgroup).
- Не «съедает» память другой пул процессов (например, база данных на хосте без ограничений в
system.slice).
Лимиты CPU: CPUQuota, CPUWeight и ожидания от них
CPU в systemd обычно ограничивают двумя подходами:
CPUQuota— жёсткая квота в процентах от одного ядра (в терминах systemd). Например,200%примерно означает «два ядра».CPUWeight— относительный вес (share), работает при конкуренции: кто важнее, тому больше.
Для production чаще выбирают CPUQuota как «страховочный потолок», а веса используют для приоритизации (например, веб важнее фоновых задач).
Пример: ограничить Docker-пул до 3 ядер
sudo systemctl set-property docker.service CPUQuota=300%
Проверка:
systemctl show docker.service -p CPUQuota -p CPUQuotaPerSecUSec
Нюанс: кратковременные «спайки» CPU
Даже при CPUQuota кратковременные всплески возможны на уровне планировщика и статистики (особенно если меряете «в моменте»). Корректнее оценивать на интервале по метрикам и сравнивать с ожидаемым профилем нагрузки.
I/O и дисковая справедливость: IOWeight и реальные ограничения
В cgroups v2 с systemd вы часто видите IOWeight. Это вес для распределения дискового времени между группами, а не жёсткий лимит мегабайт/сек.
Почему это важно: IOWeight помогает, когда несколько групп конкурируют за один диск. Если диск свободен — высокий вес не ускорит, низкий не замедлит. Но когда начинается «толкотня», веса решают, кто «душится» первым.
Пример: дать Docker меньший вес, чем системным сервисам
sudo systemctl set-property docker.service IOWeight=200
Диапазон веса у systemd — от 1 до 10000. Если вы хотите, чтобы сервисы на хосте (например, база данных или SSH) оставались отзывчивыми при бэкапах/сборках внутри контейнеров, вы снижаете вес Docker-пула или, наоборот, повышаете вес критичных сервисов в отдельном slice.
Как проверить эффект
Проверка «на глаз» — через latency и очередь диска. Минимальный набор:
iostat -xz 1
Если видите высокий await и загрузку диска под 100% при «шуме» от контейнеров — веса и разнесение по cgroup часто помогают стабилизировать хост.
Делегирование и Docker: почему важен Delegate
Контейнеры сами создают вложенные cgroup для процессов внутри. Чтобы systemd не мешал этому, для сервиса Docker обычно включают делегирование: Delegate=yes. В большинстве современных пакетов Docker это уже сделано, но при кастомных юнитах/альтернативных рантаймах встречаются сюрпризы.
Проверьте:
systemctl show docker.service -p Delegate
Если Delegate=no, возможны странные эффекты: контейнеры не могут корректно управлять подгруппами, а лимиты применяются не так, как вы ожидаете. Исправление зависит от вашей схемы запуска Docker (пакетный юнит или свой), но общий принцип один — делегировать управление поддеревом контейнерному рантайму.
Куда ставить лимиты: docker.service или отдельный slice
Ограничивать прямо docker.service — быстро и просто. Но в production часто удобнее сделать отдельный slice (например, docker.slice) и запускать в нём связанные сервисы (docker, containerd, иногда buildkit), чтобы управлять потолком «для контейнерного мира» централизованно.
Практический плюс slice: вы меньше привязаны к конкретному названию юнита и можете переносить политику между узлами, где docker/containerd могут отличаться.
Пример: политика лимитов для docker.slice
sudo systemctl edit docker.slice
Содержимое:
[Slice]
CPUQuota=400%
MemoryHigh=10G
MemoryMax=12G
IOWeight=200
Дальше нужно убедиться, что нужные сервисы реально живут в этом slice. Для юнита это задаётся параметром Slice= (обычно в секции [Service]), например через drop-in для конкретного сервиса.
Если вы впервые вводите ограничения на узле, начинайте с мягких порогов (
MemoryHigh, веса CPU/IO) и наблюдайте. Жёсткие потолки (MemoryMax,CPUQuota) вводите после того, как собрали базовую статистику потребления.

Защита файловой системы: ProtectSystem в связке с Docker
ProtectSystem — это не про cgroups, но это важная часть «production hardening» в systemd. Параметр делает части файловой системы доступными только для чтения для процесса сервиса.
Где это может быть полезно: для вспомогательных демонов вокруг Docker, для самописных воркеров, для сервисов на хосте. Но к самому docker.service применять ProtectSystem=strict чаще всего нельзя без тщательной настройки: Docker должен писать в свои каталоги (например, /var/lib/docker), работать с сокетами и т.д.
Тем не менее, понимать механику полезно: если вы запускаете отдельный systemd-юнит для обслуживания (лог-шейпер, агент бэкапов, сбор метрик), то ProtectSystem вместе с ReadWritePaths и NoNewPrivileges может заметно снизить последствия компрометации.
Пример: жёсткая защита для утилитарного сервиса
[Service]
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/myapp
NoNewPrivileges=true
Для Docker-хоста это чаще применяют не к Docker, а к окружению: агентам, синхронизаторам, экспортерам, где запись нужна в 1–2 каталога.
Как отлаживать: почему лимиты «не сработали»
Проверяем, что процессы действительно внутри нужной cgroup
Найдите PID интересующего процесса (например, dockerd) и посмотрите его cgroup:
pidof dockerd
cat /proc/$(pidof dockerd | awk '{print $1}')/cgroup
В v2 вы увидите одну строку с путём. Сверьте её с ControlGroup у соответствующего юнита.
Смотрим параметры через systemctl, а не «на глаз»
Частый источник путаницы — вы измеряете «всю систему», а ограничили только часть. Для корректной картины используйте:
systemctl status docker.service
systemctl show docker.service -p Slice -p ControlGroup
systemd-cgtop
Ловим OOM-события и причины
Если сработал OOM внутри cgroup, логика обычно такая: лимит памяти достигнут, ядро выбирает «жертву» в рамках группы. Ищите следы:
journalctl -k -g oom
journalctl -u docker.service --since "-1h"
Полезно также понимать, включён ли на системе дополнительный менеджер давления памяти (например, systemd-oomd), но это отдельная тема. Здесь важно: MemoryMax — это не предупреждение, это «стена».
Практические рекомендации для production limits на Docker-хосте
- Сначала зафиксируйте базовую телеметрию: хотя бы пики RAM/CPU по узлу и по контейнерам, иначе лимиты будут «с потолка».
- Делайте два уровня: общий потолок на пул контейнеров (юнит/слайс) и точные лимиты на критичные контейнеры.
- Память ограничивайте аккуратно: начните с
MemoryHigh, затем добавляйтеMemoryMaxс запасом. - CPU: потолок + приоритеты:
CPUQuotaкак «не дать съесть всё»,CPUWeightдля справедливости. - I/O: это про конкуренцию:
IOWeightпомогает, когда диск становится узким местом, но не заменяет правильную архитектуру хранения. - Безопасность systemd применяйте точечно:
ProtectSystemотлично подходит для «утилитарных» сервисов, но для Docker требует продуманного списка разрешённых путей.
Если вместе с лимитами вы наводите порядок и по транспортному уровню (HTTPS, mTLS в отдельных компонентах, безопасные панели), проще централизованно закрывать такие задачи через нормальные SSL-сертификаты и не держать «временные» самоподписанные решения в production.
Мини-чеклист: что проверить после внедрения
- После перезагрузки лимиты сохранились (drop-in на месте):
systemctl show .... - Процессы действительно в нужном slice/cgroup:
/proc/PID/cgroup,systemctl show -p ControlGroup. - При нагрузке узел остаётся отзывчивым: SSH не «умирает», системные сервисы не голодают по CPU/IO.
- При достижении лимитов поведение ожидаемое: нет лавины рестартов, есть понятные логи OOM/pressure.
Итог
Связка cgroups v2 + systemd resource control — практичный способ навести порядок с ресурсами на Docker-хосте: задать понятные production limits, защитить узел от «плохих» контейнеров и сделать поведение предсказуемым. Начинайте с наблюдения и мягких ограничений, фиксируйте конфиг через drop-in, и постепенно вводите жёсткие потолки там, где они действительно нужны.
Если вы ещё и усиливаете изоляцию контейнеров (дополнительные sandbox-рантаймы), посмотрите обзор подходов и компромиссов: изоляция контейнеров через gVisor и Firecracker: подходы и компромиссы.


