Ограничение ресурсов: CPU, память, OOM и cgroups

Рейтинг: 65.7% · 17 голосов
Практический курс по Docker: образы, контейнеры, тома, сети, Compose и продакшен. Уроки по главам с обсуждением.
Ответить
Аватара пользователя
Marina_DevOps
Сообщения: 54
Зарегистрирован: 11 май 2026, 05:31

Ограничение ресурсов: CPU, память, OOM и cgroups

Сообщение Marina_DevOps »

Оглавление курса (46)
  1. Что такое Docker и какие задачи он решает
  2. Установка Docker и запуск первого контейнера
  3. Образы: слои, теги и реестр Docker Hub
  4. Пишем свой Dockerfile
  5. Тома и хранение данных: volumes и bind mounts
  6. Сети в Docker: связываем контейнеры между собой
  7. Переменные окружения и конфигурация контейнеров
  8. Docker Compose: поднимаем многоконтейнерное приложение
  9. Оптимизация образов: multi-stage сборка, размер и кэш слоёв
  10. Логи, отладка и мониторинг контейнеров
  11. Базовая безопасность контейнеров
  12. Подготовка к продакшену: что важно учесть
  13. Dockerfile глубже: ENTRYPOINT и CMD, HEALTHCHECK, .dockerignore, запуск не от root
  14. Реестры образов: приватные registry, push и pull, теги и digest, imagePullSecrets
  15. BuildKit и buildx: multi-arch сборки, секреты сборки, экспорт кэша
  16. Docker в CI/CD: автосборка, сканирование образов (Trivy, Docker Scout), публикация
  17. Итоговый проект и куда расти: от Dockerfile до прода, обзор оркестрации (Kubernetes, Podman, OCI)
  18. Архитектура Docker: dockerd, containerd, runc и OCI
  19. Как работает изоляция: namespaces и cgroups
  20. Образы изнутри: слои, OverlayFS и storage driver
  21. Жизненный цикл контейнера и политики перезапуска
  22. Отладка контейнеров: exec, attach, nsenter, debug
  23. Сети Docker глубже: bridge, overlay, macvlan и iptables
  24. Docker Compose глубже: profiles, healthcheck-зависимости, override
  25. Данные и тома глубже: драйверы, бэкап, права
  26. Секреты и конфигурация: как не светить пароли
  27. Ограничение ресурсов: CPU, память, OOM и cgroups (вы здесь)
  28. Здоровье и автозапуск: HEALTHCHECK, init и сигналы
  29. Логирование контейнеров: драйверы, ротация, сбор
  30. Мониторинг контейнеров: stats, cAdvisor, Prometheus
  31. Безопасность глубже: rootless, user namespaces, seccomp, capabilities
  32. Supply chain: сканирование, SBOM и подпись образов
  33. Глубокая оптимизация образов: distroless, scratch, кэш
  34. Multi-arch и сборка под ARM: buildx и эмуляция
  35. Реестры в продакшене: Harbor и облачные registry
  36. Docker без Docker: Podman, containerd, nerdctl, Buildah
  37. От Compose к оркестрации: когда контейнеров становится много
  38. Docker Swarm: встроенная оркестрация
  39. Сборка образов в CI без демона: kaniko, BuildKit, Buildah
  40. Траблшутинг Docker: типичные ошибки и как чинить
  41. Производительность и эксплуатация Docker-хоста
  42. Docker Desktop, WSL2 и альтернативы на Windows и Mac
  43. Контейнеризация приложений: бэкенд, фронтенд, БД
  44. Docker и Kubernetes: containerd, миграция, kompose
  45. Лучшие практики и антипаттерны Docker
  46. Сквозной проект: production-ready стек и путь дальше
Зачем вообще ограничивать ресурсы контейнера

