На втором запуске сборки 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, а детали конфигурации вы настраиваете сами.
Ещё одна мелочь, которая сильно меняет картину: смотрите в логах не итоговую скорость, а список шагов с пометкой, взяты они из кеша или выполнены. Именно по нему видно, где кеш работает, где он обещан, но не срабатывает, а где его и не нужно было.
Когда следующая сборка снова холодная
Однажды после длинных праздников кто-то запускает сборку, и лог показывает знакомую картину: базовый образ скачивается, пакеты ставятся, зависимости собираются с нуля. Первая реакция в команде: кеш сломался. Но в действительности он сделал ровно то, для чего его настроили: записи прожили назначенный срок, и сборка мусора освободила диск, который понадобился для живых проектов.
Это допустимая цена выбранного срока хранения. Один холодный запуск раз в несколько недель стоит дешевле, чем диск, переполненный слоями, которые никто не открывал. Он же заново наполняет кеш актуальными слоями, и следующая сборка снова будет почти мгновенной. Если же холодные запуски стали случаться слишком часто, это сигнал пересмотреть срок или лимит, и теперь у вас есть замеры времени, диска и трафика, по которым это решение можно принять, а не угадать.