Раз в полгода кто-то в чате спрашивает: «У меня контейнер ест больше 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.