Представь типичную картину: на одной машине крутится десяток контейнеров. Ночью один из них ловит утечку памяти или прилетает аномальный трафик, процесс начинает жрать RAM как не в себя. Без ограничений Linux в какой-то момент вызывает глобальный OOM killer, и тот по своей логике (а не по твоей) убивает какой-то процесс - возможно, не виновника, а твою базу данных или sshd. Утро ты встречаешь с лежащим сервером и без понятного виновника. Это и есть боль, которую решают docker лимиты: ты ставишь забор вокруг каждого контейнера, и взбесившийся сосед душит сам себя внутри своей клетки, а не утаскивает за собой весь хост.

По умолчанию контейнер не ограничен ничем. Он видит всю память хоста, все ядра CPU, может наплодить тысячи процессов. Контейнер - это не виртуалка, это просто процессы на твоём ядре, изолированные неймспейсами. А вот за квоты и лимиты отвечает отдельный механизм - cgroups (control groups). docker cgroups - это и есть тот рычаг, которым Docker душит аппетиты контейнера. Сегодня разберём, какие флаги докер транслирует в cgroups, что происходит под капотом на cgroups v2, как ловить OOM и throttling, и почему JVM внутри контейнера может думать, что у неё 64 ГБ, когда ты дал ей 512 МБ.

Изображение

Как docker ресурсы ложатся на cgroups v2

cgroups - это иерархия в виртуальной ФС /sys/fs/cgroup. Каждый контейнер получает свою подгруппу, а Docker пишет в её файлы лимиты, которые ядро затем жёстко соблюдает. На современных дистрибутивах (Ubuntu 22.04+, Debian 11+, Fedora 31+, generic systemd-системы) по умолчанию работает cgroups v2 - единая унифицированная иерархия. Старая v1 с отдельными контроллерами memory/cpu/blkio ещё встречается на легаси-ядрах, но новое строй сразу на v2.

Проверить версию просто:

Код: Выделить всё

$ stat -fc %T /sys/fs/cgroup/
cgroup2fs
Если видишь cgroup2fs - это v2. Если tmpfs - у тебя v1. Дальше разговор про v2, она важнее на 2026 год.

Ключевая идея: каждому флагу docker run соответствует конкретный файл в cgroup контейнера. Запустим контейнер с лимитами и заглянем внутрь:

Код: Выделить всё

$ docker run -d --name lim --memory=512m --cpus=1.5 nginx
$ CID=$(docker inspect -f '{{.Id}}' lim)
$ cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.max
536870912
$ cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.max
150000 100000
Разберём вывод по полям. memory.max = 536870912 байт - это ровно 512 МиБ, твой hard limit по памяти. cpu.max = "150000 100000" читается как "quota period": за каждые 100000 микросекунд (период) контейнеру разрешено сжечь 150000 микросекунд CPU-времени. 150000/100000 = 1.5 - те самые полтора ядра. Вот так абстрактный флаг --cpus превращается в две голые цифры, которые понимает планировщик ядра.

Память: hard limit, soft reservation и swap

docker memory limit задаётся флагом --memory (он же -m). Это жёсткий потолок. Контейнер физически не может выйти за него: как только суммарный RSS его процессов упрётся в memory.max, ядро попытается отжать память (сбросить кэш, свопнуть), а если не выйдет - запускает OOM killer внутри cgroup и убивает процесс контейнера. Глобально хост при этом не страдает - в этом весь смысл.

Со свопом тонкость, на которой спотыкаются все. Флаг --memory-swap задаёт сумму память + своп, а не своп отдельно. То есть:
  • --memory=512m --memory-swap=1g - 512 МБ RAM + до 512 МБ свопа.
  • --memory=512m без --memory-swap - своп равен лимиту памяти, итого 512 RAM + 512 swap.
  • --memory=512m --memory-swap=512m - своп выключен полностью, ровно 512 МБ и ни байтом больше.
  • --memory-swap=-1 - своп не ограничен (контейнер может использовать весь своп хоста).
