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.
COPY . . в стадии build, и пересобирается только эта ветка
и шаг финальной стадии, который берёт из неё бинарь.Почему CACHED и что сбрасывает кэш
В ключ RUN входят текст команды, окружение с объявленными к этому месту ARG,
монтирования, платформа и ключ родителя. Для COPY считается сумма содержимого файлов, время
изменения в неё не входит. Первые четыре строки таблицы сняты на Dockerfile выше, остальные - на
одностадийном, где версия уходит в go build -ldflags "-X main.version=${VERSION}":
| Что поменялось | go mod download | go build | Финальный COPY --from=build |
|---|---|---|---|
touch cmd/server/main.go | CACHED | CACHED | CACHED |
строка кода в orders.go | CACHED | заново, 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 build | CACHED | заново | - |
строка кода, 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 скачивается готовый слой с модулями.
| Чистый builder | mode=min | mode=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.
С 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 для него не отмечает.
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/tzdata | 8,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-cache | created | Слой | Манифест | Index |
|---|---|---|---|---|
| без настроек | разный | разный | разный | разный |
--build-arg SOURCE_DATE_EPOCH=1767225600 | 2026-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 проверяет бинарь из образа без исходников.
Вопросы
4mode=max и подтягивают перед сборкой, а потом
ищут шаг, который первым получает новый ключ.Что искать в логе
С --progress=plain найди первую вершину без CACHED, зависимые шаги выполнятся за ней.
| Что в логе | Причина |
|---|---|
нет importing cache manifest | не передан --cache-from |
… not found в каждой сборке (в первой это нормально) | никто не пишет --cache-to или ref другой |
всё заново с WORKDIR | mode=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.
В кэш попадают слои сборочных стадий: токен, записанный шагом в /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.
$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без gccgo 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 эмулятор не нужен.
SOURCE_DATE_EPOCH и
rewrite-timestamp, index совпадёт только без provenance. Выкатывают по дайджесту, потому что тег переезжает при каждом push.Что меняется от сборки к сборке
| Источник | Куда попадает | Чем фиксируют |
|---|---|---|
| время сборки | created и история в конфиге | SOURCE_DATE_EPOCH |
| время файлов | tar слоя | rewrite-timestamp=true |
startedOn | attestation, 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, и что генераторы не вносят меняющиеся данные. Только после фиксации входов сравни бинарные артефакты.