MAATRIX / Блог / Как cgroups ограничивают контейнер и что происходит при упоре в лимит

Как cgroups ограничивают контейнер и что происходит при упоре в лимит

MAATRIX

Контейнер падает без единой строчки в логе приложения, а вы часами гадаете, что случилось — база данных, сеть, диск? Часто ответ проще, чем кажется: контейнер уперся в лимит, который вы сами же выставили в docker run или docker-compose.yml. Разница между «убило по памяти» и «замедлило по CPU» огромная, и если понимать, как это работает на уровне cgroups, диагностика превращается из гадания в две команды.

Иерархия cgroups v2: контроллеры, которые видит Docker

Начиная с ядер, где включён unified hierarchy (cgroup v2 — сейчас это дефолт в Ubuntu 22.04+, Debian 11+, AlmaLinux 9), все ограничения ресурсов живут в одном дереве под /sys/fs/cgroup/. У каждого контроллера — своя зона ответственности, и Docker использует четыре основных:

  • cpu — сколько процессорного времени может использовать группа процессов, через cpu.max и cpu.weight.
  • memory — сколько оперативной памяти (и подкачки) можно занять, через memory.max, memory.high, memory.swap.max.
  • io — приоритет и лимиты на дисковый ввод-вывод, через io.max и io.weight.
  • pids — максимальное число процессов/потоков внутри группы, через pids.max. Это защита от fork-бомб внутри контейнера.

Каждый контейнер — это отдельная cgroup-директория. С драйвером cgroupfs путь выглядит примерно как /sys/fs/cgroup/docker/<container_id>/, с драйвером systemd (сейчас это дефолт у большинства дистрибутивов) — /sys/fs/cgroup/system.slice/docker-<container_id>.scope/. Проверить, какой драйвер у вас, можно так:

docker info | grep -i "cgroup driver"

Важно понимать: это не виртуализация и не отдельное ядро. Все процессы контейнера — обычные процессы хоста, просто ядро считает их ресурсы и обрезает по лимитам конкретной cgroup. Отсюда и вытекает вся логика того, что происходит при упоре в лимит — реагирует не гипервизор, а планировщик и OOM killer самого хоста, только в границах одной группы.

Как Docker транслирует флаги в лимиты cgroup

Флаги docker run — это просто удобный интерфейс поверх записи значений в файлы cgroup. Разберём по одному.

--memory (-m). Задаёт memory.max — жёсткий потолок потребления RSS+cache памяти группой. Пример:

docker run -d --memory=512m --memory-swap=512m myapp

Если не задать --memory-swap отдельно, Docker по умолчанию выставит его равным --memory, то есть подкачка для контейнера будет запрещена — либо влезли в 512 МБ, либо получите OOM. Если явно указать --memory-swap больше, чем --memory, разница станет доступным контейнеру swap-пространством.

--cpus. Начиная с Docker 1.13 это самый понятный способ ограничить CPU — говорит «этому контейнеру доступно не больше N процессорных ядер в пересчёте на время». Внутри это транслируется в cpu.max:

docker run -d --cpus=1.5 myapp

даёт группе квоту, эквивалентную полутора ядрам за период (обычно 100000 микросекунд period, 150000 microsecond quota — именно так это записано в cpu.max как 150000 100000).

--cpu-shares. Это не жёсткий лимит, а вес при дележе CPU между контейнерами, когда ядра реально заняты все. Транслируется в cpu.weight. Если ядра простаивают, --cpu-shares вообще ни на что не влияет — контейнер может занять всё доступное время. Вес работает только в конкуренции.

--pids-limit. Ограничивает число процессов/потоков — полезно как защита от рекурсивного форка внутри приложения, который иначе способен положить весь хост.

Таблица для быстрой ориентации:

Флаг DockerФайл cgroup v2Тип лимита
--memorymemory.maxжёсткий потолок
--memory-swapmemory.swap.maxжёсткий потолок на своп
--cpuscpu.maxжёсткая квота времени
--cpu-sharescpu.weightвес при конкуренции
--pids-limitpids.maxжёсткий потолок числа процессов
--device-read-bps / --device-write-bpsio.maxлимит пропускной способности диска

