На втором запуске сборки Docker почти всё уже готово: шаги один за другим помечаются как использованные из кеша, и образ появляется за считанные секунды. А на соседнем runner та же сборка с тем же коммитом снова скачивает базовый образ, ставит системные пакеты и собирает зависимости с нуля. Разработчик смотрит на два лога и резонно спрашивает: мы же уже всё это собирали, почему мир приходится строить заново?

Ответ прозаичен: кеш лежит там, где его создали. Локальный кеш BuildKit принадлежит конкретному builder на конкретной машине. Пока runner один, этого достаточно. Когда их два или три, а ещё иногда пересоздаются, кеш перестаёт быть общим по умолчанию. Дальше начинаются две задачи: научить исполнителей обмениваться кешем и не дать этому кешу съесть весь диск. Обе решаются, но у каждой есть цена, и лучше увидеть её заранее.

Один Dockerfile, две сборки и одна изменённая зависимость

Чтобы разговор не был абстрактным, возьмём небольшое приложение и опишем эксперимент, который стоит провести у себя. Сначала холодная сборка: чистый builder, пустой кеш, ничего не импортируется. Затем повтор того же коммита на той же машине. Затем изменение одной зависимости в файле блокировки и ещё один запуск. Три замера дают три разных числа, и именно их сравнение показывает, сколько кеш реально экономит.

Условно разложим сборку на стадии и посмотрим, что с каждой происходит в этих трёх сценариях. Это не результаты измерений, а логика, которую вы проверите собственными логами.

Что ожидать от стадий при трёх сценариях (схема, а не замер)
СтадияХолодная сборкаПовтор без измененийИзменена одна зависимость
Получение базового образаЗагружается целикомУже на дискеУже на диске
Системные пакетыВыполняетсяБерётся из кешаБерётся из кеша, если шаг стоит выше изменения
Установка зависимостей приложенияВыполняетсяБерётся из кешаВыполняется заново
Копирование исходниковВыполняетсяБерётся из кешаВыполняется заново, ведь вход изменился ниже по цепочке
Компиляция или упаковкаВыполняетсяБерётся из кешаВыполняется заново

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

Что на самом деле запоминает BuildKit и что заставляет его забыть

Кешируется результат инструкции при условии, что не изменились её входы. Входами служат сама инструкция, результат предыдущего шага и, для COPY и ADD, содержимое копируемых файлов. Совпал ключ, и результат берётся готовым. Не совпал, и шаг выполняется, а вместе с ним все последующие шаги, потому что их родитель теперь другой. Это рассуждение о принципе работы, и оно объясняет почти все странности кеша.

Отсюда несколько причин, по которым повтор не превращается в попадание в кеш.

  • Изменился вход выше по цепочке. Правка одного файла, который копируется рано, обесценивает всё дальнейшее.
  • Изменился базовый образ. Если тег плавающий и под ним теперь другой образ, фундамент у цепочки новый, и кеш поверх него не подходит.
  • Другой builder или другой исполнитель. Локальные записи не видны соседу, пока они явно не переданы.
  • Кеш удалён очисткой. Об этом ниже: это не авария, а обычное следствие выбранной политики.

Есть и обратная ловушка. Шаг может честно отработать из кеша и при этом быть неверным по смыслу. Если команда вроде обновления индекса пакетов закеширована неделю назад, то вы получаете недельную давность и не замечаете этого: сборка быстрая, зелёная, но не свежая. Поэтому «быстро» и «правильно» нужно проверять отдельно, а любое повторение сборки не следует называть попаданием в кеш. Попадание видно в логе по конкретным шагам, а не по общему впечатлению от скорости.

Как кеш добирается до соседнего runner

Buildx умеет хранить кеш в нескольких местах: inline, registry и local. Какие из них доступны, зависит от драйвера builder и от image store, и это стоит проверить на своей установке до того, как писать конвейер вокруг конкретного варианта (документация Docker по backend для кеша). Сравним их по смыслу для команды из нескольких runner.

  • Local. Кеш выгружается в каталог на диске. Удобен, когда runner один или когда каталог вы сами переносите между запусками. Для нескольких машин нужен свой механизм доставки каталога, и это отдельная работа.
  • Inline. Сведения о кеше встраиваются в сам собранный образ. Просто, но кеш и результат оказываются склеены, а это вносит путаницу, о которой мы поговорим чуть ниже.
  • Registry. Кеш публикуется в registry как отдельная запись, которую другие исполнители могут забрать. Для нескольких runner это наиболее естественный вариант: общее место уже есть, и у всех к нему есть доступ.

