Главная/Основы Containerization/Урок

2.4. Cgroups

Цели

После этого материала вы сможете:

  • объяснить, что такое control group и какую задачу она решает;
  • объяснить разницу между cgroup v1 и v2 и определить, что используется в вашей системе;
  • найти cgroup конкретного container в файловой системе;
  • связать флаги docker run с конкретными файлами в /sys/fs/cgroup;
  • прочитать текущее потребление и лимиты container напрямую из ядра;
  • объяснить, что происходит при превышении лимита памяти и лимита CPU;
  • объяснить, почему приложение внутри container «не видит» свой лимит.

Предварительные знания

Ключевые термины

ТерминОбъяснение
cgroupControl 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 году и развивалась несогласованно: каждый контроллер получил собственную иерархию.

text
/sys/fs/cgroup/
├── memory/      ← своя иерархия для памяти
├── cpu/         ← своя для CPU
├── blkio/       ← своя для ввода-вывода
├── pids/
└── ...

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

v2 (2016) исправляет это: одна иерархия, все контроллеры работают с одним деревом.

text
/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.

Проверка:

bash
stat -fc %T /sys/fs/cgroup/

cgroup2fs — v2, tmpfs — v1 или гибрид.

Основные контроллеры

КонтроллерЧто ограничиваетКлючевые файлы v2
memoryОперативная память и swapmemory.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Привязка к конкретным ядрам и узлам NUMAcpuset.cpus, cpuset.mems

Соответствие флагов Docker и файлов cgroup

Это ключевая таблица урока. Флаг docker run — не абстракция: он записывает значение в конкретный файл.

Флаг docker runФайл cgroup v2Значение
--memory=512mmemory.max536870912
--memory-reservation=256mmemory.low268435456
--memory-swap=1gmemory.swap.maxвычисляется как memory-swap минус memory
--cpus=1.5cpu.max150000 100000
--cpu-shares=512cpu.weightпересчитывается в диапазон 1–10000
--cpuset-cpus=0,1cpuset.cpus0-1
--pids-limit=100pids.max100
--blkio-weight=500io.weight500

Формат 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:

bash
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-системах). Путь формируется так:

text
/sys/fs/cgroup/system.slice/docker-<полный-id-container>.scope/

Для rootless mode путь другой:

text
/sys/fs/cgroup/user.slice/user-<uid>.slice/user@<uid>.service/user.slice/docker-<id>.scope/

Проверить путь напрямую можно через /proc/<pid>/cgroup:

bash
cat /proc/<host-pid>/cgroup
text
0::/system.slice/docker-c8f4a1b2....scope

Формат v2: 0::<путь-относительно-корня-иерархии>.

Делегирование и rootless

В rootless mode пользователь не имеет права записи в корневую иерархию cgroup. Systemd делегирует ему поддерево, но по умолчанию не все контроллеры: обычно только memory и pids.

Поэтому в rootless mode --memory работает, а --cpus — нет, пока не настроено делегирование. Это разбиралось в уроке 1.6.

Проверить делегированные контроллеры:

bash
cat "/sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers"

Команды и примеры

Определение версии

bash
stat -fc %T /sys/fs/cgroup/
cat /sys/fs/cgroup/cgroup.controllers
text
cgroup2fs
cpuset cpu io memory hugetlb pids rdma misc

Нахождение cgroup container

bash
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"
text
/sys/fs/cgroup/system.slice/docker-c8f4a1b2d3e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1.scope

Чтение лимитов

bash
echo "memory.max:   $(cat "$FULL_PATH/memory.max")"
echo "cpu.max:      $(cat "$FULL_PATH/cpu.max")"
echo "pids.max:     $(cat "$FULL_PATH/pids.max")"
text
memory.max:   268435456
cpu.max:      50000 100000
pids.max:     50

Сверка с заданными флагами:

ФлагФайлЗначениеПроверка
--memory=256mmemory.max268435456256 × 1024 × 1024 ✓
--cpus=0.5cpu.max50000 10000050000 / 100000 = 0.5 ✓
--pids-limit=50pids.max50

Флаги — не абстракция, а прямая запись в файлы ядра.

Чтение текущего потребления

bash
echo "memory.current: $(cat "$FULL_PATH/memory.current") байт"
echo "pids.current:   $(cat "$FULL_PATH/pids.current")"
cat "$FULL_PATH/cpu.stat"
text
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

bash
docker exec cg-demo cat /sys/fs/cgroup/memory.max
docker exec cg-demo cat /sys/fs/cgroup/cpu.max
text
268435456
50000 100000

Внутри container путь — просто /sys/fs/cgroup/, потому что cgroup namespace показывает его собственную группу как корень. Приложение может прочитать свой лимит, не зная о структуре host.

Сравним с тем, что показывает /proc:

