Модуль: DevOps · Уровень: Middle+/Senior
TL;DR#
- Docker-образ — это набор read-only слоёв (union filesystem, OverlayFS) + JSON-манифест. Каждая инструкция
RUN/COPY/ADDсоздаёт новый слой;ENV/WORKDIR/CMDсоздают слои-метаданные нулевого размера. - Для Go используйте multistage build: stage сборки на
golang:1.x, финальный образ —scratchилиgcr.io/distroless/static. Итог: 5–15 МБ вместо 800+ МБ. - Статическая линковка:
CGO_ENABLED=0даёт полностью статический бинарь, который работает вscratch. С CGO нужен glibc/musl в рантайме. - Кэш слоёв ломается на первом изменившемся слое и всех последующих. Поэтому
COPY go.mod go.sumиgo mod downloadидут ДОCOPY . .. .dockerignoreобязателен: исключает.git,node_modules, локальные бинарники из build context (ускоряет билд и уменьшает риск утечки секретов).- Безопасность: non-root
USER, минимальный базовый образ, никаких секретов в слоях, multi-stage чтобы не тащить toolchain.
Теория#
Образы и слои#
Образ состоит из:
- слоёв (layers) — tar-архивы дельт файловой системы, адресуемые по SHA256 (content-addressable);
- манифеста — список digest’ов слоёв + указатель на config;
- config — метаданные (
Env,Cmd,Entrypoint,WorkingDir,User, история).
Слои переиспользуются между образами по digest. Если два образа имеют один и тот же базовый слой, на диске он хранится один раз. При docker pull тянутся только отсутствующие слои.
Контейнер при запуске добавляет тонкий writable layer поверх read-only слоёв (copy-on-write). Все изменения в нём эфемерны и теряются при удалении контейнера.
# посмотреть слои и их размер
docker history myimage:tag
docker image inspect myimage:tag --format '{{json .RootFS.Layers}}'
# dive — лучший инструмент для анализа слоёв и "впустую" занятого места
dive myimage:tagMultistage build для Go#
Классический паттерн: тяжёлый builder + лёгкий runtime.
# --- builder stage ---
FROM golang:1.22 AS builder
WORKDIR /src
# 1) сначала зависимости — слой кэшируется, пока не изменились go.mod/go.sum
COPY go.mod go.sum ./
RUN go mod download
# 2) потом исходники
COPY . .
# статическая сборка
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /out/app ./cmd/app
# --- runtime stage ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]Что делают флаги:
-ldflags="-s -w"— убирает таблицу символов и DWARF debug info, бинарь меньше на ~25–30%. Минус: хуже стектрейсы/профилирование. Для прод-сервиса обычно оставляют (символы Go-функций для panic-трейсов остаются и без-s -w, теряется только отладочная информация для gdb/delve).-trimpath— убирает абсолютные пути сборки из бинаря (репродьюсибилити + не светим структуру FS машины сборки).-X main.version=...— инъекция версии на этапе компиляции (вместо чтения из файла в рантайме).
scratch vs distroless vs alpine#
| База | Размер | libc | shell | non-root | CA-сертификаты | tzdata |
|---|---|---|---|---|---|---|
scratch | ~0 | нет | нет | руками | нет (надо копировать) | нет |
distroless/static | ~2 МБ | нет | нет | образ :nonroot есть | да | да |
distroless/base | ~20 МБ | glibc | нет | да | да | да |
alpine | ~5 МБ | musl | sh | руками | apk add ca-certificates | руками |
- scratch — абсолютный минимум. Подходит только для
CGO_ENABLED=0. Нужно вручную добавить CA-сертификаты (иначе HTTPS-запросы упадут сx509: certificate signed by unknown authority) и tzdata, если используете таймзоны.
FROM scratch
# CA для исходящего HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# таймзоны, если нужны time.LoadLocation
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /out/app /app
ENTRYPOINT ["/app"]Альтернатива tzdata: собрать с go build -tags timetzdata — Go вкомпилирует базу таймзон в бинарь.
distroless (от Google) — нет shell, нет пакетного менеджера, есть CA + tzdata, есть тег
:nonroot. Рекомендуемый дефолт для прода: безопасность scratch + готовые сертификаты. Минус для дебага: нет shell,kubectl execбесполезен (используйте ephemeral debug containers или:debugтеги).alpine — есть shell (удобно дебажить), но musl libc: при CGO возможны несовместимости с glibc-сборками, и иногда DNS-резолвинг ведёт себя иначе.
CGO и статическая линковка#
CGO_ENABLED=0→ Go использует чистый Go-резолвер DNS и net-стек, бинарь полностью статический, работает вscratch. Это дефолт для контейнеров.CGO_ENABLED=1(дефолт при наличии C-компилятора и cross-зависимостей вродеnet/os/userв некоторых конфигурациях) → бинарь динамически линкуется с glibc. Вscratchупадёт сno such file or directory(нетld-linux.so).
Когда CGO реально нужен: SQLite (mattn/go-sqlite3), некоторые крипто/ML-библиотеки, libpq напрямую. Тогда:
# статическая линковка с musl
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache build-base
ENV CGO_ENABLED=1
RUN go build -ldflags="-linkmode external -extldflags '-static'" -o /out/app .
# рантайм: alpine (musl) или distroless/base (glibc)Проверка статичности: ldd app → not a dynamic executable, либо file app → statically linked.
Кэширование слоёв#
Docker кэширует каждый слой. Кэш инвалидируется, если:
- изменилась инструкция в Dockerfile;
- для
COPY/ADD— изменилось содержимое копируемых файлов (по checksum); - инвалидирован любой предыдущий слой (каскад).
Главный приём — отделить зависимости от кода, т.к. код меняется на каждом коммите, а go.mod — редко:
COPY go.mod go.sum ./
RUN go mod download # кэшируется надолго
COPY . . # инвалидируется на каждом коммите
RUN go build ...BuildKit + cache mounts — переиспользование Go build/module cache между билдами без раздувания слоёв:
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /out/app ./cmd/appВключается через DOCKER_BUILDKIT=1 или docker buildx. В CI кэш можно экспортировать: --cache-to type=registry,... / --cache-from.
.dockerignore#
Build context (всё в каталоге) отправляется демону. Большой context = медленный билд + риск утечки.
.git
.gitignore
*.md
bin/
dist/
node_modules/
.env
*.local
testdata/large/
Dockerfile
.dockerignoreБез него COPY . . затащит .git (история + потенциальные секреты) и локальные бинарники в кэш-чувствительный слой.
Безопасность образа#
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/app /app
USER 65532:65532 # nonroot uid в distroless
ENTRYPOINT ["/app"]Чек-лист:
- non-root:
USERс числовым uid (k8srunAsNonRoot: trueпроверяет именно числовой uid, а не имя). - минимальная база: меньше пакетов = меньше CVE.
- никаких секретов в слоях: даже если удалить файл в следующем
RUN, он остаётся в предыдущем слое. Используйте BuildKit secrets:RUN --mount=type=secret,id=token .... - read-only rootfs в рантайме (
securityContext.readOnlyRootFilesystem: true) — статический Go-бинарь обычно не пишет в FS. - сканирование:
trivy image myimage:tag,grype,docker scout. - pin версий:
golang:1.22.3или по digestgolang@sha256:..., неlatest.
Подводные камни / gotchas#
- HTTPS падает в scratch — забыли скопировать
ca-certificates.crt. Симптом:x509: certificate signed by unknown authority. - CGO неожиданно включён — на машине есть gcc, забыли
CGO_ENABLED=0. Бинарь становится динамическим и не запускается в scratch. Всегда явно ставьтеCGO_ENABLED=0для контейнеров. - Таймзоны не работают —
time.LoadLocation("Europe/Moscow")возвращает ошибку в scratch без zoneinfo. Решение:-tags timetzdataили копирование/usr/share/zoneinfo. - GOOS/GOARCH при cross-build на ARM Mac — собираете на M1/M2, деплоите на amd64. Нужно
GOARCH=amd64илиdocker buildx build --platform linux/amd64. - Слой с
RUN apt-get updateбез очистки — раздувает образ. Объединяйте:apt-get update && apt-get install -y X && rm -rf /var/lib/apt/lists/*в одном RUN. ADDвместоCOPY—ADDраспаковывает архивы и качает URL, что неочевидно. Для копирования файлов всегдаCOPY.latestтег — невоспроизводимые билды. Пинуйте версии.- Кэш не инвалидируется по go.sum — если копируете только
go.mod, новые зависимости из обновлённогоgo.sumне подтянутся корректно при verify. Копируйте оба файла. - PID 1 и сигналы —
ENTRYPOINT ["/app"](exec form) делает приложение PID 1, и оно само должно обрабатывать SIGTERM. Shell form (ENTRYPOINT /app) запускает через/bin/sh -c, который не форвардит сигналы (в scratch shell вообще нет). Всегда exec form.
Вопросы на собеседовании#
В: Почему multistage build, а не просто golang:1.22 как финальный образ?
О: Финальный образ не должен содержать toolchain (компилятор, кэши модулей, исходники). Это ~800 МБ против ~10 МБ, плюс лишняя attack surface (компилятор, shell, пакеты с CVE) и утечка исходников. Multistage оставляет в рантайме только бинарь.
В: Что такое слой образа и когда переиспользуется кэш?
О: Слой — это content-addressable (по SHA256) дельта файловой системы, создаваемая инструкциями RUN/COPY/ADD. Кэш переиспользуется, если инструкция и её входные данные (для COPY — checksum файлов) не изменились и не инвалидирован ни один предыдущий слой.
В: В чём разница между scratch, distroless и alpine для Go? О: scratch — пусто, нужно вручную добавлять CA/tzdata, только для CGO_ENABLED=0. distroless — нет shell/пакетов, но есть CA+tzdata+nonroot, лучший дефолт для безопасности. alpine — есть shell (удобный дебаг), но musl libc может конфликтовать с CGO/glibc-сборками.
В: Почему статический бинарь падает в scratch с ошибкой про сертификаты?
О: Это не про линковку — Go-резолвер работает, но для верификации TLS нужны корневые CA-сертификаты, которых в scratch нет. Надо скопировать /etc/ssl/certs/ca-certificates.crt из builder.
В: Как ускорить сборку Go-образа в CI?
О: (1) Отделить COPY go.mod go.sum + go mod download от COPY . . для кэша слоёв. (2) BuildKit cache mounts для /go/pkg/mod и /root/.cache/go-build. (3) Хороший .dockerignore. (4) --cache-from/--cache-to для переиспользования кэша между раннерами CI.
В: Как сделать образ безопасным? О: Минимальная база (distroless/scratch), non-root с числовым uid, никаких секретов в слоях (BuildKit secrets вместо ARG/ENV), pin версий по digest, read-only rootfs, сканирование trivy/grype, multistage чтобы не тащить toolchain.
В: Почему ENTRYPOINT лучше писать в exec-форме?
О: Exec form (["/app"]) делает приложение PID 1 напрямую и доставляет ему сигналы (SIGTERM при остановке). Shell form оборачивает в /bin/sh -c, который перехватывает сигналы и не форвардит их, ломая graceful shutdown; к тому же в scratch/distroless нет shell.
В: Как уменьшить размер Go-бинаря?
О: -ldflags="-s -w" (убрать debug-инфу), -trimpath, UPX-сжатие (осторожно, замедляет старт и ловится антивирусами), отключить ненужные фичи через build tags. Главное — multistage с минимальной базой.
На что копают на senior+#
- Reproducible builds: pin базовых образов по digest,
-trimpath, фиксированные версии Go,SOURCE_DATE_EPOCH. Понимание, почему два билда одного коммита должны давать идентичный digest. - Supply chain security: подпись образов (cosign/sigstore), SBOM (syft), provenance attestations (SLSA), сканирование на каждом этапе пайплайна.
- BuildKit изнутри: чем отличается от legacy builder (параллельный граф сборки, cache mounts, secrets, frontend
# syntax=), как устроен экспорт/импорт кэша в registry. - OCI vs Docker image spec: что такое manifest list (multi-arch), как
docker buildxсобирает образы под несколько платформ одновременно. - Layer squashing trade-offs: когда стоит схлопывать слои (меньше overhead) и когда нет (теряется переиспользование кэша между образами).
- Глубокое понимание CGO: когда runtime принудительно включает CGO (например для
os/userи cgo-резолвера),-tags netgo,osusergoдля чистого Go,linkmode externalсо static-extldflags. - Эфемерность writable layer и persistent state: почему данные в контейнере нельзя хранить, как это связано с volumes и stateless-дизайном сервисов.