Важная деталь: registry-кеш хранится отдельно от итогового образа, и внешний кеш нужно явно импортировать и экспортировать. Сам по себе он не подтянется, даже если все runner смотрят в один registry. В команде сборки это выглядит так (адрес и имена условные):

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/team/app:buildcache \
  --cache-to type=registry,ref=registry.example.com/team/app:buildcache,mode=max \
  -t registry.example.com/team/app:1.8.2 \
  --push .

Здесь --cache-from говорит: перед работой загляни в эту запись и возьми оттуда готовое. --cache-to говорит: после сборки выгрузи накопленное туда же. Режим mode=max выгружает и слои промежуточных стадий, а не только итоговые. Для многостадийных Dockerfile это обычно именно то, что нужно, но цена такого режима известна: кеш в registry становится заметно объёмнее. Подходит ли он вам, покажет замер, а не привычка.

Кеш и релиз: две разные записи с разной судьбой

В примере выше стоят два тега, и это принципиально. app:1.8.2 является release artifact: то, что вы тестируете, продвигаете и в случае чего откатываете. app:buildcache всего лишь запись для ускорения сборки. Её можно перезаписывать при каждом запуске, удалять целиком, терять без последствий, и худшее, что случится, будет одна холодная сборка.

Если смешать эти роли, возникают неприятные ситуации. Кто-то решает «почистить лишнее» и удаляет тег, на который ссылается развёртывание. Или наоборот: тег с кешем попадает в список образов для выкладки, потому что выглядит как образ. Держите разные имена, разные правила очистки и разные права: исполнитель сборки может перезаписывать кеш, но не обязан иметь право трогать релизные теги. Если свой registry вы только собираетесь поднимать, то общая идея описана в материале о собственном registry как страховке сборок; для кеша он полезен ещё и тем, что трафик к нему не зависит от внешних лимитов.

Чего не должно быть в слоях

Общий кеш хранит слои, а слои читаются всеми, у кого есть доступ к записи. Если токен приватного репозитория попал в слой через COPY или ARG, он может раскрыться, а для передачи секретов во время сборки предусмотрен отдельный механизм build secrets. Документация подчёркивает это именно в контексте кеша и образов. Для общего кеша правило становится строже: один неосторожный шаг закрепляется в записи, которую скачивают все runner, и живёт там до очистки. Поэтому секреты передавайте через build secrets, а если токен уже оказался в слое, считайте его скомпрометированным и перевыпустите, а не просто пересоберите образ.

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

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

АРЕНДОВАТЬ СЕРВЕР

Диск: кеш, который растёт, пока вы не смотрите

Теперь о том, почему кеш нельзя хранить бесконечно. Каждое изменение lock-файла, каждая новая ветка, каждое обновление базового образа создают новые записи. Старые не исчезают: BuildKit не знает, пригодятся ли они кому-то. Через несколько недель в кеше лежат десятки версий слоя с зависимостями, и подавляющее большинство из них уже никогда не будет использовано, но место занимают наравне с актуальными.

Покажем масштаб на условной арифметике. Допустим, слой с зависимостями весит около 600 МБ, а lock-файл меняется в среднем раз в день. За месяц это порядка 18 ГБ только от одного слоя одного проекта, не считая слоёв компиляции и системных пакетов, и не считая второго проекта. Числа выдуманы для иллюстрации, ваши будут другими, но механика одна: рост идёт линейно со скоростью изменений, а не со скоростью использования.

На VPS с NVMe это упирается в конкретный предел. Диск делят система, образы, тома, логи, а нередко и сам registry. Тарифы MAATRIX различаются по объёму NVMe, от 40 ГБ у Ba до 320 ГБ у Djed, и это просто размер диска, а не обещание, сколько проектов и сборок он вынесет. Сколько занимает ваш кеш и как быстро он растёт, покажут только замеры на ваших проектах. Практический вывод: выделите кешу бюджет заранее и считайте его частью общего баланса диска, а не остатком.

Как BuildKit решает, что удалять

У BuildKit есть сборка мусора, и она работает по правилам, которые применяются последовательно, ориентируясь на возраст, тип и объём записей. Более конкретное правило может удалить старые данные раньше, чем сработает общий лимит. Настройки политики зависят от используемого драйвера builder, поэтому точные параметры ищите в документации под свой вариант (описание сборки мусора кеша).

