open atlas
↑ К треку
Docker: контейнеры как система DOCK · 05 · 01

BuildKit как граф сборки: LLB, контентно-адресуемые ключи кеша и почему сборка — это DAG, а не скрипт

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

DOCK Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

CI месяцами был зелёным и собирался за 45 секунд, а однажды утром каждая сборка стала занимать шесть минут — и в Dockerfile ничего не менялось. Команда смотрела на ту же строку COPY . ., что катали год, и была уверена, что сломался кеш реестра. Настоящее изменение было выше: кто-то добавил в репозиторий .env.local и сгенерированный build-info.txt, который CI писал до docker build. Теперь каждая сборка давала чуть отличающийся файл в контексте, дайджест входа COPY . . менялся на каждом прогоне, каждый слой после него промахивался мимо кеша, и RUN npm ci пересобирался с нуля — те 5 минут 15 секунд, что раньше были одним попаданием. Лечилось строчкой в .dockerignore, но урок — в модели. BuildKit не «решал» пересобрать строку 8. Он вычислил контентный дайджест входов этой операции, не нашёл совпадающей записи в кеше и корректно её выполнил. Ключом кеша никогда не был номер строки. Им были байты.

Dockerfile — это граф, а не скрипт

Классическая модель — «Docker выполняет мой Dockerfile сверху вниз, одна строка — один слой» — описывает legacy-билдер и неверна для BuildKit, билдера по умолчанию с Docker 23.0. Фронтенд BuildKit разбирает Dockerfile в LLB (Low-Level Build definition): ориентированный ацикличный граф операций. Каждый узел — вершина вроде «выполнить команду на этом rootfs», «скопировать эти файлы из контекста» или «спулить этот образ»; рёбра — зависимости по данным. Затем солвер обходит DAG и выполняет вершины, чьи входы готовы, параллельно, насколько позволяют воркеры.

Практическое следствие — параллелизм, который вы не писали. Две независимые стадии FROM — скажем, стадия сборки Go и отдельная стадия, тянущая статические ассеты — не связаны ребром, поэтому BuildKit выполняет их одновременно на разных воркерах. Последовательная 6-минутная сборка с двумя независимыми 3-минутными ветками может завершиться чуть дольше 3 минут. Это достаётся бесплатно именно потому, что сборка — граф: солвер видит независимость веток и планирует их конкурентно там, где legacy-билдер прогнал бы их одну за другой.

# syntax=docker/dockerfile:1
# Эти две стадии — независимые вершины графа LLB,
# BuildKit собирает их конкурентно, а не последовательно.
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download           # зависит только от go.mod/go.sum
COPY . .
RUN go build -o /app ./cmd/server

FROM node:22 AS assets
WORKDIR /web
COPY web/package*.json ./
RUN npm ci                     # идёт параллельно с go mod download
COPY web/ ./
RUN npm run build

Ключи кеша — дайджесты, а не номера строк

Когда шаг, который должен был закешироваться, вдруг выполняется — первый инстинкт обвинить Docker. Но правильный вопрос всегда другой: какой байт изменился? Каждая вершина LLB вычисляет ключ кеша из дайджеста своих входов. Для RUN ключ сворачивает дайджест родительского слоя (всё состояние ФС, поверх которого он запускается) плюс строку команды. Для COPY ключ сворачивает родителя плюс дайджест точного содержимого файлов, которые копируются — вычисленный из контекста сборки, с учётом .dockerignore. Солвер ищет этот ключ в локальном кеше и любом импортированном удалённом кеше; совпадение — это попадание в кеш, и заранее вычисленный выходной слой вершины переиспользуется без выполнения. Промах выполняет вершину и сохраняет результат под этим ключом.

Вот почему правильный порядок инструкций — оптимизация сборки с наибольшим рычагом. COPY go.mod go.sum ./, затем RUN go mod download до COPY . . означает, что дорогая вершина скачивания зависит лишь от двух мелких файлов: меняешь исходники приложения — дайджесты этих файлов те же, поэтому скачивание попадает в кеш, а пересобирается только дешёвый go build. Переверни — COPY . . до go mod download — и любая правка исходника меняет дайджест copy-вершины, каскадируя промах в скачивание. Правильно упорядоченная сборка Node попадает в кеш и завершается за ~4 секунды; перевёрнутая перезапускает npm ci на ~90 секунд при любой однострочной правке. Те же инструкции, тот же финальный образ, разница в 20×, решённая целиком тем, какая вершина стоит выше волатильного входа.

