2.4. Cgroups
Цели
После этого материала вы сможете:
- объяснить, что такое control group и какую задачу она решает;
- объяснить разницу между cgroup v1 и v2 и определить, что используется в вашей системе;
- найти cgroup конкретного container в файловой системе;
- связать флаги
docker runс конкретными файлами в/sys/fs/cgroup; - прочитать текущее потребление и лимиты container напрямую из ядра;
- объяснить, что происходит при превышении лимита памяти и лимита CPU;
- объяснить, почему приложение внутри container «не видит» свой лимит.
Предварительные знания
- 2.1. Процессы и containers;
- 2.3. Linux namespaces — cgroup namespace;
- чтение файлов в
/procи/sys.
Ключевые термины
| Термин | Объяснение |
|---|---|
cgroup | Control group — группа процессов с общими ограничениями и общим учётом ресурсов |
controller | Подсистема, управляющая конкретным ресурсом: memory, cpu, io, pids |
иерархия | Дерево cgroup. Ограничения родителя действуют на всех потомков |
cgroup v2 | Вторая версия: единая иерархия для всех контроллеров |
throttling | Принудительное замедление процесса при исчерпании квоты CPU |
OOM killer | Механизм ядра, завершающий процесс при исчерпании памяти |
delegation | Передача управления частью иерархии непривилегированному пользователю |
Теория
Задача cgroups
Namespaces отвечают на вопрос «что процесс видит». Cgroups отвечают на вопрос «сколько процесс может потребить».
Без cgroups один container с утечкой памяти способен исчерпать память всей машины и вызвать OOM killer, который завершит случайные процессы — возможно, критичные. Цикл while true займёт всё процессорное время. Программа, порождающая процессы в цикле, исчерпает таблицу процессов ядра.
Cgroup решает это: группе процессов назначаются лимиты, и ядро их принудительно соблюдает.
Второе назначение, не менее важное, — учёт. Cgroup ведёт статистику по группе: сколько памяти использовано, сколько процессорного времени потрачено, сколько операций ввода-вывода выполнено. На этом построена команда docker stats.
cgroup v1 и v2
v1 появилась в 2007 году и развивалась несогласованно: каждый контроллер получил собственную иерархию.
/sys/fs/cgroup/
├── memory/ ← своя иерархия для памяти
├── cpu/ ← своя для CPU
├── blkio/ ← своя для ввода-вывода
├── pids/
└── ...
Процесс мог находиться в разных местах разных иерархий, что усложняло и настройку, и понимание.
v2 (2016) исправляет это: одна иерархия, все контроллеры работают с одним деревом.
/sys/fs/cgroup/
├── cgroup.controllers ← доступные контроллеры
├── cgroup.subtree_control ← включённые для потомков
├── memory.max
├── cpu.max
├── system.slice/
│ └── docker-<id>.scope/
│ ├── memory.max
│ ├── memory.current
│ ├── cpu.max
│ └── ...
└── user.slice/
Ubuntu использует v2 по умолчанию с версии 21.10. Docker Engine 29 объявил поддержку v1 устаревшей. Курс ориентирован на v2.
Проверка:
stat -fc %T /sys/fs/cgroup/
cgroup2fs — v2, tmpfs — v1 или гибрид.
Основные контроллеры
| Контроллер | Что ограничивает | Ключевые файлы v2 |
|---|---|---|
memory | Оперативная память и swap | memory.max, memory.current, memory.high, memory.events |
cpu | Процессорное время | cpu.max, cpu.weight, cpu.stat |
io | Пропускная способность дисковых операций | io.max, io.stat |
pids | Количество процессов | pids.max, pids.current |
cpuset | Привязка к конкретным ядрам и узлам NUMA | cpuset.cpus, cpuset.mems |
Соответствие флагов Docker и файлов cgroup
Это ключевая таблица урока. Флаг docker run — не абстракция: он записывает значение в конкретный файл.
Флаг docker run | Файл cgroup v2 | Значение |
|---|---|---|
--memory=512m | memory.max | 536870912 |
--memory-reservation=256m | memory.low | 268435456 |
--memory-swap=1g | memory.swap.max | вычисляется как memory-swap минус memory |
--cpus=1.5 | cpu.max | 150000 100000 |
--cpu-shares=512 | cpu.weight | пересчитывается в диапазон 1–10000 |
--cpuset-cpus=0,1 | cpuset.cpus | 0-1 |
--pids-limit=100 | pids.max | 100 |
--blkio-weight=500 | io.weight | 500 |
Формат cpu.max — два числа: квота и период в микросекундах. 150000 100000 означает «150 000 микросекунд процессорного времени на каждые 100 000 микросекунд реального времени», то есть полтора ядра.
Что происходит при превышении лимита
Память. Ядро пытается освободить память из кэшей. Если не удаётся — вызывается OOM killer внутри cgroup: завершается процесс из этой группы, а не случайный процесс системы. Container с убитым главным процессом переходит в exited с кодом 137.
Число 137 = 128 + 9, где 9 — номер SIGKILL. Механизм подробно разбирается в разделе 13.
Важное отличие: memory.max — жёсткий предел, memory.high — мягкий. При достижении memory.high процесс не убивается, а притормаживается, чтобы дать ядру время освободить память. Docker по умолчанию использует memory.max.
CPU. Процесс не убивается — он притормаживается (throttling). Когда квота на текущий период исчерпана, ядро снимает процесс с выполнения до начала следующего периода.
Практическое следствие: приложение, ограниченное по CPU, не падает, а работает медленно и с рывками. Это сложнее заметить, чем OOM, и часто проявляется как «непонятные задержки в ответах». Диагностика — через cpu.stat, где ведётся счётчик nr_throttled.
Процессы. При достижении pids.max вызовы fork() и clone() возвращают ошибку EAGAIN. Приложение получает «Resource temporarily unavailable».
Почему приложение не видит свой лимит
Файлы /proc/meminfo и /proc/cpuinfo не изолируются namespace — они отражают ресурсы host. Приложение, читающее их, узнаёт о ресурсах машины, а не о своём лимите.
Практические последствия, которые проявятся в разделе 06:
os.cpu_count()в Python вернёт число CPU host;- Gunicorn с формулой
2 * CPU + 1создаст worker-процессы по числу CPU машины; - сборщик мусора JVM (и аналогичные механизмы) неверно оценит доступную память.
Правильный способ узнать реальный лимит — прочитать его из cgroup:
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/cpu.max
Благодаря cgroup namespace (урок 2.3) внутри container эти пути указывают на его собственную cgroup, а не на корень host.
Внутренний механизм
Где находится cgroup container
Docker использует cgroup driver systemd (значение по умолчанию на systemd-системах). Путь формируется так:
/sys/fs/cgroup/system.slice/docker-<полный-id-container>.scope/
Для rootless mode путь другой:
/sys/fs/cgroup/user.slice/user-<uid>.slice/user@<uid>.service/user.slice/docker-<id>.scope/
Проверить путь напрямую можно через /proc/<pid>/cgroup:
cat /proc/<host-pid>/cgroup
0::/system.slice/docker-c8f4a1b2....scope
Формат v2: 0::<путь-относительно-корня-иерархии>.
Делегирование и rootless
В rootless mode пользователь не имеет права записи в корневую иерархию cgroup. Systemd делегирует ему поддерево, но по умолчанию не все контроллеры: обычно только memory и pids.
Поэтому в rootless mode --memory работает, а --cpus — нет, пока не настроено делегирование. Это разбиралось в уроке 1.6.
Проверить делегированные контроллеры:
cat "/sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers"
Команды и примеры
Определение версии
stat -fc %T /sys/fs/cgroup/
cat /sys/fs/cgroup/cgroup.controllers
cgroup2fs
cpuset cpu io memory hugetlb pids rdma misc
Нахождение cgroup container
docker run -d --name cg-demo --memory=256m --cpus=0.5 --pids-limit=50 \
alpine sleep 3600
CPID="$(docker inspect cg-demo --format '{{.State.Pid}}')"
CGROUP_PATH="$(awk -F: '{print $3}' /proc/"$CPID"/cgroup)"
FULL_PATH="/sys/fs/cgroup${CGROUP_PATH}"
echo "$FULL_PATH"
/sys/fs/cgroup/system.slice/docker-c8f4a1b2d3e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1.scope
Чтение лимитов
echo "memory.max: $(cat "$FULL_PATH/memory.max")"
echo "cpu.max: $(cat "$FULL_PATH/cpu.max")"
echo "pids.max: $(cat "$FULL_PATH/pids.max")"
memory.max: 268435456
cpu.max: 50000 100000
pids.max: 50
Сверка с заданными флагами:
| Флаг | Файл | Значение | Проверка |
|---|---|---|---|
--memory=256m | memory.max | 268435456 | 256 × 1024 × 1024 ✓ |
--cpus=0.5 | cpu.max | 50000 100000 | 50000 / 100000 = 0.5 ✓ |
--pids-limit=50 | pids.max | 50 | ✓ |
Флаги — не абстракция, а прямая запись в файлы ядра.
Чтение текущего потребления
echo "memory.current: $(cat "$FULL_PATH/memory.current") байт"
echo "pids.current: $(cat "$FULL_PATH/pids.current")"
cat "$FULL_PATH/cpu.stat"
memory.current: 507904 байт
pids.current: 1
usage_usec 3821
user_usec 1204
system_usec 2617
nr_periods 0
nr_throttled 0
throttled_usec 0
Поля cpu.stat:
| Поле | Значение |
|---|---|
usage_usec | Всего процессорного времени, микросекунд |
nr_periods | Сколько периодов квоты прошло |
nr_throttled | Сколько раз группа была приторможена |
throttled_usec | Суммарное время в состоянии throttling |
nr_throttled — главный индикатор нехватки CPU. Растущее значение означает, что приложению не хватает выделенной квоты. Симптом на уровне пользователя — периодические задержки.
То же изнутри container
docker exec cg-demo cat /sys/fs/cgroup/memory.max
docker exec cg-demo cat /sys/fs/cgroup/cpu.max
268435456
50000 100000
Внутри container путь — просто /sys/fs/cgroup/, потому что cgroup namespace показывает его собственную группу как корень. Приложение может прочитать свой лимит, не зная о структуре host.
Сравним с тем, что показывает /proc:
docker exec cg-demo nproc
docker exec cg-demo sh -c "grep MemTotal /proc/meminfo"
8
MemTotal: 16063220 kB
Восемь CPU и 16 GB памяти — это ресурсы host, не container. Лимиты 0.5 CPU и 256 MB здесь не отражены. Именно поэтому расчёт worker-процессов по nproc даёт неверный результат.
Демонстрация OOM
docker run --rm --memory=32m --memory-swap=32m python:3.13-slim \
python -c "
data = []
for i in range(1000):
data.append('x' * 1024 * 1024) # по 1 MB за итерацию
print(f'{i+1} MB', flush=True)
"
1 MB
2 MB
...
20 MB
Вывод обрывается, команда завершается. Проверим код возврата:
echo $?
137
137 = 128 + 9 (SIGKILL). Процесс убит OOM killer внутри cgroup.
--memory-swap=32m равный --memory отключает swap: без него процесс уходил бы в подкачку и работал медленно, но не падал.
Счётчик событий OOM:
docker run -d --name oom-demo --memory=32m --memory-swap=32m python:3.13-slim \
python -c "d=[];
import time
while True:
d.append('x'*1024*1024); time.sleep(0.01)"
sleep 5
CPID="$(docker inspect oom-demo --format '{{.State.Pid}}' 2>/dev/null || echo 0)"
docker inspect oom-demo --format 'OOMKilled: {{.State.OOMKilled}} ExitCode: {{.State.ExitCode}}'
docker rm -f oom-demo
OOMKilled: true ExitCode: 137
Поле .State.OOMKilled отличает OOM от других причин SIGKILL. Это важно при диагностике: exit code 137 сам по себе означает лишь «убит сигналом 9», а кто послал сигнал — вопрос отдельный.
Демонстрация throttling
docker run -d --name cpu-demo --cpus=0.2 alpine \
sh -c 'while true; do :; done'
sleep 10
CPID="$(docker inspect cpu-demo --format '{{.State.Pid}}')"
CG="/sys/fs/cgroup$(awk -F: '{print $3}' /proc/"$CPID"/cgroup)"
cat "$CG/cpu.stat"
usage_usec 2004512
user_usec 1998341
system_usec 6171
nr_periods 102
nr_throttled 101
throttled_usec 8123445
nr_throttled 101 из nr_periods 102 — процесс тормозился почти в каждом периоде. Ожидаемо: бесконечный цикл хочет целое ядро, а получил 0.2.
Сверим с docker stats:
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}' cpu-demo
NAME CPU %
cpu-demo 19.98%
Ровно заданные 20 %.
Уборка:
docker rm -f cpu-demo cg-demo
Изменение лимитов на лету
docker run -d --name upd-demo --memory=128m alpine sleep 3600
docker update --memory=256m --memory-swap=256m upd-demo
CPID="$(docker inspect upd-demo --format '{{.State.Pid}}')"
cat "/sys/fs/cgroup$(awk -F: '{print $3}' /proc/"$CPID"/cgroup)/memory.max"
268435456
Лимит изменён без перезапуска. Не все параметры допускают изменение на лету — например, --pids-limit требует пересоздания container.
docker rm -f upd-demo
Практическое упражнение
Задание. Напишите скрипт cgroup-report.sh <container>, который выводит для container таблицу: параметр, заданный лимит, текущее потребление, доля использования.
Обязательные параметры: память, CPU, процессы. Дополнительно — счётчик throttling и признак OOM.
Скрипт должен корректно обрабатывать случай «лимит не задан» (в cgroup v2 это значение max).
Подсказки
Подсказка 1
Путь к cgroup:
CG="/sys/fs/cgroup$(awk -F: '{print $3}' /proc/$CPID/cgroup)"
Подсказка 2
cpu.max содержит два числа. Доля CPU = квота / период. Значение max в первом поле означает отсутствие лимита.
Подсказка 3
Для перевода байтов в мегабайты используйте awk '{printf "%.1f", $1/1048576}'.
Решение
Сначала выполните задание самостоятельно.
Показать решение
#!/usr/bin/env bash
# cgroup-report.sh — фактические лимиты и потребление container.
set -euo pipefail
NAME="${1:?Использование: $0 <container>}"
CPID="$(docker inspect "$NAME" --format '{{.State.Pid}}')"
[ "$CPID" = "0" ] && { echo "Container не запущен." >&2; exit 1; }
CG="/sys/fs/cgroup$(awk -F: '{print $3}' "/proc/$CPID/cgroup")"
[ -d "$CG" ] || { echo "cgroup не найдена: $CG" >&2; exit 1; }
read_or() { [ -r "$1" ] && cat "$1" || echo "$2"; }
mb() { awk '{printf "%.1f MB", $1/1048576}' <<< "$1"; }
echo "Container: $NAME"
echo "cgroup: $CG"
echo
printf '%-14s %-14s %-14s %s\n' ПАРАМЕТР ЛИМИТ ТЕКУЩЕЕ ДОЛЯ
printf '%s\n' "------------------------------------------------------"
# Память
mem_max="$(read_or "$CG/memory.max" max)"
mem_cur="$(read_or "$CG/memory.current" 0)"
if [ "$mem_max" = "max" ]; then
printf '%-14s %-14s %-14s %s\n' memory "не задан" "$(mb "$mem_cur")" "-"
else
pct="$(awk -v c="$mem_cur" -v m="$mem_max" 'BEGIN{printf "%.1f%%", c*100/m}')"
printf '%-14s %-14s %-14s %s\n' memory "$(mb "$mem_max")" "$(mb "$mem_cur")" "$pct"
fi
# CPU
cpu_max="$(read_or "$CG/cpu.max" "max 100000")"
quota="$(awk '{print $1}' <<< "$cpu_max")"
period="$(awk '{print $2}' <<< "$cpu_max")"
usage_usec="$(awk '/^usage_usec/{print $2}' "$CG/cpu.stat" 2>/dev/null || echo 0)"
if [ "$quota" = "max" ]; then
printf '%-14s %-14s %-14s %s\n' cpu "не задан" "$((usage_usec/1000000)) c" "-"
else
cores="$(awk -v q="$quota" -v p="$period" 'BEGIN{printf "%.2f ядра", q/p}')"
printf '%-14s %-14s %-14s %s\n' cpu "$cores" "$((usage_usec/1000000)) c" "-"
fi
# Процессы
pids_max="$(read_or "$CG/pids.max" max)"
pids_cur="$(read_or "$CG/pids.current" 0)"
if [ "$pids_max" = "max" ]; then
printf '%-14s %-14s %-14s %s\n' pids "не задан" "$pids_cur" "-"
else
pct="$(awk -v c="$pids_cur" -v m="$pids_max" 'BEGIN{printf "%.1f%%", c*100/m}')"
printf '%-14s %-14s %-14s %s\n' pids "$pids_max" "$pids_cur" "$pct"
fi
echo
echo "=== Признаки нехватки ресурсов ==="
nr_throttled="$(awk '/^nr_throttled/{print $2}' "$CG/cpu.stat" 2>/dev/null || echo 0)"
nr_periods="$(awk '/^nr_periods/{print $2}' "$CG/cpu.stat" 2>/dev/null || echo 0)"
printf ' CPU throttling: %s из %s периодов' "$nr_throttled" "$nr_periods"
if [ "${nr_throttled:-0}" -gt 0 ]; then
echo " [!] приложению не хватает выделенного CPU"
else
echo " [+] нет"
fi
oom="$(awk '/^oom_kill /{print $2}' "$CG/memory.events" 2>/dev/null || echo 0)"
printf ' OOM kills: %s' "${oom:-0}"
if [ "${oom:-0}" -gt 0 ]; then
echo " [!] процессы завершались по нехватке памяти"
else
echo " [+] нет"
fi
Проверка:
docker run -d --name rep --memory=128m --cpus=0.5 --pids-limit=64 \
alpine sh -c 'while true; do :; done'
sleep 8
chmod +x cgroup-report.sh
./cgroup-report.sh rep
docker rm -f rep
Ожидаемый вывод:
Container: rep
cgroup: /sys/fs/cgroup/system.slice/docker-....scope
ПАРАМЕТР ЛИМИТ ТЕКУЩЕЕ ДОЛЯ
------------------------------------------------------
memory 128.0 MB 0.6 MB 0.5%
cpu 0.50 ядра 4 c -
pids 64 1 1.6%
=== Признаки нехватки ресурсов ===
CPU throttling: 79 из 80 периодов [!] приложению не хватает выделенного CPU
OOM kills: 0 [+] нет
Проверка результата
docker run -d --name verify --memory=100m alpine sleep 60
CPID=$(docker inspect verify --format '{{.State.Pid}}')
cat "/sys/fs/cgroup$(awk -F: '{print $3}' /proc/$CPID/cgroup)/memory.max"
docker rm -f verify
Ожидается 104857600 (100 × 1024 × 1024). Совпадение подтверждает, что вы умеете находить cgroup и читать лимиты.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Расчёт worker-процессов по nproc в container | /proc/cpuinfo не изолирован | Читать cpu.max из cgroup |
Ожидание, что --memory виден в /proc/meminfo | /proc/meminfo отражает host | Читать memory.max |
Толкование exit code 137 только как OOM | 137 означает SIGKILL от кого угодно | Проверить .State.OOMKilled в docker inspect |
| Игнорирование CPU throttling | Приложение не падает, а медленно работает | Следить за nr_throttled в cpu.stat |
--memory без --memory-swap | Процесс уходит в swap вместо падения; поведение неочевидно | Задать --memory-swap равным --memory для отключения swap |
| Пути cgroup v1 на системе с v2 | Разные файлы и семантика | Проверять версию перед использованием путей |
--cpus не работает в rootless | Контроллер cpu не делегирован | Настроить Delegate= — см. урок 1.6 |
--cpu-shares как жёсткий лимит | Это относительный вес при конкуренции, а не предел | Для жёсткого предела использовать --cpus |
Контрольные вопросы
На понимание:
- Чем задача cgroups отличается от задачи namespaces?
- Почему при исчерпании памяти процесс убивается, а при исчерпании квоты CPU — нет?
- Что означает значение
50000 100000в файлеcpu.max? - Почему приложение внутри container видит все CPU host?
- Чем
memory.maxотличается отmemory.high?
На применение:
- Как найти cgroup конкретного работающего container?
- Как определить, что container страдает от нехватки CPU, а не от медленного кода?
- Как узнать реальный лимит памяти изнутри container?
На диагностику:
- Container завершился с кодом
137. Как отличить OOM отdocker kill? - Приложение периодически отвечает с задержкой в несколько секунд, при этом нагрузка на CPU host низкая. Куда смотреть?
Краткое резюме
- Cgroups ограничивают и учитывают потребление ресурсов группой процессов.
- cgroup v2 — единая иерархия; курс ориентирован на неё, v1 объявлена устаревшей в Docker 29.
- Флаги
docker runзаписывают значения в конкретные файлы/sys/fs/cgroup. - Cgroup container находится по пути из
/proc/<pid>/cgroup. - Превышение лимита памяти вызывает OOM killer внутри cgroup и exit code
137. - Превышение квоты CPU вызывает throttling — приложение замедляется, но не падает.
nr_throttledвcpu.stat— главный индикатор нехватки CPU./proc/meminfoи/proc/cpuinfoне изолируются и показывают ресурсы host.- Реальный лимит читается из cgroup: внутри container это путь
/sys/fs/cgroup/. .State.OOMKilledвdocker inspectотличает OOM от прочих причинSIGKILL.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Control Group v2 (kernel documentation) | https://docs.kernel.org/admin-guide/cgroup-v2.html | Единая иерархия, файлы интерфейса контроллеров memory, cpu, io, pids, семантика max/high/current |
| Runtime options with Memory, CPUs, and GPUs | https://docs.docker.com/engine/containers/resource_constraints/ | Флаги --memory, --cpus, --memory-swap, --pids-limit и их поведение |
| docker update reference | https://docs.docker.com/reference/cli/docker/container/update/ | Изменение лимитов работающего container |
| docker stats reference | https://docs.docker.com/reference/cli/docker/container/stats/ | Источник метрик и их интерпретация |
cgroups(7) man page | https://man7.org/linux/man-pages/man7/cgroups.7.html | Общая модель cgroups, отличия v1 и v2 |
| Docker Engine 29 release notes | https://docs.docker.com/engine/release-notes/29/ | Объявление cgroup v1 устаревшей |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Capabilities
Главное оглавление