Multi-stage build: образ без компилятора и мусора сборки

Multi-stage build: образ без компилятора и мусора сборки

Приём один для всех языков: собираем в одном образе, где есть компилятор и dev-зависимости, а запускаем в другом, где нет ни того, ни другого. Дальше примеры на Go, PHP и Python - выбирай свой язык во вкладках.

Проблема: в образ уехало всё

# Один stage - образ ~1 ГБ
FROM golang:1.22-alpine
WORKDIR /src
COPY . .
RUN go build -o /bin/app ./cmd/api
EXPOSE 8080
CMD ["/bin/app"]

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

У интерпретируемых языков проблема та же, только мусор другой. В образе с PHP остаются composer и dev-пакеты из require-dev, в образе с Python - кэш pip, заголовочные файлы и компиляторы, которые понадобились, чтобы собрать колёса для psycopg или lxml. Бинарника нет, но лишние сотни мегабайт и лишние CVE есть.

Решение: multi-stage

# ---------- stage 1: builder ----------
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/app ./cmd/api

# ---------- stage 2: runtime ----------
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /bin/app /app/app
EXPOSE 8080
CMD ["/app/app"]

Из builder переносится один файл - скомпилированный бинарник. Компилятор, исходники и кэш модулей остаются в первом stage и в финальный образ не попадают.

# ---------- stage 1: зависимости ----------
FROM composer:2 AS vendor
WORKDIR /src
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-progress

# ---------- stage 2: runtime ----------
FROM php:8.3-fpm-alpine
WORKDIR /app
COPY --from=vendor /src/vendor ./vendor
COPY . .
EXPOSE 9000
CMD ["php-fpm"]

Из builder переносится готовый vendor. Сам composer в рантайме не нужен, а --no-dev оставляет за бортом PHPUnit и прочие dev-зависимости.

# ---------- stage 1: колёса ----------
FROM python:3.12-slim AS builder
WORKDIR /src
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

# ---------- stage 2: runtime ----------
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Из builder переносятся собранные колёса. Если какой-то пакет требовал компилятор (частый случай с драйверами БД), компилятор остался в первом stage.

COPY --from=builder - ключевая строка во всех трёх случаях. Мы берём из первого stage только результат сборки и кладём его в чистый образ.

Multi-stage: builder с Go-компилятором (~1 GB) собирает бинарь, runtime берёт только 15 MB

Go умеет компилировать статические бинарники без зависимости от C-библиотек. `CGO_ENABLED=0` отключает CGO - результат работает в любом Linux-контейнере, даже в scratch (пустом). Без этого флага бинарник может зависеть от glibc, которой нет в alpine/scratch.

Сколько это даёт на разных языках

Разница в выигрыше принципиальная, и знать её полезно до того, как начнёшь спорить с коллегой.

ЯзыкЧто переносимБыло / сталоПочему столько
Goодин бинарник~1 ГБ → 15 МБкомпилятор и исходники не нужны в рантайме вообще
PHPкаталог vendor~450 МБ → ~120 МБинтерпретатор нужен, уходит composer и dev-пакеты
Pythonсобранные колёса~1 ГБ → ~180 МБинтерпретатор нужен, уходят компиляторы и кэш pip

У компилируемых языков выигрыш кратный, у интерпретируемых - в разы, а не в десятки раз. Но три причины делать multi-stage остаются одинаковыми: меньше поверхность атаки, быстрее pull в CI и деплое, нет dev-зависимостей в продакшене.

Дальше в уроке идёт часть про Go: статическую компиляцию и пустые образы. Для PHP и Python эти приёмы не применяются - базовый образ с интерпретатором заменить на scratch нельзя, поэтому там выбор сводится к -slim против -alpine.

Выбор runtime-образа для компилируемых языков

<ComparisonTable title="Варианты runtime-образа" headers={["Образ", "Размер", "Shell", "Пакеты", "Когда использовать"]} rows={[ ["scratch", "0 МБ", "Нет", "Ничего", "Минимум: только бинарник"], ["alpine:3.19", "~5 МБ", "sh", "apk (минимальный набор)", "Когда нужен shell для отладки"], ["distroless", "~2 МБ", "Нет", "Минимальный runtime", "Безопасность: нет shell - нет атак"], ["ubuntu:24.04", "~70 МБ", "bash", "apt (полный набор)", "Когда нужны системные пакеты"] ]} />

scratch - пустой образ

FROM scratch
COPY --from=builder /bin/app /app
CMD ["/app"]

Образ = только твой бинарник. Плюс - минимальная поверхность атаки. Минус - нет shell, нет docker exec -it ... sh, нет CA-сертификатов (HTTPS не работает).

alpine - оптимальный баланс

FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /bin/app /app/app
CMD ["/app/app"]

Shell для отладки, CA-сертификаты для HTTPS, часовые пояса для корректных timestamp.

distroless - безопасность без shell

FROM gcr.io/distroless/static-debian12
COPY --from=builder /bin/app /app
CMD ["/app"]