Викторина

В Dockerfile COPY . . на строке 8, затем RUN npm ci на строке 9. Разработчик правит одну строку в src/app.js и пересобирает. Почему npm ci перезапускается, хотя package.json не менялся?

Почему это работает

Почему ключи на основе дайджестов, а не таймстампов или номеров строк? Потому что контентная адресация делает кеш корректным при переупорядочивании, шеринге и распределении. Вершина, ключованная дайджестом входов, даёт тот же ключ на любой машине, поэтому кеш, наполненный в CI, импортируется на ноутбук разработчика и попадает. Инвалидация по таймстампу промахивалась бы каждый раз, когда меняется mtime, даже если байты те же, а ключевание по строкам ломалось бы в момент переноса инструкции. Цена — режим отказа из Hook: всё, что возмущает входные байты — сгенерированный файл в контексте, mtime-чувствительная сборка, которую BuildKit прочитал, — меняет дайджест и сбрасывает слой. Контентная адресация безжалостна, но честна: кеш отражает ровно то, что вошло.

Чтение лога сборки как обхода графа

Стоит увидеть сборку как DAG — и лог читается иначе. CACHED рядом с шагом значит, что солвер нашёл ключ кеша этой вершины уже удовлетворённым. Шаги, напечатанные вперемешку, а не строго в порядке файла, — это независимые вершины, решаемые конкурентно. => [build 4/7] и => [assets 2/4], появляющиеся вместе, — это двухстадийный параллелизм, а не глюк рендера. А шаг, который вы ждали закешированным, но он выполняется, говорит ровно одно: дайджест его входов отличается от прошлого раза. Вопрос для отладки — никогда не «почему Docker решил пересобрать эту строку», а «какой входной байт изменился». Обычно это контекст (добавьте .dockerignore), иногда базовый образ, у которого сдвинулся тег (пиньте по дайджесту), изредка build-arg, свёрнутый в строку команды.

Викторина

Dockerfile определяет две стадии с FROM, не разделяющие COPY --from или иную зависимость между собой. Что делает BuildKit, чего не делал legacy-билдер?

Вспомните перед уходом
  1. 01
    Объясни, что такое LLB и как вычисляется ключ кеша вершины для RUN против COPY.
  2. 02
    Почему порядок инструкций даёт разницу во времени сборки в 20×, и как отлаживать неожиданный промах кеша?
Итог

BuildKit, билдер по умолчанию с Docker 23.0, не выполняет Dockerfile сверху вниз — его фронтенд компилирует файл в LLB, контентно-адресуемый ориентированный ацикличный граф, чьи вершины — операции сборки, а рёбра — зависимости по данным. Солвер запускает вершины, чьи входы готовы, конкурентно везде, где порядок не навязан ребром, поэтому две независимые стадии FROM собираются параллельно, и сборка, выглядящая последовательной, завершается близко ко времени своей самой медленной ветки. У каждой вершины есть ключ кеша, вычисленный из дайджеста входов: RUN сворачивает дайджест родительского слоя плюс строку команды, COPY сворачивает родителя плюс дайджест точных байтов файлов, копируемых из контекста после фильтра .dockerignore. Солвер ищет этот ключ в локальном и любом импортированном удалённом кеше — попадание переиспользует сохранённый слой и печатает CACHED без выполнения, промах выполняет вершину и сохраняет её выход. Вот почему номера строк не важны никогда, а порядок важен огромно: COPY go.mod и RUN go mod download до COPY . . держат дорогое скачивание ключованным на мелких стабильных файлах, так что правки исходника оставляют его в кеше (~4с) и пересобирается лишь дешёвая компиляция, тогда как перевёрнутый порядок сбрасывает установку при каждой правке (~90с) — те же инструкции, ~20× разницы. И вот почему загадочная шестиминутная сборка никогда не была про строку 8: сгенерированный файл просочился в контекст, дайджест входа COPY-вершины сдвинулся, и всё ниже корректно промахнулось. Теперь, когда ты видишь шаг с неожиданным промахом, — перестань спрашивать «почему Docker пересобрал эту строку» и спрашивай «какой входной байт изменился»: обычно это контекст, иногда сдвинувшийся тег базы, изредка build-arg в строке команды.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.