Заметка

Production Dockerfile: distroless, кэш и секреты сборки

Чеклист на десяток строк: что уезжает в прод, а что остаётся в builder-стадии. Замеры баз, кэш-маунты BuildKit, секреты без следа в слоях и аттестации, которые приезжают сами.

Базовый образ, в котором Go-сервис собирается, весит 241 МБ. Базовый образ, в котором он должен работать, — 2,21 МБ. Между этими числами и лежит смысл production Dockerfile: в прод не должно уехать ничего, что нужно было только на сборке. Все размеры ниже — замер docker images на linux/amd64.

Multi-stage: отдельно сборка, отдельно рантайм

Multi-stage обычно продают как способ уменьшить образ. Уменьшение — побочный эффект. Главное в том, что финальный слой начинается с чистой базы и содержит ровно то, что скопировали руками: компилятор, пакетный менеджер, заголовочные файлы и shell в него не попадают, потому что их никто не копировал.

# syntax=docker/dockerfile:1
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /bin/app ./cmd/app

FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /bin/app /app
USER 65532:65532
ENTRYPOINT ["/app"]

Первая строка не украшение: она подтягивает актуальный фронтенд Dockerfile вместо вшитого в демон, а от версии фронтенда зависит доступность --mount и --link из следующих разделов.

БазаРазмерКогда брать
distroless/static-debian132,21 МБстатические бинарники: Go с CGO_ENABLED=0, Rust
alpine:3.248,42 МБнужен shell, busybox или пакетный менеджер
distroless/base-debian1324,3 МБдинамическая линковка против glibc
debian:trixie-slim78,6 МБсистемные зависимости, которых нет под musl

Тег без суффикса дистрибутива сейчас указывает на -debian13, но upstream предупреждает, что со временем он переедет на следующий Debian. Суффикс лучше писать явно.

Кэш: порядок слоёв решает больше, чем флаги

Правило одно: что меняется редко — копируется раньше. Манифест зависимостей отдельным COPY, установка, и только потом исходники. В обратном порядке правка одной строки кода инвалидирует установку пакетов.

Поверх этого BuildKit даёт кэш-маунты, которые переживают инвалидацию слоя: каталог хранится на хосте и подключается в сборку.

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && apt-get install --no-install-recommends -y gcc

sharing=locked здесь обязателен — apt не переживает двух параллельных сборок в одном каталоге. Второй недооценённый флаг: COPY --link кладёт скопированное собственным слоем, независимым от предыдущих, и слой переиспользуется даже после пересборки базы.

Секрет, попавший в слой, остаётся в слое

Образ — не архив, а стек неизменяемых слоёв. Токен, записанный в одном RUN и удалённый в следующем, никуда не делся: rm убрал его только из верхнего представления файловой системы.

docker history --no-trunc my-app:latest
docker save my-app:latest | tar -xO | strings | grep -i 'token\|secret'

Достать его может любой, у кого есть доступ на pull, — без единого эксплойта. Рабочий способ ровно один: --mount=type=secret монтирует файл на время выполнения инструкции, и он не попадает ни в слой, ни в манифест.

RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci

ARG для этого не годится даже в builder-стадии: значение остаётся в её слоях и в кэше сборки на хосте, а multi-stage защищает только финальный образ. Секреты рантайма — отдельная задача, там работают External Secrets Operator и workload identity, а не сборка.

Non-root и сигналы

USER с числовым UID — не косметика. Kubernetes с runAsNonRoot: true не поднимет контейнер, у которого в конфиге образа записано имя пользователя, а не число: kubelet не умеет проверить, что имя не разрешится в root. Теги nonroot у distroless используют 65532.

Ходовое поверье, что USER обязан стоять до CMD, иначе контейнер стартует под root, — неверно. Два образа с обратным порядком этих инструкций дают один и тот же uid=1001: значение берётся из последней инструкции USER в файле, позиция относительно CMD роли не играет. Проверка занимает минуту, и её стоит проделать, прежде чем переносить это правило из чужого чеклиста в свой.

Ломается другое — форма записи точки входа. Shell-форма ENTRYPOINT app запускает /bin/sh -c, и PID 1 достаётся шеллу: сигнал до процесса не доходит, docker stop досиживает таймаут и добивает контейнер. В документации Docker разница замерена — 10,19 секунды против 0,20. Exec-форма ENTRYPOINT ["/app"] этой проблемы не имеет.

Аттестации приезжают сами, SBOM — нет

BuildKit добавляет provenance-аттестацию уровня mode=min по умолчанию: запись «кто и из чего собрал» есть, даже если её не просили. SBOM по умолчанию не генерируется.

docker buildx build --provenance=mode=max --sbom=true --push -t app:1.0 .

Для внутреннего сервиса это гигиена, для продукта на рынке ЕС — требование Cyber Resilience Act со сроком в сентябре 2026. Provenance из BuildKit — заготовка под уровни SLSA, а не замена им: подпись и верификация остаются на пайплайне.

Чем платишь за distroless

В образе нет shell — не работает ни HEALTHCHECK с curl, ни привычный docker exec sh. Health-проверка уезжает к kubelet, отладка — в эфемерный контейнер (kubectl debug --target) или в тег :debug, который добавляет busybox. Если приложению shell нужен в рантайме, distroless просто не подходит, и это нормальный исход: alpine на 8,42 МБ тоже не тащит в прод компилятор.

Чеклист

  • Multi-stage; в финальный слой копируются только артефакты.
  • База пришпилена дайджестом @sha256:…, а не одним тегом.
  • Зависимости копируются и ставятся до исходников; тяжёлые кэши — через --mount=type=cache.
  • Ни одного секрета через ARG или ENV — только --mount=type=secret.
  • USER с числовым UID, ENTRYPOINT в exec-форме.
  • .dockerignore закрывает .git, .env, node_modules, состояние Terraform.
  • --sbom=true включён осознанно, а не «когда попросят».

Ни один пункт не требует нового инструмента: всё перечисленное — BuildKit, включённый по умолчанию с Docker Engine 23.0. Остаётся написать десяток строк в правильном порядке.

© 2026 axyi.ru · CC BY 4.0