BuildKit: современная сборка

Старый сборщик Docker выполнял Dockerfile строго сверху вниз, по одной инструкции за раз, и не умел ни прятать секреты, ни переиспользовать кэш пакетного менеджера. BuildKit меняет правила: он строит граф зависимостей, гонит независимые стадии параллельно и даёт безопасные mount-механизмы для секретов и кэша. В свежих версиях Docker он включён по умолчанию.

BuildKit — это современный бэкенд сборки образов Docker, который выполняет инструкции Dockerfile как граф зависимостей, параллелит независимые шаги и предоставляет mount-механизмы для секретов и кэша.

Зачем это нужно на практике? Классический сборщик линеен: он идёт по Dockerfile сверху вниз и не понимает, что две независимые стадии можно собрать одновременно. У него нет штатного способа передать токен приватного репозитория так, чтобы тот не остался в истории образа, — поэтому люди прокидывали секреты через ARG или COPY, и ключи утекали в слои. Нет у него и кэша пакетного менеджера: каждая сборка заново качала зависимости. BuildKit решает обе проблемы. Он быстрее за счёт параллелизма и умного кэширования по контенту и безопаснее за счёт того, что секреты и кэш существуют только во время выполнения команды и не превращаются в слои образа.

Как включить

В новых версиях Docker (Docker Desktop и свежий Docker Engine) BuildKit уже работает по умолчанию, и делать ничего не нужно. На более старых установках его включают переменной окружения DOCKER_BUILDKIT=1 перед командой сборки. Отдельный интерфейс docker buildx — это расширенный клиент поверх BuildKit; именно он нужен для мультиплатформенных сборок и отдельных билдеров.

# Явно включить BuildKit для одной сборки (на старых версиях)
DOCKER_BUILDKIT=1 docker build -t myapp .

# Проверить, что buildx доступен
docker buildx version

Чтобы заработали mount-фичи (секреты и кэш), Dockerfile должен начинаться со строки активации синтаксиса. Это самая первая строка файла, до любого FROM:

# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app

Без этой строки парсер использует старый набор инструкций, и любой --mount будет восприниматься как ошибка синтаксиса.

Build args

ARG объявляет переменную времени сборки. Её значение можно подменить флагом --build-arg, но в самом образе после сборки её уже нет. ENV, наоборот, задаёт переменную окружения, которая остаётся в финальном образе и доступна процессу в работающем контейнере. У ARG есть область видимости: переменная, объявленная до первого FROM, видна только в строках до FROM (например, чтобы подставить тег базового образа), а чтобы использовать её внутри стадии, ARG нужно объявить заново уже после FROM.

# syntax=docker/dockerfile:1
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine

# Чтобы переменная была видна внутри стадии, объявляем её снова
ARG APP_ENV=production
ENV APP_ENV=${APP_ENV}
docker build --build-arg NODE_VERSION=18 --build-arg APP_ENV=staging -t myapp .

Важное предупреждение: --build-arg не предназначен для секретов. Значения build-аргументов сохраняются в метаданных образа и видны через docker history. Передавать так пароль или токен — значит опубликовать его вместе с образом.

Secret mounts

Для секретов BuildKit даёт отдельный механизм: --mount=type=secret. Секрет подключается к команде RUN как файл на время её выполнения и исчезает сразу после. Он не попадает ни в один слой образа — в отличие от COPY, который физически копирует файл в слой, и от ARG, который оседает в истории.

# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./

# Файл .npmrc с токеном доступен только во время этого RUN
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
docker build --secret id=npmrc,src=.npmrc -t myapp .

Внутри сборки секрет лежит по указанному пути (target), npm читает токен, ставит зависимости — и после завершения RUN файла больше нет. В готовом образе токена не найти даже через docker history.

Cache mounts

--mount=type=cache подключает к команде RUN постоянный кэш-каталог, который переживает сборки, но не становится слоем образа. Это идеально для каталогов пакетных менеджеров: повторная сборка не качает зависимости заново, а образ при этом не раздувается, потому что кэш живёт отдельно от слоёв.

# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

Тот же приём для других экосистем — меняется только целевой каталог:

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

# Go — кэш загруженных модулей
RUN --mount=type=cache,target=/go/pkg/mod \
    go build ./...

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

Параллельные стадии и buildx

В multi-stage Dockerfile стадии, не зависящие друг от друга, BuildKit собирает одновременно. Если у вас есть стадия сборки фронтенда и стадия сборки бэкенда, и финальный образ копирует артефакты из обеих, эти две стадии пойдут параллельно — без всякой ручной настройки.

Для сборки под несколько архитектур используется docker buildx с флагом --platform. Одна команда соберёт образ сразу под amd64 и arm64:

docker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest --push .

Флаг --push здесь нужен потому, что мультиплатформенный результат — это манифест из нескольких образов, который удобнее сразу отправить в реестр.

Как это работает под капотом

BuildKit не выполняет Dockerfile построчно. Он сначала разбирает его в DAG — направленный ациклический граф инструкций, где рёбра — это зависимости (например, RUN зависит от предшествующего COPY). Узлы, между которыми нет связей, выполняются параллельно. Кэш считается по контенту: если входные данные шага не изменились, BuildKit берёт готовый результат вместо повторного выполнения. А mount-механизмы (secret и cache) существуют исключительно в момент выполнения конкретного RUN и принципиально не становятся слоями — поэтому ни секрет, ни кэш не оказываются в итоговом образе.

Частые ошибки

  • Забыли строку # syntax=docker/dockerfile:1 в начале файла — и любой --mount падает с ошибкой синтаксиса.
  • Кладут секрет через ARG или ENV вместо --mount=type=secret — токен утекает в docker history и остаётся в образе.
  • Ждут параллелизма от стадий, которые на самом деле зависят одна от другой по цепочке — граф их выстроит последовательно, ускорения не будет.
  • Путают build-time секрет (нужен только во время сборки, через --secret) с runtime-секретом (нужен работающему контейнеру, передаётся через переменные окружения или docker secret уже при запуске).

Итоги

  • BuildKit — современный движок сборки Docker; в свежих версиях включён по умолчанию, на старых — через DOCKER_BUILDKIT=1.
  • Mount-фичи требуют строки # syntax=docker/dockerfile:1 в самом начале Dockerfile.
  • ARG и --build-arg — для значений времени сборки, но не для секретов: они видны в docker history.
  • --mount=type=secret передаёт токены и ключи безопасно — секрет живёт только во время RUN и не попадает в слои.
  • --mount=type=cache ускоряет повторные сборки, кэшируя каталоги npm, pip и go, и при этом не раздувает образ.
  • Независимые стадии multi-stage идут параллельно, а docker buildx --platform собирает образ сразу под несколько архитектур.
Проверьте себя
1. Почему секрет, переданный через RUN --mount=type=secret, безопаснее, чем переданный через --build-arg?
AПотому что --build-arg вообще не работает в BuildKit
BПотому что значение --build-arg сохраняется в метаданных образа и видно через docker history, а secret mount существует только во время RUN и не попадает в слои
CПотому что --build-arg безопасен для паролей, просто медленнее
DПотому что secret mount шифрует весь итоговый образ
2. Что произойдёт с размером финального образа, если использовать RUN --mount=type=cache,target=/root/.npm?
AКэш-маунт раздувает финальный образ на размер всех скачанных пакетов
BРазмер образа не растёт: кэш хранится в билдере отдельно от слоёв и не попадает в образ
CОбраз нельзя будет запустить без подключённого кэша
DКэш автоматически копируется в слой ENV