MAATRIX / Блог / Потолок конкурентных сборок Docker: почему шестая идёт вдвое дольше первой

Потолок конкурентных сборок Docker: почему шестая идёт вдвое дольше первой

MAATRIX

Один docker build на пустом раннере идёт, скажем, три минуты. Логично ожидать, что шесть параллельных сборок займут те же три минуты хором — CI на то и существует, чтобы гнать пайплайны параллельно. На практике первая-вторая сборка действительно почти не замечают друг друга, а вот шестая может идти не в полтора, а в два раза дольше одиночной — и это не баг конкретного раннера, а нормальное поведение системы, где несколько процессов делят CPU, диск и сеть без изоляции по приоритету. Разберём, откуда берётся эта нелинейность, что в ней можно исправить кэшем BuildKit, а что упирается в железо, и как посчитать для своего сервера число job-ов, после которого добавлять параллелизm уже вредно.

Из чего состоит одна сборка с точки зрения ресурсов

Сборка Docker-образа — это не один тип нагрузки, а последовательность фаз, каждая из которых давит на свой ресурс:

  • Pull базового образа — сеть на входе, плюс запись слоёв на диск сразу после скачивания.
  • RUN apt-get install / pip install / npm ci — сеть (скачивание пакетов) и диск (распаковка, запись файлов) одновременно, CPU почти простаивает.
  • Компиляция (cargo build, webpack, go build, mvn package) — это CPU, часто многопоточный внутри самого job-а, независимо от того, сколько ядер вы номинально выделили контейнеру.
  • Copy и commit слоя — диск: BuildKit считает diff между слоями и пишет новый слой в overlay2, это операция записи множества файлов, часто мелких.

Одиночная сборка проходит эти фазы последовательно, и в моменте использует один ресурс интенсивно, а остальные — почти нет. Когда сборок несколько одновременно, они почти никогда не синхронизированы по фазам: пока одна пишет слои на диск, другая качает пакеты, третья компилирует — казалось бы, ресурсы должны красиво чередоваться. На деле это работает только для первых двух-трёх параллельных сборок, а дальше фазы начинают перекрываться чаще, чем не перекрываются, и именно тогда начинается нелинейная деградация.

Почему ускорение не растёт линейно с числом сборок

Пока сборок мало и они не упираются в самый узкий ресурс сервера, время каждой почти не меняется от того, что рядом крутится ещё одна — планировщик ОС и диск справляются с чередованием фаз без заметной очереди. Проблема начинается, когда суммарный спрос на самый дефицитный ресурс (обычно диск или сеть, реже CPU) превышает его пропускную способность: с этого момента каждая новая параллельная сборка не просто «встаёт в очередь сама» — она замедляет все уже выполняющиеся сборки, потому что все они теперь делят один и тот же физический канал или очередь I/O.

Возьмём условный, иллюстративный пример (не бенчмарк — на вашем железе цифры будут другими, важна форма кривой, а не значения):

Параллельных сборокВремя одной сборкиСуммарное время (одна волна)Сборок в минуту
13 мин3 мин0.33
2~3.2 мин3.2 мин0.63
3~3.6 мин3.6 мин0.83
4~4.3 мин4.3 мин0.93
5~5.2 мин5.2 мин0.96
6~6.1 мин6.1 мин0.98

Пропускная способность (сборок в минуту) растёт всё медленнее и на 5-6 сборках почти выходит на плато, хотя каждая отдельная сборка при этом длится вдвое дольше одиночной. Это и есть классическая кривая с убывающей отдачей: до точки насыщения добавление параллелизма почти бесплатно, после неё — вы платите временем каждой сборки за то же самое совокупное количество работы в единицу времени. А если добавить job-ы сверх точки насыщения, суммарная пропускная способность может даже начать падать — конкуренция за ресурс создаёт накладные расходы (переключение контекста, случайный доступ к диску вместо последовательного, повторные TCP-соединения), которых не было бы при последовательном выполнении.

Важный нюанс: «вдвое дольше» — это не значит «вы теряете половину пользы от параллелизма». Суммарная пропускная способность на 6 параллельных сборках всё равно намного выше, чем на одной. Проблема в другом: если для вас критично время одной конкретной сборки (например, PR-чек, который блокирует мёрж), а не суммарная пропускная способность раннера, то после точки насыщения каждый дополнительный job ухудшает именно то, что вам важно — задержку, а не throughput.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Конкуренция за CPU: компиляция и установка пакетов

CPU обычно насыщается позже диска и сети, но с одной оговоркой: многие тулчейны компиляции сами параллелятся внутри job-а и не знают, что рядом работают ещё пять таких же. make -j$(nproc), cargo build с параллельной кодогенерацией, webpack с несколькими worker-ами — каждый такой процесс, оказавшись внутри контейнера без явного CPU-лимита, видит число ядер хоста, а не то, что вы мысленно выделили под один job. Шесть параллельных сборок, каждая из которых внутри пытается занять все ядра хоста, создают в шесть раз больше рантайм-потоков, чем физических ядер — планировщик ОС честно делит время между ними, но накладные расходы на переключение контекста и промахи процессорного кэша съедают часть полезной работы.

