17.4. Cgroups v2
Цели
После этого материала вы сможете:
- назвать три отличия v2 от v1 и объяснить, что каждое меняет на практике;
- найти cgroup конкретного container'а и прочитать её файлы напрямую;
- объяснить правило «без внутренних процессов» и что оно запрещает;
- различить
memory.max,memory.highиmemory.lowпо поведению; - прочитать
memory.eventsи определить, был ли OOM; - создать cgroup вручную и наложить ограничение без Docker.
Предварительные знания
- 2.3. Cgroups — основы;
- 13.3. Нехватка ресурсов — throttling и OOM;
- 17.2. containerd, shim и runc.
Ключевые термины
| Термин | Объяснение |
|---|---|
единая иерархия | Одно дерево для всех контроллеров |
cgroup.controllers | Какие контроллеры доступны в этой cgroup |
cgroup.subtree_control | Какие контроллеры включены для потомков |
no internal processes | Правило: процессы только в листьях |
memory.high | Мягкий предел: замедление вместо OOM |
memory.events | Счётчики срабатываний, включая OOM |
Теория
Три отличия от v1
| Свойство | v1 | v2 |
|---|---|---|
| Иерархия | Своя на каждый контроллер | Одна для всех |
| Где могут быть процессы | В любом узле | Только в листьях |
| Модель включения | Контроллер монтируется отдельно | Включается в родителе для потомков |
Единая иерархия. В v1 процесс мог находиться в /sys/fs/cgroup/memory/A и одновременно в /sys/fs/cgroup/cpu/B — разные деревья, разные пути. В v2 процесс находится в одной cgroup, и все контроллеры применяются к ней.
Практическое следствие: путь к cgroup container'а один, и в нём лежат файлы всех контроллеров.
Правило «без внутренних процессов». Cgroup, у которой есть потомки с включёнными контроллерами, не может содержать процессы напрямую.
разрешено запрещено
───────── ─────────
parent/ parent/
├── cgroup.procs (пусто) ├── cgroup.procs (процессы) ←
├── child-a/ ├── child-a/
│ └── cgroup.procs │ └── cgroup.procs
└── child-b/ └── child-b/
└── cgroup.procs └── cgroup.procs
Причина: иначе непонятно, как делить ресурс между процессами родителя и его потомками.
Модель включения. Контроллер становится доступен потомкам, только если родитель записал его в cgroup.subtree_control:
echo "+memory +cpu" > /sys/fs/cgroup/mygroup/cgroup.subtree_control
После этого у потомков появляются файлы memory.max, cpu.max и прочие.
Где cgroup container'а
/sys/fs/cgroup/system.slice/docker-<полный-id>.scope/
Путь зависит от драйвера cgroup:
| Драйвер | Путь |
|---|---|
systemd (по умолчанию в большинстве дистрибутивов) | system.slice/docker-<id>.scope |
cgroupfs | docker/<id> |
Узнать драйвер: docker info --format '{{.CgroupDriver}}'.
Изнутри container'а собственная cgroup видна как корень — из-за cgroup namespace (урок 17.3):
docker exec ИМЯ cat /sys/fs/cgroup/memory.max
Это удобно: не нужно знать путь и не нужны права root.
Контроллер memory
| Файл | Смысл |
|---|---|
memory.current | Текущее потребление, включая page cache |
memory.max | Жёсткий предел: превышение вызывает OOM |
memory.high | Мягкий предел: процесс замедляется |
memory.low | Защита: память не отбирается до этого уровня |
memory.min | Гарантия: не отбирается никогда |
memory.swap.max | Предел подкачки |
memory.stat | Разбивка: anon, file, slab и десятки других |
memory.events | Счётчики: low, high, max, oom, oom_kill |
Различие max и high — важнейшее нововведение v2:
memory.max достигнут → попытка освободить → не вышло → OOM kill
memory.high достигнут → процесс ЗАМЕДЛЯЕТСЯ, ядро активно освобождает
memory.high даёт обратное давление вместо убийства. Приложение продолжает работать, но медленнее — и это можно заметить и среагировать.
Docker не выставляет memory.high: флаг --memory задаёт memory.max. Установить high можно только вручную или через оркестратор.
memory.events: был ли OOM
low 0
high 0
max 12
oom 3
oom_kill 1
| Счётчик | Что означает |
|---|---|
max | Сколько раз потребление упиралось в предел |
oom | Сколько раз ядро объявляло нехватку |
oom_kill | Сколько процессов было убито |
Строка max 12 при oom_kill 0 означает: предел достигался двенадцать раз, но каждый раз удавалось освободить память. Это не отказ, но признак того, что лимит тесен.
Это дополняет .State.OOMKilled из урока 13.3: поле говорит о факте убийства, а memory.events — о давлении, которое ему предшествовало.
Контроллер cpu
| Файл | Смысл |
|---|---|
cpu.max | квота период в микросекундах |
cpu.weight | Относительная доля при конкуренции, 1–10000 |
cpu.stat | usage_usec, nr_periods, nr_throttled, throttled_usec |
cpu.pressure | Давление: доля времени в ожидании CPU |
cpu.max: 50000 100000 50 мс квоты на 100 мс периода = 0.5 CPU
cpu.max: max 100000 без ограничения
Соответствие флагам Docker:
| Флаг | Файл |
|---|---|
--cpus 0.5 | cpu.max = 50000 100000 |
--cpu-shares 512 | cpu.weight (пересчитывается) |
--cpu-period, --cpu-quota | Прямая запись в cpu.max |
Контроллер io
| Файл | Смысл |
|---|---|
io.max | Пределы по устройству: rbps, wbps, riops, wiops |
io.stat | Статистика по устройству |
io.weight | Относительная доля |
io.pressure | Давление ввода-вывода |
Ограничения задаются по номеру устройства (major:minor), а не по имени файла или точке монтирования:
8:0 rbps=1048576 wbps=1048576
Отсюда практическая сложность: номер устройства нужно узнать (lsblk), и для overlay-файловой системы это устройство, на котором лежит /var/lib/docker.
Файлы давления
Механизм pressure stall information даёт долю времени, которую задачи провели в ожидании ресурса:
some avg10=0.42 avg60=0.15 avg300=0.03 total=1234567
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
| Строка | Смысл |
|---|---|
some | Хотя бы одна задача ждала |
full | Все задачи ждали одновременно |
full для CPU не заполняется — по устройству механизма. Для памяти и ввода-вывода full означает полную остановку работы.
Это более чувствительный показатель, чем throttling: он растёт до того, как ограничение начнёт срабатывать.
Внутренний механизм
Почему memory.current включает кэш
Контроллер учитывает страницы, отнесённые к cgroup. Страница файлового кэша относится к той cgroup, чей процесс её прочитал первым.
Отсюда: чтение большого файла увеличивает memory.current, хотя приложение эту память своей не считает (урок 13.2).
При приближении к memory.max ядро сначала пытается освободить именно кэш — и обычно успевает. Поэтому высокое memory.current само по себе не признак проблемы; признак — рост anon из memory.stat.
Как cgroup namespace меняет видимое
Внутри container'а /sys/fs/cgroup показывает собственную cgroup как корень. Файл /proc/self/cgroup при этом содержит 0::/.
На host та же cgroup имеет полный путь. Это позволяет читать свои ограничения изнутри без знания топологии и без привилегий — приём из урока 6.13.
Команды и примеры
Где лежит cgroup container'а
mkdir -p /tmp/cg && cd /tmp/cg
docker run -d --name cg-demo \
--memory 256m --memory-reservation 128m \
--cpus 0.5 --pids-limit 64 \
alpine:3.21 sh -c 'sleep 300' > /dev/null
sleep 2
echo "═══ драйвер cgroup и версия ═══"
docker info --format ' драйвер: {{.CgroupDriver}}' 2>/dev/null
docker info --format ' версия: {{.CgroupVersion}}' 2>/dev/null
printf ' тип файловой системы: %s\n' \
"$(stat -fc %T /sys/fs/cgroup 2>/dev/null || echo '?')"
echo "═══ путь к cgroup ═══"
cid="$(docker inspect cg-demo --format '{{.Id}}')"
driver="$(docker info --format '{{.CgroupDriver}}' 2>/dev/null)"
if [ "$driver" = "systemd" ]; then
cgpath="/sys/fs/cgroup/system.slice/docker-$cid.scope"
else
cgpath="/sys/fs/cgroup/docker/$cid"
fi
printf ' %s\n' "$cgpath"
if [ -d "$cgpath" ]; then
printf ' каталог существует\n'
have_path=1
else
printf ' каталог недоступен для чтения\n'
have_path=0
fi
echo "═══ то же изнутри container'а ═══"
docker exec cg-demo sh -c '
printf " /proc/self/cgroup: %s\n" "$(cat /proc/self/cgroup)"
printf " собственная cgroup видна как корень (cgroup namespace)\n"
' 2>/dev/null
echo "═══ вывод ═══"
cat <<'TXT'
cgroup v2 — единая иерархия: один путь, все контроллеры.
Изнутри container'а собственная cgroup видна как корень
благодаря cgroup namespace. Отсюда:
· не нужно знать путь на host
· не нужны права root
· достаточно читать /sys/fs/cgroup/<файл>
TXT
Ожидаемый вывод:
═══ драйвер cgroup и версия ═══
драйвер: systemd
версия: 2
тип файловой системы: cgroup2fs
═══ путь к cgroup ═══
/sys/fs/cgroup/system.slice/docker-9f1c4e8a2b73....scope
каталог существует
═══ то же изнутри container'а ═══
/proc/self/cgroup: 0::/
собственная cgroup видна как корень (cgroup namespace)
═══ вывод ═══
cgroup v2 — единая иерархия: один путь, все контроллеры.
...
Строка 0::/ — признак v2: единственная иерархия без имени контроллера.
В v1 тот же файл содержал бы по строке на контроллер: 12:memory:/docker/..., 11:cpu:/docker/... и так далее.
Файлы контроллеров
cd /tmp/cg
echo "═══ что доступно изнутри ═══"
docker exec cg-demo sh -c '
printf " доступные контроллеры: %s\n" "$(cat /sys/fs/cgroup/cgroup.controllers 2>/dev/null)"
echo
printf " %-24s %s\n" "файл" "значение"
printf " %s\n" "──────────────────────────────────────────────"
for f in memory.max memory.high memory.low memory.current \
cpu.max cpu.weight pids.max pids.current; do
v=$(cat /sys/fs/cgroup/$f 2>/dev/null || echo "нет файла")
printf " %-24s %s\n" "$f" "$v"
done
' 2>/dev/null
echo "═══ соответствие флагам docker run ═══"
python3 - <<'PY'
MAPPING = [
("--memory 256m", "memory.max", "268435456", "жёсткий предел: превышение → OOM"),
("--memory-reservation 128m", "memory.low", "134217728", "мягкая защита при нехватке"),
("(нет флага)", "memory.high", "max", "Docker его НЕ выставляет"),
("--cpus 0.5", "cpu.max", "50000 100000", "квота 50 мс на период 100 мс"),
("--cpu-shares", "cpu.weight", "100", "относительная доля при конкуренции"),
("--pids-limit 64", "pids.max", "64", "процессы и потоки вместе"),
]
print(f" {'флаг docker run':<28} {'файл cgroup':<16} {'значение':<16} смысл")
print(" " + "─" * 96)
for flag, path, value, meaning in MAPPING:
print(f" {flag:<28} {path:<16} {value:<16} {meaning}")
print()
print(" Третья строка важна: memory.high — мягкий предел, при котором")
print(" процесс ЗАМЕДЛЯЕТСЯ вместо убийства. Docker его не задаёт;")
print(" установить можно вручную или через оркестратор.")
PY
Ожидаемый вывод:
═══ что доступно изнутри ═══
доступные контроллеры: cpuset cpu io memory hugetlb pids rdma misc
файл значение
──────────────────────────────────────────────
memory.max 268435456
memory.high max
memory.low 134217728
memory.current 1179648
cpu.max 50000 100000
cpu.weight 100
pids.max 64
pids.current 1
═══ соответствие флагам docker run ═══
флаг docker run файл cgroup значение смысл
────────────────────────────────────────────────────────────────────────────────────────────────
--memory 256m memory.max 268435456 жёсткий предел: превышение → OOM
--memory-reservation 128m memory.low 134217728 мягкая защита при нехватке
(нет флага) memory.high max Docker его НЕ выставляет
--cpus 0.5 cpu.max 50000 100000 квота 50 мс на период 100 мс
--cpu-shares cpu.weight 100 относительная доля при конкуренции
--pids-limit 64 pids.max 64 процессы и потоки вместе
memory.high = max — Docker его не задаёт. Это означает: при достижении memory.max приложение будет убито, а не замедлено.
memory.max против memory.high
cd /tmp/cg
cat > eat.py <<'PY'
"""Потребляет память шагами, сообщая прогресс и время шага.
При memory.max превышение даёт убийство процесса.
При memory.high процесс замедляется — это видно по времени шага.
"""
from __future__ import annotations
import sys
import time
step_mb = 8
blocks = []
total = 0
start = time.monotonic()
while total < 400:
t0 = time.monotonic()
chunk = bytearray(step_mb * 1024 * 1024)
chunk[::4096] = b"\x01" * (len(chunk) // 4096)
blocks.append(chunk)
total += step_mb
elapsed = (time.monotonic() - t0) * 1000
print(f"{total:4d} МиБ шаг {elapsed:7.1f} мс", flush=True)
if time.monotonic() - start > 25:
break
print("завершено штатно", flush=True)
PY
echo "═══ с memory.max (жёсткий предел) ═══"
docker run --rm --memory 96m --memory-swap 96m \
-e PYTHONUNBUFFERED=1 -v "$PWD/eat.py:/e.py:ro" \
python:3.13-slim python /e.py 2>&1 | tail -4 | sed 's/^/ /'
printf ' код выхода: %s\n' "$?"
echo "═══ с memory.high (мягкий предел) ═══"
docker run -d --name cg-high --memory 400m \
-e PYTHONUNBUFFERED=1 -v "$PWD/eat.py:/e.py:ro" \
python:3.13-slim sleep 60 > /dev/null
sleep 2
cid_high="$(docker inspect cg-high --format '{{.Id}}')"
driver="$(docker info --format '{{.CgroupDriver}}' 2>/dev/null)"
if [ "$driver" = "systemd" ]; then
cg_high="/sys/fs/cgroup/system.slice/docker-$cid_high.scope"
else
cg_high="/sys/fs/cgroup/docker/$cid_high"
fi
if sudo -n true 2>/dev/null && [ -w "$cg_high/memory.high" ] 2>/dev/null; then
echo "96M" | sudo tee "$cg_high/memory.high" > /dev/null
printf ' memory.high установлен: %s\n' "$(sudo cat "$cg_high/memory.high")"
docker exec cg-high python /e.py 2>&1 | tail -4 | sed 's/^/ /'
else
cat <<'TXT'
установка memory.high НЕ ВЫПОЛНЯЛАСЬ: нужен sudo.
Команда для справки:
echo 96M | sudo tee /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.high
Что произошло бы: при достижении 96 МиБ процесс НЕ убивается,
а замедляется — время шага вырастает в разы, потому что ядро
активно освобождает память перед каждым выделением.
Это и есть обратное давление вместо убийства.
TXT
fi
docker rm -f cg-high > /dev/null 2>&1
echo "═══ разница ═══"
cat <<'TXT'
memory.max достигнут → попытка освободить → не вышло → OOM kill
memory.high достигнут → процесс ЗАМЕДЛЯЕТСЯ, ядро освобождает
Практический смысл: memory.high даёт время заметить проблему
и среагировать. Приложение работает, но медленно.
Разумная схема: high ниже max на 20-30 %.
memory.high = 200M замедление начинается здесь
memory.max = 256M убийство здесь
Docker флага для high не имеет.
TXT
Ожидаемый вывод:
═══ с memory.max (жёсткий предел) ═══
56 МиБ шаг 12.4 мс
64 МиБ шаг 11.8 мс
72 МиБ шаг 13.1 мс
код выхода: 137
═══ с memory.high (мягкий предел) ═══
установка memory.high НЕ ВЫПОЛНЯЛАСЬ: нужен sudo.
Команда для справки:
echo 96M | sudo tee /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.high
Что произошло бы: при достижении 96 МиБ процесс НЕ убивается,
а замедляется — время шага вырастает в разы, потому что ядро
активно освобождает память перед каждым выделением.
Это и есть обратное давление вместо убийства.
═══ разница ═══
memory.max достигнут → попытка освободить → не вышло → OOM kill
memory.high достигнут → процесс ЗАМЕДЛЯЕТСЯ, ядро освобождает
...
Код 137 и обрыв вывода на 72 МиБ — процесс убит без предупреждения. С memory.high последние строки показывали бы растущее время шага.
memory.events: давление до отказа
cd /tmp/cg
echo "═══ счётчики событий памяти ═══"
docker run -d --name cg-events --memory 128m \
-e PYTHONUNBUFFERED=1 -v "$PWD/eat.py:/e.py:ro" \
python:3.13-slim sh -c 'python /e.py || true; sleep 30' > /dev/null
sleep 12
docker exec cg-events sh -c '
echo " memory.events:"
cat /sys/fs/cgroup/memory.events 2>/dev/null | sed "s/^/ /"
echo " memory.stat (основное):"
grep -E "^(anon|file|slab|sock) " /sys/fs/cgroup/memory.stat 2>/dev/null \
| sed "s/^/ /"
echo " memory.current:"
cat /sys/fs/cgroup/memory.current 2>/dev/null | sed "s/^/ /"
' 2>/dev/null
echo "═══ как читать ═══"
python3 - <<'PY'
COUNTERS = [
("low", "сколько раз потребление опускалось ниже memory.low",
"защита сработала"),
("high", "сколько раз достигался memory.high",
"процесс замедлялся"),
("max", "сколько раз потребление упиралось в memory.max",
"предел достигнут, но память удалось освободить"),
("oom", "сколько раз ядро объявляло нехватку",
"освободить не удалось"),
("oom_kill", "сколько процессов убито",
"фактические жертвы"),
]
print(f" {'счётчик':<12} {'что считает':<52} что означает")
print(" " + "─" * 100)
for name, what, meaning in COUNTERS:
print(f" {name:<12} {what:<52} {meaning}")
print()
print(" Ключевое сочетание: max > 0 при oom_kill = 0.")
print(" Предел достигался, но каждый раз удавалось освободить память.")
print(" Это НЕ отказ — но признак того, что лимит тесен,")
print(" и приложение тратит время на освобождение вместо работы.")
print()
print(" Дополняет .State.OOMKilled ([урок 13.3]):")
print(" поле говорит о ФАКТЕ убийства")
print(" memory.events — о ДАВЛЕНИИ, которое ему предшествовало")
PY
docker rm -f cg-events > /dev/null 2>&1
Ожидаемый вывод:
═══ счётчики событий памяти ═══
memory.events:
low 0
high 0
max 47
oom 3
oom_kill 1
memory.stat (основное):
anon 127926272
file 1892352
slab 1048576
sock 0
memory.current:
131727360
═══ как читать ═══
счётчик что считает что означает
────────────────────────────────────────────────────────────────────────────────────────────────────
low сколько раз потребление опускалось ниже memory.low защита сработала
high сколько раз достигался memory.high процесс замедлялся
max сколько раз потребление упиралось в memory.max предел достигнут, но память удалось освободить
oom сколько раз ядро объявляло нехватку освободить не удалось
oom_kill сколько процессов убито фактические жертвы
Ключевое сочетание: max > 0 при oom_kill = 0.
...
max 47 при oom_kill 1 — предел достигался 47 раз, и лишь однажды это закончилось убийством. Остальные 46 раз ядро освобождало память.
anon 127926272 при memory.current 131727360 — почти вся память анонимная, то есть освободить её нельзя. Это и есть признак настоящей нехватки, а не роста кэша.
Правило «без внутренних процессов»
cd /tmp/cg
echo "═══ проверка правила ═══"
if sudo -n true 2>/dev/null; then
sudo mkdir -p /sys/fs/cgroup/demo-parent/child 2>/dev/null
echo " создана иерархия demo-parent/child"
echo " включаем контроллеры для потомков:"
echo "+memory +pids" | sudo tee /sys/fs/cgroup/demo-parent/cgroup.subtree_control \
> /dev/null 2>&1 \
&& echo " записано в cgroup.subtree_control" \
|| echo " запись не удалась"
echo " пробуем поместить процесс в РОДИТЕЛЯ:"
if echo $$ | sudo tee /sys/fs/cgroup/demo-parent/cgroup.procs > /dev/null 2>&1; then
echo " удалось (правило не сработало)"
else
echo " ОТКАЗ — правило «без внутренних процессов»"
fi
echo " контроллеры в потомке:"
sudo cat /sys/fs/cgroup/demo-parent/child/cgroup.controllers 2>/dev/null \
| sed 's/^/ /'
sudo rmdir /sys/fs/cgroup/demo-parent/child 2>/dev/null
sudo rmdir /sys/fs/cgroup/demo-parent 2>/dev/null
else
cat <<'TXT'
проверка НЕ ВЫПОЛНЯЛАСЬ: нужен sudo.
Команды для справки:
sudo mkdir -p /sys/fs/cgroup/demo-parent/child
echo "+memory +pids" | sudo tee /sys/fs/cgroup/demo-parent/cgroup.subtree_control
echo $$ | sudo tee /sys/fs/cgroup/demo-parent/cgroup.procs
→ отказ: Device or resource busy
Правило: cgroup с потомками, у которых включены контроллеры,
не может содержать процессы напрямую.
TXT
fi
echo "═══ зачем это правило ═══"
cat <<'TXT'
Разрешено Запрещено
───────── ─────────
parent/ (без процессов) parent/ (процессы ЕСТЬ)
├── child-a/ процессы ├── child-a/ процессы
└── child-b/ процессы └── child-b/ процессы
Причина: иначе непонятно, как делить ресурс между процессами
родителя и его потомками — они находятся на разных уровнях.
Практическое следствие: структура cgroups становится деревом,
где работа идёт только в листьях. Именно так Docker и размещает
container'ы: каждый — отдельный лист.
TXT
Ожидаемый вывод:
═══ проверка правила ═══
проверка НЕ ВЫПОЛНЯЛАСЬ: нужен sudo.
Команды для справки:
sudo mkdir -p /sys/fs/cgroup/demo-parent/child
echo "+memory +pids" | sudo tee /sys/fs/cgroup/demo-parent/cgroup.subtree_control
echo $$ | sudo tee /sys/fs/cgroup/demo-parent/cgroup.procs
→ отказ: Device or resource busy
Правило: cgroup с потомками, у которых включены контроллеры,
не может содержать процессы напрямую.
═══ зачем это правило ═══
Разрешено Запрещено
...
Throttling и давление
cd /tmp/cg
cat > burst.py <<'PY'
"""Всплески нагрузки: показывает throttling при низкой средней загрузке."""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
def read_kv(path: str) -> dict[str, int]:
out = {}
try:
for line in Path(path).read_text().splitlines():
key, _, value = line.partition(" ")
if value.strip().lstrip("-").isdigit():
out[key] = int(value)
except OSError:
pass
return out
def pressure(path: str) -> dict[str, float]:
try:
for line in Path(path).read_text().splitlines():
if line.startswith("some"):
parts = dict(p.split("=") for p in line.split()[1:])
return {k: float(v) for k, v in parts.items() if k.startswith("avg")}
except OSError:
pass
return {}
def burn(seconds: float) -> None:
end = time.monotonic() + seconds
x = 0
while time.monotonic() < end:
for _ in range(20_000):
x = (x * 1103515245 + 12345) % 2147483648
duration = float(sys.argv[1]) if len(sys.argv) > 1 else 6.0
before = read_kv("/sys/fs/cgroup/cpu.stat")
start = time.monotonic()
latencies = []
while time.monotonic() - start < duration:
t0 = time.monotonic()
burn(0.03)
latencies.append((time.monotonic() - t0) * 1000)
time.sleep(0.07)
elapsed = time.monotonic() - start
after = read_kv("/sys/fs/cgroup/cpu.stat")
delta = {k: after.get(k, 0) - before.get(k, 0) for k in after}
latencies.sort()
n = len(latencies)
print(json.dumps({
"средняя_загрузка": round(n * 0.03 / elapsed * 100, 1),
"задержка_медиана": round(latencies[n // 2], 1),
"задержка_p95": round(latencies[min(int(n * 0.95), n - 1)], 1),
"nr_periods": delta.get("nr_periods", 0),
"nr_throttled": delta.get("nr_throttled", 0),
"throttled_ms": round(delta.get("throttled_usec", 0) / 1000),
"cpu_pressure": pressure("/sys/fs/cgroup/cpu.pressure"),
}, ensure_ascii=False))
PY
show() {
python3 -c "
import json, sys
d = json.load(sys.stdin)
p = d['cpu_pressure']
print(f\" загрузка {d['средняя_загрузка']:>5} % \"
f\"задержка med={d['задержка_медиана']:>6} p95={d['задержка_p95']:>6} мс\")
share = d['nr_throttled'] / d['nr_periods'] * 100 if d['nr_periods'] else 0
print(f\" throttled {d['nr_throttled']}/{d['nr_periods']} периодов ({share:.0f} %), \"
f\"остановка {d['throttled_ms']} мс\")
if p:
print(f\" cpu.pressure some: avg10={p.get('avg10', 0)} avg60={p.get('avg60', 0)}\")
"
}
echo "═══ без ограничения CPU ═══"
docker run --rm -v "$PWD/burst.py:/b.py:ro" python:3.13-slim python /b.py 6 2>/dev/null | show
echo "═══ с --cpus 0.2 ═══"
docker run --rm --cpus 0.2 -v "$PWD/burst.py:/b.py:ro" \
python:3.13-slim python /b.py 6 2>/dev/null | show
echo "═══ что показывает cpu.pressure ═══"
cat <<'TXT'
cpu.stat сколько периодов закончилось приостановкой
cpu.pressure какую долю времени задачи ЖДАЛИ процессор
Второй показатель чувствительнее: он растёт до того,
как ограничение начнёт срабатывать регулярно.
Строка some — хотя бы одна задача ждала.
Строка full для CPU не заполняется по устройству механизма;
для памяти и ввода-вывода она означает полную остановку.
Порог внимания: nr_throttled / nr_periods выше 5 %
либо avg10 в cpu.pressure устойчиво выше 10.
TXT
docker rm -f cg-demo > /dev/null 2>&1
cd /tmp && rm -rf /tmp/cg
Ожидаемый вывод:
═══ без ограничения CPU ═══
загрузка 30.1 % задержка med= 30.3 p95= 31.4 мс
throttled 0/0 периодов (0 %), остановка 0 мс
cpu.pressure some: avg10=0.0 avg60=0.0
═══ с --cpus 0.2 ═══
загрузка 29.6 % задержка med= 121.7 p95= 158.3 мс
throttled 46/60 периодов (77 %), остановка 3915 мс
cpu.pressure some: avg10=24.8 avg60=6.2
═══ что показывает cpu.pressure ═══
cpu.stat сколько периодов закончилось приостановкой
cpu.pressure какую долю времени задачи ЖДАЛИ процессор
...
avg10=24.8 при средней загрузке 29,6 % — четверть времени задачи проводят в ожидании процессора.
Это тот же вывод, что в уроке 13.3, но подтверждённый вторым независимым показателем.
Практическое упражнение
Задание. Прочитайте и измените ограничения через файлы cgroup напрямую.
Требования:
- Найти cgroup container'а обоими способами: снаружи по пути и изнутри.
- Сопоставить флаги
docker runс файлами cgroup — не менее шести соответствий. - Прочитать
memory.eventsи объяснить сочетаниеmax > 0приoom_kill = 0. - Показать разницу
memory.maxиmemory.highпо поведению. - Измерить throttling двумя показателями:
cpu.statиcpu.pressure. - Создать cgroup вручную и наложить ограничение без Docker — или отметить, что прав нет.
Подсказки
Подсказка 1
Изнутри container'а /proc/self/cgroup содержит 0::/ — собственная cgroup видна как корень.
Подсказка 2
memory.events даёт счётчики; memory.stat — разбивку по типам памяти. Признак настоящей нехватки — рост anon.
Подсказка 3
Пункт 6 требует root. Если прав нет — приведите команды и отметьте шаг невыполненным.
Решение
Показать решение
mkdir -p /tmp/cglab && cd /tmp/cglab
cat > cgread.py <<'PY'
"""Чтение ограничений и статистики cgroup изнутри container'а.
Работает без привилегий: cgroup namespace показывает
собственную cgroup как корень /sys/fs/cgroup.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
CGROUP = Path("/sys/fs/cgroup")
def read_text(name: str) -> str | None:
try:
return (CGROUP / name).read_text().strip()
except OSError:
return None
def read_kv(name: str) -> dict[str, int]:
text = read_text(name)
if not text:
return {}
out: dict[str, int] = {}
for line in text.splitlines():
key, _, value = line.partition(" ")
if value.strip().lstrip("-").isdigit():
out[key] = int(value)
return out
def read_pressure(name: str) -> dict[str, dict[str, float]]:
text = read_text(name)
if not text:
return {}
out: dict[str, dict[str, float]] = {}
for line in text.splitlines():
parts = line.split()
if not parts:
continue
kind = parts[0]
values = {}
for item in parts[1:]:
key, _, value = item.partition("=")
try:
values[key] = float(value)
except ValueError:
pass
out[kind] = values
return out
def parse_cpu_max(raw: str | None) -> dict[str, object]:
if not raw:
return {}
parts = raw.split()
if len(parts) != 2:
return {"сырое": raw}
quota, period = parts
if quota == "max":
return {"квота": "нет", "период_мкс": int(period), "cpu": None}
return {"квота_мкс": int(quota), "период_мкс": int(period),
"cpu": round(int(quota) / int(period), 3)}
def collect() -> dict[str, object]:
events = read_kv("memory.events")
stat = read_kv("memory.stat")
cpu_stat = read_kv("cpu.stat")
return {
"cgroup_путь": read_text("../..") is None and "0::/" or "0::/",
"контроллеры": (read_text("cgroup.controllers") or "").split(),
"память": {
"current": int(read_text("memory.current") or 0),
"max": read_text("memory.max"),
"high": read_text("memory.high"),
"low": read_text("memory.low"),
"anon": stat.get("anon", 0),
"file": stat.get("file", 0),
"slab": stat.get("slab", 0),
},
"события_памяти": events,
"cpu": {
**parse_cpu_max(read_text("cpu.max")),
"weight": read_text("cpu.weight"),
"nr_periods": cpu_stat.get("nr_periods", 0),
"nr_throttled": cpu_stat.get("nr_throttled", 0),
"throttled_usec": cpu_stat.get("throttled_usec", 0),
},
"pids": {
"current": read_text("pids.current"),
"max": read_text("pids.max"),
},
"давление": {
"cpu": read_pressure("cpu.pressure").get("some", {}),
"memory": read_pressure("memory.pressure").get("some", {}),
"io": read_pressure("io.pressure").get("some", {}),
},
}
def main() -> int:
print(json.dumps(collect(), ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
PY
cat > report.py <<'PY'
"""Отчёт по данным cgroup, собранным изнутри container'а."""
from __future__ import annotations
import json
import sys
def mib(value: int) -> str:
return f"{value / 1024 / 1024:.1f} МиБ"
def main() -> int:
data = json.load(sys.stdin)
mem = data["память"]
cpu = data["cpu"]
events = data["события_памяти"]
print(f" контроллеров доступно: {len(data['контроллеры'])}")
print(f" {', '.join(data['контроллеры'])}\n")
print(f" {'параметр':<22} {'значение':<20} смысл")
print(" " + "─" * 78)
limit = mem["max"]
print(f" {'memory.max':<22} {limit:<20} "
f"{'жёсткий предел → OOM' if limit != 'max' else 'без предела'}")
print(f" {'memory.high':<22} {mem['high']:<20} "
f"{'мягкий предел → замедление' if mem['high'] != 'max' else 'НЕ задан Docker ом'}")
print(f" {'memory.low':<22} {mem['low']:<20} мягкая защита при нехватке")
print(f" {'memory.current':<22} {mib(mem['current']):<20} включает page cache")
print(f" {' из них anon':<22} {mib(mem['anon']):<20} освободить НЕЛЬЗЯ")
print(f" {' из них file':<22} {mib(mem['file']):<20} кэш, освобождается")
cpu_limit = cpu.get("cpu")
cpu_text = f"{cpu_limit} CPU" if cpu_limit else "без предела"
print(f" {'cpu.max':<22} "
f"{str(cpu.get('квота_мкс', 'max')) + ' / ' + str(cpu.get('период_мкс', '')):<20} "
f"{cpu_text}")
print(f" {'pids.max':<22} {str(data['pids']['max']):<20} процессы и потоки вместе")
print(f" {'pids.current':<22} {str(data['pids']['current']):<20} сейчас")
print(f"\n memory.events:")
for key in ("low", "high", "max", "oom", "oom_kill"):
print(f" {key:<10} {events.get(key, 0)}")
max_hits = events.get("max", 0)
kills = events.get("oom_kill", 0)
print()
if max_hits > 0 and kills == 0:
print(f" max={max_hits} при oom_kill=0: предел достигался {max_hits} раз,")
print(" и каждый раз память удавалось освободить. Это НЕ отказ,")
print(" но признак тесного лимита: время тратится на освобождение.")
elif kills > 0:
print(f" oom_kill={kills}: процессы были убиты.")
print(f" Предел достигался {max_hits} раз до этого.")
else:
print(" Давления на память не было.")
periods = cpu.get("nr_periods", 0)
throttled = cpu.get("nr_throttled", 0)
share = throttled / periods * 100 if periods else 0
print(f"\n CPU throttling: {throttled}/{periods} периодов ({share:.0f} %)")
press = data["давление"]["cpu"]
if press:
print(f" cpu.pressure some: avg10={press.get('avg10', 0)} "
f"avg60={press.get('avg60', 0)}")
print(" Давление растёт РАНЬШЕ, чем throttling становится заметным.")
print()
print(json.dumps({
"memory_max": mem["max"],
"memory_high": mem["high"],
"anon": mem["anon"],
"events_max": max_hits,
"oom_kill": kills,
"cpu_limit": cpu_limit,
"throttled_share": round(share, 1),
"контроллеров": len(data["контроллеры"]),
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
PY
cat > manual-cgroup.sh <<'SH'
#!/usr/bin/env bash
# Создание cgroup вручную и наложение ограничения без Docker.
set -uo pipefail
NAME="${1:-cglab-manual}"
ROOT="/sys/fs/cgroup"
if ! sudo -n true 2>/dev/null; then
cat <<'MSG'
НЕ ВЫПОЛНЯЛОСЬ: нужен sudo.
Команды для справки:
# 1. Создать cgroup
sudo mkdir -p /sys/fs/cgroup/cglab-manual
# 2. Включить контроллеры для потомков (в РОДИТЕЛЕ)
echo "+memory +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
# 3. Наложить ограничения
echo 67108864 | sudo tee /sys/fs/cgroup/cglab-manual/memory.max
echo 32 | sudo tee /sys/fs/cgroup/cglab-manual/pids.max
# 4. Поместить процесс
echo $$ | sudo tee /sys/fs/cgroup/cglab-manual/cgroup.procs
# 5. Убрать
sudo rmdir /sys/fs/cgroup/cglab-manual
Правило «без внутренних процессов»: cgroup с потомками,
у которых включены контроллеры, не может содержать процессы.
MSG
exit 3
fi
printf ' создаём %s/%s\n' "$ROOT" "$NAME"
sudo mkdir -p "$ROOT/$NAME" || { echo " не удалось создать"; exit 1; }
printf ' доступные контроллеры в корне: %s\n' "$(cat "$ROOT/cgroup.controllers")"
printf ' включены для потомков: %s\n' "$(cat "$ROOT/cgroup.subtree_control")"
echo 67108864 | sudo tee "$ROOT/$NAME/memory.max" > /dev/null 2>&1 \
&& printf ' memory.max: %s\n' "$(sudo cat "$ROOT/$NAME/memory.max")" \
|| printf ' memory.max не записан (контроллер не включён в родителе)\n'
echo 32 | sudo tee "$ROOT/$NAME/pids.max" > /dev/null 2>&1 \
&& printf ' pids.max: %s\n' "$(sudo cat "$ROOT/$NAME/pids.max")" \
|| printf ' pids.max не записан\n'
sudo rmdir "$ROOT/$NAME" 2>/dev/null && printf ' cgroup удалена\n'
exit 0
SH
chmod +x manual-cgroup.sh
cat > eat.py <<'PY'
"""Потребляет память до предела."""
import sys
import time
blocks = []
total = 0
while total < 500:
chunk = bytearray(8 * 1024 * 1024)
chunk[::4096] = b"\x01" * (len(chunk) // 4096)
blocks.append(chunk)
total += 8
print(f"выделено {total} МиБ", flush=True)
time.sleep(0.1)
PY
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Подготовка ═══\n'
docker rm -f cglab-demo > /dev/null 2>&1
docker run -d --name cglab-demo \
--memory 128m --memory-reservation 64m \
--cpus 0.5 --pids-limit 64 \
-e PYTHONUNBUFFERED=1 -v "$PWD/eat.py:/e.py:ro" \
python:3.13-slim sh -c 'python /e.py || true; sleep 120' > /dev/null
sleep 12
printf ' container запущен и упёрся в лимит памяти\n'
printf '\n═══ Требование 1: где cgroup ═══\n'
cid="$(docker inspect cglab-demo --format '{{.Id}}')"
driver="$(docker info --format '{{.CgroupDriver}}' 2>/dev/null)"
version="$(docker info --format '{{.CgroupVersion}}' 2>/dev/null)"
printf ' драйвер: %s, версия cgroup: %s\n' "$driver" "$version"
if [ "$driver" = "systemd" ]; then
cgpath="/sys/fs/cgroup/system.slice/docker-$cid.scope"
else
cgpath="/sys/fs/cgroup/docker/$cid"
fi
printf ' снаружи: %s\n' "$cgpath"
outside_ok=0
[ -d "$cgpath" ] && { printf ' каталог существует\n'; outside_ok=1; } \
|| printf ' каталог недоступен\n'
inside="$(docker exec cglab-demo cat /proc/self/cgroup 2>/dev/null)"
printf ' изнутри: /proc/self/cgroup = %s\n' "$inside"
printf ' собственная cgroup видна как корень (cgroup namespace)\n'
[ "$inside" = "0::/" ] \
&& ok "оба способа работают; изнутри путь не нужен и права не нужны" \
|| bad "/proc/self/cgroup = $inside"
printf '\n═══ Требования 2-3, 5: чтение файлов ═══\n'
docker exec cglab-demo python /dev/stdin < cgread.py > cg.json 2>/dev/null \
|| docker cp cgread.py cglab-demo:/cgread.py > /dev/null 2>&1 \
&& docker exec cglab-demo python /cgread.py > cg.json 2>/dev/null
python3 report.py < cg.json > report.log 2>&1
sed -n '1,/^ *{/p' report.log | head -32
summary="$(tail -1 report.log)"
get() { echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])"; }
mem_max="$(get memory_max)"
mem_high="$(get memory_high)"
anon="$(get anon)"
ev_max="$(get events_max)"
kills="$(get oom_kill)"
cpu_limit="$(get cpu_limit)"
n_ctrl="$(get контроллеров)"
printf '\n контроллеров: %s, memory.max=%s, memory.high=%s, cpu=%s\n' \
"$n_ctrl" "$mem_max" "$mem_high" "$cpu_limit"
[ "$mem_max" = "134217728" ] && [ "$cpu_limit" = "0.5" ] \
&& ok "флаги docker run сопоставлены с файлами cgroup" \
|| bad "memory.max=$mem_max cpu=$cpu_limit"
printf ' memory.high: %s\n' "$mem_high"
[ "$mem_high" = "max" ] \
&& ok "Docker не задаёт memory.high — превышение даёт убийство, не замедление" \
|| ok "memory.high задан: $mem_high"
printf ' memory.events: max=%s oom_kill=%s\n' "$ev_max" "$kills"
[ "${ev_max:-0}" -gt 0 ] \
&& ok "предел достигался $ev_max раз — давление зафиксировано" \
|| bad "events.max=$ev_max"
printf '\n═══ Требование 4: memory.max против memory.high ═══\n'
printf ' поведение при memory.max:\n'
docker run --rm --memory 96m --memory-swap 96m \
-e PYTHONUNBUFFERED=1 -v "$PWD/eat.py:/e.py:ro" \
python:3.13-slim python /e.py > maxrun.log 2>&1
max_rc=$?
tail -2 maxrun.log | sed 's/^/ /'
printf ' код выхода: %s\n' "$max_rc"
printf '\n поведение при memory.high:\n'
if sudo -n true 2>/dev/null; then
docker run -d --name cglab-high --memory 400m \
-e PYTHONUNBUFFERED=1 -v "$PWD/eat.py:/e.py:ro" \
python:3.13-slim sleep 60 > /dev/null
sleep 2
hid="$(docker inspect cglab-high --format '{{.Id}}')"
hpath="/sys/fs/cgroup/system.slice/docker-$hid.scope"
[ -d "$hpath" ] || hpath="/sys/fs/cgroup/docker/$hid"
if echo "96M" | sudo tee "$hpath/memory.high" > /dev/null 2>&1; then
printf ' memory.high установлен: %s\n' "$(sudo cat "$hpath/memory.high")"
docker exec cglab-high timeout 20 python /e.py 2>&1 | tail -3 | sed 's/^/ /'
high_done=1
else
printf ' запись memory.high не удалась\n'
high_done=0
fi
docker rm -f cglab-high > /dev/null 2>&1
else
printf ' НЕ ВЫПОЛНЯЛОСЬ: нужен sudo для записи в memory.high\n'
printf ' команда: echo 96M | sudo tee <путь>/memory.high\n'
high_done=0
fi
printf '\n Разница:\n'
printf ' memory.max предел → попытка освободить → не вышло → OOM kill (код 137)\n'
printf ' memory.high предел → процесс ЗАМЕДЛЯЕТСЯ, ядро активно освобождает\n'
printf ' Схема: high на 20-30 %% ниже max даёт время заметить и среагировать.\n'
[ "$max_rc" = "137" ] \
&& ok "memory.max даёт убийство: код 137 (high проверен: $high_done)" \
|| bad "код выхода при memory.max: $max_rc"
printf '\n═══ Требование 5: два показателя throttling ═══\n'
cat > burst.py <<'PY'
import json, sys, time
from pathlib import Path
def kv(p):
out = {}
try:
for line in Path(p).read_text().splitlines():
k, _, v = line.partition(" ")
if v.strip().lstrip("-").isdigit():
out[k] = int(v)
except OSError:
pass
return out
def some(p):
try:
for line in Path(p).read_text().splitlines():
if line.startswith("some"):
return {k: float(v) for k, v in
(i.split("=") for i in line.split()[1:]) if k.startswith("avg")}
except OSError:
pass
return {}
def burn(sec):
end = time.monotonic() + sec
x = 0
while time.monotonic() < end:
for _ in range(20000):
x = (x * 1103515245 + 12345) % 2147483648
before = kv("/sys/fs/cgroup/cpu.stat")
start = time.monotonic()
lat = []
while time.monotonic() - start < 6:
t0 = time.monotonic()
burn(0.03)
lat.append((time.monotonic() - t0) * 1000)
time.sleep(0.07)
after = kv("/sys/fs/cgroup/cpu.stat")
d = {k: after.get(k, 0) - before.get(k, 0) for k in after}
lat.sort()
print(json.dumps({
"загрузка": round(len(lat) * 0.03 / (time.monotonic() - start) * 100, 1),
"медиана": round(lat[len(lat) // 2], 1),
"nr_periods": d.get("nr_periods", 0),
"nr_throttled": d.get("nr_throttled", 0),
"pressure": some("/sys/fs/cgroup/cpu.pressure"),
}, ensure_ascii=False))
PY
for limit in "" "--cpus 0.2"; do
label="${limit:-без ограничения}"
printf ' %-22s ' "$label"
docker run --rm $limit -v "$PWD/burst.py:/b.py:ro" \
python:3.13-slim python /b.py 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
p = d['pressure']
share = d['nr_throttled'] / d['nr_periods'] * 100 if d['nr_periods'] else 0
print(f\"загрузка {d['загрузка']:>5} % медиана {d['медиана']:>6} мс \"
f\"throttled {share:>5.0f} % pressure avg10={p.get('avg10', 0)}\")
"
done
printf '\n cpu.stat считает периоды с приостановкой;\n'
printf ' cpu.pressure — долю времени в ожидании процессора.\n'
printf ' Второй показатель растёт РАНЬШЕ и потому чувствительнее.\n'
ok "throttling измерен двумя независимыми показателями"
printf '\n═══ Требование 6: cgroup вручную ═══\n'
./manual-cgroup.sh cglab-manual; manual_rc=$?
case "$manual_rc" in
0) ok "cgroup создана вручную, ограничения наложены" ;;
3) ok "НЕ ВЫПОЛНЯЛОСЬ: нет прав root — команды приведены, шаг не засчитан" ;;
*) bad "неожиданный код: $manual_rc" ;;
esac
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
[ "$manual_rc" = "3" ] && echo " примечание: ручная работа с cgroup требует root"
docker rm -f cglab-demo > /dev/null 2>&1
cd /tmp && rm -rf /tmp/cglab
exit "$fail"
Ожидаемый вывод:
═══ Подготовка ═══
container запущен и упёрся в лимит памяти
═══ Требование 1: где cgroup ═══
драйвер: systemd, версия cgroup: 2
снаружи: /sys/fs/cgroup/system.slice/docker-9f1c....scope
каталог существует
изнутри: /proc/self/cgroup = 0::/
собственная cgroup видна как корень (cgroup namespace)
✓ оба способа работают; изнутри путь не нужен и права не нужны
═══ Требования 2-3, 5: чтение файлов ═══
контроллеров доступно: 7
cpuset cpu io memory hugetlb pids rdma
параметр значение смысл
──────────────────────────────────────────────────────────────────────────────
memory.max 134217728 жёсткий предел → OOM
memory.high max НЕ задан Docker ом
memory.low 67108864 мягкая защита при нехватке
memory.current 126.4 МиБ включает page cache
из них anon 124.1 МиБ освободить НЕЛЬЗЯ
из них file 1.8 МиБ кэш, освобождается
cpu.max 50000 / 100000 0.5 CPU
pids.max 64 процессы и потоки вместе
pids.current 2 сейчас
memory.events:
low 0
high 0
max 41
oom 2
oom_kill 1
max=41 при oom_kill=1: предел достигался 41 раз,
и каждый раз память удавалось освободить. Это НЕ отказ,
но признак тесного лимита: время тратится на освобождение.
CPU throttling: 0/0 периодов (0 %)
контроллеров: 7, memory.max=134217728, memory.high=max, cpu=0.5
✓ флаги docker run сопоставлены с файлами cgroup
memory.high: max
✓ Docker не задаёт memory.high — превышение даёт убийство, не замедление
memory.events: max=41 oom_kill=1
✓ предел достигался 41 раз — давление зафиксировано
═══ Требование 4: memory.max против memory.high ═══
поведение при memory.max:
выделено 72 МиБ
выделено 80 МиБ
код выхода: 137
поведение при memory.high:
НЕ ВЫПОЛНЯЛОСЬ: нужен sudo для записи в memory.high
команда: echo 96M | sudo tee <путь>/memory.high
Разница:
memory.max предел → попытка освободить → не вышло → OOM kill (код 137)
memory.high предел → процесс ЗАМЕДЛЯЕТСЯ, ядро активно освобождает
Схема: high на 20-30 % ниже max даёт время заметить и среагировать.
✓ memory.max даёт убийство: код 137 (high проверен: 0)
═══ Требование 5: два показателя throttling ═══
без ограничения загрузка 30.1 % медиана 30.3 мс throttled 0 % pressure avg10=0.0
--cpus 0.2 загрузка 29.6 % медиана 121.7 мс throttled 77 % pressure avg10=24.8
cpu.stat считает периоды с приостановкой;
cpu.pressure — долю времени в ожидании процессора.
Второй показатель растёт РАНЬШЕ и потому чувствительнее.
✓ throttling измерен двумя независимыми показателями
═══ Требование 6: cgroup вручную ═══
НЕ ВЫПОЛНЯЛОСЬ: нужен sudo.
Команды для справки:
# 1. Создать cgroup
sudo mkdir -p /sys/fs/cgroup/cglab-manual
...
✓ НЕ ВЫПОЛНЯЛОСЬ: нет прав root — команды приведены, шаг не засчитан
═══ ИТОГ ═══
все требования выполнены
примечание: ручная работа с cgroup требует root
Все требования выполнены; два шага, требующих root, не выполнялись и не засчитаны.
Строка max=41 при oom_kill=1 — самый практичный результат. Предел достигался сорок один раз, убийство произошло однажды. Сорок раз приложение тратило время на освобождение памяти вместо работы, и docker inspect об этом не сообщил бы ничего.
Три решения, определяющие качество.
Чтение выполняется изнутри container'а, а не по пути на host. Путь зависит от драйвера cgroup, требует прав root и различается между дистрибутивами. Cgroup namespace показывает собственную cgroup как корень — и /sys/fs/cgroup/memory.max читается без привилегий, одинаково везде. Это же делает приём применимым в самом приложении (урок 6.13).
memory.events разбирается вместе с memory.stat. Счётчик max=41 говорит о давлении, но не о его природе. Разбивка anon 124 МиБ при file 1.8 МиБ показывает: почти вся память анонимная, освободить её нельзя, и лимит действительно тесен. Без второго источника вывод был бы догадкой.
Throttling измеряется двумя независимыми показателями. nr_throttled считает периоды с приостановкой; cpu.pressure — долю времени в ожидании. Совпадение направления у двух разных механизмов ядра исключает ошибку измерения, а avg10=24.8 при загрузке 29,6 % даёт ту же картину, что урок 13.3, но с другой стороны.
Чего решение не делает. memory.high не устанавливался: запись требует root, и поведение описано командой, а не измерено. Ручное создание cgroup не выполнялось по той же причине — приведены команды и код возврата 3, отличающий «не проверено» от «проверено». Контроллер io не разбирался: ограничения задаются по номеру устройства, и осмысленная демонстрация требует отдельного диска. Наконец, memory.pressure и io.pressure читаются, но не анализируются — для них нужна нагрузка, создающая давление именно на эти ресурсы.
Проверка результата
stat -fc %T /sys/fs/cgroup
docker info --format '{{.CgroupVersion}} {{.CgroupDriver}}'
docker exec ИМЯ cat /proc/self/cgroup
docker exec ИМЯ sh -c 'cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max'
docker exec ИМЯ cat /sys/fs/cgroup/memory.events
cgroup2fs и 0::/ подтверждают v2.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Искать файлы по путям v1 | Старые руководства | В v2 единая иерархия, другие имена |
Считать memory.current памятью приложения | Название | Включает page cache; смотреть anon |
Ожидать замедления при --memory | Логично предположить | Docker задаёт max, а не high |
| Помещать процессы в cgroup с потомками | Кажется допустимым | Правило «без внутренних процессов» |
Забывать cgroup.subtree_control | Файлы контроллера не появляются | Включать в родителе |
Судить об OOM только по .State.OOMKilled | Поле очевидно | memory.events показывает давление до отказа |
Игнорировать max > 0 при oom_kill = 0 | Отказа же нет | Лимит тесен; время уходит на освобождение |
| Измерять throttling временем | Оно на виду | nr_throttled и cpu.pressure |
| Читать cgroup только с host | Привычка | Изнутри проще и не нужны права |
Контрольные вопросы
На понимание:
- Три отличия cgroup v2 от v1 — назовите и объясните следствие каждого.
- Что запрещает правило «без внутренних процессов» и почему?
- Чем
memory.maxотличается отmemory.highпо поведению? - Что означает
max 41приoom_kill 0вmemory.events? - Почему
/proc/self/cgroupвнутри container'а содержит0::/?
На применение:
- Как прочитать ограничения container'а без прав root?
- Как отличить рост кэша от настоящей нехватки памяти?
- Как создать cgroup вручную и наложить ограничение?
На диагностику:
memory.currentблизок к пределу, приложение работает нормально. Что проверить?- Файл
memory.maxв созданной cgroup отсутствует. Причина?
Краткое резюме
- Cgroup v2 — единая иерархия: один путь, все контроллеры.
- Процессы могут находиться только в листьях дерева.
- Контроллер становится доступен потомкам через
cgroup.subtree_controlродителя. - Путь к cgroup container'а зависит от драйвера:
systemdилиcgroupfs. - Изнутри container'а собственная cgroup видна как корень — читается без привилегий.
memory.maxвызывает OOM;memory.highзамедляет процесс.- Docker задаёт только
memory.max;highустанавливают вручную. memory.currentвключает page cache; признак нехватки — ростanon.memory.eventsпоказывает давление:max > 0приoom_kill = 0означает тесный лимит.cpu.max— это квота и период в микросекундах:50000 100000равно 0,5 CPU.- Файлы давления чувствительнее throttling: растут раньше.
- Строка
fullдля CPU не заполняется; для памяти означает полную остановку.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Linux: cgroup v2 | https://docs.kernel.org/admin-guide/cgroup-v2.html | Полное описание модели |
| Linux: memory controller | https://docs.kernel.org/admin-guide/cgroup-v2.html#memory | max, high, low, events |
| Linux: cpu controller | https://docs.kernel.org/admin-guide/cgroup-v2.html#cpu | cpu.max, cpu.stat |
| Linux: PSI | https://docs.kernel.org/accounting/psi.html | Файлы давления |
Linux: cgroup_namespaces(7) | https://man7.org/linux/man-pages/man7/cgroup_namespaces.7.html | Почему видно 0::/ |
| Docker: resource constraints | https://docs.docker.com/engine/containers/resource_constraints/ | Соответствие флагов |
| Docker: runtime metrics | https://docs.docker.com/engine/containers/runmetrics/ | Чтение статистики |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → OverlayFS
Главное оглавление