Главная/Docker internals/Урок

17.4. Cgroups v2

Цели

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

  • назвать три отличия v2 от v1 и объяснить, что каждое меняет на практике;
  • найти cgroup конкретного container'а и прочитать её файлы напрямую;
  • объяснить правило «без внутренних процессов» и что оно запрещает;
  • различить memory.max, memory.high и memory.low по поведению;
  • прочитать memory.events и определить, был ли OOM;
  • создать cgroup вручную и наложить ограничение без Docker.

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

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

ТерминОбъяснение
единая иерархияОдно дерево для всех контроллеров
cgroup.controllersКакие контроллеры доступны в этой cgroup
cgroup.subtree_controlКакие контроллеры включены для потомков
no internal processesПравило: процессы только в листьях
memory.highМягкий предел: замедление вместо OOM
memory.eventsСчётчики срабатываний, включая OOM

Теория

Три отличия от v1

Свойствоv1v2
ИерархияСвоя на каждый контроллерОдна для всех
Где могут быть процессыВ любом узлеТолько в листьях
Модель включенияКонтроллер монтируется отдельноВключается в родителе для потомков

Единая иерархия. В v1 процесс мог находиться в /sys/fs/cgroup/memory/A и одновременно в /sys/fs/cgroup/cpu/B — разные деревья, разные пути. В v2 процесс находится в одной cgroup, и все контроллеры применяются к ней.

Практическое следствие: путь к cgroup container'а один, и в нём лежат файлы всех контроллеров.

Правило «без внутренних процессов». Cgroup, у которой есть потомки с включёнными контроллерами, не может содержать процессы напрямую.

text
разрешено                    запрещено
─────────                    ─────────
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:

bash
echo "+memory +cpu" > /sys/fs/cgroup/mygroup/cgroup.subtree_control

После этого у потомков появляются файлы memory.max, cpu.max и прочие.

Где cgroup container'а

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

Путь зависит от драйвера cgroup:

ДрайверПуть
systemd (по умолчанию в большинстве дистрибутивов)system.slice/docker-<id>.scope
cgroupfsdocker/<id>

Узнать драйвер: docker info --format '{{.CgroupDriver}}'.

Изнутри container'а собственная cgroup видна как корень — из-за cgroup namespace (урок 17.3):

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

text
memory.max      достигнут → попытка освободить → не вышло → OOM kill
memory.high     достигнут → процесс ЗАМЕДЛЯЕТСЯ, ядро активно освобождает

memory.high даёт обратное давление вместо убийства. Приложение продолжает работать, но медленнее — и это можно заметить и среагировать.

Docker не выставляет memory.high: флаг --memory задаёт memory.max. Установить high можно только вручную или через оркестратор.

memory.events: был ли OOM

text
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.statusage_usec, nr_periods, nr_throttled, throttled_usec
cpu.pressureДавление: доля времени в ожидании CPU
text
cpu.max: 50000 100000     50 мс квоты на 100 мс периода = 0.5 CPU
cpu.max: max 100000       без ограничения

Соответствие флагам Docker:

ФлагФайл
--cpus 0.5cpu.max = 50000 100000
--cpu-shares 512cpu.weight (пересчитывается)
--cpu-period, --cpu-quotaПрямая запись в cpu.max

Контроллер io

ФайлСмысл
io.maxПределы по устройству: rbps, wbps, riops, wiops
io.statСтатистика по устройству
io.weightОтносительная доля
io.pressureДавление ввода-вывода

Ограничения задаются по номеру устройства (major:minor), а не по имени файла или точке монтирования:

text
8:0 rbps=1048576 wbps=1048576

Отсюда практическая сложность: номер устройства нужно узнать (lsblk), и для overlay-файловой системы это устройство, на котором лежит /var/lib/docker.

Файлы давления

Механизм pressure stall information даёт долю времени, которую задачи провели в ожидании ресурса:

text
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'а

bash
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

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

text
═══ драйвер 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/... и так далее.

Файлы контроллеров

bash
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

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

text
═══ что доступно изнутри ═══
  доступные контроллеры: 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

