voidlearn
 

2.2Сборка образа и реестр

Поймёшь, как BuildKit превращает Dockerfile в граф шагов и когда может переиспользовать результат. Разберёшь контекст, multi-stage и multi-arch сборку, секреты, base image и неизменность digest.

Что сможешь объяснить после этой главы
  • Почему шаг помечен CACHED и какая правка заставит заново выполнить go mod download.
  • Чем cache mount отличается от кэша слоёв и почему в CI он не выручает.
  • Куда утекает токен из ARG, даже когда docker history финального образа чист.
  • Как собрать образ под amd64 и arm64 без эмуляции и что лежит в реестре под одним тегом.
  • Что ломается в scratch и как отлаживать контейнер без shell.
  • Почему два билда одного коммита дают разные дайджесты и как добиться одинаковых.

Примеры сняты на Docker Desktop 4.91: Docker Engine 29.8.0 со встроенным BuildKit v0.33.0, buildx 0.37.0, containerd image store, ВМ на arm64, Go 1.27.1. HTTP-сервис orders построен на chi и prometheus/client_golang, в сборке десять модулей. Дайджесты, размеры и секунды у тебя будут свои.

Граф вместо списка команд

Dockerfile сначала превращается в LLB: граф операций вроде «скачать базу», «выполнить команду», «скопировать файлы», где каждая вершина адресуется хешем своего описания. BuildKit идёт от цели (последней стадии или --target) к источникам, выполняет только то, от чего цель зависит, и запускает одновременно всё независимое.

FROM golang:1.27.1-alpine AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download

FROM deps AS build
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server

FROM deps AS test
COPY . .
RUN go vet ./... && go test ./...

FROM alpine:3.24 AS certs
RUN apk add --no-cache ca-certificates-bundle

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]
$ docker build --no-cache --progress=plain -t orders .
…
#10 [deps 4/4] RUN go mod download
#10 ...

#11 [certs 2/2] RUN apk add --no-cache ca-certificates-bundle
#11 0.958 OK: 8433 KiB in 16 packages
#11 DONE 1.0s

#12 [stage-4 1/2] COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
#12 DONE 0.0s

#10 [deps 4/4] RUN go mod download
#10 DONE 2.6s
…
#16 exporting manifest sha256:95b12a08142978e00a89873f834bc7da47107aab0da853771df91da0471287d8 done
#16 exporting attestation manifest sha256:751dab29dae9dae6e924ad724cf12f96286f3567d8a75c97fde47174e6302bbe done
#16 exporting manifest list sha256:44b0b4800674dd141cf1e6ae8b76f2e7341137305bfd04f7ee5e406226f0dd3a done

#N нумерует вершины по мере появления в выводе и со строками Dockerfile не связан; безымянная пятая стадия зовётся stage-4, счёт с нуля. #10 ... значит, что вывод прервали: пока качались модули, certs выполнила apk add, а финальная стадия скопировала сертификаты. Стадии test в выводе нет, цели она не нужна. В хвосте index (manifest list): манифест образа и attestation с provenance.

Пересборка после правки internal/orders/orders.go certs 1/2 FROM alpine:3.24 certs 2/2 RUN apk add ca-certificates-bundle deps 1/4 FROM golang: 1.27.1-alpine deps 2–4/4 COPY go.mod go.sum ./ RUN go mod download build 1–2/2 COPY . . RUN go build -trimpath контекст сборки load build context изменился orders.go test FROM deps AS test цели не нужна, не строится stage-4 1/2 COPY --from=certs stage-4 2/2 COPY --from=build новый бинарь exporting to image manifest, config, attestation, index CACHED: ключ совпал выполняется заново не входит в граф цели Ключ RUN складывается из команды, окружения с ARG и ключа родителя, ключ COPY - из содержимого файлов. Ключ сменился - вершина выполняется, а за ней все, кто получает её результат, пока содержимое не совпадёт.
Граф сборки. Сертификаты и модули готовят разные стадии параллельно. Правка одного Go-файла меняет ключ COPY . . в стадии build, и пересобирается только эта ветка и шаг финальной стадии, который берёт из неё бинарь.

Почему CACHED и что сбрасывает кэш

В ключ RUN входят текст команды, окружение с объявленными к этому месту ARG, монтирования, платформа и ключ родителя. Для COPY считается сумма содержимого файлов, время изменения в неё не входит. Первые четыре строки таблицы сняты на Dockerfile выше, остальные - на одностадийном, где версия уходит в go build -ldflags "-X main.version=${VERSION}":

