Модуль: 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:tag

Multistage 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#

БазаРазмерlibcshellnon-rootCA-сертификатыtzdata
scratch~0нетнетрукаминет (надо копировать)нет
distroless/static~2 МБнетнетобраз :nonroot естьдада
distroless/base~20 МБglibcнетдадада
alpine~5 МБmuslshруками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 appnot a dynamic executable, либо file appstatically 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 (k8s runAsNonRoot: 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 или по digest golang@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 вместо COPYADD распаковывает архивы и качает 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-дизайном сервисов.