3. Multi stage build
Многоэтапная сборка: от гигабайтных билдеров к минимальным образам
В предыдущей статье мы исследовали кэширование слоев и тонкую настройку флагов компилятора. Однако какая бы виртуозная оптимизация ни применялась в рамках одноэтапного Dockerfile, итоговый образ неизбежно останется перегруженным.
Если в Dockerfile используется единственная инструкция FROM golang:1.22, то в продакшен-образ гарантированно попадут компилятор Go, исходные файлы, тесты, ассемблер, утилиты линковки и системный пакетный менеджер. Всё это — чистый «мертвый груз» весом в сотни мегабайт. В боевой среде такой балласт замедляет выкачивание образов (Pull Time) в кластере и катастрофически расширяет поверхность для потенциальных атак.
В компилируемых языках — Си, C++, Rust и Go — среда сборки (Build Environment) и среда исполнения (Runtime Environment) кардинально различаются. Сборка требует богатого набора инструментов, но для выполнения полностью статического Go-бинарника не нужна даже операционная система — достаточно лишь ядра Linux.
1. Архитектурная концепция Multi-Stage Build
Движок Docker позволяет объявлять инструкцию FROM несколько раз внутри одного Dockerfile. Каждая последующая директива FROM начинает новый этап сборки (Stage) с абсолютно чистого листа, отсекая все слои и временные файлы предыдущих этапов.
Это фундаментальное решение породило классический паттерн:
- Этап сборщика (Builder Stage): Тяжелый образ с компилятором, где скачиваются зависимости и компилируется бинарник.
- Этап выполнения (Runner Stage): Минимальный чистый образ, в который копируется только один скомпилированный бинарный файл и необходимые статические сертификаты.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Stage1["Stage 1 - Builder - 800 MB"]
A["golang:1.22-alpine"]:::process --> B["go mod download"]
B --> C["go build -o /myapp"]
end
subgraph Stage2["Stage 2 - Runner - 20 MB"]
D["alpine:latest"]:::process --> E["COPY --from=0 /myapp ."]
E --> F["USER appuser"]
F --> G["ENTRYPOINT ./myapp"]
end
C -->|"COPY --from=builder"| E
2. Идеальный production-ready Multi-Stage Dockerfile для Go
Соберем воедино все передовые инженерные практики: именованные этапы, кэширование зависимостей, статическую сборку, работу от непривилегированного пользователя и системные сертификаты:
# -------------------------------------------------------------
# ЭТАП 1: Сборка (Builder)
# Именование этапа через 'AS builder' улучшает читаемость
# -------------------------------------------------------------
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Шаг 1: Кэширование зависимостей
COPY go.mod go.sum ./
RUN go mod download
# Шаг 2: Копирование исходного кода
COPY . .
# Шаг 3: Статическая компиляция под Linux x86_64
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w -X main.buildVersion=$(cat version.txt)" \
-trimpath -o /myapp .
# -------------------------------------------------------------
# ЭТАП 2: Эксплуатация (Runner)
# -------------------------------------------------------------
FROM alpine:latest AS runner
# Устанавливаем корневые CA-сертификаты и данные о часовых поясах (tzdata)
# Это критично для корректной работы исходящих HTTPS-запросов и time.LoadLocation
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
# Создаем системную группу и непривилегированного пользователя
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Копируем бинарник из этапа builder с немедленной сменой владельца (--chown)
COPY --from=builder --chown=appuser:appgroup /myapp /app/myapp
# Переключаемся на непривилегированного пользователя
USER appuser
# Запуск приложения
ENTRYPOINT ["/app/myapp"]
3. Выбор базового образа для Runner: scratch, distroless или alpine?
Выбор базового образа для финального этапа — это стратегический компромисс между максимальной безопасностью, минимальным размером и удобством диагностики инцидентов:
| Характеристика | scratch |
distroless (Google) |
alpine |
|---|---|---|---|
| Размер образа | 10–20 МБ (только бинарник) | ~25–30 МБ | ~30–35 МБ |
Командный шелл (sh/bash) |
❌ Отсутствует | ❌ Отсутствует | ✅ Присутствует (BusyBox) |
CA-сертификаты (ca-certificates) |
❌ Нужно копировать вручную | ✅ Включены из коробки | ✅ Доступны через apk |
Временные зоны (tzdata) |
❌ Нужно копировать вручную | ✅ Включены из коробки | ✅ Доступны через apk |
Возможность docker exec |
❌ Невозможна | ❌ Невозможна | ✅ Возможна |
| Уровень защищенности от эксплойтов | ⭐️⭐️⭐️ Максимальный | ⭐️⭐️⭐️ Максимальный | ⭐️⭐️ Высокий |
1. Образ scratch (Абсолютный вакуум)
В образе scratch нет вообще ничего — ни одного файла, ни оболочки shell, ни директории /etc/passwd.
- Плюсы: Минимальный физический размер. Абсолютная защита: злоумышленник физически не может запустить шелл-скрипт или скачать вредоносный бинарник.
- Минусы: Нельзя зайти в контейнер через
docker exec -it ... shдля дебага. Отсутствуют CA-сертификаты (ваши исходящие HTTPS-запросы упадут с ошибкойx509: certificate signed by unknown authority). Отсутствуют файлы временных зон (time.LoadLocationвернет ошибку). -
Решение: Скопировать сертификаты и tzdata на этапе
builder:DOCKERFILE COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
2. Образы Google distroless (Золотой корпоративный стандарт)
Линейка образов gcr.io/distroless/static-debian12 специально спроектирована инженерами Google для статических приложений.
- Плюсы: Образ содержит корневые сертификаты, базу таймзон
/usr/share/zoneinfo, файл/etc/passwdс дефолтным пользователемnonroot(UID 65532), но не содержит командной оболочки shell. Это гарантирует непробиваемость перед атаками типа Command Injection при нулевых затратах на ручную конфигурацию. - Минусы: Отладка через
execпо-прежнему невозможна (нетsh).
3. Образ alpine (Прагматичный выбор для разработки)
- Плюсы: Наличие минималистичного шелла BusyBox позволяет в критической ситуации подключиться к контейнеру командой
docker exec -it <id> shи запустить сетевую диагностику черезnetstatилиwget. - Минусы: Использует стандартную библиотеку языка Си
musl libcвместоglibc.
COPY --chown: Изящное владение файлами
В классическом одноэтапном подходе файлы, скопированные в образ, автоматически принадлежат пользователю root. Чтобы перевести их на appuser, разработчики выполняли:
COPY /myapp /app/myapp
RUN chown -R appuser:appgroup /app
Вспомним механизм Copy-on-Write (CoW) из лекции 1. Контейнеризация. Основы: команда RUN chown не модифицирует файл на месте, а копирует его целиком в новый слой OverlayFS, фактически удваивая вес бинарника в итоговом образе!
Флаг --chown в директиве COPY:
COPY --from=builder --chown=appuser:appgroup /myapp /app/myapp
назначает корректного владельца непосредственно в момент записи файла в слой контейнера, не создавая лишних мегабайт в снимках файловой системы.
5. Расширенный паттерн: Инъекция отладочных инструментов (Delve)
Что делать, если в тестовом окружении или на staging-стенде требуется подключиться к живому Go-процессу интерактивным отладчиком Delve (dlv) или снять профили через pprof, но тащить тяжелые отладочные пакеты в продакшен строго запрещено?
Многоэтапная сборка позволяет формировать альтернативные сборочные цели (Build Targets):
# Базовый образ выполнения
FROM alpine:latest AS runner
WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /myapp /app/myapp
USER appuser
ENTRYPOINT ["/app/myapp"]
# Альтернативная отладочная цель
FROM runner AS debug
USER root
# Устанавливаем отладчик Delve и сетевые утилиты
RUN go install github.com/go-delve/delve/cmd/dlv@latest && \
apk --no-cache add curl bind-tools
USER appuser
ENTRYPOINT ["/go/bin/dlv", "--listen=:40000", "--headless=true", "--api-version=2", "exec", "/app/myapp"]
В файле docker-compose.yaml для локальной разработки достаточно указать директиву target: debug, в то время как боевой CI/CD соберет легковесный target: runner.
Итог
- Multi-Stage Build кардинально разделяет среду компиляции и среду исполнения, уменьшая размер продакшен-образов Go с сотен мегабайт до компактных 15–25 МБ.
- Выбор Runner:
distroless— эталон безопасности для боевого продакшена;alpine— удобный компромисс для сервисов, требующих оперативной ручной диагностики;scratch— предельный минимализм, требующий ручной подкладки сертификатов. COPY --chown: Исключает раздувание слоев OverlayFS за счет атомарного назначения прав при копировании артефактов.- Совместимость libc: При сборке с CGO среда компилятора (
glibcvsmusl) обязана строго совпадать со средой выполнения во избежание сбоев динамического компоновщика.
Контейнер упакован, оптимизирован и надежно изолирован. Однако современные распределенные системы состоят из десятков взаимодействующих контейнеров, которым требуется защищенная и быстрая сетевая связность. В следующей лекции мы погрузимся в сетевую подсистему Docker: 4. Docker networking.