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 только результат сборки и кладём его в чистый образ.
Сколько это даёт на разных языках
Разница в выигрыше принципиальная, и знать её полезно до того, как начнёшь спорить с коллегой.
| Язык | Что переносим | Было / стало | Почему столько |
|---|---|---|---|
| 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-тег), см. отладку контейнеров.
Оптимизация кэша: 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 раз меньше
Полный 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 download→COPY . .→ 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 - проверь ответ сервера