Если вы задаёте лимиты через docker-compose.yml, то же самое пишется в секции deploy.resources.limits (для Swarm-режима) или через mem_limit/cpus на верхнем уровне сервиса для обычного docker compose up. Подробный разбор синтаксиса и типичных ошибок при настройке лимитов CPU и памяти есть в статье про ресурсы и лимиты CPU и памяти в Docker.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Упор в лимит памяти: убивает OOM killer, но не хост

Вот здесь чаще всего путаются. Когда процесс внутри контейнера пытается выделить память сверх memory.max, ядро не убивает хост и не роняет демон Docker. Оно вызывает cgroup-scoped OOM killer, который ищет жертву внутри этой же cgroup — то есть среди процессов конкретного контейнера — и убивает один из них (обычно тот, у кого выше oom_score, часто это и есть сам главный процесс приложения).

Итог для вас: контейнер просто исчезает или перезапускается (если задан restart: unless-stopped или always), а хост и соседние контейнеры продолжают работать как ни в чём не бывало. Это принципиально отличается от общесистемного OOM, где ядро в панике убивает что придётся по всей машине.

Как узнать постфактум, что случилось именно это:

docker inspect <container_id> --format='{{.State.OOMKilled}}'

Если ответ true — контейнер убит именно cgroup OOM killer'ом по памяти. Дополнительно можно посмотреть системный лог ядра:

dmesg -T | grep -i "killed process"
journalctl -k --since "-1 hour" | grep -i oom

Там будет видно имя процесса, PID и объём памяти на момент убийства.

Нюанс, который стоит держать в голове: memory.max — это жёсткая стена, а есть ещё промежуточный порог memory.high (Docker его напрямую не выставляет, но им можно управлять вручную через cgroup, если нужно троттлить память до убийства). При достижении memory.high ядро начинает агрессивно вытеснять страницы кэша и притормаживать процесс, ещё не убивая его — это мягче, чем жёсткий memory.max, но Docker CLI такой промежуточный лимит из коробки не предоставляет.

Частая практическая ловушка: контейнер с базой данных (PostgreSQL, Redis) настраивает внутренние буферы и кэши исходя из всей памяти хоста, а не из выставленного --memory, потому что многие приложения не читают cgroup-лимиты сами — они смотрят /proc/meminfo, который показывает память хоста целиком. В результате Redis или Postgres запросят у ОС больше, чем позволяет cgroup, и получат OOM ещё до того, как реально «поймут», что памяти мало. Разборы для конкретных случаев есть в статьях про высокое потребление памяти Redis и про postgresql out of memory.

Упор в лимит CPU: throttling — это замедление, а не смерть

С процессором логика принципиально другая, и это важно понимать, чтобы не искать несуществующий «CPU OOM killer» — его не существует.

Когда контейнер с --cpus=1 (то есть квота 100000 100000 в cpu.max) за один period (100 мс по умолчанию) исчерпывает выделенную квоту времени на всех своих потоках суммарно, планировщик ядра просто не даёт этим потокам исполняться до начала следующего period. Процесс не убивается, не получает сигнала, ничего не падает — он просто перестаёт получать процессорное время на оставшийся отрезок. Это называется CFS throttling (Completely Fair Scheduler throttling).

Снаружи это выглядит как то, что приложение внезапно «зависает» на десятки-сотни миллисекунд без причины — HTTP-запросы, которые обычно отвечают быстро, вдруг тянутся дольше, health-check не успевает уложиться в timeout, хотя top на хосте показывает, что свободного CPU полно (свободного — да, но не для конкретно этой cgroup).

Посмотреть, троттлится ли контейнер, можно напрямую из cgroup:

cat /sys/fs/cgroup/system.slice/docker-<id>.scope/cpu.stat

В выводе будут поля nr_periods (сколько периодов прошло), nr_throttled (сколько из них закончились троттлингом) и throttled_usec (суммарное время в микросекундах, которое процессы провели в ожидании). Если nr_throttled растёт и заметная доля от nr_periods — контейнеру физически не хватает выставленной квоты CPU при текущей нагрузке, и решение — либо поднять --cpus, либо оптимизировать код, либо развести нагрузку по нескольким репликам.

Как посмотреть реальное потребление cgroup