Содержит CA-сертификаты и tzdata, но нет shell. Злоумышленник не может получить интерактивную сессию, даже эксплуатируя уязвимость. Обратная сторона: ты тоже не зайдёшь внутрь через docker exec sh - для таких образов есть отдельные приёмы отладки (debug-контейнер в тех же namespace-ах, :debug-тег), см. отладку контейнеров.

Если приложение делает HTTPS-запросы (к API, S3, OAuth), в scratch не будет CA-сертификатов. Решения: alpine + `ca-certificates`, distroless, или скопировать сертификаты из builder: `COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/`.

Оптимизация кэша: go mod download

# Сначала go.mod/go.sum - кэш зависимостей
COPY go.mod go.sum ./
RUN go mod download

# Потом остальной код
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/app ./cmd/api

Если изменился только код (без новых зависимостей), go mod download берётся из кэша. Экономия 30-60 секунд на каждой сборке. Это то же правило слоёв, что и для npm ci: редко меняющееся ставим выше, часто меняющееся - ниже.

Build arguments

FROM golang:1.22-alpine AS builder
ARG VERSION=dev
ARG COMMIT=unknown
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build \
 -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT}" \
 -o /bin/app ./cmd/api
docker build \
 --build-arg VERSION=1.2.3 \
 --build-arg COMMIT=$(git rev-parse --short HEAD) \
 -t myapp:1.2.3 .

-ldflags вшивает версию и хэш коммита прямо в бинарник - удобно для health endpoint и логов.

Сравнение размеров

# Собрать обе версии
docker build -f Dockerfile.single -t app:single .    # без multi-stage
docker build -f Dockerfile.multi -t app:multi .       # с multi-stage

docker images | grep app
# app   single   1.1 GB
# app   multi    15 MB    ← в 70 раз меньше
Маленький образ = быстрый pull (10 МБ vs 1 ГБ по сети), меньше поверхность атаки (меньше пакетов = меньше CVE), быстрый запуск. В CI это экономия минут на каждой сборке.

Полный production Dockerfile

# ---------- builder ----------
FROM golang:1.22-alpine AS builder
ARG VERSION=dev
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
 -ldflags "-s -w -X main.version=${VERSION}" \
 -o /bin/app ./cmd/api

# ---------- runtime ----------
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata \
  && addgroup -S app && adduser -S app -G app

WORKDIR /app
COPY --from=builder /bin/app /app/app

USER app
EXPOSE 8080
CMD ["/app/app"]

Флаги -s -w в ldflags убирают символы отладки - бинарник ещё на 20-30% меньше.

Мини-задание

  • Собери своё приложение multi-stage (Dockerfile для своего языка выше) и посмотри размер: docker images
  • Собери то же одним stage, без разделения, и сравни размеры
  • Найди в финальном образе то, чего там быть не должно: docker run --rm your-image ls -la по каталогу проекта
  • На Go: попробуй FROM scratch вместо alpine и посмотри, что сломается
  • На PHP и Python: убери --no-dev (или собери без wheels) и сравни размер снова

Итог

  • Multi-stage работает на любом языке: builder собирает, runtime получает только результат. У Go это бинарник, у PHP - vendor, у Python - установленные колёса.
  • Выигрыш разный: у компилируемых языков десятки раз, у интерпретируемых - в разы. Причины делать одинаковые: меньше уязвимостей, быстрее pull, нет dev-зависимостей в проде.
  • CGO_ENABLED=0 - статический бинарник без C-зависимостей, работает в alpine/scratch.
  • Alpine - баланс: shell для отладки, CA-сертификаты для HTTPS, 5 МБ базы.
  • scratch - минимум, distroless - безопасность. Выбирай под задачу.
  • Кэш: go.mod + go mod downloadCOPY . . → build. Build args для версии.

Типичная ошибка

Забыть CGO_ENABLED=0 и получить бинарник, который зависит от glibc. В alpine используется musl, не glibc - результат: exec format error или not found при запуске. Если видишь такую ошибку в alpine/scratch - первым делом проверь CGO_ENABLED.

Вторая ошибка - копировать в runtime stage весь /src вместо одного бинарника. COPY --from=builder /src /app - и весь исходный код в продакшен-образе.

Мини-практика (10-15 минут)

Создай минимальный Go HTTP-сервер и собери его тремя способами:

# 1. Создай приложение
mkdir /tmp/go-multi && cd /tmp/go-multi
go mod init example.com/test

cat > main.go <<'EOF'
package main

import (
    "fmt"
    "net/http"
)

var version = "dev"

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "version: %s\n", version)
    })
    http.ListenAndServe(":8080", nil)
}
EOF

# 2. Собери multi-stage (alpine runtime)
# Напиши Dockerfile, собери, запусти, проверь curl localhost:8080

# 3. Замени runtime на scratch - что произойдёт?
# 4. Добавь --build-arg VERSION=1.0.0 - проверь ответ сервера

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.