Multi-stage builds: builder + runtime
В модуле 6 мы видели, как RUN с очисткой может уменьшить размер слоя, но всё равно остаётся проблема: build-time dependencies (gcc, build-essential, dev-headers) попадают в образ и весят сотни мегабайт. Их нельзя «удалить» в последующих RUN’ах — overlayfs создаст whiteout, файлы останутся в нижних слоях. Решение — multi-stage build: один Dockerfile, несколько «stages», финальный stage берёт только нужные артефакты из builder-stage.
В этом уроке: синтаксис multi-stage, реальный пример Python с psycopg2 (1.2GB → 180MB), и почему для Go это даёт ещё более радикальное снижение (1GB → 8MB через scratch).
VFS — виртуальная файловая система Linux
Идея multi-stage
В Dockerfile можно иметь несколько FROM. Каждый FROM начинает новый stage с чистого базового образа. Между stages можно копировать файлы через COPY --from=<stage>.
# Stage 1: builder
FROM python:3.13 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --user -r requirements.txt # установка в /root/.local/
# Stage 2: runtime
FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local # копируем установленные пакеты
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "main.py"]
Что происходит при build:
- Docker строит stage
builderцеликом (FROM python:3.13 — полный 1GB образ с компиляторами, COPY, RUN pip install). - Docker строит финальный stage из FROM python:3.13-slim (~145MB).
COPY --from=builder /root/.local /root/.localкопирует ТОЛЬКО директорию с установленными python-пакетами из builder.- Финальный образ = python:3.13-slim + установленные пакеты + код. Builder-stage в финальный образ НЕ попадает.
$ docker build -t app:v1 .
$ docker images
REPOSITORY TAG SIZE
app v1 195MB # вместо 1.2GB если бы всё было в одном stage
Builder image (промежуточный) после build удаляется — он не tag’ируется, остаётся в build cache.
Реальный пример: Python ETL с psycopg2
psycopg2 — Postgres adapter для Python — компилируется из C при pip install. Это требует gcc, libpq-dev. Без multi-stage:
# Single-stage (плохо): 1.2GB
FROM python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# build-essential остаётся в образе -- ~300MB лишнего
COPY . .
CMD ["python", "etl.py"]
Финальный размер ~480MB. build-essential нужен был только для компиляции psycopg2 — в runtime он бесполезен.
Multi-stage версия:
# Stage 1: builder
FROM python:3.13-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Установка пакетов в venv для удобного COPY
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Stage 2: runtime
FROM python:3.13-slim
# Только runtime-libs для psycopg2 -- libpq5, не libpq-dev
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Копируем venv с уже установленным psycopg2
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY . .
CMD ["python", "etl.py"]
Результат:
$ docker build -t etl:v1 .
$ docker images etl
REPOSITORY TAG SIZE
etl v1 180MB # вместо 480MB
300MB экономии. Builder использует полный slim + build-essential для компиляции, в runtime попадает только venv с готовыми .so файлами и runtime libpq5.
COPY —from: между stages
COPY --from=<stage> копирует файлы из другого stage:
FROM python:3.13 AS builder
# ... что-то собираем ...
FROM python:3.13-slim
COPY --from=builder /build/dist/wheel.whl /tmp/
COPY --from=builder /usr/lib/x86_64-linux-gnu/libpq.so.5 /usr/lib/x86_64-linux-gnu/
<stage> это имя из FROM ... AS <name> или индекс (0-based) если без имени.
Можно копировать из любого образа, не только stage:
FROM python:3.13-slim
# Копируем готовый бинарь из чужого образа
COPY --from=hashicorp/terraform:1.7 /bin/terraform /usr/local/bin/terraform
Это полезно для встраивания CLI-инструментов (terraform, kubectl, aws-cli) без apt install / curl.
Несколько builder-stages
Multi-stage не ограничено двумя:
# Stage 1: общие deps
FROM python:3.13 AS common-deps
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev && \
rm -rf /var/lib/apt/lists/*
# Stage 2: основное приложение
FROM common-deps AS app-builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage 3: тесты (опциональный)
FROM common-deps AS test-builder
WORKDIR /build
COPY requirements-test.txt .
RUN pip install --user --no-cache-dir -r requirements-test.txt
COPY . .
RUN pytest tests/
# Stage 4: финальный runtime
FROM python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && \
rm -rf /var/lib/apt/lists/*
COPY --from=app-builder /root/.local /root/.local
COPY . /app
ENV PATH=/root/.local/bin:$PATH
WORKDIR /app
CMD ["python", "main.py"]
Это позволяет:
- Шарить общие шаги (common-deps) между stages
- Отдельный test-stage для CI (можно собрать только до этого:
docker build --target test-builder) - Финальный slim runtime
Цель сборки: —target
Флаг --target <stage> собирает только до указанного stage:
# Собрать только builder-stage (для отладки)
docker build --target builder -t app:builder .
# Собрать тесты (но не финальный runtime)
docker build --target test-builder -t app:test .
# Полный build (default = последний FROM)
docker build -t app:v1 .
В CI это используется для разделения этапов:
docker build --target test-builder→ запустить тестыdocker build(полный) → собрать production-образ
Go: scratch как финальный base
Для статически слинкованных бинарников (Go, Rust с static linking) можно использовать FROM scratch — абсолютно пустой образ. Никакого OS, никакого shell, только бинарник.
# Stage 1: builder
FROM golang:1.23 AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app ./cmd/etl
# Stage 2: scratch runtime
FROM scratch
COPY --from=builder /app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]
$ docker build -t etl-go:v1 .
$ docker images etl-go
REPOSITORY TAG SIZE
etl-go v1 8.2MB # !
8 МБ. Для сравнения: тот же сервис на Python — 180MB. На Java — 250-400MB. Go scratch это золотой стандарт для micro-сервисов.
Ограничения scratch:
- Нет shell —
docker exec image bashне работает - Нет ca-certificates по умолчанию — для HTTPS нужно скопировать через COPY
- Нет /tmp по умолчанию (если приложение пишет в /tmp — создавать через WORKDIR /tmp)
- Сложно отлаживать в runtime (нет debug tools)
Альтернатива scratch — distroless от Google:
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
Distroless ~5-15MB, имеет ca-certificates, glibc для динамически слинкованных бинарников, минимальный /tmp. Без shell, но более удобный чем scratch.
Когда multi-stage не нужен
Не каждый Dockerfile должен быть multi-stage. Если:
- Образ уже маленький (меньше 200MB) и нет компиляторов
- Базовый образ slim/alpine не имеет build-deps по умолчанию
- Pip ставит только wheels без компиляции
— то single-stage Dockerfile в порядке. Multi-stage добавляет сложности, и если эффект меньше 50MB, овчинка не стоит выделки.
Где multi-stage обязателен:
- Любая компиляция (psycopg2, numpy на Alpine, C-extensions)
- Сборка фронта (npm build) перед serve
- Go, Rust → scratch / distroless
- Java → builder со всем JDK + runtime с только JRE
Best practices
1. Используй AS name, а не индексы.
FROM python:3.13 AS builder # хорошо
FROM python:3.13-slim AS runtime
# Не:
FROM python:3.13
FROM python:3.13-slim
COPY --from=0 ... # хрупко: добавление нового stage сместит индексы
2. Builder может быть «жирным», runtime — слим.
FROM python:3.13 AS builder # full image, есть gcc
# ...
FROM python:3.13-slim # slim, минимальный
# ...
3. Используй venv для удобного COPY.
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install -r requirements.txt
# В runtime:
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
venv делает «весь Python» переносимым через одно COPY.
4. Pin builder и runtime под одну Python-версию.
FROM python:3.13 AS builder # 3.13
FROM python:3.13-slim # тоже 3.13 -- venv совместим
Если builder Python 3.12 и runtime Python 3.13 — venv может работать неправильно (разные пути в site-packages, разные ABI).
Попробуй сам
Сравни single-stage и multi-stage версии одного приложения:
mkdir multistage-demo && cd multistage-demo
cat > requirements.txt <<'EOF'
psycopg2==2.9.10
pandas==2.2.3
EOF
# Single-stage
cat > Dockerfile.single <<'EOF'
FROM python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
EOF
# Multi-stage
cat > Dockerfile.multi <<'EOF'
FROM python:3.13-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
EOF
# Build обоих
docker build -f Dockerfile.single -t demo:single .
docker build -f Dockerfile.multi -t demo:multi .
# Сравнение
docker images demo
# REPOSITORY TAG SIZE
# demo single ~500MB
# demo multi ~210MB # экономия ~300MB
cd .. && rm -rf multistage-demo
docker rmi demo:single demo:multi