Практическое исправление — ограничивать параллелизм компилятора внутри сборки, а не только число сборок снаружи:

docker build --build-arg MAKEFLAGS=-j2 .
docker build --cpuset-cpus="0-1" .          # жёсткая привязка к конкретным ядрам

Без этого расчёт «6 сборок по 2 vCPU каждая на 12-ядерном сервере» на бумаге сходится, а по факту каждая сборка внутри пытается занять все 12 ядер разом, и реальная картина далека от расчётной.

Конкуренция за диск: запись слоёв образа

Диск чаще всего оказывается настоящим потолком параллельных сборок, и происходит это не из-за объёма данных, а из-за характера доступа. Одна сборка пишет слои более-менее последовательно. Несколько параллельных сборок одновременно распаковывают base-образы, пишут новые файлы в overlay2, создают и удаляют временные файлы кэша пакетных менеджеров — и диск переходит в режим случайного доступа с конкурирующими потоками, для которого практическая пропускная способность в разы ниже паспортной последовательной. Особенно болезненно это на дисках с небольшой глубиной очереди команд: они физически не успевают обслуживать несколько параллельных потоков I/O без роста задержки каждого запроса — механика разобрана в статье про очереди NVMe и почему один поток не выжимает диск.

Проверить, упирается ли текущая параллельная нагрузка в диск, можно прямо во время сборки:

iostat -x 1        # %util около 100 и растущая avgqu-sz — диск уже узкое место
docker system df    # сколько места реально съедено слоями и кэшем сборки

Практические меры, которые снижают давление на диск при параллельных сборках:

  • вынести docker data-root на отдельный быстрый диск, не тот, где лежит checkout репозитория и логи CI;
  • периодически чистить неиспользуемые слои и кэш (docker builder prune, docker system prune --volumes) — разросшийся кэш сам по себе не замедляет запись, но раздувает объём метаданных файловой системы, с которым приходится работать при каждой сборке;
  • не размещать раннер на том же диске, что и продакшен-база данных или другой I/O-чувствительный сервис — тогда пиковая нагрузка сборок не аукается соседям.

Конкуренция за сеть: одновременное скачивание образов и пакетов

Четвёртый (и часто недооценённый) потолок — канал раннера. Если шесть сборок одновременно тянут базовые образы из Docker Hub и пакеты через apt-get/pip/npm, они делят один аплинк, и на VPS со скромным каналом это превращается в такое же линейное замедление всех сразу, как перегрузка диска. Хуже того: если несколько сборок используют один и тот же базовый образ, но кэш ещё не прогрет, каждая тянет его заново — сеть и диск нагружаются избыточно на ровном месте.

Единственное решение, которое реально снимает эту нагрузку, а не просто расширяет канал, — локальный registry-mirror и локальный кэш пакетов:

# /etc/docker/daemon.json — pull-through mirror для Docker Hub
{
  "registry-mirrors": ["https://mirror.example.internal"]
}

Развернуть такой mirror на своём сервере — задача на пару часов, разбор частых ошибок при настройке приватного registry (в том числе как pull-through cache) — в статье про приватный Docker registry на сервере. Для пакетных менеджеров логика та же: локальный прокси-кэш npm/pip/apt избавляет параллельные сборки от повторного скачивания одних и тех же версий пакетов из внешней сети при каждом запуске.

Роль BuildKit: кэш слоёв и параллелизм внутри самой сборки

Уже несколько лет BuildKit — билдер по умолчанию в docker build, и у него есть два механизма, которые напрямую влияют на то, как ведут себя параллельные сборки: собственный параллелизм внутри одной сборки и переиспользуемый кэш между сборками.

Параллелизм внутри сборки. BuildKit строит граф зависимостей (DAG) шагов сборки и умеет исполнять независимые ветки multi-stage Dockerfile параллельно — например, если у вас отдельная стадия сборки фронтенда и отдельная стадия сборки бэкенда, не зависящие друг от друга, BuildKit запустит их одновременно в рамках одного docker build, а не последовательно. Это увеличивает пиковую нагрузку одной сборки на CPU/диск/сеть, но сокращает её общее время — при расчёте лимита параллельных job-ов это стоит учитывать: одна сборка с несколькими независимыми стадиями по факту потребляет ресурсы как две-три «логические» сборки одновременно. Разбор того, как строить multi-stage Dockerfile и на чём он экономит, — в статье про оптимизацию образа через multi-stage build.

Кэш-монтирование. Для пакетных менеджеров BuildKit умеет держать персистентный кэш между сборками, который не попадает в слой образа:

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

RUN --mount=type=cache,target=/root/.npm \
    npm ci

Это резко сокращает сетевую и дисковую нагрузку повторных сборок одного и того же проекта — пакеты не перекачиваются и не переустанавливаются заново, если lock-файл не менялся. Грабля здесь в том, что по умолчанию доступ к одному и тому же cache-mount id между параллельными сборками сериализуется (sharing=locked), чтобы не повредить кэш — то есть шесть сборок с одинаковым target кэша могут по очереди ждать друг друга на этом шаге, а не работать параллельно. Если это критично, у монтирования есть параметр sharing=shared (для кэшей, безопасных к параллельной записи) или отдельные id кэша на разные ветки сборки.

