Multi-stage builds в Docker: собираем Go-приложение правильно

Как уменьшить Docker-образ Go-приложения с помощью multi-stage сборки и не потерять удобство эксплуатации

Multi-stage builds в Docker позволяют разделить этап сборки и финальный runtime-образ. На практике это самый прямой способ уменьшить размер образа и убрать из production всё, что нужно только во время компиляции.

Если хотите после базового Dockerfile перейти к production-практикам вокруг сборки, посмотрите раздел про линтеры, secrets, proxy и HEALTHCHECK. А если у вас много сервисов на одном стеке, пригодится материал про golden builder image и registry.

flowchart LR A["Исходный код\ngo.mod, *.go"] --> B["Builder stage\n(golang:alpine)"] B -->|"go mod download\ngo build"| C["Бинарник"] C -->|"COPY --from=builder"| D["Final stage\n(alpine / scratch)"] D --> E["Production-образ\n~15 МБ"] style A fill:#f9f3e3,stroke:#8b7355 style B fill:#e8dcc8,stroke:#8b7355 style C fill:#d4c5a9,stroke:#8b7355 style D fill:#c9e4c5,stroke:#5b8a5e style E fill:#a8d5a2,stroke:#5b8a5e

flowchart LR
    A["Исходный код\ngo.mod, *.go"] --> B["Builder stage\n(golang:alpine)"]
    B -->|"go mod download\ngo build"| C["Бинарник"]
    C -->|"COPY --from=builder"| D["Final stage\n(alpine / scratch)"]
    D --> E["Production-образ\n~15 МБ"]

    style A fill:#f9f3e3,stroke:#8b7355
    style B fill:#e8dcc8,stroke:#8b7355
    style C fill:#d4c5a9,stroke:#8b7355
    style D fill:#c9e4c5,stroke:#5b8a5e
    style E fill:#a8d5a2,stroke:#5b8a5e

Проблема: толстый образ

Типичный Dockerfile для Go-приложения выглядит так:

FROM golang:1.26
WORKDIR /app
COPY . .
RUN go build -o server ./cmd/server
# Весь SDK, исходники и кеш модулей остаются в образе
CMD ["./server"]

Такой образ обычно получается очень большим, потому что в нём остаются Go SDK, зависимости, кеш модулей и исходники.

Решение: multi-stage

# Stage 1: сборка
FROM golang:1.26-alpine AS builder
WORKDIR /app
# Сначала копируем только манифесты — слой кешируется,
# пока зависимости не меняются
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Статическая линковка без cgo; -s -w убирают отладочные символы
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o server ./cmd/server

# Stage 2: финальный образ — только бинарник и сертификаты
FROM alpine:3.23
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/server /usr/local/bin/
CMD ["server"]

В моём случае итоговый образ уменьшился примерно до ~15 МБ. Точная цифра зависит от бинарника, базового образа и архитектуры.

Мини-бенчмарк: как оценивать эффект

Не ориентируйтесь на чужие цифры, снимайте метрики в своём проекте:

# Сборка "толстого" образа
docker build -f Dockerfile.single -t app:single .

# Сборка multi-stage образа
docker build -f Dockerfile -t app:multi .

# Сравнение размеров
docker images app:single app:multi

Хорошая практика: фиксировать результат в README или в описании PR. Минимум полезно записать размер образа, время сборки и время старта контейнера.

Почему это работает

Каждый FROM начинает новый этап. В финальный образ попадает только то, что мы явно копируем через COPY --from=. Компилятор, исходники и кеш модулей остаются в промежуточном слое.

Ключевые моменты

  1. CGO_ENABLED=0 отключает cgo и для большинства pure-Go сервисов позволяет собрать самодостаточный бинарник без зависимости от glibc.
  2. -ldflags="-s -w" убирает отладочную информацию и дополнительно уменьшает бинарник.
  3. alpine даёт компактный базовый образ, хотя итоговый размер всё равно зависит от тега и платформы.
  4. ca-certificates нужны для HTTPS-запросов из приложения.

Ещё компактнее: scratch

Если приложению не нужны shell и утилиты, можно пойти ещё дальше:

FROM scratch
# Без базового образа сертификаты нужно копировать явно
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /
CMD ["/server"]

Размер обычно получается ещё меньше, чем с alpine: в образ попадают только бинарник и сертификаты.

Безопасность: что добавить сразу

Multi-stage уменьшает поверхность атаки, но этого недостаточно. Минимальный baseline для production:

  1. Запускайте приложение не от root.
  2. Пиньте версии базовых образов, а при необходимости и digest.
  3. Не передавайте секреты через ARG или ENV в Dockerfile.
  4. Проверяйте финальный образ сканером уязвимостей в CI.

Пример runtime-stage с non-root пользователем:

FROM alpine:3.23
# Создаём системную группу и пользователя для запуска без root
RUN apk --no-cache add ca-certificates && addgroup -S app && adduser -S app -G app
COPY --from=builder /app/server /usr/local/bin/server
USER app
CMD ["server"]

Пиннинг версий: что фиксировать обязательно

Чтобы сборка была воспроизводимой, фиксируйте версии на всех уровнях:

  1. Базовый образ: alpine:3.23 лучше, чем alpine:latest.
  2. Версии пакетов ОС, если пакетный менеджер это поддерживает.
  3. Версии внешних бинарников, которые скачиваете в Dockerfile.
  4. По возможности digest базового образа: FROM alpine:3.23@sha256:....

Анти-паттерн:

FROM alpine:latest
RUN apk add --no-cache curl

Лучше:

FROM alpine:3.23
RUN apk add --no-cache curl

Установка пакетов без лишних следов

Базовые принципы:

  1. Объединяйте обновление индекса и установку в одном RUN.
  2. Не храните менеджеры пакетов и dev-инструменты в финальном runtime-образе.
  3. Используйте multi-stage: всё тяжёлое остаётся в builder-слоях.
  4. Для Debian/Ubuntu очищайте индексы пакетов после установки.

Пример для alpine:

RUN apk --no-cache add ca-certificates wget

Пример для debian/ubuntu:

# Обновление индекса и установка в одном RUN — иначе кеш слоёв
# может подхватить устаревший индекс
RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates wget \
 && rm -rf /var/lib/apt/lists/*  # очищаем индексы, чтобы не раздувать слой

Идея простая: то, что не нужно приложению в runtime, не должно попасть в финальный слой.

Не забывайте про .dockerignore

Даже хороший multi-stage Dockerfile теряет часть пользы, если в контекст сборки улетают лишние файлы. Docker сначала отправляет build context демону, и только потом начинает выполнять инструкции из Dockerfile.

Минимум стоит исключить:

  • .git
  • tmp/, dist/, build/
  • локальные логи и кеши
  • .env и другие чувствительные файлы, которые не нужны для сборки

Пример:

.git
.idea
node_modules
tmp
dist
build
.env
*.log

Это уменьшает размер контекста, ускоряет отправку файлов в build и снижает риск случайно протащить в образ то, чего там быть не должно.

Когда scratch не лучший выбор

scratch отлично работает для простых сервисов, но может усложнить эксплуатацию:

  • сложнее дебажить, потому что нет shell и стандартных утилит;
  • дополнительные файлы приходится добавлять вручную: certs, timezone, иногда passwd и group;
  • иногда разумнее выбрать alpine или distroless и получить чуть больший размер, но более удобную поддержку.

Практическое правило простое: сначала обеспечьте надёжность и наблюдаемость сервиса, а уже потом дожимайте размер.

Где уместен distroless

Между scratch и alpine есть ещё один полезный вариант — distroless.

Идея у него простая: оставить минимальный runtime без shell и лишних утилит, но при этом не собирать всё совсем “с нуля”, как в scratch.

Пример:

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
# В distroless уже есть встроенный пользователь nonroot
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Когда это удобно:

  • нужен минимальный runtime-образ, но не хочется вручную собирать окружение для scratch;
  • сервис уже собирается как статический бинарник;
  • важна меньшая поверхность атаки, чем у обычного alpine-образа.

Практически выбор часто выглядит так:

  • scratch — самый минимальный вариант, но и самый жёсткий по эксплуатации;
  • distroless — компромисс между минимализмом и удобством;
  • alpine — чуть тяжелее, но проще для повседневной поддержки и диагностики.

Типичные ошибки

  1. COPY . . до go mod download ломает кеш слоёв: любое изменение в коде заставляет заново скачивать зависимости.
  2. Секреты через ARG или ENV в Dockerfile могут попасть в историю слоёв и логи сборки.
  3. Запуск контейнера от root без причины увеличивает риск при компрометации приложения.
  4. Непинованные базовые образы делают сборку невоспроизводимой.
  5. Лишние пакеты в runtime-образе тащат в production shell, compilers и debug-tools, которые там не нужны.

Вывод

Multi-stage builds — не “оптимизация на потом”, а базовая практика для production-сборки контейнеров. В большинстве проектов их стоит использовать по умолчанию.

Если хотите пойти дальше, следующий уровень — выстроить вокруг Dockerfile нормальный production-процесс: линтеры, сканирование, secrets, proxy и HEALTHCHECK, а для больших стеков ещё и golden builder image с отдельным pipeline.

Обсуждение в Telegram

Присоединиться →

Комментарии