Раз в полгода кто-то в чате спрашивает: «У меня контейнер ест больше CPU, чем --cpus=2. Это баг?». Это не баг. Это разница между двумя разными механизмами ограничения CPU, которые на первый взгляд кажутся взаимозаменяемыми. На самом деле они решают разные задачи, и путаница тут — норма.
Что делает –cpus
--cpus=N — это квота на использование CPU за период времени. Под капотом это cpu.cfs_quota_us и cpu.cfs_period_us в cgroup. Грубо говоря, контейнеру разрешается использовать N процессорных секунд за каждую секунду реального времени.
Например, --cpus=2:
cpu.cfs_period_us=100000(100 мс — стандартный период)cpu.cfs_quota_us=200000(200 мс — две полных «секунды» CPU за период)
Контейнер может работать на любых ядрах, но в сумме за 100 мс он не съест больше 200 мс CPU-времени.
Проверить:
cat /sys/fs/cgroup/system.slice/docker-$(docker inspect --format '{{.Id}}' mycontainer).scope/cpu.max
# 200000 100000
Это работает в среднем. На коротких промежутках контейнер может прыгать с 0 до 16 ядер и обратно. Что приводит к интересным эффектам.
Что делает –cpuset-cpus
--cpuset-cpus="0,1" — это набор ядер, на которых процессу разрешено выполняться. Под капотом — cpuset.cpus в cgroup. Это не квота, это маска: ядра 0 и 1 — да, остальные — нет.
Контейнер с --cpuset-cpus="0,1" может максимум загрузить эти два ядра на 100% и больше никуда не дёрнется.
cat /sys/fs/cgroup/system.slice/docker-$(docker inspect --format '{{.Id}}' mycontainer).scope/cpuset.cpus
# 0-1
Почему --cpus ведёт себя «странно»
С --cpus=2 контейнер может:
- Загрузить ядро 0 на 100% и ядро 1 на 100% за первые 100 мс
- Получить throttling
- Простоять до конца окна
- Повторить
В мониторинге за секунду вы увидите 200% CPU, и подумаете «всё ок, лимит работает». Но в момент пика контейнер занимал 16 ядер по чуть-чуть, или 2 ядра целиком — это зависит от того, как планировщик его раскидал. Латентность отдельных запросов от этого скачет дико.
Особенно больно это на latency-sensitive нагрузках. У меня был случай: redis в контейнере с --cpus=1. По графикам — нагрузка 80-90%. По факту — p99 latency прыгает от 1 мс до 80 мс, потому что в моменты throttling redis буквально замораживается на десятки миллисекунд.
Лечится переключением на --cpuset-cpus:
docker run --cpuset-cpus="3" --memory=1g redis:7.4-alpine
После этого redis сидит на ядре 3, никто ему не мешает, никаких CFS-throttling-windows. p99 стабильный.
Когда что использовать
--cpus подходит, когда:
- Вы запускаете много короткоживущих контейнеров (CI-задачи, batch-jobs)
- Вам важно ограничить «среднюю» нагрузку, а не пиковую
- У вас batch/throughput-сценарий и не важна latency
- Контейнеров много и закреплять каждый на ядро нет смысла
--cpuset-cpus подходит, когда:
- Latency-sensitive сервис (redis, постгрес, веб-проксики)
- Вам важна предсказуемость, а не «средняя» цифра
- Вы тюните NUMA-локальность (привязка к ядрам одной NUMA-ноды)
- Боретесь с noisy neighbors
В проде у меня примерно так:
- nginx, redis, postgres, прокси —
--cpuset-cpus - CI-runners, бэкап-задачи, разовая аналитика —
--cpus
Комбинация — да, это легально
Можно использовать и то, и другое одновременно:
docker run --cpuset-cpus="2-5" --cpus=2 nginx
Это значит: контейнеру разрешено работать на ядрах 2-5, но в сумме не больше 2 ядер CPU-времени. На практике редко имеет смысл — обычно одно из двух.
–cpu-shares — а это что?
Третий вариант, который часто путают: --cpu-shares=N. Это относительный вес при конкуренции за CPU. Дефолт — 1024. Если у контейнера A shares=1024, у B — 2048, и они конкурируют за одно ядро, B получит 2/3 времени.
Важно: это работает только при конкуренции. Если ядро свободно — контейнер с shares=512 спокойно загрузит его на 100%. Это про fair share, а не про лимит.
docker run -d --cpu-shares=2048 important-service
docker run -d --cpu-shares=512 backup-job
Полезно, когда вы не хотите жёсткого лимита, но хотите, чтобы при конкуренции важный сервис получал больше.
Подводный камень: docker compose и YAML-формат
В docker-compose синтаксис разный для разных версий. Современный:
services:
nginx:
image: nginx:1.27
cpuset: "2-5"
deploy:
resources:
limits:
cpus: '2.0'
memory: 512M
cpuset тут — это --cpuset-cpus. А deploy.resources.limits.cpus — это --cpus. В старых compose-файлах был ещё cpu_count, cpus на верхнем уровне — путаница знатная. Сверяйтесь с актуальной документацией под свою версию compose.
Cgroup v2 нюансы
На Debian 12 / Ubuntu 24.04 docker по дефолту работает на cgroup v2. Имена файлов поменялись: вместо cpu.cfs_quota_us/cpu.cfs_period_us теперь единый cpu.max. Логика та же, но скрипты, которые парсят старые пути, ломаются.
cat /sys/fs/cgroup/system.slice/docker-XXX.scope/cpu.max
# 200000 100000
# или
# max 100000 <- без лимита
Итог
--cpus — это квота, не пиннинг. Для latency-чувствительных сервисов используйте --cpuset-cpus. Для batch — --cpus. Для общей шейпинга при конкуренции — --cpu-shares. И помните, что троттлинг через CFS quota — самая частая причина непонятных «лагов» в контейнере, которые не видны в метриках CPU usage.