open atlas
↑ К треку
Docker: контейнеры как система DOCK · 02 · 02

Cgroups: бюджет ресурсов, который ядро действительно навязывает

Если namespaces решают, что контейнер видит, то cgroups решают, сколько он может потребить. Контроллеры cgroup v2 учитывают CPU, память, pids и IO. memory.max — жёсткая стена: перешагнул — ядро OOM-убивает внутри cgroup; CPU — квота, а не стена.

DOCK Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Сервис был стабилен месяцами, потом начал умирать без лишнего трафика — код выхода 137, несколько раз в час, всегда в середине запроса. 137 — это 128 + 9: SIGKILL. Дашборды показывали JVM-heap далеко от -Xmx, так что неделю команда гонялась за фантомной утечкой. Правда была в dmesg: Memory cgroup out of memory: Killed process. У контейнера memory.max был 512 МиБ. JVM, которой рассказали только про хостовые 64 ГиБ, рассчитала off-heap-буферы, metaspace и стеки потоков под машину, на которой не работала, — и сумма перешла 512 МиБ. У этой черты ядро не торгуется: когда учтённая память cgroup достигает memory.max и reclaim не может освободить достаточно, OOM-killer внутри cgroup срабатывает и шлёт SIGKILL процессу внутри этой cgroup, а не на хосте. Фикс был не в добавлении памяти; он был в том, чтобы сказать рантайму правду (-XX:MaxRAMPercentage, читающий лимит cgroup). Контейнер всегда мог видеть RAM хоста. Просто использовать её ему не позволяли.

Контроллеры: одно дерево, учтённые ресурсы

Прежде чем выставить хоть один лимит, спроси себя: что происходит, когда он нарушается? Ответ у CPU и памяти разный — и путаница между ними это самая частая cgroups-ошибка в продакшене.

Control group (cgroup) — узел в дереве процессов, который ядро учитывает и ограничивает по каждому контроллеру (механизм учёта и принудительного ограничения одного ресурса). Современные системы работают на cgroup v2 — единой объединённой иерархии (один mount в /sys/fs/cgroup), заменившей раздельные деревья v1 по контроллерам. Контроллеры, которые волнуют рантайм контейнеров:

  • cpucpu.max это "QUOTA PERIOD" в микросекундах: "50000 100000" значит 50 мс CPU на окно 100 мс = 0.5 ядра. cpu.weight (дефолт 100) задаёт пропорциональную долю при конкуренции.
  • memorymemory.max это жёсткий лимит в байтах; memory.high — мягкий throttle, запускающий reclaim до стены; memory.low защищает минимум от reclaim.
  • pidspids.max ограничивает число процессов/потоков, прямая защита от fork-бомбы или текущего entrypoint (см. прошлый урок).
  • ioio.max rate-лимитит блочный IO (BPS и IOPS) по устройству.

Вместе эти четыре контроллера покрывают каждый ресурс, которым может злоупотребить шумный сосед: процессорное время, память, слоты процессов и пропускную способность диска. Пропусти любой — и оставишь это измерение неограниченным: контейнер без pids.max, например, может fork-бомбить хост так же эффективно, как контейнер без memory.max.

runc пишет лимиты контейнера из блока linux.resources OCI-config.json в эти файлы при старте, затем перемещает PID 1 контейнера в cgroup, чтобы каждый потомок учитывался вместе. docker run -m 512m --cpus=0.5 --pids-limit=200 становится memory.max=536870912, cpu.max="50000 100000", pids.max=200. Чтение memory.current или cpu.stat изнутри или снаружи cgroup — то, как мониторинг видит реальное потребление, а не то, что показывает free, который (для cgroup-неосведомлённого инструментария) всё ещё показывает хост.

/sys/fs/cgroup/system.slice/docker-<id>.scope/
├── cpu.max          50000 100000          # 0.5 ядра
├── cpu.stat         usage_usec, nr_throttled, throttled_usec
├── memory.max       536870912             # стена 512 МиБ
├── memory.high      483183820             # ~90%, мягкий throttle
├── memory.current   421330944             # живой заряд
├── memory.events    oom 0  oom_kill 0     # растёт при каждом OOM-kill
└── pids.max         200

CPU — это квота; память — это стена

Два лимита отказывают противоположно, и смешивать их — классическая ошибка. CPU троттлится, никогда не убивается: если контейнер превышает квоту cpu.max внутри периода, ядро просто перестаёт его планировать до следующего периода — латентность скачет, nr_throttled растёт, но процесс жив. Память — жёсткая стена: перешагни memory.max, и нет «троттлить сильнее» — ядро запускает reclaim, и если не может освободить достаточно, OOM-killer в пределах cgroup шлёт SIGKILL процессу внутри cgroup. Это и есть 137 из Hook. memory.high существует именно чтобы смягчить это: выставленный ниже memory.max, он троттлит аллокации и форсирует reclaim рано, превращая жёсткое убийство в back-pressure, — но это не гарантия, стеной является только memory.max.

