Learning Platform
Глоссарий Troubleshooting
Урок 09.01 · 24 мин
Средний
dockerdockerfilemulti-stageoptimizationimage-size

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:

  1. Docker строит stage builder целиком (FROM python:3.13 — полный 1GB образ с компиляторами, COPY, RUN pip install).
  2. Docker строит финальный stage из FROM python:3.13-slim (~145MB).
  3. COPY --from=builder /root/.local /root/.local копирует ТОЛЬКО директорию с установленными python-пакетами из builder.
  4. Финальный образ = 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.

Multi-stage: builder + runtime
Builder с компиляторами; runtime со slim base + только артефакты
Builder StageStage 1 (builder): полный python:3.13 (~1GB) с gcc, build-essential, dev-headers. pip install компилирует psycopg2, numpy и т.п.
COPY --from=builder
Runtime StageStage 2 (runtime): python:3.13-slim (~145MB) без компиляторов. Получает только готовые wheels/site-packages из builder.
Image size195MBФинальный образ содержит только runtime stage. Builder stage не попадает в image, но остаётся в 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

Проверка знанийKnowledge check
Команда строит Dockerfile с multi-stage: builder использует python:3.13 для компиляции psycopg2, runtime использует python:3.13-slim. В builder: RUN python -m venv /opt/venv; pip install psycopg2. В runtime: COPY --from=builder /opt/venv /opt/venv. При запуске контейнер падает с 'libpq.so.5: cannot open shared object file'. Что не учтено и как исправить?
ОтветAnswer
Psycopg2 это C-extension, скомпилированный против libpq-dev в builder-stage. Он динамически линкуется с libpq.so.5 в runtime. Builder-stage имел libpq-dev из apt install, поэтому всё работало. Runtime python:3.13-slim не содержит libpq.so.5 -- венв скопирован, но shared library отсутствует. Решение: в runtime-stage установить libpq5 (не libpq-dev, нужен только runtime!): RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*. Общее правило: при multi-stage builder ставит -dev пакеты для компиляции; runtime ставит без -dev суффикса для shared libs.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что такое multi-stage build в Dockerfile?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5