Главная/Dockerfile/Урок

5.7. Multi-stage builds

Цели

После этого материала вы сможете:

  • объяснить, какую задачу решают multi-stage builds и почему они безопаснее однослойной сборки;
  • писать multi-stage Dockerfile с именованными стадиями;
  • собирать конкретную стадию через --target и применять это для тестов и отладки;
  • выделять общую базовую стадию, чтобы не дублировать инструкции;
  • измерять эффект и обосновывать его числами;
  • назвать случаи, когда multi-stage не нужен.

Предварительные знания

Ключевые термины

ТерминОбъяснение
стадияЧасть Dockerfile, начинающаяся с FROM
--targetФлаг сборки, останавливающий процесс на указанной стадии
builder stageСтадия, где выполняется сборка и остаются инструменты
runtime stageФинальная стадия, попадающая в образ
базовая стадияОбщая стадия, от которой наследуются остальные
distrolessОбраз без оболочки и пакетного менеджера

Теория

Задача

Для сборки приложения нужны компиляторы, заголовочные файлы, инструменты. Для его работы — нет. Но в однослойной сборке всё это остаётся в образе навсегда.

Попытка удалить не помогает — данные остаются в предыдущем слое (урок 3.2):

dockerfile
FROM python:3.13-slim
RUN apt-get update && apt-get install -y build-essential   # +180 MB
RUN pip install psycopg2
RUN apt-get purge -y build-essential                       # слой не уменьшился

Multi-stage решает это иначе: сборка идёт в одной стадии, а в финальный образ копируется только результат.

text
   стадия builder                        финальная стадия
   ┌──────────────────────┐              ┌──────────────────────┐
   │ python:3.13-slim     │              │ python:3.13-slim     │
   │ + build-essential    │   COPY       │ + только .venv       │
   │ + заголовки          │  --from ──►  │                      │
   │ + исходники          │              │                      │
   │ + скомпилированное   │              │                      │
   │                      │              │                      │
   │  ОТБРАСЫВАЕТСЯ       │              │  это и есть образ    │
   └──────────────────────┘              └──────────────────────┘
        420 MB                                  160 MB

Всё, что не скопировано явно, в образ не попадает — включая историю слоёв стадии сборки.

Второй эффект: безопасность

Уменьшение размера — заметное, но не главное преимущество.

Финальный образ не содержит:

  • компиляторов и make — злоумышленник не соберёт эксплойт внутри container;
  • исходного кода — только скомпилированный результат;
  • инструментов сборки с их уязвимостями — сканер не найдёт то, чего нет;
  • секретов, использованных при сборке (при корректном разделении стадий).

Последний пункт важен: даже секрет, попавший в слой стадии сборки, не окажется в финальном образе, если эта стадия не копируется целиком. Тем не менее это не замена secret mounts (урок 5.6) — только дополнительный барьер.

Синтаксис

dockerfile
FROM python:3.13-slim AS builder
# ... сборка ...

FROM python:3.13-slim
COPY --from=builder /app/.venv /app/.venv

Стадии можно именовать через AS и ссылаться по имени. Без имени — по порядковому номеру начиная с нуля, но это хрупко: вставка стадии сдвинет нумерацию.

Источником --from может быть:

ИсточникПример
Именованная стадияCOPY --from=builder /app /app
Номер стадииCOPY --from=0 /app /app
Внешний образCOPY --from=ghcr.io/astral-sh/uv:0.12.0 /uv /bin/

--target

Флаг останавливает сборку на указанной стадии:

bash
docker build --target builder -t app:builder .

Практические применения:

Отладка. Собрать стадию сборки и зайти в неё, чтобы понять, почему что-то не компилируется.

Стадия тестов. Отдельная стадия, запускающая тесты. Она не входит в финальный образ, но её можно собрать явно — и падение тестов остановит сборку.

Разные образы из одного файла. Стадия dev с инструментами отладки и стадия prod без них.

Общая базовая стадия

Дублирование инструкций между стадиями — частая проблема. Решается выделением базы:

dockerfile
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser

FROM base AS builder
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
 && rm -rf /var/lib/apt/lists/*
# ...

FROM base AS runtime
COPY --from=builder /app/.venv /app/.venv
USER 10001:10001

Стадия base собирается один раз и переиспользуется. Изменение общих настроек делается в одном месте.

Порядок стадий и параллельность

BuildKit собирает независимые стадии параллельно (урок 5.6). Но стадии, зависящие друг от друга через COPY --from, выполняются последовательно.

Практическое следствие: если стадии lint и test независимы, они пойдут одновременно. Если test копирует результат из build, придётся ждать.

Когда multi-stage не нужен

СитуацияПочему
Чистый Python без компилируемых зависимостейКомпиляторы не нужны; выигрыш околонулевой
Приложение уже собрано вне DockerНечего разделять
Образ и так минимальныйУсложнение без выгоды
Отладочный образИнструменты нужны внутри

Для типичного FastAPI-приложения на чистых Python-пакетах (без psycopg2, lxml, научных библиотек) multi-stage даёт единицы мегабайт. Проверяйте измерением, а не по привычке.


Внутренний механизм

Что происходит со стадиями сборки

Каждая стадия — полноценный образ со своими слоями. Он существует в кэше BuildKit, но:

  • не получает тега;
  • не отображается в docker images без --all;
  • не публикуется при docker push;
  • удаляется командой docker builder prune.

Финальный образ содержит только слои последней стадии плюс слои, созданные инструкциями COPY --from.

Важно: COPY --from создаёт новый слой в финальном образе. Он не переиспользует слой исходной стадии — данные копируются физически.

Почему история стадий не попадает в образ

Финальный образ имеет собственную конфигурацию, где history содержит только шаги последней стадии. Шаги стадии сборки в него не входят.

Отсюда следствие для безопасности: docker history финального образа не покажет команды стадии сборки — включая те, где мог передаваться секрет через ARG. Это не делает ARG безопасным (секрет остаётся в кэше сборки на машине), но ограничивает утечку через опубликованный образ.


Команды и примеры

Подготовка

bash
mkdir -p /tmp/multistage/app && cd /tmp/multistage

cat > requirements.txt <<'EOF'
fastapi==0.141.1
uvicorn[standard]==0.52.0
pydantic==2.13.4
psycopg2==2.9.11
EOF

cat > app/main.py <<'PY'
import sys

print(f"Python {sys.version_info.major}.{sys.version_info.minor}")
try:
    import psycopg2
    print("psycopg2 доступен")
except ImportError:
    print("psycopg2 НЕ доступен")
PY

cat > .dockerignore <<'EOF'
Dockerfile*
.dockerignore
__pycache__
*.pyc
EOF

Пакет psycopg2 (не psycopg2-binary) компилируется из исходников и требует build-essential и libpq-dev — это делает пример реалистичным.

Однослойная сборка

bash
cat > Dockerfile.single <<'EOF'
FROM python:3.13-slim
WORKDIR /app

RUN apt-get update \
 && apt-get install -y --no-install-recommends build-essential libpq-dev \
 && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF

docker build -q -f Dockerfile.single -t ms:single . > /dev/null
docker images ms:single --format '{{.Size}}'
docker run --rm ms:single
text
612MB
Python 3.13
psycopg2 доступен

612 MB, из которых основная часть — инструменты сборки, не нужные при работе.

Multi-stage

bash
cat > Dockerfile.multi <<'EOF'
# syntax=docker/dockerfile:1

# ── Стадия сборки: здесь остаются компиляторы ──
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 /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# ── Финальная стадия: только runtime ──
FROM python:3.13-slim

# libpq5 — runtime-библиотека для psycopg2, без заголовков и компилятора
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" \
    PYTHONUNBUFFERED=1

WORKDIR /app
COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF

docker build -q -f Dockerfile.multi -t ms:multi . > /dev/null

echo "=== сравнение ==="
docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | grep -E 'ms:|REPO'
echo
docker run --rm ms:multi
text
=== сравнение ===
REPOSITORY:TAG   SIZE
ms:multi         198MB
ms:single        612MB

Python 3.13
psycopg2 доступен

612 MB → 198 MB. Приложение работает идентично.

Проверим, что компиляторов действительно нет:

bash
for img in ms:single ms:multi; do
    printf '%-12s gcc: %s   make: %s\n' "$img" \
        "$(docker run --rm "$img" sh -c 'command -v gcc >/dev/null && echo есть || echo нет')" \
        "$(docker run --rm "$img" sh -c 'command -v make >/dev/null && echo есть || echo нет')"
done
text
ms:single    gcc: есть   make: есть
ms:multi     gcc: нет    make: нет

Это и есть аргумент безопасности: в ms:multi невозможно скомпилировать код внутри container.

Стадия тестов

bash
cat > tests_app.py <<'PY'
def test_import():
    import psycopg2  # noqa: F401


def test_math():
    assert 2 + 2 == 4
PY

cat > Dockerfile.test <<'EOF'
# syntax=docker/dockerfile:1

FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app

FROM base 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"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

# ── Стадия тестов: не входит в финальный образ ──
FROM builder AS test
RUN --mount=type=cache,target=/root/.cache/pip pip install pytest==9.1.1
COPY app/ ./app/
COPY tests_app.py .
RUN pytest -q tests_app.py

# ── Финальная стадия ──
FROM base AS runtime
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"
COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF

echo "=== обычная сборка: стадия test пропускается ==="
docker build -q -f Dockerfile.test -t ms:runtime . > /dev/null && echo "собрано"

echo
echo "=== явный запуск тестов ==="
docker build --target test -f Dockerfile.test -t ms:test . 2>&1 | grep -E 'pytest|passed|DONE' | tail -3
text
=== обычная сборка: стадия test пропускается ===
собрано
=== явный запуск тестов ===
 => [test 4/4] RUN pytest -q tests_app.py

Стадия test не входит в финальный образ, поэтому при обычной сборке не выполняется. Запуск через --target test делает падение тестов ошибкой сборки:

bash
cat >> tests_app.py <<'PY'


def test_failing():
    assert 1 == 2, "намеренно провальный тест"
PY

docker build --target test -f Dockerfile.test -t ms:test . 2>&1 | tail -4
echo "exit code: $?"
text
 => ERROR [test 4/4] RUN pytest -q tests_app.py
...
FAILED tests_app.py::test_failing - AssertionError: намеренно провальный тест
exit code: 1

Сборка упала. Это способ сделать тесты обязательной частью pipeline — подробно в разделе 15.

bash
# убираем провальный тест
head -n -4 tests_app.py > tmp && mv tmp tests_app.py

Общая базовая стадия

Без неё инструкции дублируются:

bash
cat > Dockerfile.dup <<'EOF'
FROM python:3.13-slim AS builder
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
# ... сборка ...

FROM python:3.13-slim AS runtime
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
# ... runtime ...
CMD ["true"]
EOF

С базовой стадией:

bash
cat > Dockerfile.base <<'EOF'
# syntax=docker/dockerfile:1

FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser

FROM base 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"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

FROM base AS runtime
RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 \
 && rm -rf /var/lib/apt/lists/*
COPY --from=builder --chown=appuser:appuser /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY --chown=appuser:appuser app/ ./app/
USER 10001:10001
CMD ["python", "app/main.py"]
EOF

docker build -q -f Dockerfile.base -t ms:base . > /dev/null
docker run --rm ms:base
docker run --rm ms:base id
text
Python 3.13
psycopg2 доступен
uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)

Общие настройки заданы один раз. Слой стадии base переиспользуется обеими наследниками — на диске он лежит однократно.

Отладка стадии сборки

bash
docker build -q --target builder -f Dockerfile.base -t ms:builder . > /dev/null

echo "=== что есть в стадии сборки ==="
docker run --rm ms:builder sh -c '
    echo -n "gcc: "; command -v gcc || echo нет
    echo -n "venv: "; ls -d /opt/venv 2>/dev/null || echo нет
    echo -n "пакетов в venv: "; ls /opt/venv/lib/python3.13/site-packages | wc -l
'

echo
echo "=== интерактивная отладка ==="
docker run --rm ms:builder python -c "import psycopg2; print(psycopg2.__file__)"
text
=== что есть в стадии сборки ===
gcc: /usr/bin/gcc
venv: /opt/venv
пакетов в venv: 42
=== интерактивная отладка ===
/opt/venv/lib/python3.13/site-packages/psycopg2/__init__.py

Возможность зайти в стадию сборки — главный инструмент при разборе ошибок компиляции.

Разные образы из одного файла

bash
cat > Dockerfile.variants <<'EOF'
# syntax=docker/dockerfile:1

FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1
WORKDIR /app

FROM base 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"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

# ── Вариант для разработки: с инструментами отладки ──
FROM builder AS dev
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install ipython==9.7.0 pytest==9.1.1
COPY app/ ./app/
CMD ["python", "app/main.py"]

# ── Вариант для production: минимальный ──
FROM base AS prod
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"
COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF

docker build -q --target dev  -f Dockerfile.variants -t ms:dev  . > /dev/null
docker build -q --target prod -f Dockerfile.variants -t ms:prod . > /dev/null

docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | grep -E 'ms:dev|ms:prod|REPO'

echo
echo "--- ipython есть только в dev ---"
for t in dev prod; do
    printf '%-8s %s\n' "$t" \
        "$(docker run --rm "ms:$t" sh -c 'command -v ipython >/dev/null && echo "есть" || echo "нет"')"
done
text
REPOSITORY:TAG   SIZE
ms:prod          198MB
ms:dev           681MB

--- ipython есть только в dev ---
dev      есть
prod     нет

Один Dockerfile, два образа под разные задачи. Подход развивается в разделе 10.

Параллельность стадий

bash
cat > Dockerfile.parallel <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app

FROM base AS deps-a
RUN sleep 4 && echo "A" > /a.txt

FROM base AS deps-b
RUN sleep 4 && echo "B" > /b.txt

FROM base AS final
COPY --from=deps-a /a.txt /
COPY --from=deps-b /b.txt /
CMD ["sh", "-c", "cat /a.txt /b.txt"]
EOF

s="$(date +%s.%N)"
docker build -q --no-cache -f Dockerfile.parallel -t ms:par . > /dev/null
e="$(date +%s.%N)"
awk -v a="$s" -v b="$e" 'BEGIN{printf "две независимые стадии по 4 c: %.1f c\n", b-a}'
text
две независимые стадии по 4 c: 6.2 c

Не 8 секунд — стадии собирались одновременно.

Когда multi-stage не даёт выигрыша

bash
mkdir -p /tmp/pure-python/app && cd /tmp/pure-python
cat > requirements.txt <<'EOF'
fastapi==0.141.1
uvicorn[standard]==0.52.0
pydantic==2.13.4
EOF
echo "print('чистый Python')" > app/main.py
cat > .dockerignore <<'EOF'
Dockerfile*
.dockerignore
EOF

cat > Dockerfile.single <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF

cat > Dockerfile.multi <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

FROM python:3.13-slim
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF

docker build -q -f Dockerfile.single -t pp:single . > /dev/null
docker build -q -f Dockerfile.multi  -t pp:multi  . > /dev/null
docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | grep -E 'pp:|REPO'
text
REPOSITORY:TAG   SIZE
pp:multi         243MB
pp:single        246MB

Разница 3 MB. Для чистого Python без компилируемых зависимостей multi-stage усложняет Dockerfile почти без выгоды.

Вывод: применяйте по измерению, а не по правилу.

Уборка

bash
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=ms:*') 2>/dev/null || true
docker rmi -f pp:single pp:multi 2>/dev/null || true
rm -rf /tmp/multistage /tmp/pure-python

Практическое упражнение

Задание. Дан однослойный Dockerfile Python-приложения с компилируемыми зависимостями. Преобразуйте его в multi-stage и докажите результат измерениями.

dockerfile
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y build-essential libpq-dev git curl
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install pytest ruff
RUN ruff check app/
RUN pytest tests/
CMD ["python", "app/main.py"]

Требования к результату:

  1. Финальный образ вдвое меньше исходного.
  2. В финальном образе нет gcc, git, pytest, ruff.
  3. Проверки (ruff, pytest) выполняются в отдельной стадии и по-прежнему могут остановить сборку.
  4. Приложение работает от непривилегированного пользователя.
  5. Есть общая базовая стадия без дублирования инструкций.

Приведите замеры размера и подтверждение каждого пункта командой.

Подсказки

Подсказка 1

Стадия проверок не должна входить в финальный образ — она нужна только при явном --target.

Подсказка 2

Для psycopg2 в финальной стадии нужна только runtime-библиотека libpq5, без libpq-dev и компилятора.

Подсказка 3

Виртуальное окружение в /opt/venv копируется одной инструкцией COPY --from.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
#!/usr/bin/env bash
# multistage-convert.sh — преобразование в multi-stage с проверками.
set -uo pipefail

WORK="$(mktemp -d)"
trap 'docker rmi -f $(docker images -q --filter "reference=conv:*") >/dev/null 2>&1 || true;
      rm -rf "$WORK"' EXIT
cd "$WORK"

mkdir -p app tests
cat > requirements.txt <<'EOF'
fastapi==0.141.1
uvicorn[standard]==0.52.0
psycopg2==2.9.11
EOF

cat > app/main.py <<'PY'
"""Точка входа приложения."""
import sys


def main() -> int:
    print(f"Python {sys.version_info.major}.{sys.version_info.minor}")
    import psycopg2

    print(f"psycopg2 {psycopg2.__version__.split()[0]}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
PY

cat > tests/test_app.py <<'PY'
def test_import_psycopg2():
    import psycopg2  # noqa: F401


def test_arithmetic():
    assert sum(range(5)) == 10
PY

cat > .dockerignore <<'EOF'
Dockerfile*
.dockerignore
__pycache__
*.pyc
.pytest_cache
.ruff_cache
EOF

# ── Исходный вариант ──
cat > Dockerfile.before <<'EOF'
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y build-essential libpq-dev git curl
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install pytest ruff
RUN ruff check app/
RUN pytest tests/
CMD ["python", "app/main.py"]
EOF

# ── Multi-stage ──
cat > Dockerfile.after <<'EOF'
# syntax=docker/dockerfile:1

# ── Общая база: настройки и пользователь, без дублирования ──
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser

# ── Сборка: компиляторы остаются здесь ──
FROM base 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
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# ── Проверки: не входят в финальный образ ──
FROM builder AS check
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install pytest==9.1.1 ruff==0.16.0
COPY app/ ./app/
COPY tests/ ./tests/
RUN ruff check app/
RUN pytest -q tests/

# ── Финальный образ: только runtime ──
FROM base AS runtime
RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 \
 && rm -rf /var/lib/apt/lists/*
COPY --from=builder --chown=appuser:appuser /opt/venv /opt/venv
COPY --chown=appuser:appuser app/ ./app/
USER 10001:10001
CMD ["python", "app/main.py"]
EOF

echo "═══ Сборка ═══"
docker build -q -f Dockerfile.before -t conv:before . > /dev/null
docker build -q -f Dockerfile.after  -t conv:after  . > /dev/null
echo "готово"

echo
echo "═══ 1. Размер ═══"
docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | grep -E 'conv:|REPO'
before_b="$(docker image inspect conv:before --format '{{.Size}}')"
after_b="$(docker image inspect conv:after --format '{{.Size}}')"
awk -v a="$before_b" -v b="$after_b" \
    'BEGIN{printf "  сокращение: %.0f%%\n", (1-b/a)*100}'

echo
echo "═══ 2. Инструменты сборки в финальном образе ═══"
for tool in gcc git pytest ruff; do
    printf '  %-8s before: %-6s after: %s\n' "$tool" \
        "$(docker run --rm conv:before sh -c "command -v $tool >/dev/null && echo есть || echo нет")" \
        "$(docker run --rm --entrypoint sh conv:after -c "command -v $tool >/dev/null && echo есть || echo нет")"
done

echo
echo "═══ 3. Проверки останавливают сборку ═══"
echo -n "  корректный код: "
docker build -q --target check -f Dockerfile.after -t conv:check . > /dev/null 2>&1 \
    && echo "сборка прошла" || echo "сборка упала"

cat >> tests/test_app.py <<'PY'


def test_intentionally_broken():
    assert 1 == 2
PY
echo -n "  провальный тест: "
docker build -q --target check -f Dockerfile.after -t conv:check . > /dev/null 2>&1 \
    && echo "сборка прошла (ОШИБКА: тесты не блокируют)" || echo "сборка упала — тесты работают"
head -n -4 tests/test_app.py > t && mv t tests/test_app.py

echo
echo "═══ 4. Непривилегированный пользователь ═══"
printf '  before: %s\n' "$(docker run --rm --entrypoint id conv:before -un 2>/dev/null || echo root)"
printf '  after:  %s\n' "$(docker run --rm --entrypoint id conv:after -un)"

echo
echo "═══ 5. Приложение работает ═══"
docker run --rm conv:after | sed 's/^/  /'

Ожидаемый вывод:

text
═══ Сборка ═══
готово

═══ 1. Размер ═══
REPOSITORY:TAG   SIZE
conv:after       201MB
conv:before      689MB
  сокращение: 71%

═══ 2. Инструменты сборки в финальном образе ═══
  gcc      before: есть   after: нет
  git      before: есть   after: нет
  pytest   before: есть   after: нет
  ruff     before: есть   after: нет

═══ 3. Проверки останавливают сборку ═══
  корректный код: сборка прошла
  провальный тест: сборка упала — тесты работают

═══ 4. Непривилегированный пользователь ═══
  before: root
  after:  appuser

═══ 5. Приложение работает ═══
  Python 3.13
  psycopg2 2.9.11

Разбор решения.

Сокращение на 71 % складывается из трёх источников: build-essential с зависимостями (~180 MB), git и curl (~40 MB), pytest с ruff и их зависимостями (~30 MB), плюс отсутствие исходников тестов и кэша pip.

Стадия check — ключевое проектное решение. Она наследуется от builder, поэтому переиспользует установленные зависимости, но не входит в цепочку финального образа. При обычной сборке BuildKit её пропускает; в CI она запускается явно:

bash
docker build --target check -t app:check .   # проверки
docker build -t app:latest .                  # публикуемый образ

Падение тестов останавливает первую команду, и pipeline не доходит до публикации.

Общая база base устраняет дублирование ENV, WORKDIR и создания пользователя. Обратите внимание, что PATH с /opt/venv/bin задан именно там — он нужен и builder, и runtime.

Пользователь создаётся в base, а USER переключается только в runtime. Это важно: в стадии builder нужны права root для apt-get. Переключение в конце — общее правило (урок 5.2).

Флаг --chown у COPY --from избавляет от отдельной инструкции RUN chown -R, которая продублировала бы всё виртуальное окружение из-за copy-up (урок 5.3).

Что можно улучшить дальше. Финальный образ всё ещё основан на python:3.13-slim (~126 MB). Для дальнейшего сокращения существуют distroless-образы без оболочки и пакетного менеджера — но они усложняют отладку, и этот компромисс разбирается в разделе 11.

Проверка результата

bash
mkdir -p /tmp/vms && cd /tmp/vms
printf 'FROM alpine:3.21 AS builder\nRUN echo "артефакт" > /result.txt\nRUN apk add --no-cache gcc\n\nFROM alpine:3.21\nCOPY --from=builder /result.txt /\nCMD ["cat","/result.txt"]\n' > Dockerfile
docker build -q -t vms:1 . > /dev/null
docker run --rm vms:1
docker run --rm vms:1 sh -c 'command -v gcc >/dev/null && echo "gcc есть" || echo "gcc отсутствует"'
docker rmi -f vms:1 > /dev/null; cd /tmp && rm -rf /tmp/vms

Ожидается вывод артефакта и отсутствие gcc — он остался в отброшенной стадии.

Типичные ошибки

ОшибкаПричинаИсправление
Ссылка на стадию по номеруКорочеВставка стадии сдвинет нумерацию; использовать AS <имя>
Копирование всей стадии сборкиКажется прощеТеряется смысл разделения; копировать только артефакты
Забыты runtime-библиотекиВ стадии сборки они былиlibpq5 для psycopg2, libxml2 для lxml и подобные
Дублирование инструкций между стадиямиНе выделена базаОбщая стадия base, от которой наследуются остальные
USER в стадии сборкиКопируют из финальнойapt-get требует root; переключаться в конце
RUN chown -R после COPY --fromПривычкаCopy-up дублирует данные; использовать COPY --chown
Стадия тестов в цепочке финального образаКажется, что так надёжнееТесты попадут в образ; выносить в отдельную стадию и запускать через --target
Multi-stage для чистого PythonПрименяют по правилуВыигрыш околонулевой; проверять измерением
Ожидание, что стадия сборки исчезнет из кэшаЕё нет в docker imagesОна в кэше BuildKit; чистится docker builder prune
Секрет в стадии сборки считают безопаснымОн не попал в финальный образОстаётся в кэше на машине сборки; использовать secret mounts

Контрольные вопросы

На понимание:

  1. Почему удаление компилятора инструкцией RUN apt-get purge не уменьшает образ, а multi-stage — уменьшает?
  2. Какие три преимущества, кроме размера, даёт multi-stage?
  3. Почему стадия, не используемая финальным образом, не собирается?
  4. Что происходит со слоями стадии сборки после завершения сборки?
  5. Почему COPY --from создаёт новый слой, а не переиспользует слой исходной стадии?

На применение:

  1. Как сделать так, чтобы падение тестов останавливало сборку, но тесты не попадали в образ?
  2. Как собрать два разных образа — для разработки и для production — из одного Dockerfile?
  3. Как зайти внутрь стадии сборки для отладки ошибки компиляции?

На диагностику:

  1. После перехода на multi-stage приложение падает с ImportError: libpq.so.5: cannot open shared object file. Причина?
  2. Multi-stage применён, но образ уменьшился всего на 5 MB. Стоит ли оставлять усложнение?

Краткое резюме

  1. Multi-stage разделяет окружение сборки и окружение выполнения; в образ попадает только скопированное.
  2. Кроме размера, даёт безопасность: нет компиляторов, исходников и лишних уязвимостей.
  3. Стадии именуются через AS; ссылка по номеру хрупка.
  4. --target собирает конкретную стадию — для отладки, тестов и вариантов образа.
  5. Стадия, не входящая в финальный образ, не собирается без явного запроса.
  6. Общая базовая стадия устраняет дублирование инструкций.
  7. Независимые стадии собираются параллельно.
  8. В финальной стадии нужны runtime-библиотеки, даже если заголовочные файлы не нужны.
  9. COPY --from --chown избавляет от RUN chown -R и связанного copy-up.
  10. Для чистого Python без компилируемых зависимостей выигрыш мал — проверяйте измерением.

Официальные источники

ИсточникСсылкаЧто подтверждает
Multi-stage buildshttps://docs.docker.com/build/building/multi-stage/Синтаксис AS, COPY --from, --target, использование внешнего образа как источника
Dockerfile reference: FROMhttps://docs.docker.com/reference/dockerfile/#fromИменование стадий, начало новой стадии
Dockerfile reference: COPYhttps://docs.docker.com/reference/dockerfile/#copyФлаг --from и его источники
Building best practiceshttps://docs.docker.com/build/building/best-practices/Рекомендация использовать multi-stage, общие базовые стадии
docker build referencehttps://docs.docker.com/reference/cli/docker/buildx/build/Флаг --target, --no-cache-filter
BuildKithttps://docs.docker.com/build/buildkit/Параллельная сборка стадий, пропуск неиспользуемых
Python: venvhttps://docs.python.org/3/library/venv.htmlПереносимость виртуального окружения между стадиями

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → HEALTHCHECK
Главное оглавление

Markdown на GitHub ↗