Что поменялосьgo mod downloadgo buildФинальный COPY --from=build
touch cmd/server/main.goCACHEDCACHEDCACHED
строка кода в orders.goCACHEDзаново, 5,6 сзаново
комментарий в orders.go без сдвига строкCACHEDзаново, 5,5 сCACHED
go get github.com/go-chi/chi/v5@v5.3.1заново, 2,9 сзановозаново
новый --build-arg VERSION, ARG VERSION сразу после FROMзановозаново-
новый --build-arg VERSION, ARG VERSION перед go buildCACHEDзаново-
строка кода, COPY . . стоит до go mod downloadзаново, 2,5 сзаново-

Строка с комментарием показывает, где каскад останавливается: go build выполнился, но выдал прежний бинарь байт в байт (номера строк есть в бинаре, а они не сдвинулись), ключ COPY --from=build считается по содержимому, и обе сборки вывели один и тот же exporting manifest sha256:ec2ba98c…, а index новый из-за нового attestation.

Номер коммита в начале стадии

--build-arg VERSION=$CI_COMMIT_SHA при ARG VERSION в начале стадии даёт каждому коммиту новый ключ для всех RUN ниже, и слой с модулями не переиспользуется ни при каком внешнем кэше, хотя go mod download переменную не читает. Объявляй ARG прямо перед шагом, который её читает. Это касается и TARGETOS, TARGETARCH: они видны глобально, но внутри стадии их тоже объявляют.

Контекст сборки и .dockerignore

docker build . отдаёт BuildKit каталог целиком, это контекст сборки. В опыте в каталоге лежат дамп tmp/dump.sql на 300 МиБ, собранный bin/server и .git на 313 МиБ, в историю которого этот дамп однажды закоммитили.

$ docker build --progress=plain -t orders .
…
#7 transferring context: 654.51MB 4.0s done
…
$ echo "more notes" >> README.md
$ docker build --progress=plain -t orders .
…
#7 transferring context: 3.61kB done
…
#12 [build 2/2] RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server
#12 DONE 7.6s

Вторая передача весит 3,61 кБ: BuildKit помнит, что прислали из этого каталога, и досылает изменённое. Свежему раннеру CI помнить нечего. А правка README.md сменила сумму COPY . ., и сервис скомпилировался заново. Копия каталога с .dockerignore из строк .git, bin/, tmp/, *.md передала при первой сборке 7,20 кБ, и после правки README.md шаг COPY . . остался CACHED. Без .git в контексте Go не вошьёт в бинарь ревизию.

Cache mounts: кэш компилятора вне слоёв

После правки кода RUN go build выполняется поверх нового слоя COPY . ., а кэш компилятора прошлой сборки остался в слое, который новому шагу не родитель. Стандартная библиотека и зависимости компилируются заново. Cache mount подключает к одному RUN каталог из хранилища BuildKit. Каталог переживает сборки, в слой не попадает, а его содержимое на ключ шага не влияет.

RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server

Пять раз правим строку в orders.go и повторяем сборку. Без cache mount шаг go build шёл 4,9–5,0 с, а вся docker build 5,5–5,6 с; с ним шаг занял 0,3 с, сборка 0,9 с. Стадия build обоих вариантов, собранная через --target build:

$ docker run --rm orders-build-layers du -sh /root/.cache/go-build /go/pkg/mod
132.8M	/root/.cache/go-build
38.5M	/go/pkg/mod
$ docker run --rm orders-build-cachemount du -sh /root/.cache/go-build /go/pkg/mod
du: /root/.cache/go-build: No such file or directory
du: /go/pkg/mod: No such file or directory

Содержимое лежит в builder: docker buildx du --verbose --filter type=exec.cachemount показал две записи, 40,39 и 141,1 МБ, с id "//go/pkg/mod" и id "//root/.cache/go-build". Идентификатор по умолчанию равен пути target, так что все сборки на этом builder делят один каталог, даже из разных проектов. Go это выдерживает: по go help cache кэш безопасен при одновременных запусках; для apt ставят sharing=locked. Кэш может и пропасть: первое правило сборщика мусора в docker buildx inspect относится к exec.cachemount (здесь 48 часов и 2,764 ГиБ), и сборка обязана работать с пустым каталогом.

Внешний кэш для CI

Builder на одноразовом раннере стартует пустым, поэтому кэш выносят наружу: --cache-to выгружает его после сборки, --cache-from подтягивает перед ней, обычно из отдельного тега в реестре. Драйвер docker выгружает кэш, только если включён containerd image store (в Docker Engine 29 он по умолчанию на новых установках), иначе нужен builder в контейнере. У того своя версия BuildKit (здесь v0.32.2) и настройки в buildkitd.toml, а образ из него попадает в Docker с --load.