Что это означает практически? Политика читается как каскад. Первое правило, допустим, убирает записи старше определённого срока. Следующее учитывает тип записи и может быть строже для тех, что легко восстановить. Последнее держит общий объём в рамках лимита. Из-за этой последовательности порядок правил важен: узкое правило успевает сработать раньше общего потолка, и именно оно решает судьбу большей части старых данных. Если поставить общий лимит слишком щедро, а узкие правила слишком мягко, кеш всё равно дорастёт до потолка и начнёт выбрасывать записи уже по общему объёму, возможно, не самые бесполезные.

Здесь нужен осознанный выбор срока хранения. Есть два крайних подхода и всё, что между ними.

  • Короткий срок. Диск спокоен, но после выходных или отпуска команда встречает холодную сборку. Для редко меняющихся проектов это неприятно.
  • Длинный срок. Холодных сборок меньше, но диск заполняется хламом, и в какой-то момент проблема приходит в самый неудобный момент: сборка падает из-за нехватки места.
  • Промежуточный. Срок чуть больше самого длинного обычного перерыва между сборками плюс лимит объёма, который оставляет запас свободного места для остального.

Отдельно напомним про registry-кеш: удаление на стороне BuildKit его не касается, ведь это записи в хранилище registry. Запись buildcache перезаписывается, но слои, на которые она раньше ссылалась, остаются в хранилище, пока их не соберёт очистка самого registry. Не забудьте про неё: это два разных диска или два разных раздела одного диска, и лимит нужен на каждом.

Порядок слоёв: как один рефакторинг обнуляет накопленное

Теперь история, которая случается чаще, чем хочется. В проекте решили «навести порядок» в Dockerfile и поменяли местами шаги. Результат тот же, образ работает, а сборки стали заметно длиннее и никто не понимает почему.

Вот вариант, который ломает кеш при каждом коммите:

FROM node:20
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

Здесь весь репозиторий копируется до установки зависимостей. Любая правка в любом файле меняет вход шага COPY . ., а значит, обесценивает npm ci, хотя зависимости не менялись. Зависимости ставятся заново на каждый коммит.

Вариант, дружелюбный к кешу:

FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Теперь установка зависимостей зависит только от двух файлов. Пока они не менялись, npm ci берётся из кеша, а пересчитываются только копирование исходников и сборка. Принцип общий: чем реже меняется вход, тем выше по Dockerfile стоит шаг.

Теперь о главном для нашей темы. Переход от первого варианта ко второму, как и любая перестановка, меняет ключи всех затронутых шагов. Накопленный кеш по старому порядку становится бесполезным: записи лежат на диске и в registry, но запросов к ним больше не будет. Первая сборка после рефакторинга холодная, причём для всех runner сразу, ведь импортировать нечего. А старые записи будут занимать место, пока их не заберёт сборка мусора. Это нормальная цена за улучшение, но её стоит учитывать: не делайте таких перестановок перед срочным релизом и не удивляйтесь следующему скачку объёма. Ровно по тем же причинам одна невинная правка в начале Dockerfile, например смена переменной окружения, способна обнулить всё ниже.

Измерять нужно время, диск и трафик вместе

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

Предлагаем простую схему замера. Берите один и тот же коммит и прогоните его в нескольких условиях, записывая одни и те же показатели.

Схема замеров для общего кеша
ПрогонЧто проверяетЧто записать
Холодная сборка, кеш пуст, импорта нетБазовую стоимость сборкиОбщее время, время по шагам
Повтор на той же машинеЭффект локального кешаВремя, число шагов из кеша
Другой runner с импортом из registryЦенность общего кешаВремя, объём скачанного, время импорта
Изменена одна зависимость, импорт включёнРеалистичный будний деньВремя, какие шаги пересчитались
Тот же прогон после экспортаЦену выгрузкиОбъём отправленного, рост хранилища и диска

Дальше сравнивайте три колонки: время, диск, трафик. Если общий кеш экономит минуту на сборке, но ежедневно перекачивает гигабайты и раз в неделю упирается в нехватку места, то решать вам, оправдан ли обмен. Для небольшой команды с несколькими runner в одной локации он обычно оправдан, а вот когда runner и registry разнесены по разным площадкам, сетевая цена вырастает. Поэтому разумно размещать исполнителей и registry рядом: MAATRIX предлагает VPS в UK, US, RU и FR, и выбор локации для сборочного контура стоит делать по близости компонентов друг к другу. Обычно хватает root-доступа, NVMe и обычных инструментов Docker, а детали конфигурации вы настраиваете сами.

Ещё одна мелочь, которая сильно меняет картину: смотрите в логах не итоговую скорость, а список шагов с пометкой, взяты они из кеша или выполнены. Именно по нему видно, где кеш работает, где он обещан, но не срабатывает, а где его и не нужно было.

Когда следующая сборка снова холодная

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

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