Представь типичную картину: на одной машине крутится десяток контейнеров. Ночью один из них ловит утечку памяти или прилетает аномальный трафик, процесс начинает жрать 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
Ключевая идея: каждому флагу 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
Память: 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 - своп не ограничен (контейнер может использовать весь своп хоста).
Есть ещё --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
Код: Выделить всё
$ 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
Теперь 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
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
Мини-лаба: спровоцируй 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 и в фоне, если хочешь успеть заинспектить, либо смотри код выхода сразу):
Найди строку "Memory cgroup out of memory" - это твой локальный OOM.
Код: Выделить всё
$ echo $? 137 $ dmesg -T | grep -i "out of memory" | tail -2 - Теперь 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.
Контрольные вопросы
- Чем --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. И главное помни про разрыв между лимитом и видимостью: контейнер видит память хоста, поэтому рантайму надо отдельно сообщить, в каких рамках он живёт. Поставил забор - проверь, что приложение внутри о нём знает.