Dockerfile multi-stage: как собрать тонкий образ Go-приложения
Когда впервые собираешь Go-приложение в Docker, обычно получаешь образ на 800 МБ: вся golang:1.23 внутри плюс твой бинарь на 20 МБ. И это работает, но вытягивать 800 МБ при каждом deploy и хранить такой балласт в registry — расточительно. Multi-stage build решает это в три-четыре строчки.
Покажу свой стандартный Dockerfile для Go-сервисов, объясню каждый шаг и расскажу, где обычно ловят грабли. Версии — Go 1.23, Docker 27.
Что такое multi-stage
Multi-stage — это когда в одном Dockerfile несколько FROM. Каждый FROM начинает новую stage с чистым state. Между ними можно копировать файлы через COPY --from=<stage>.
Идея в том, что в первой stage у тебя жирный образ с компилятором, исходниками и всеми инструментами. Ты собираешь бинарь, а потом копируешь его в маленькую финальную stage без компилятора. В итоге в registry уезжает только финальный образ — компактный.
Базовый рабочий пример
# syntax=docker/dockerfile:1.7
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/app /app
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/app"]Это рабочий минимум. Размер итогового образа — около 25 МБ при бинаре на 20 МБ. Что здесь происходит:
Stage 1: builder
FROM golang:1.23-alpine AS builder — берём компилятор Go в alpine. Можно golang:1.23 на debian, но alpine на ~200 МБ легче, и для статической линковки этого достаточно.
COPY go.mod go.sum ./ и RUN go mod download — кешируем зависимости отдельным слоем. Если код меняется, а зависимости — нет, этот слой переиспользуется и сборка ускоряется в разы.
CGO_ENABLED=0 — выключаем cgo, чтобы получить полностью статический бинарь без зависимостей от libc. Без этого бинарь не запустится в distroless или scratch.
Stage 2: финальный образ
gcr.io/distroless/static-debian12:nonroot — это образ от Google, в котором есть только tzdata, ca-certificates и /etc/passwd с nonroot-юзером. Никакого shell, никакого pkg-manager, ничего лишнего. ~2 МБ.
COPY --from=builder копирует бинарь из первой stage. Финальный образ не содержит ни компилятора, ни исходников.
USER nonroot — запуск от unprivileged user (UID 65532). Это важная штука для безопасности: даже если в приложении нашли RCE, атакующий не получит root в контейнере.
Что часто упускают
1. ldflags для уменьшения бинаря
Базовый go build оставляет в бинаре отладочную информацию и таблицы символов. Их можно срезать:
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w -X main.version=$VERSION -X main.commit=$COMMIT" \
-o /out/app ./cmd/api-s -w убирает символы и DWARF — экономия 20-30% размера бинаря. -trimpath убирает абсолютные пути из бинаря (полезно для воспроизводимых сборок).
-X main.version=... — встраиваем версию в бинарь. Потом в коде:
package main
var (
version = "dev"
commit = "none"
)В рантайме можно отдавать через /version эндпоинт или метрику.
2. Кеш модулей через BuildKit
Кеш-маунты сильно ускоряют локальные сборки и CI. Нужно # syntax=docker/dockerfile:1.7 в первой строке.
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/app ./cmd/apiЭти кеши не попадают в финальный слой, но переиспользуются между сборками. На больших проектах разница в разы.
3. Build args для платформ
Если собираешь multi-arch (amd64 + arm64) — используй TARGETOS, TARGETARCH:
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder
ARG TARGETOS
ARG TARGETARCH
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app ./cmd/apiBUILDPLATFORM — это где запущен docker build (твой Mac или CI-нода). TARGETPLATFORM — куда мы собираем. Так builder собирает с эмуляцией только для финальной архитектуры, остальная часть идёт нативно.
4. Не клади в финальный образ лишнее
Видел такое:
FROM golang:1.23-alpine AS builder
# ...
FROM alpine:3.20
COPY --from=builder /out/app /app
RUN apk add --no-cache curl bash jq
ENTRYPOINT ["/app"]В финальный образ ставится curl, bash, jq «на всякий случай для дебага». Это +10 МБ и куча CVE в твоих security-сканах. Если нужно ходить в pod внутри — используй kubectl debug, не таскай инструменты в продакшен-образ.
Образ ещё меньше: scratch
Если совсем выжать килобайты — финальная stage из scratch:
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /out/app /app
USER 65532
ENTRYPOINT ["/app"]scratch — это абсолютно пустой образ. Бинарь, ca-certificates (для HTTPS) и /etc/passwd (для USER) — больше ничего. Размер — точно как сам бинарь.
Минусы scratch: нет tzdata (если используешь часовые пояса — нужно копировать), нет /tmp по умолчанию (некоторые либы падают на этом), нельзя зайти exec-ом для дебага (в k8s можно через kubectl debug --image=...).
Я обычно беру distroless как разумный компромисс: чуть жирнее, но есть всё нужное.
.dockerignore — обязательно
Без .dockerignore весь корень проекта (включая .git, node_modules, локальные binaries) копируется в build context. Сборка тормозит, кеш слоёв ломается на любом мелком изменении.
# .dockerignore
.git
.github
*.md
Dockerfile
.dockerignore
bin/
coverage/
.vscode/
.idea/
node_modules/
*.logВ Go-проектах обычно bin/ с локальными артефактами и vendor/ (если используется) — vendor можно оставить, чтобы не качать модули по сети.
Финальный шаблон, который я использую
# syntax=docker/dockerfile:1.7
ARG GO_VERSION=1.23
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS builder
RUN apk add --no-cache git ca-certificates tzdata
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
ARG TARGETOS
ARG TARGETARCH
ARG VERSION=dev
ARG COMMIT=none
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build \
-trimpath \
-ldflags="-s -w -X main.version=$VERSION -X main.commit=$COMMIT" \
-o /out/app ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/app /app
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]Билдим:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg VERSION=$(git describe --tags --always) \
--build-arg COMMIT=$(git rev-parse HEAD) \
-t registry.example.com/api:v1.5.0 \
--push .Что запомнить
Multi-stage обязателен для Go: builder с компилятором → distroless со статическим бинарём. CGO_ENABLED=0, -trimpath -ldflags="-s -w", кеш модулей через BuildKit, USER nonroot, .dockerignore. Размер 20-30 МБ для типичного API.
Куда копать: официальный гайд по multi-stage, distroless образы, и BuildKit-документация по cache mounts. Если интересно про reproducible builds — Bazel rules_go или ko могут собирать ещё детерминированнее.