Если хочешь честный тест на OOM без свопа-подушки - ставь --memory-swap равным --memory. Иначе процесс уйдёт в своп, начнёт дико тормозить, и ты будешь долго гадать, почему всё медленно, вместо честного падения.

Есть ещё --memory-reservation - это мягкий лимит (soft limit, в cgroup v2 ложится на memory.high/memory.low в зависимости от реализации). Он не убивает контейнер при превышении, а лишь создаёт давление: когда на хосте дефицит памяти, ядро в первую очередь поджимает тех, кто перебрал свой reservation. Логика такая: --memory-reservation=256m --memory=512m означает "в норме держись в 256, но в пике можешь до 512". Reservation всегда должен быть меньше hard limit, иначе смысла нет.

CPU: cpus, cpu-shares и cpuset

docker cpu limit можно задать тремя разными по смыслу способами, и их путают чаще всего.

--cpus=N - это квота, абсолютный потолок. --cpus=0.5 значит "не больше половины одного ядра, сколько бы ядер ни было свободно". Это hard cap: даже на простаивающей машине контейнер не возьмёт больше. Под капотом - тот самый cpu.max.

--cpu-shares=N - это вес, относительная доля, а не потолок. По умолчанию у всех 1024. Шары работают только при конкуренции: если два контейнера дерутся за одно ядро и у одного shares=1024, а у другого 512, первый получит 2/3 времени, второй 1/3. Но если второй простаивает, первый спокойно забирает всё ядро целиком. То есть shares не режут, а лишь приоритезируют под нагрузкой. В cgroup v2 это поле cpu.weight (диапазон 1..10000, Docker пересчитывает из старого 2..262144).

--cpuset-cpus="0,1" - это привязка к конкретным физическим ядрам. Контейнер будет исполняться только на CPU 0 и 1. Полезно для NUMA-чувствительных нагрузок, для изоляции latency-critical сервисов от шумных соседей, для воспроизводимых бенчмарков. Это уже не про "сколько", а про "где именно".

Практическое правило: для предсказуемости в проде бери --cpus (жёсткая квота). --cpu-shares - когда хочешь мягко расставить приоритеты и не терять КПД на простое. --cpuset - для тонкой настройки производительности.

Диагностика: ловим OOM и CPU throttling

Когда контейнер падает, первое, что нужно различить - его убил OOM или он сам упал. Признак OOM-смерти: контейнер уходит с кодом 137. Это 128 + 9, где 9 - это SIGKILL. Docker прямо хранит флаг:

Код: Выделить всё

$ docker inspect -f '{{.State.OOMKilled}} {{.State.ExitCode}}' lim
true 137
OOMKilled=true и ExitCode=137 - однозначный OOM. Подтверждение и детали - в ядерном логе хоста:

Код: Выделить всё

$ dmesg -T | grep -i oom
[Sat Jun 13 02:14:55 2026] Memory cgroup out of memory: Killed process 18342 (python3)
total-vm:1843204kB, anon-rss:519872kB, file-rss:4096kB
Ключевое слово "Memory cgroup out of memory" - это локальный OOM внутри cgroup, ровно то, чего мы добивались лимитом. anon-rss:519872kB показывает, что процесс упёрся примерно в 508 МБ - почти наш лимит 512. Если бы в логе было просто "Out of memory" без "cgroup" - это был бы глобальный OOM хоста, совсем другая беда.

Теперь CPU throttling - тихий убийца производительности. Если ты дал --cpus=0.5, а приложению нужно больше, ядро не убивает его, а тормозит: усыпляет в конце каждого периода, как только квота выбрана. Снаружи это выглядит как необъяснимые лаги при низкой нагрузке CPU на хосте. Ловится через cpu.stat:

Код: Выделить всё

$ cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.stat
usage_usec 48211934
nr_periods 9123
nr_throttled 7841
throttled_usec 31204882
Смотри на nr_throttled относительно nr_periods. Здесь 7841 из 9123 периодов контейнер был придушен - это 86%, катастрофа. throttled_usec говорит, что в сумме приложение простояло в наказание 31 секунду. Лечится либо поднятием --cpus, либо оптимизацией приложения. Высокий nr_throttled при низком общем потреблении CPU - классический симптом слишком жёсткой квоты, особенно болезненный для GC-пауз в JVM и для Node.