$ docker buildx create --name ci --driver docker-container \
    --driver-opt network=host --buildkitd-config buildkitd.toml
$ docker buildx build --builder ci \
    --cache-from type=registry,ref=localhost:5052/orders:cache-max \
    --cache-to type=registry,ref=localhost:5052/orders:cache-max,mode=max \
    -t localhost:5052/orders:v2 --push .
…
#8 [build 3/6] COPY go.mod go.sum ./
#8 CACHED

#9 [build 4/6] RUN go mod download
#9 sha256:6dbc4f0eb2c8a5ae31323c4da6712279adc20265cf57a6ad0b33e12569713105 9.45MB / 9.45MB 0.0s done
…
#11 [build 6/6] RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server
#11 DONE 4.9s

registry:3 работает в контейнере; network=host даёт builder доступ к нему на localhost, buildkitd.toml разрешает HTTP. Перед каждой сборкой кэш builder очищался, как на новом раннере. go mod download не помечен CACHED и не выполнялся: вместо вывода Go скачивается готовый слой с модулями.

Чистый buildermode=minmode=max
что выгружено1 слой, 8,5 МБ: слой финального образа11 слоёв, 130,4 МБ: база golang, модули, слои стадии build
тот же коммитвсё CACHEDвсё CACHED
правка строки Goвсё заново: база, модули 4,7 с, go build 5,0 смодули из кэша, заново COPY . . и go build 4,9 с

В min попадают только слои итогового образа (здесь один, с бинарём), и после любой правки промежуточные результаты взять негде, поэтому в CI нужен max - ценой места: в кэш уехали даже 72 МБ базового golang. Builder вместе с томом состояния удаляет docker buildx rm ci.

Cache mount в экспорт кэша не входит

С Dockerfile из прошлого раздела модули лежат в cache mount, и слой go mod download их не содержит. На чистом раннере каталог пуст, и go build снова печатает go: downloading github.com/go-chi/chi/v5 v5.3.2 и ещё девять строк: 7,2 с вместо 4,9. Документация Docker пишет то же про кэш GitHub Actions и отсылает к обходному решению buildkit-cache-dance. Для Go в CI проще держать go mod download обычным слоем, а cache mount оставить локальной сборке.

Секреты сборки

go mod download тянет приватный модуль, нужен токен. Через ARG сборка пройдёт с предупреждением SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "GIT_TOKEN").