Викторина

Контейнер A прижат к квоте cpu.max и тормозит; контейнер B только что получил SIGKILL с кодом 137. Та же нода. Что отличает два режима отказа?

Почему это работает

Почему JVM (или Go, или Node) вообще здесь оступается? Исторически рантаймы языков читают /proc/cpuinfo и sysconfig для «сколько ядер / сколько RAM» — а те сообщают хост, ведь этот файл не namespaced так, как лимит cgroup. Рантайм тогда рассчитывает пулы потоков, GC-heap и кэши буферов под 64-ядерную / 64-ГиБ машину, живя в cgroup 0.5 ядра / 512 МиБ. Современные рантаймы cgroup-осведомлены (Java UseContainerSupport, по умолчанию с JDK 10; авто-тюнинг GOMAXPROCS в Go 1.25), но режим отказа сохраняется везде, где старый рантайм, неосведомлённая библиотека или вручную заданный флаг перебивают лимит. Cgroup навязывает бюджет в любом случае; рантайму лишь нужно сказать, что бюджет существует.

Почему это изоляция, а не виртуализация

Когда слышишь «изоляция контейнеров», легко представить что-то вроде VM — приватный пул ресурсов, до которого снаружи не достать. Эта модель здесь обманет.

Cgroup не даёт контейнеру свой CPU или свою RAM — она даёт ему учтённый ломоть хостовых. Нет второго пула памяти за гипервизором; memory.current заряжает те же физические страницы, которыми управляет хостовое ядро, а безлимитный контейнер (memory.max=max) конкурирует за весь хостовый RAM. В этом весь смысл дешевизны контейнеров: нет дублированного ядра, нет предзарезервированного гостевого RAM, почти нулевой оверхед — и вся причина, почему они не VM. Ядро общее, поэтому между шумным соседом и остальной нодой стоит бюджет, а не граница. Задавай бюджеты, или один контейнер без memory.max загонит хост в глобальный OOM и уронит все остальные контейнеры на машине.

Викторина

Контейнер работает без лимита памяти (memory.max=max) и развивает медленную утечку. Что происходит с ДРУГИМИ контейнерами на той же ноде и почему?

Вспомните перед уходом
  1. 01
    Сопоставь, как контроллеры cpu и memory реагируют, когда контейнер превышает лимит, и дай конкретный механизм ядра для каждого.
  2. 02
    Объясни, почему cgroup — это изоляция, а не виртуализация, и что ломается, если контейнер работает без memory.max.
Итог

Если namespaces решают, что контейнер видит, то cgroups решают, сколько ему позволено потребить — и ядро это навязывает. Control group — узел в дереве процессов, учитываемый и ограничиваемый по контроллеру; современные системы используют cgroup v2, единую иерархию под /sys/fs/cgroup. Контроллеры, важные для контейнеров: cpu (cpu.max как пара квота/период, cpu.weight для пропорциональной доли), memory (memory.max как жёсткая стена в байтах, memory.high как мягкий throttle с ранним reclaim, memory.low для защиты пола), pids (pids.max против fork-бомб и утечек зомби) и io (io.max для блочного IO). runc пишет их из linux.resources OCI-конфига и помещает PID 1 контейнера в cgroup, чтобы все потомки учитывались вместе. Два главных лимита отказывают противоположно: превысь cpu.max — и тебя троттлят, паузят до следующего периода, медленно но живо, с ростом nr_throttled; превысь memory.max — и OOM-killer в пределах cgroup шлёт SIGKILL процессу внутри cgroup, всплывая как код 137. Классический отказ — рантайм, читающий ядра и RAM хоста вместо лимита cgroup, рассчитывающий себя под машину, на которой не работает, и переходящий memory.max — лечится не добавлением памяти, а тем, чтобы сделать рантайм cgroup-осведомлённым. И поскольку cgroup учитывает ломоть общих хостовых ресурсов, а не вырезает приватный пул, безлимитный контейнер — опасность для всей ноды: утечка без memory.max может загнать хост в глобальный OOM и убить посторонние контейнеры. Теперь, увидев код 137, первым делом смотри в dmesg | grep -i oom и memory.events — а не в профайлер хипа. Увидев скачки латентности без OOM — проверь nr_throttled в cpu.stat.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.