PIDs, ulimit, blkio - забор по остальным ресурсам

Память и CPU - не всё. --pids-limit=100 ограничивает число процессов/тредов в контейнере (ложится на cgroup pids.max). Это прямая защита от fork-бомбы и от утечки тредов: без него взбесившийся процесс наплодит десятки тысяч задач и положит хост по лимиту PID ядра.

--ulimit задаёт классические posix-лимиты процесса, например число открытых файлов: --ulimit nofile=65536:65536 (soft:hard). Частая боль высоконагруженных сервисов - дефолтные лимиты на дескрипторы, и контейнер ловит "too many open files" на ровном месте.

--blkio-weight=500 (диапазон 10..1000) - относительный вес дисковых операций, аналог cpu-shares, но для I/O. Полезно, когда несколько контейнеров делят один диск и один из них забивает канал бэкапами, душа остальных. Есть и жёсткие лимиты пропускной способности: --device-read-bps, --device-write-bps, --device-read-iops, --device-write-iops на конкретное устройство.

Грабли: приложение видит память всего хоста

Это самая коварная и недооценённая ловушка. cgroups ограничивает использование памяти, но не подменяет то, что приложение видит при опросе системы. /proc/meminfo внутри контейнера по-прежнему показывает всю RAM хоста. И многие рантаймы строят свои внутренние настройки исходя именно из "видимой" памяти, а не из cgroup-лимита.

Классика жанра - JVM. Старые версии, не зная про cgroup, считали max heap как четверть памяти хоста. Дашь контейнеру --memory=512m на машине с 64 ГБ - JVM решит, что ей положено 16 ГБ heap, начнёт его наращивать и нарвётся на docker oom задолго до того, как сама заподозрит неладное: 137, рестарт, и в её собственных логах ни намёка на OutOfMemoryError, потому что убило её снаружи, ядром. Современные JDK (11+, а уж тем более 17/21) cgroup-aware по умолчанию и читают memory.max - но всё равно подстрахуйся флагом -XX:MaxRAMPercentage=75.0 вместо фиксированного -Xmx, чтобы heap масштабировался от лимита. Node.js аналогично: при больших лимитах задавай --max-old-space-size явно, иначе V8 берёт ориентир не от cgroup.

Вывод простой: лимит в Docker и осведомлённость рантайма о лимите - две разные вещи. Ставишь --memory - обязательно скажи приложению внутри, в каких рамках оно живёт.

Лимиты в Compose

В docker compose (v2, файл compose.yaml без version:) лимиты задаются в блоке deploy.resources. Это работает и без Swarm - docker compose up их применяет:

Код: Выделить всё

services:
  api:
    image: myorg/api:1.4
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 512M
          pids: 200
        reservations:
          cpus: "0.25"
          memory: 256M
    mem_swappiness: 0
limits - жёсткие потолки (аналог --cpus/--memory/--pids-limit), reservations - мягкие гарантии (аналог --cpu-shares-смысла и --memory-reservation). mem_swappiness: 0 отключает агрессивный своп для сервиса. Держи limits и reservations осмысленно разнесёнными: reservation - типичное потребление, limit - аварийный потолок с запасом.

Мини-лаба: спровоцируй OOM и throttling руками