Внешний кэш между машинами. Для CI с несколькими раннерами полезен экспорт/импорт кэша через registry, а не только локальный кэш на одном хосте:

docker buildx build \
  --cache-to type=registry,ref=registry.example.internal/app:cache \
  --cache-from type=registry,ref=registry.example.internal/app:cache \
  -t registry.example.internal/app:latest .

Это даёт эффект даже при первом запуске на новом раннере — не нужно прогревать локальный кэш заново, а значит меньше нагрузки на CPU (не пересобираются неизменившиеся слои) и на сеть (не перекачиваются пакеты, которые уже собирались на другой машине). Для сборки нескольких связанных образов сразу стоит смотреть на docker buildx bake — он строит общий граф зависимостей для нескольких target-ов и переиспользует кэш между ними эффективнее, чем несколько независимых вызовов docker build.

Как найти оптимальное число параллельных job-ов на своём железе

Формула «число vCPU минус запас» здесь не работает, потому что реальный потолок почти никогда не CPU, а диск или сеть, и увидеть это можно только замером на своём железе, а не по паспортным характеристикам. Методика:

  1. Измерьте одиночную сборку. Запустите docker build в изоляции, зафиксируйте время (time docker build ...) и одновременно смотрите iostat -x 1, mpstat -P ALL 1, nload — какой ресурс ближе всего к насыщению уже на одной сборке.
  2. Прогоните серию с растущим N. Запустите N одинаковых сборок одновременно (проще всего — xargs -P N или GNU parallel), для каждого N зафиксируйте суммарное время волны и время каждой отдельной сборки:
for n in 1 2 3 4 5 6 8; do
  echo "=== concurrent=$n ==="
  /usr/bin/time -f "%e sec" \
    seq $n | xargs -P $n -I{} docker build -t bench:{} -f Dockerfile.bench .
done
  1. Постройте throughput = N / суммарное_время для каждого N и найдите точку, где кривая перестаёт расти почти пропорционально — обычно она заметна визуально: после неё каждая следующая единица N даёт всё меньший прирост сборок в минуту, а время одной сборки при этом растёт заметно быстрее, чем прежде.
  2. Возьмите N чуть ниже точки насыщения, а не на ней. На точке насыщения сервер уже работает на пределе конкретного ресурса — любой посторонний всплеск (backup, ротация логов, чужой процесс на этом же хосте) толкает систему за предел и превращает стабильные сборки в нестабильные с таймаутами.
  3. Разделите сценарии по важности задержки. Если для вас критично время одной сборки (блокирующий PR-чек) — берите N заметно ниже точки насыщения. Если важна суммарная пропускная способность ночных пересборок каталога образов — можно балансировать ближе к плато throughput, смирившись с тем, что каждая отдельная сборка идёт медленнее.
  4. Пересчитывайте при росте проекта. Число, найденное на монорепозитории из 200 файлов, не годится для того же репозитория через год на 2000 файлов — растёт и объём компиляции, и объём слоёв, которые нужно записать на диск.

Этот текст — про сам docker build и его внутреннюю механику. Если нужен общий расчёт лимита параллелизма CI-раннера по всем четырём ресурсам сразу и настройка concurrent в конфиге раннера — методика подробно разобрана в статье про предел параллельных сборок и сколько job-ов выдержит раннер.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Поможет ли просто добавить CPU и RAM, если диск уже узкое место?

Нет, и это частая ошибка при масштабировании раннера — если замер iostat показывает насыщение диска, апгрейд CPU/RAM почти не сдвинет точку деградации; нужен диск с большей глубиной очереди (NVMe вместо SATA SSD) или вынос сборок на отдельный физический том.

Стоит ли ограничивать каждую сборку через docker build --memory/--cpuset-cpus?

Да, если сборки запускаются на общем хосте без внешней изоляции по cgroups — без явных лимитов один job может неявно захватить больше ресурса, чем предполагал ваш расчёт параллелизма, особенно если внутри используется make -j$(nproc) без переопределения.

Даёт ли удалённый BuildKit-билдер (buildkitd как отдельный сервис) больше параллелизма, чем локальный?

Он не увеличивает физические ресурсы, но позволяет вынести кэш и вычисления сборки на отдельную машину от CI-раннера, разделив нагрузку — полезно, если раннер параллельно занят чем-то ещё помимо сборок.

Почему после смены базового образа параллелизм внезапно перестаёт помогать?

Смена base-образа или обновление lock-файла инвалидирует кэш слоёв — все параллельные сборки одновременно начинают тянуть новые слои и переустанавливать зависимости с нуля, то есть в момент cache miss нагрузка на диск и сеть кратно выше обычной, и именно тогда чаще всего видна самая жёсткая деградация.

Можно ли считать точку насыщения один раз и забыть про неё?

Нет — она зависит от версии зависимостей, объёма кода и того, что ещё крутится на хосте помимо сборок; разумно перепроверять раз в несколько месяцев или после заметного роста репозитория.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →