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
Проблема: толстый образ
Типичный 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=. Компилятор, исходники и кеш модулей остаются в промежуточном слое.
Ключевые моменты
CGO_ENABLED=0отключает cgo и для большинства pure-Go сервисов позволяет собрать самодостаточный бинарник без зависимости от glibc.-ldflags="-s -w"убирает отладочную информацию и дополнительно уменьшает бинарник.alpineдаёт компактный базовый образ, хотя итоговый размер всё равно зависит от тега и платформы.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:
- Запускайте приложение не от
root. - Пиньте версии базовых образов, а при необходимости и digest.
- Не передавайте секреты через
ARGилиENVв Dockerfile. - Проверяйте финальный образ сканером уязвимостей в 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"]Пиннинг версий: что фиксировать обязательно
Чтобы сборка была воспроизводимой, фиксируйте версии на всех уровнях:
- Базовый образ:
alpine:3.23лучше, чемalpine:latest. - Версии пакетов ОС, если пакетный менеджер это поддерживает.
- Версии внешних бинарников, которые скачиваете в Dockerfile.
- По возможности 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Установка пакетов без лишних следов
Базовые принципы:
- Объединяйте обновление индекса и установку в одном
RUN. - Не храните менеджеры пакетов и dev-инструменты в финальном runtime-образе.
- Используйте multi-stage: всё тяжёлое остаётся в builder-слоях.
- Для 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.
Минимум стоит исключить:
.gittmp/,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— чуть тяжелее, но проще для повседневной поддержки и диагностики.
Типичные ошибки
COPY . .доgo mod downloadломает кеш слоёв: любое изменение в коде заставляет заново скачивать зависимости.- Секреты через
ARGилиENVв Dockerfile могут попасть в историю слоёв и логи сборки. - Запуск контейнера от
rootбез причины увеличивает риск при компрометации приложения. - Непинованные базовые образы делают сборку невоспроизводимой.
- Лишние пакеты в runtime-образе тащат в production shell, compilers и debug-tools, которые там не нужны.
Вывод
Multi-stage builds — не “оптимизация на потом”, а базовая практика для production-сборки контейнеров. В большинстве проектов их стоит использовать по умолчанию.
Если хотите пойти дальше, следующий уровень — выстроить вокруг Dockerfile нормальный production-процесс: линтеры, сканирование, secrets, proxy и HEALTHCHECK, а для больших стеков ещё и golden builder image с отдельным pipeline.
Комментарии