ARG GIT_TOKEN
ENV GOPRIVATE=git.example.com/*
COPY go.mod go.sum ./
RUN echo "machine git.example.com login ci password ${GIT_TOKEN}" > /root/.netrc \
 && go mod download \
 && rm /root/.netrc
$ docker build --build-arg GIT_TOKEN=glpat-DEMO-arg-123 -t orders-arg .
$ docker history --no-trunc --format '{{.CreatedBy}}' orders-arg
RUN |1 GIT_TOKEN=glpat-DEMO-arg-123 /bin/sh -c echo "machine git.example.com login ci password ${GIT_TOKEN}" > /root/.netrc  && go mod download  && rm /root/.netrc # buildkit
COPY go.mod go.sum ./ # buildkit
ENV GOPRIVATE=git.example.com/*
ARG GIT_TOKEN=glpat-DEMO-arg-123
…

Файла в слое нет, токен дважды в истории. ENV добавил бы его в .Config.Env и в окружение каждого контейнера. Если ARG живёт в сборочной стадии, а финальная собрана FROM scratch, история образа чистая. Но токен виден в записи сборки (docker buildx history inspect, таблица BUILD ARG), в attestation при --provenance=mode=max (в режиме по умолчанию min аргументов нет) и в экспорте кэша mode=max, если шаг записал его в файл: строка из git config --global url…insteadOf нашлась в root/.gitconfig одного из блобов.

Для секретов есть RUN --mount=type=secret: значение передаётся по сессии сборки и видно одному шагу, по умолчанию файлом /run/secrets/<id>.

RUN --mount=type=secret,id=netrc,target=/root/.netrc \
    ls -l /root/.netrc && go mod download
$ docker build --progress=plain --secret id=netrc,src=../netrc -t orders-secret .
…
#8 0.066 -r--------    1 root     root            64 Sep 16 22:37 /root/.netrc
…
$ docker history --no-trunc --format '{{.CreatedBy}}' orders-secret | head -1
RUN /bin/sh -c ls -l /root/.netrc && go mod download # buildkit
$ docker run --rm orders-secret ls -l /root/.netrc
ls: /root/.netrc: No such file or directory

.netrc читает go (в go help goauth способ по умолчанию называется netrc) и git, если модуль берётся из репозитория напрямую: на локальном HTTP-сервере git ls-remote после ответа 401 повторил запрос с логином и паролем из ~/.netrc. Для переменной окружения есть --mount=type=secret,id=git_token,env=GIT_TOKEN (Dockerfile 1.10+) в паре с --secret id=git_token,env=GIT_TOKEN.

Новый токен не сбрасывает кэш

Содержимое секрета в ключ шага не входит: после замены токена сборка вывела CACHED на всех шагах. Если результат зависит от прав токена, кэш этого не заметит.

Кросс-сборка и multi-arch образ

Образ, собранный на Mac с чипом M, по умолчанию linux/arm64, и на узле amd64 он не запустится. На этой arm64-машине видно обратное:

$ docker run --rm orders-amd64 -selfcheck
WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested
exec /server: exec format error

Чужой машинный код ядро запустит через эмулятор из binfmt_misc. В этой ВМ его нет, и RUN под amd64 на драйвере docker падает с exec /bin/sh: exec format error; у builder в контейнере свои эмуляторы, buildkit-qemu-x86_64 и другие. Go эмуляция не нужна: компилятор собирает под любую поддерживаемую пару GOOS/GOARCH.

FROM --platform=$BUILDPLATFORM golang:1.27.1-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]
$ docker buildx build --builder ci --platform linux/amd64,linux/arm64 \
    -t localhost:5052/orders:cross --push .
…
#12 [linux/arm64 build 4/6] RUN go mod download
#12 DONE 2.5s
…
#15 [linux/arm64->amd64 build 6/6] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64     go build -trimpath -o /out/server ./cmd/server
#15 DONE 9.2s
…
#14 [linux/arm64 build 6/6] RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64     go build -trimpath -o /out/server ./cmd/server
#14 DONE 9.3s

linux/arm64->amd64 читается «выполняется на arm64, собирает под amd64». Модули скачаны один раз: до ARG TARGETOS TARGETARCH вершины обеих платформ совпадают. Финальной стадии без RUN эмулировать нечего. С FROM golang:1.27.1-alpine без --platform сборочная стадия идёт дважды, под amd64 внутри QEMU: go build для amd64 занял 92,8 с против 9,7 с у arm64, вся сборка 103,2 с против 22,3 с. Оба замера после очистки кэша builder.

$ docker buildx imagetools inspect localhost:5052/orders:cross
Name:      localhost:5052/orders:cross
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:855e7de9fbdf810ba74e51eeb1ecb322f9cda7ceb5acb154a6a749d34756bcd7

Manifests:
  Name:        localhost:5052/orders:cross@sha256:c4a44cbb0e9312050c497a9595e0ec276d311e214a6ecded2b0a678ec7a1f65e
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/amd64
…
  Name:        localhost:5052/orders:cross@sha256:45250bb28203536a8109c7ffa6aa362433fe5eb61a8fafdecb1e596eb47908af
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    unknown/unknown
  Annotations:
    vnd.docker.reference.digest: sha256:c4a44cbb0e9312050c497a9595e0ec276d311e214a6ecded2b0a678ec7a1f65e
    vnd.docker.reference.type:   attestation-manifest
…

Тег указывает на index: манифесты linux/amd64 и linux/arm64 и два attestation с платформой unknown/unknown, каждый ссылается на свой образ. При pull клиент берёт из index манифест своей архитектуры. Драйвер docker с containerd image store такой образ тоже собрал (docker image ls --tree показал обе платформы), хотя таблица возможностей драйверов в документации multi-arch для него не отмечает.

сборка на $BUILDPLATFORM = linux/arm64 TARGETPLATFORM в реестре FROM --platform= $BUILDPLATFORM golang RUN go mod download одна вершина на обе go build GOARCH=amd64 go build GOARCH=arm64 distroless linux/amd64 distroless linux/arm64 index orders:cross manifest amd64 manifest arm64 attestation attestation 855e7de9… Кросс-компиляция: 22,3 с на обе платформы, go build 9,2 и 9,3 с идут параллельно на родной архитектуре. Через QEMU (FROM golang без --platform): сборочная стадия дважды, go build под amd64 92,8 с, всего 103,2 с. CGO_ENABLED=0 задаётся явно: при кросс-сборке cgo выключен сам, при нативной с gcc в образе включается.
Multi-arch без эмуляции. Сборочная стадия выполняется на архитектуре builder и расходится на две компиляции только там, где появляется TARGETARCH. Финальные стадии берут базу нужной платформы, реестр получает два манифеста под одним index.

База: scratch, distroless, alpine

Один и тот же бинарь с флагом -selfcheck: он вызывает user.Current(), time.LoadLocation("Europe/Moscow") и делает HTTPS-запрос. Везде USER 65532:65532; размер по CONTENT SIZE для linux/arm64 из docker image ls --tree, это сжатые слои, которые скачивает pull.

БазаРазмерuser.CurrentЧасовой поясHTTPS
scratch + бинарь8,51 МБошибкаошибкаошибка x509
scratch + ca-certificates.crt + time/tzdata8,72 МБошибкадада
distroless/static-debian13:nonroot (база 862 кБ)9,37 МБnonrootдада
alpine:3.24 (база 4,18 МБ)12,7 МБошибкаошибкада
$ docker run --rm orders-scratch -selfcheck
version: dev
uid: 65532
user.Current: user: Current requires cgo or $USER set in environment
time.LoadLocation: unknown time zone Europe/Moscow
http.Get: Get "https://proxy.golang.org/": tls: failed to verify certificate: x509: certificate signed by unknown authority

Ядру нужен только номер, и USER 65532:65532 работает без /etc/passwd. Имя ищет Go: без cgo он читает /etc/passwd, а не найдя записи, собирает пользователя из $USER и домашнего каталога. В alpine файл есть, но записи для 65532 нет; distroless nonroot держит строку nonroot:x:65532:65532. Базы часовых поясов нет ни в scratch, ни в alpine. import _ "time/tzdata" (или -tags timetzdata) встраивает её в бинарь, здесь плюс 398 КБ, документация пакета говорит «около 450 KB». Сертификаты в alpine входят в базу, в scratch файл копируют из сборочной стадии.

Отлаживать такой контейнер нечем: docker exec orders-app sh отвечает exec: "sh": executable file not found in $PATH. Плагин Docker Desktop docker debug приносит свой shell с инструментами, не меняя образ. Без Desktop рядом запускают контейнер в пространствах имён сервиса:

$ docker run --rm --pid=container:orders-app --network=container:orders-app busybox:1.37 \
    sh -c 'ps; wget -qO- http://127.0.0.1:8080/orders/42; ls -l /proc/1/root/'
PID   USER     TIME  COMMAND
    1 65532     0:00 /server
   28 root      0:00 sh -c ps; wget -qO- http://127.0.0.1:8080/orders/42; ls -l /proc/1/root/
   33 root      0:00 ps
{"id":"42","status":"new"}
ls: /proc/1/root/: Permission denied

Файлы чужого процесса видны через /proc/1/root, но доступ ядро проверяет как для ptrace: root контейнера получил Permission denied к процессу пользователя 65532, а с --cap-add SYS_PTRACE прочитал его /etc/passwd. У distroless есть и теги debug с busybox; в Kubernetes для этого служит kubectl debug, глава 3.4.

Воспроизводимость

Две сборки docker build --no-cache подряд из одного кода дали манифесты 0bf05b6d5d6b и 28c413e49795, хотя SHA-256 файла /server в обоих 48d5207b68f2. В docker image inspect у них разные .Created (22:45:11 и 22:45:20) и разные слои: в tar-архиве записано время изменения /server, то есть момент сборки. Другой слой даёт другой diff_id в конфиге, другой конфиг - другой манифест.

Две сборки --no-cachecreatedСлойМанифестIndex
без настроекразныйразныйразныйразный
--build-arg SOURCE_DATE_EPOCH=17672256002026-01-01T00:00:00Zразныйразныйразный
плюс rewrite-timestamp=true в --outputтот жетот же10ff9a6faf54 в обеихразный
плюс --provenance=falseтот жетот жетот жеindex нет

SOURCE_DATE_EPOCH (BuildKit v0.11+) задаёт время в конфиге и истории, время файлов в слоях переписывает rewrite-timestamp=true (v0.13+). Index и после этого разный: в provenance записаны startedOn, finishedOn и invocationId. Значение для CI даёт git log -1 --pretty=%ct, время коммита в секундах. На драйвере docker сборка с этой опцией падает с exporter option "rewrite-timestamp" conflicts with "unpack", если не указать push=true,unpack=false; таблица снята на builder в контейнере с push=true.

В Go к одинаковому бинарю ведёт -trimpath: без него сборка из /src и из /app дала SHA-256 8076b22d19f4 и 2538ed4b4ff3, с ним оба раза 48d5207b68f2. -buildvcs по умолчанию auto: в git-репозитории Go вшивает ревизию и время коммита. В golang:1.27.1-alpine git нет. Если .git попал в контекст, auto пропускает эти сведения (mod example.com/orders (devel) в go version -m), а -buildvcs=true падает с error obtaining VCS status: exec: "git": executable file not found in $PATH; без .git молчат оба.

Остаётся база: builder в контейнере разрешает тег в реестре при каждой сборке (драйвер docker без --pull берёт локальную копию), и обновлённый на Docker Hub образ тихо сменит базу. Дайджест фиксирует базу, а тег рядом с ним BuildKit не проверяет:

$ docker buildx imagetools inspect golang:1.27.1-alpine --format '{{.Manifest.Digest}}'
sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125
$ cat Dockerfile.digest
FROM golang:1.26-alpine@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125
RUN go version
$ docker build --no-cache --progress=plain -f Dockerfile.digest .
…
#5 0.060 go version go1.27.1 linux/arm64

Тег, дайджест и метки

В реестре всё адресовано по содержимому: слой и конфиг лежат блобами, манифест - JSON со ссылками на них, index ссылается на манифесты, дайджест каждого - хеш его байтов. Изменяемое звено одно, тег. Опыт: образ с метками OCI дважды пушим под тегом 1.4, между сборками коммит.

FROM gcr.io/distroless/static-debian13:nonroot
ARG VERSION=dev REVISION CREATED
LABEL org.opencontainers.image.title="orders" \
      org.opencontainers.image.source="https://git.example.com/shop/orders" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${CREATED}"
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]
$ docker buildx build --builder ci --build-arg VERSION=1.4.0 \
    --build-arg REVISION=$(git rev-parse HEAD) \
    --build-arg CREATED=$(git log -1 --format=%cI) \
    -t localhost:5052/orders:1.4 --push .
$ docker buildx imagetools inspect localhost:5052/orders:1.4 --format '{{.Manifest.Digest}}'
sha256:cc5e19eb246eb9ce4d03b6ff3b82764081c226c8ef29a454e4b8034cb86425a1
$ git commit -qam "orders: new status"          # и та же сборка с VERSION=1.4.1
$ docker buildx imagetools inspect localhost:5052/orders:1.4 --format '{{.Manifest.Digest}}'
sha256:0f83b6a48a19b43e48d973bbf08e96c1389353d59691094447f736324e4f8832
$ docker run --rm localhost:5052/orders@sha256:cc5e19eb246eb9ce4d03b6ff3b82764081c226c8ef29a454e4b8034cb86425a1 -selfcheck
version: 1.4.0
…
$ docker pull localhost:5052/orders
Using default tag: latest
Error response from daemon: failed to resolve reference "localhost:5052/orders:latest": localhost:5052/orders:latest: not found

Тег 1.4 переехал, прошлый образ доступен по дайджесту. Тег latest подставляется, когда тег не указан; сам он не двигается и может не существовать. В манифест выкатки пишут дайджест, для multi-arch - дайджест index. Продвижение образа между окружениями, сканирование, SBOM и подпись разобраны в статье «CI/CD», глава 4.3. Метки видны в docker buildx imagetools inspect --format '{{json .Image.Config.Labels}}'; они лежат в конфиге и входят в дайджест, поэтому created берут из времени коммита: значение из date сломало бы воспроизводимость.

$ docker cp -q $(docker create localhost:5052/orders:1.4):/server ./server
$ go version -m ./server
./server: go1.27.1
	path	example.com/orders/cmd/server
	mod	example.com/orders	v0.0.0-20260916230120-a59789e5fa4d
	…
	dep	github.com/go-chi/chi/v5	v5.3.2	h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
	…
	build	-trimpath=true
	build	CGO_ENABLED=0
	build	GOARCH=arm64
	…
	build	vcs.revision=a59789e5fa4d1727a4b6ac676c67689428e22031
	build	vcs.time=2026-09-16T23:01:20Z
	build	vcs.modified=false

Это Go вшивает сам. Строки vcs.* и версия главного модуля из коммита (с Go 1.24) появились, потому что в сборочную стадию поставлен git, а .git попал в контекст. Версия Go, зависимости с хешами из go.sum и флаги есть в любом бинаре; по ним и по символам govulncheck -mode=binary проверяет бинарь из образа без исходников.

Вопросы

4
Суть: на одноразовом раннере builder стартует пустым, кэш слоёв и cache mounts пропадают вместе с ним. Кэш выгружают в реестр в mode=max и подтягивают перед сборкой, а потом ищут шаг, который первым получает новый ключ.

Что искать в логе

С --progress=plain найди первую вершину без CACHED, зависимые шаги выполнятся за ней.

Что в логеПричина
нет importing cache manifestне передан --cache-from
… not found в каждой сборке (в первой это нормально)никто не пишет --cache-to или ref другой
всё заново с WORKDIRmode=min у multi-stage или новая база под старым тегом
COPY go.mod в кэше, go mod download выполняетсявыше объявлен ARG с меняющимся значением
go: downloading … внутри go buildмодули в cache mount, он в экспорт не входит
COPY . . выполняется без правок кодав контексте .git или артефакты джобов

Ветка без своего кэша

$ docker buildx build --builder ci \
    --cache-from type=registry,ref=localhost:5052/orders:cache-feature-x \
    --cache-from type=registry,ref=localhost:5052/orders:cache-main \
    --cache-to type=registry,ref=localhost:5052/orders:cache-feature-x,mode=max \
    -t localhost:5052/orders:feature-x --push .
…
#7 importing cache manifest from localhost:5052/orders:cache-feature-x
#7 ERROR: failed to configure registry cache importer: localhost:5052/orders:cache-feature-x: not found

#6 importing cache manifest from localhost:5052/orders:cache-main
#6 inferred cache manifest type: application/vnd.oci.image.manifest.v1+json done
#6 DONE 0.6s
…
#9 [build 3/6] COPY go.mod go.sum ./
#9 CACHED

Кэша ветки ещё нет, и BuildKit взял слои из кэша main. Cache mounts так не переносятся: либо go mod download обычным слоем, либо долгоживущий builder на постоянном раннере, либо перенос каталога отдельными шагами CI, как в buildkit-cache-dance.

В mode=max уезжает и то, что писать было нельзя

В кэш попадают слои сборочных стадий: токен, записанный шагом в /root/.gitconfig, нашёлся в блобе кэша при финальном образе из scratch. Доступ к тегу кэша равен доступу к таким файлам.

Суть: через RUN --mount=type=secret: токен виден одному шагу и не пишется ни в слой, ни в историю. ARG оставляет его в docker history, в записи сборки и в provenance mode=max, а записанный в файл - в кэше mode=max.

Как сделать

FROM golang:1.27.1-alpine AS build
WORKDIR /src
ENV GOPRIVATE=git.example.com/*
COPY go.mod go.sum ./
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
    go mod download
$ docker build --secret id=netrc,src=../netrc -t orders-secret .
$ docker history --no-trunc --format '{{.CreatedBy}}' orders-secret | head -1
RUN /bin/sh -c go mod download # buildkit

GOPRIVATE уводит эти модули мимо публичного зеркала и базы контрольных сумм (go help private). .netrc читают go и git; если модуль берётся из репозитория напрямую, в стадию нужен apk add git, в golang:1.27.1-alpine его нет. Токен из переменной CI передают как --secret id=git_token,env=GIT_TOKEN и монтируют --mount=type=secret,id=git_token,env=GIT_TOKEN.

Как не допустить ARG

Директива в первой строке Dockerfile превращает предупреждение в ошибку:

$ cat Dockerfile
# check=error=true
FROM alpine:3.24
ARG GIT_TOKEN
RUN echo building
$ docker build --build-arg GIT_TOKEN=x .
…
ERROR: failed to build: failed to solve: lint violation found for rules: SecretsUsedInArgOrEnv
Отозванный токен кэш не проверит

Секрет не входит в ключ шага: после замены значения всё было CACHED. Ошибка доступа всплывёт, когда шаг выполнится по другой причине, например после правки go.mod.

Суть: образ собран под linux/arm64, узел amd64, и ядро не исполняет бинарь чужой архитектуры. Нужен multi-arch index: сборочная стадия на $BUILDPLATFORM, Go компилирует под TARGETOS/TARGETARCH, финальные стадии берут базу своей платформы.

Как убедиться

docker image inspect -f '{{.Os}}/{{.Architecture}}' локально или docker buildx imagetools inspect для тега в реестре. Если под тегом одна платформа, причина найдена.

Как собирать

  • FROM --platform=$BUILDPLATFORM только у сборочной стадии.
  • ARG TARGETOS TARGETARCH после go mod download, тогда модули качаются один раз.
  • CGO_ENABLED=0 явно. В golang:1.27.1-alpine без gcc go env CGO_ENABLED даёт 0, после apk add gcc musl-dev - 1, а для GOARCH=amd64 на arm64 снова 0: в базе с gcc родная платформа собралась бы с cgo, чужая без.
  • docker buildx build --platform linux/amd64,linux/arm64 … --push. Здесь это 22,3 с против 103,2 с через QEMU.

Если нужен cgo

$ CGO_ENABLED=1 GOARCH=amd64 go build -o /tmp/hello .
# runtime/cgo
gcc: error: unrecognized command-line option '-m64'

Это golang:1.27.1-alpine на arm64 с gcc: родной компилятор C под amd64 не собирает. Нужен кросс-компилятор C под цель (его задают в CC) или сборка под эмуляцией.

Список платформ не гарантирует эмуляцию

docker buildx inspect default здесь перечисляет linux/amd64, а RUN под amd64 падает с exec /bin/sh: exec format error. Builder в контейнере приносит свой QEMU, и одна команда на разных builder ведёт себя по-разному. Кросс-компиляции Go эмулятор не нужен.

Суть: дайджест манифеста зависит от конфига со временем сборки и от слоёв со временем файлов, дайджест index - ещё и от provenance. Манифест фиксируют SOURCE_DATE_EPOCH и rewrite-timestamp, index совпадёт только без provenance. Выкатывают по дайджесту, потому что тег переезжает при каждом push.

Что меняется от сборки к сборке

ИсточникКуда попадаетЧем фиксируют
время сборкиcreated и история в конфигеSOURCE_DATE_EPOCH
время файловtar слояrewrite-timestamp=true
startedOnattestation, index--provenance=false или сравнение манифестов платформ
путь исходниковбинарь-trimpath
обновлённая база под тем же тегомслои базыFROM …@sha256:

Тег или дайджест

После второго push localhost:5052/orders:1.4 запускал 1.4.1, а 1.4.0 по-прежнему запускался по @sha256:cc5e19eb…. Реплики, которые скачают образ по тегу в разное время, могут получить разный код. В манифест выкатки пишут дайджест, для multi-arch - дайджест index. Продвижение одного дайджеста между окружениями разобрано в статье «CI/CD», глава 4.3.

Воспроизводимость и неизменность - разные свойства

Воспроизводимость отвечает на вопрос «собран ли образ из этого коммита»: пересобери и сравни манифест. Выкатке нужно, чтобы записанный дайджест не менялся. latest опасен тем, что переезжает, и воспроизводимая сборка этого не лечит.

Что стоит запомнить

  • Кэш шага зависит от операции и её входов, а не только от номера строки Dockerfile.
  • Cache mount ускоряет работу builder, но его содержимое не является слоем приложения.
  • Digest адресует содержимое образа; tag - изменяемое имя ссылки.

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

  • ARG с секретом может попасть в метаданные и логи сборки.
  • Одинаковый исходный код не гарантирует идентичный digest при меняющихся базовых образах или времени сборки.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Найди причину сброшенного кэша

Первый шаг выполняется снова после изменения main.go. Почему слой с модулями не был использован?

COPY . .
RUN go mod download
RUN go build ./cmd/api
Ответ и разбор

COPY . . включает main.go в ключ входа шага и меняет его при правке файла. Следующий слой зависит от результата COPY, поэтому и go mod download теряет кэш. Скопируй файлы модуля отдельным шагом до исходников.

Код-ревью

2. Убери секрет из сборки

В CI команда передаёт токен так. Что здесь опасно и как передать секрет через BuildKit?

ARG PRIVATE_TOKEN
RUN curl -H "Authorization: Bearer $PRIVATE_TOKEN" https://packages.example.test
Ответ и разбор

ARG не предназначен для хранения секрета: значение может оказаться в истории сборки, метаданных или логах. Используй BuildKit secret mount и читай токен из временного файла внутри одного шага; не копируй его в слой и не печатай команду с раскрытым значением.

Применение

3. Проверь доверие к образу

Прод разворачивает api:stable, а команда уверена, что на всех узлах будет один бинарь. Что изменишь в поставке, чтобы закрепить конкретное содержимое?

Ответ и разбор

Тег stable можно передвинуть. Получи digest собранного образа и разворачивай ссылку вида api@sha256:...; для multi-arch manifest убедись, что выбранная платформа входит в этот digest. Отдельно проверяй подпись и происхождение артефакта, если этого требует процесс поставки.

Применение

4. Разбери расхождение билдов

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

Ответ и разбор

Сравни digest базовых образов, платформу и версию builder, а также время/метаданные и фактический контекст сборки. Проверь, что в контекст не попали локальные файлы через отсутствие правил .dockerignore, и что генераторы не вносят меняющиеся данные. Только после фиксации входов сравни бинарные артефакты.