Самый быстрый способ — встроенная команда Docker:

docker stats --no-stream

Она покажет CPU %, MEM USAGE / LIMIT, NET I/O, BLOCK I/O по каждому контейнеру за текущий момент. Для непрерывного наблюдения уберите --no-stream — обновление будет идти в реальном времени, как top.

Если нужны точные цифры без прослойки Docker CLI, читайте файлы cgroup напрямую — так делает и сам docker stats под капотом:

cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.current
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.stat
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/cpu.stat
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/pids.current

Найти нужный ID контейнера в путях cgroup:

docker inspect --format='{{.Id}}' <container_name>

memory.stat особенно полезен — там видна разбивка на anon (память процессов), file (страничный кэш), kernel_stack и другие категории. Если основную массу занимает file, это в основном вытесняемый кэш файловой системы, а не «настоящая» утечка — ядро отдаст его первым при нехватке места, прежде чем дойдёт до OOM killer'а по анонимной памяти. Общий подход к диагностике утечек памяти на сервере — в статье утечка памяти на сервере.

Диагностика: «контейнер падает без видимой ошибки»

Стандартный чек-лист, когда логи приложения молчат, а контейнер просто исчез или перезапустился:

  1. Проверить код завершения и флаг OOM.
docker inspect <container_id> --format='ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'

Код 137 (128+9, то есть SIGKILL) в сочетании с OOMKilled=true — почти всегда упор в memory.max.

  1. Свериться с журналом ядра, если Docker уже успел пересоздать контейнер и inspect показывает уже новое состояние:
journalctl -k --since "-2 hour" | grep -iE "oom|killed process"
  1. Проверить, не троттлится ли CPU, если падений по памяти нет, но приложение «зависает» и не успевает пройти health-check:
cat /sys/fs/cgroup/.../cpu.stat | grep throttled
  1. Посмотреть текущие лимиты контейнера — иногда лимит выставлен по ошибке, например скопирован из другого сервиса в compose-файле:
docker inspect <container_id> --format='{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}'
  1. Проверить pids.max, если ошибка — не про память и не про CPU, а приложение просто не может создать новый поток/процесс (частый симптом — ошибки вида "resource temporarily unavailable" или "cannot fork" внутри контейнера):
cat /sys/fs/cgroup/.../pids.current
cat /sys/fs/cgroup/.../pids.max

Если ни один из пунктов не дал ответа, стоит проверить, не убивает ли процесс сам restart policy из-за упавшего health-check, а не cgroup — это отдельная причина, которую разбирает статья контейнер сразу падает: причины и решение.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Если не задать --memory вообще, у контейнера лимита нет?

Верно, по умолчанию у контейнера доступна вся память хоста — cgroup создаётся, но memory.max стоит в max (без ограничения). Ограничивать явно стоит на любом продакшен-сервере, иначе один упавший в утечку контейнер способен вытеснить память у соседей.

Можно ли увидеть, что контейнер вот-вот упрётся в лимит, до того как это случится?

Да, через docker stats или прямое чтение memory.current против memory.max — если отношение стабильно растёт к 90%+, стоит поднять лимит или разобраться с потреблением заранее, не дожидаясь OOM.

Throttling CPU можно полностью выключить, оставив только вес (--cpu-shares)?

Да — если не указывать --cpus (и не задавать cpu.max вручную), жёсткой квоты не будет, останется только вес для конкуренции за ядра. Это разумно на сервере с небольшим числом контейнеров, где важнее пропускная способность, чем строгая изоляция.

cgroups v1 работают иначе?

Структурно да — там контроллеры смонтированы отдельными деревьями (/sys/fs/cgroup/memory/, /sys/fs/cgroup/cpu/ и так далее), а не одной unified-иерархией, и часть имён файлов другая (memory.limit_in_bytes вместо memory.max). Логика OOM killer'а и throttling'а по сути та же, но текущие дистрибутивы уже почти повсеместно на v2.

Лимиты cgroup работают одинаково в Docker и в LXC-контейнерах?

Механизм ядра один и тот же, но управление разное — LXC исторически даёт более прямой доступ к настройке cgroup через конфиг контейнера. Сравнение подходов есть в статье Docker или LXC: что выбрать для сервера.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →