5.7. Multi-stage builds
Цели
После этого материала вы сможете:
- объяснить, какую задачу решают multi-stage builds и почему они безопаснее однослойной сборки;
- писать multi-stage
Dockerfileс именованными стадиями; - собирать конкретную стадию через
--targetи применять это для тестов и отладки; - выделять общую базовую стадию, чтобы не дублировать инструкции;
- измерять эффект и обосновывать его числами;
- назвать случаи, когда multi-stage не нужен.
Предварительные знания
- 5.3. COPY, ADD и RUN —
COPY --from; - 5.5. Build cache;
- 5.6. BuildKit — параллельная сборка стадий;
- 3.2. Слои и copy-on-write — почему удаление не помогает.
Ключевые термины
| Термин | Объяснение |
|---|---|
стадия | Часть Dockerfile, начинающаяся с FROM |
--target | Флаг сборки, останавливающий процесс на указанной стадии |
builder stage | Стадия, где выполняется сборка и остаются инструменты |
runtime stage | Финальная стадия, попадающая в образ |
базовая стадия | Общая стадия, от которой наследуются остальные |
distroless | Образ без оболочки и пакетного менеджера |
Теория
Задача
Для сборки приложения нужны компиляторы, заголовочные файлы, инструменты. Для его работы — нет. Но в однослойной сборке всё это остаётся в образе навсегда.
Попытка удалить не помогает — данные остаются в предыдущем слое (урок 3.2):
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 решает это иначе: сборка идёт в одной стадии, а в финальный образ копируется только результат.
стадия builder финальная стадия
┌──────────────────────┐ ┌──────────────────────┐
│ python:3.13-slim │ │ python:3.13-slim │
│ + build-essential │ COPY │ + только .venv │
│ + заголовки │ --from ──► │ │
│ + исходники │ │ │
│ + скомпилированное │ │ │
│ │ │ │
│ ОТБРАСЫВАЕТСЯ │ │ это и есть образ │
└──────────────────────┘ └──────────────────────┘
420 MB 160 MB
Всё, что не скопировано явно, в образ не попадает — включая историю слоёв стадии сборки.
Второй эффект: безопасность
Уменьшение размера — заметное, но не главное преимущество.
Финальный образ не содержит:
- компиляторов и
make— злоумышленник не соберёт эксплойт внутри container; - исходного кода — только скомпилированный результат;
- инструментов сборки с их уязвимостями — сканер не найдёт то, чего нет;
- секретов, использованных при сборке (при корректном разделении стадий).
Последний пункт важен: даже секрет, попавший в слой стадии сборки, не окажется в финальном образе, если эта стадия не копируется целиком. Тем не менее это не замена secret mounts (урок 5.6) — только дополнительный барьер.
Синтаксис
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
Флаг останавливает сборку на указанной стадии:
docker build --target builder -t app:builder .
Практические применения:
Отладка. Собрать стадию сборки и зайти в неё, чтобы понять, почему что-то не компилируется.
Стадия тестов. Отдельная стадия, запускающая тесты. Она не входит в финальный образ, но её можно собрать явно — и падение тестов остановит сборку.
Разные образы из одного файла. Стадия dev с инструментами отладки и стадия prod без них.
Общая базовая стадия
Дублирование инструкций между стадиями — частая проблема. Решается выделением базы:
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 безопасным (секрет остаётся в кэше сборки на машине), но ограничивает утечку через опубликованный образ.
Команды и примеры
Подготовка
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 — это делает пример реалистичным.
Однослойная сборка
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
612MB
Python 3.13
psycopg2 доступен
612 MB, из которых основная часть — инструменты сборки, не нужные при работе.
Multi-stage
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
=== сравнение ===
REPOSITORY:TAG SIZE
ms:multi 198MB
ms:single 612MB
Python 3.13
psycopg2 доступен
612 MB → 198 MB. Приложение работает идентично.
Проверим, что компиляторов действительно нет:
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
ms:single gcc: есть make: есть
ms:multi gcc: нет make: нет
Это и есть аргумент безопасности: в ms:multi невозможно скомпилировать код внутри container.
Стадия тестов
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
=== обычная сборка: стадия test пропускается ===
собрано
=== явный запуск тестов ===
=> [test 4/4] RUN pytest -q tests_app.py
Стадия test не входит в финальный образ, поэтому при обычной сборке не выполняется. Запуск через --target test делает падение тестов ошибкой сборки:
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: $?"
=> ERROR [test 4/4] RUN pytest -q tests_app.py
...
FAILED tests_app.py::test_failing - AssertionError: намеренно провальный тест
exit code: 1
Сборка упала. Это способ сделать тесты обязательной частью pipeline — подробно в разделе 15.
# убираем провальный тест
head -n -4 tests_app.py > tmp && mv tmp tests_app.py
Общая базовая стадия
Без неё инструкции дублируются:
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
С базовой стадией:
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
Python 3.13
psycopg2 доступен
uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
Общие настройки заданы один раз. Слой стадии base переиспользуется обеими наследниками — на диске он лежит однократно.
Отладка стадии сборки
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__)"
=== что есть в стадии сборки ===
gcc: /usr/bin/gcc
venv: /opt/venv
пакетов в venv: 42
=== интерактивная отладка ===
/opt/venv/lib/python3.13/site-packages/psycopg2/__init__.py
Возможность зайти в стадию сборки — главный инструмент при разборе ошибок компиляции.
Разные образы из одного файла
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
REPOSITORY:TAG SIZE
ms:prod 198MB
ms:dev 681MB
--- ipython есть только в dev ---
dev есть
prod нет
Один Dockerfile, два образа под разные задачи. Подход развивается в разделе 10.
Параллельность стадий
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}'
две независимые стадии по 4 c: 6.2 c
Не 8 секунд — стадии собирались одновременно.
Когда multi-stage не даёт выигрыша
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'
REPOSITORY:TAG SIZE
pp:multi 243MB
pp:single 246MB
Разница 3 MB. Для чистого Python без компилируемых зависимостей multi-stage усложняет Dockerfile почти без выгоды.
Вывод: применяйте по измерению, а не по правилу.
Уборка
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 и докажите результат измерениями.
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"]
Требования к результату:
- Финальный образ вдвое меньше исходного.
- В финальном образе нет
gcc,git,pytest,ruff. - Проверки (
ruff,pytest) выполняются в отдельной стадии и по-прежнему могут остановить сборку. - Приложение работает от непривилегированного пользователя.
- Есть общая базовая стадия без дублирования инструкций.
Приведите замеры размера и подтверждение каждого пункта командой.
Подсказки
Подсказка 1
Стадия проверок не должна входить в финальный образ — она нужна только при явном --target.
Подсказка 2
Для psycopg2 в финальной стадии нужна только runtime-библиотека libpq5, без libpq-dev и компилятора.
Подсказка 3
Виртуальное окружение в /opt/venv копируется одной инструкцией COPY --from.
Решение
Сначала выполните задание самостоятельно.
Показать решение
#!/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/^/ /'
Ожидаемый вывод:
═══ Сборка ═══
готово
═══ 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 она запускается явно:
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.
Проверка результата
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 |
Контрольные вопросы
На понимание:
- Почему удаление компилятора инструкцией
RUN apt-get purgeне уменьшает образ, а multi-stage — уменьшает? - Какие три преимущества, кроме размера, даёт multi-stage?
- Почему стадия, не используемая финальным образом, не собирается?
- Что происходит со слоями стадии сборки после завершения сборки?
- Почему
COPY --fromсоздаёт новый слой, а не переиспользует слой исходной стадии?
На применение:
- Как сделать так, чтобы падение тестов останавливало сборку, но тесты не попадали в образ?
- Как собрать два разных образа — для разработки и для production — из одного
Dockerfile? - Как зайти внутрь стадии сборки для отладки ошибки компиляции?
На диагностику:
- После перехода на multi-stage приложение падает с
ImportError: libpq.so.5: cannot open shared object file. Причина? - Multi-stage применён, но образ уменьшился всего на 5 MB. Стоит ли оставлять усложнение?
Краткое резюме
- Multi-stage разделяет окружение сборки и окружение выполнения; в образ попадает только скопированное.
- Кроме размера, даёт безопасность: нет компиляторов, исходников и лишних уязвимостей.
- Стадии именуются через
AS; ссылка по номеру хрупка. --targetсобирает конкретную стадию — для отладки, тестов и вариантов образа.- Стадия, не входящая в финальный образ, не собирается без явного запроса.
- Общая базовая стадия устраняет дублирование инструкций.
- Независимые стадии собираются параллельно.
- В финальной стадии нужны runtime-библиотеки, даже если заголовочные файлы не нужны.
COPY --from --chownизбавляет отRUN chown -Rи связанного copy-up.- Для чистого Python без компилируемых зависимостей выигрыш мал — проверяйте измерением.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Multi-stage builds | https://docs.docker.com/build/building/multi-stage/ | Синтаксис AS, COPY --from, --target, использование внешнего образа как источника |
| Dockerfile reference: FROM | https://docs.docker.com/reference/dockerfile/#from | Именование стадий, начало новой стадии |
| Dockerfile reference: COPY | https://docs.docker.com/reference/dockerfile/#copy | Флаг --from и его источники |
| Building best practices | https://docs.docker.com/build/building/best-practices/ | Рекомендация использовать multi-stage, общие базовые стадии |
| docker build reference | https://docs.docker.com/reference/cli/docker/buildx/build/ | Флаг --target, --no-cache-filter |
| BuildKit | https://docs.docker.com/build/buildkit/ | Параллельная сборка стадий, пропуск неиспользуемых |
Python: venv | https://docs.python.org/3/library/venv.html | Переносимость виртуального окружения между стадиями |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → HEALTHCHECK
Главное оглавление