bash
docker exec cg-demo nproc
docker exec cg-demo sh -c "grep MemTotal /proc/meminfo"
text
8
MemTotal:       16063220 kB

Восемь CPU и 16 GB памяти — это ресурсы host, не container. Лимиты 0.5 CPU и 256 MB здесь не отражены. Именно поэтому расчёт worker-процессов по nproc даёт неверный результат.

Демонстрация OOM

bash
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)
"
text
1 MB
2 MB
...
20 MB

Вывод обрывается, команда завершается. Проверим код возврата:

bash
echo $?
text
137

137 = 128 + 9 (SIGKILL). Процесс убит OOM killer внутри cgroup.

--memory-swap=32m равный --memory отключает swap: без него процесс уходил бы в подкачку и работал медленно, но не падал.

Счётчик событий OOM:

bash
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
text
OOMKilled: true  ExitCode: 137

Поле .State.OOMKilled отличает OOM от других причин SIGKILL. Это важно при диагностике: exit code 137 сам по себе означает лишь «убит сигналом 9», а кто послал сигнал — вопрос отдельный.

Демонстрация throttling

bash
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"
text
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:

bash
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}' cpu-demo
text
NAME       CPU %
cpu-demo   19.98%

Ровно заданные 20 %.

Уборка:

bash
docker rm -f cpu-demo cg-demo

Изменение лимитов на лету

bash
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"
text
268435456

Лимит изменён без перезапуска. Не все параметры допускают изменение на лету — например, --pids-limit требует пересоздания container.

bash
docker rm -f upd-demo

Практическое упражнение

Задание. Напишите скрипт cgroup-report.sh <container>, который выводит для container таблицу: параметр, заданный лимит, текущее потребление, доля использования.

Обязательные параметры: память, CPU, процессы. Дополнительно — счётчик throttling и признак OOM.

Скрипт должен корректно обрабатывать случай «лимит не задан» (в cgroup v2 это значение max).

Подсказки

Подсказка 1

Путь к cgroup:

bash
CG="/sys/fs/cgroup$(awk -F: '{print $3}' /proc/$CPID/cgroup)"
Подсказка 2

cpu.max содержит два числа. Доля CPU = квота / период. Значение max в первом поле означает отсутствие лимита.

Подсказка 3

Для перевода байтов в мегабайты используйте awk '{printf "%.1f", $1/1048576}'.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
#!/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

Проверка:

bash
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

Ожидаемый вывод:

text
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   [+] нет

Проверка результата

bash
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 только как OOM137 означает 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

Контрольные вопросы

На понимание:

  1. Чем задача cgroups отличается от задачи namespaces?
  2. Почему при исчерпании памяти процесс убивается, а при исчерпании квоты CPU — нет?
  3. Что означает значение 50000 100000 в файле cpu.max?
  4. Почему приложение внутри container видит все CPU host?
  5. Чем memory.max отличается от memory.high?

На применение:

  1. Как найти cgroup конкретного работающего container?
  2. Как определить, что container страдает от нехватки CPU, а не от медленного кода?
  3. Как узнать реальный лимит памяти изнутри container?

На диагностику:

  1. Container завершился с кодом 137. Как отличить OOM от docker kill?
  2. Приложение периодически отвечает с задержкой в несколько секунд, при этом нагрузка на CPU host низкая. Куда смотреть?

Краткое резюме

  1. Cgroups ограничивают и учитывают потребление ресурсов группой процессов.
  2. cgroup v2 — единая иерархия; курс ориентирован на неё, v1 объявлена устаревшей в Docker 29.
  3. Флаги docker run записывают значения в конкретные файлы /sys/fs/cgroup.
  4. Cgroup container находится по пути из /proc/<pid>/cgroup.
  5. Превышение лимита памяти вызывает OOM killer внутри cgroup и exit code 137.
  6. Превышение квоты CPU вызывает throttling — приложение замедляется, но не падает.
  7. nr_throttled в cpu.stat — главный индикатор нехватки CPU.
  8. /proc/meminfo и /proc/cpuinfo не изолируются и показывают ресурсы host.
  9. Реальный лимит читается из cgroup: внутри container это путь /sys/fs/cgroup/.
  10. .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 GPUshttps://docs.docker.com/engine/containers/resource_constraints/Флаги --memory, --cpus, --memory-swap, --pids-limit и их поведение
docker update referencehttps://docs.docker.com/reference/cli/docker/container/update/Изменение лимитов работающего container
docker stats referencehttps://docs.docker.com/reference/cli/docker/container/stats/Источник метрик и их интерпретация
cgroups(7) man pagehttps://man7.org/linux/man-pages/man7/cgroups.7.htmlОбщая модель cgroups, отличия v1 и v2
Docker Engine 29 release noteshttps://docs.docker.com/engine/release-notes/29/Объявление cgroup v1 устаревшей

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Capabilities
Главное оглавление

Markdown на GitHub ↗