bash
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

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

text
═══ с 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: давление до отказа

bash
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

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

text
═══ счётчики событий памяти ═══
  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 — почти вся память анонимная, то есть освободить её нельзя. Это и есть признак настоящей нехватки, а не роста кэша.

Правило «без внутренних процессов»

bash
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

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

text
═══ проверка правила ═══
  проверка НЕ ВЫПОЛНЯЛАСЬ: нужен 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 и давление

bash
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

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

text
═══ без ограничения 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 напрямую.

Требования:

  1. Найти cgroup container'а обоими способами: снаружи по пути и изнутри.
  2. Сопоставить флаги docker run с файлами cgroup — не менее шести соответствий.
  3. Прочитать memory.events и объяснить сочетание max > 0 при oom_kill = 0.
  4. Показать разницу memory.max и memory.high по поведению.
  5. Измерить throttling двумя показателями: cpu.stat и cpu.pressure.
  6. Создать cgroup вручную и наложить ограничение без Docker — или отметить, что прав нет.

Подсказки

Подсказка 1

Изнутри container'а /proc/self/cgroup содержит 0::/ — собственная cgroup видна как корень.

Подсказка 2

memory.events даёт счётчики; memory.stat — разбивку по типам памяти. Признак настоящей нехватки — рост anon.

Подсказка 3

Пункт 6 требует root. Если прав нет — приведите команды и отметьте шаг невыполненным.

Решение

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

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

text
═══ Подготовка ═══
    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 читаются, но не анализируются — для них нужна нагрузка, создающая давление именно на эти ресурсы.

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

bash
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ПривычкаИзнутри проще и не нужны права

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

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

  1. Три отличия cgroup v2 от v1 — назовите и объясните следствие каждого.
  2. Что запрещает правило «без внутренних процессов» и почему?
  3. Чем memory.max отличается от memory.high по поведению?
  4. Что означает max 41 при oom_kill 0 в memory.events?
  5. Почему /proc/self/cgroup внутри container'а содержит 0::/?

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

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

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

  1. memory.current близок к пределу, приложение работает нормально. Что проверить?
  2. Файл memory.max в созданной cgroup отсутствует. Причина?

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

  1. Cgroup v2 — единая иерархия: один путь, все контроллеры.
  2. Процессы могут находиться только в листьях дерева.
  3. Контроллер становится доступен потомкам через cgroup.subtree_control родителя.
  4. Путь к cgroup container'а зависит от драйвера: systemd или cgroupfs.
  5. Изнутри container'а собственная cgroup видна как корень — читается без привилегий.
  6. memory.max вызывает OOM; memory.high замедляет процесс.
  7. Docker задаёт только memory.max; high устанавливают вручную.
  8. memory.current включает page cache; признак нехватки — рост anon.
  9. memory.events показывает давление: max > 0 при oom_kill = 0 означает тесный лимит.
  10. cpu.max — это квота и период в микросекундах: 50000 100000 равно 0,5 CPU.
  11. Файлы давления чувствительнее throttling: растут раньше.
  12. Строка full для CPU не заполняется; для памяти означает полную остановку.

Официальные источники

ИсточникСсылкаЧто подтверждает
Linux: cgroup v2https://docs.kernel.org/admin-guide/cgroup-v2.htmlПолное описание модели
Linux: memory controllerhttps://docs.kernel.org/admin-guide/cgroup-v2.html#memorymax, high, low, events
Linux: cpu controllerhttps://docs.kernel.org/admin-guide/cgroup-v2.html#cpucpu.max, cpu.stat
Linux: PSIhttps://docs.kernel.org/accounting/psi.htmlФайлы давления
Linux: cgroup_namespaces(7)https://man7.org/linux/man-pages/man7/cgroup_namespaces.7.htmlПочему видно 0::/
Docker: resource constraintshttps://docs.docker.com/engine/containers/resource_constraints/Соответствие флагов
Docker: runtime metricshttps://docs.docker.com/engine/containers/runmetrics/Чтение статистики

Навигация

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

Markdown на GitHub ↗