Прогони по шагам, своими глазами увидь и 137, и nr_throttled.
  • Запусти жрущий память контейнер с жёстким лимитом без свопа:

    Код: Выделить всё

    $ docker run --rm -it --name oom-test --memory=128m --memory-swap=128m \
      python:3.12-slim python3 -c "a=[]; [a.append(' '*10_000_000) for _ in range(100)]"
    
  • Дождись падения и проверь причину (запусти его без --rm и в фоне, если хочешь успеть заинспектить, либо смотри код выхода сразу):

    Код: Выделить всё

    $ echo $?
    137
    $ dmesg -T | grep -i "out of memory" | tail -2
    
    Найди строку "Memory cgroup out of memory" - это твой локальный OOM.
  • Теперь throttling. Запусти CPU-bound нагрузку с жёсткой квотой:

    Код: Выделить всё

    $ docker run -d --name cpu-test --cpus=0.2 progrium/stress \
      stress --cpu 2 --timeout 30s
    $ CID=$(docker inspect -f '{{.Id}}' cpu-test)
    $ cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.stat
    
  • Посмотри на nr_throttled - оно будет близко к nr_periods. Подними лимит до --cpus=2 и повтори: throttling почти исчезнет. Прочувствуй разницу.
  • Убери за собой: docker rm -f cpu-test oom-test 2>/dev/null.
На Docker Desktop (Windows/Mac) учти: контейнеры живут в VM (на Windows - WSL2), поэтому "хост" для лимитов - это виртуалка, и /sys/fs/cgroup ты увидишь внутри её, а память ограничена настройками самого Docker Desktop. dmesg хоста-Windows тебе ничего не покажет - лезь в логи VM.

Контрольные вопросы
  • Чем --cpus=1 принципиально отличается от --cpu-shares=1024, и в каком случае контейнер с shares сможет занять больше одного ядра?
  • Ты задал --memory=512m, но контейнер при нагрузке дико тормозит, а не падает. Какой флаг ты, скорее всего, забыл выставить и почему?
  • Контейнер ушёл с кодом 137. Как за две команды точно подтвердить, что это OOM именно внутри cgroup, а не глобальный OOM хоста?
  • Почему JVM или Node могут упасть по docker oom, даже если в их собственных логах нет ошибки нехватки памяти?
Итог

Лимиты ресурсов - это не опциональная косметика, а базовая гигиена прода. Каждый флаг (--memory, --cpus, --pids-limit) превращается в конкретные числа в файлах cgroup v2 (memory.max, cpu.max, pids.max), а ядро жёстко их соблюдает. Память бьётся hard-лимитом и OOM-киллом внутри cgroup (код 137, OOMKilled=true, "Memory cgroup out of memory" в dmesg); CPU режется квотой (--cpus) или приоритезируется весом (--cpu-shares), а пережим квоты ловится через nr_throttled в cpu.stat. И главное помни про разрыв между лимитом и видимостью: контейнер видит память хоста, поэтому рантайму надо отдельно сообщить, в каких рамках он живёт. Поставил забор - проверь, что приложение внутри о нём знает.
👍3 ❤️1 🔥3 😄 🤔1
Аватара пользователя
philoublue
Сообщения: 1
Зарегистрирован: 21 май 2026, 14:45

Re: Ограничение ресурсов: CPU, память, OOM и cgroups

Сообщение philoublue »

Блин, вот про разрыв между лимитом и тем что приложение видит - я неделю ловил 137 на джаве и не мог понять откуда, в логах чисто. Спасибо, теперь дошло про MaxRAMPercentage.
👍 ❤️1 🔥 😄 🤔
Аватара пользователя
jiipeep
Сообщения: 1
Зарегистрирован: 03 июн 2026, 02:50

Re: Ограничение ресурсов: CPU, память, OOM и cgroups

Сообщение jiipeep »

А --memory-swap реально сумма а не своп? Всю жизнь думал что это отдельный размер свопа, перепроверю свои compose файлы сегодня же
👍1 ❤️ 🔥 😄 🤔
Ответить
← Предыдущая глава
Секреты и конфигурация: как не светить пароли
Следующая глава →
Здоровье и автозапуск: HEALTHCHECK, init и сигналы

Все главы курса «Docker: контейнеризация от основ до продакшена»

Поделиться темой: ✈ Telegram VK

Вернуться в «Docker с нуля»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость