Главная/Development workflow/Урок

10.1. Development и production образы

Цели

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

  • назвать, чем требования к dev- и production-образу различаются по существу;
  • организовать оба в одном Dockerfile через стадии, не дублируя код;
  • выбрать стадию через --target и compose.override.yaml;
  • доказать, что инструменты разработки не попали в production-образ;
  • объяснить, почему стадия dev ничего не стоит при сборке production;
  • понимать, когда разделение всё же требует двух файлов.

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

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

ТерминОбъяснение
стадияИменованный этап сборки: FROM ... AS имя
--targetФлаг: собрать до указанной стадии
baseОбщая стадия-предок для dev и production
поверхность атакиСовокупность установленного, что может содержать уязвимость

Теория

Требования различаются по существу

СвойствоDevelopmentProduction
РазмерНе важенВажен: скачивание, хранение, запуск
Скорость пересборкиКритичнаНе важна
Инструменты отладкиНужныНе нужны и вредны
КомпиляторыЧасто нужныНе нужны
Исходный код в образеПриходит с hostОбязан быть внутри
ПользовательUID разработчикаФиксированный non-root
Права на записьШиреМинимальные
ВоспроизводимостьЖелательнаОбязательна
Поверхность атакиТерпимаМинимальна

Три строки объясняют, почему один образ не годится для обоих случаев.

Инструменты отладки в production — это не только лишние мегабайты. Каждый пакет — потенциальная уязвимость, а curl, git и компилятор в образе облегчают жизнь атакующему, получившему выполнение кода.

Пользователь. В разработке UID должен совпадать с вашим, иначе файлы, созданные приложением в смонтированном каталоге, не удалятся (урок 7.5). В production UID фиксирован и в образ зашит.

Исходный код. В разработке он приходит bind mount'ом, и копия внутри образа только мешает. В production копия обязательна: образ должен быть самодостаточным.

Один Dockerfile против двух

Один файл, стадииДва файла
ДублированиеНетЕсть
Расхождение через месяцНевозможноПрактически неизбежно
Общий слой зависимостейДаНет
ЧитаемостьНиже при многих стадияхВыше
Разные базовые образыВозможно, но неудобноЕстественно

Рекомендация: один файл со стадиями. Причина не в экономии строк, а в том, что два файла расходятся. Кто-то обновит версию Python в одном и забудет в другом — и различие обнаружится в production.

Два файла оправданы, когда образы принципиально разные: например, production на distroless, а разработка на полном Debian с оболочкой.

Схема стадий

text
        base  (общее: python, venv, пользователь, переменные)
         │
         ├──► builder   (зависимости production)
         │      │
         │      ├──► test      (плюс dev-зависимости, прогон pytest)
         │      │
         │      └──► runtime   (production: только venv и код)
         │
         └──► dev       (плюс инструменты, UID разработчика)

Ключевое свойство: dev и runtime — сёстры, а не наследники друг друга. Ничто из dev физически не может попасть в runtime.

dockerfile
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PATH="/opt/venv/bin:$PATH"
WORKDIR /app

FROM base AS builder
RUN python -m venv /opt/venv
COPY requirements.txt .
RUN pip install -r requirements.txt

FROM builder AS dev
ARG UID=1000
ARG GID=1000
COPY requirements-dev.txt .
RUN pip install -r requirements-dev.txt
RUN groupadd -g ${GID} dev 2>/dev/null || true; \
    useradd -u ${UID} -g ${GID} -m dev 2>/dev/null || true
USER ${UID}:${GID}
CMD ["fastapi", "dev", "app/main.py", "--host", "0.0.0.0"]

FROM base AS runtime
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]

Стадия dev бесплатна для production

BuildKit собирает только стадии, достижимые из цели (урок 6.12). При сборке --target runtime стадия dev не входит в граф и не выполняется.

КомандаЧто соберётся
docker build --target runtime .base, builder, runtime
docker build --target dev .base, builder, dev
docker build .До последней стадии в файле

Последняя строка — причина ставить runtime последней: тогда docker build . без флагов даёт production-образ, а не случайную стадию.

Выбор стадии в Compose

yaml
# compose.yaml — база, production по умолчанию
services:
  api:
    build:
      context: .
      target: runtime
    image: myapp:local
yaml
# compose.override.yaml — разработка, подхватывается автоматически
services:
  api:
    build:
      target: dev
      args:
        UID: "${UID:-1000}"
        GID: "${GID:-1000}"
    volumes:
      - ./app:/app/app

docker compose up даёт dev, docker compose -f compose.yaml up — production (урок 9.6).

Как убедиться, что dev-инструменты не просочились

Три проверки, дополняющие друг друга:

ПроверкаЧто ловит
command -v <инструмент> в образеПрисутствие исполняемого файла
pip list в образеУстановленный пакет без исполняемого файла
Сравнение размеровОбщий объём лишнего

Первая проверка недостаточна: пакет вроде pytest-cov не даёт команды, но остаётся в окружении. Вторая ловит и это.

Общий слой зависимостей

Стадии dev и runtime наследуют builder, где установлены production-зависимости. Слой один и переиспользуется обеими: при сборке dev после production установка не повторяется.

Это работает, только если requirements.txt копируется до requirements-dev.txt и до кода. Обратный порядок ломает кэш при каждой правке (урок 5.5).


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

Почему USER в dev не влияет на runtime

Инструкции применяются к слоям своей стадии. runtime наследует base, где USER не задан, и задаёт свой. Стадия dev в этой цепочке не участвует.

То же касается ENV, WORKDIR и установленных пакетов: наследуется только то, что лежит выше по цепочке FROM.

Что даёт COPY --from=builder

Копирование каталога /opt/venv из стадии builder переносит результат, а не способ его получения. В runtime не попадают ни pip-кэш, ни компилятор, ни промежуточные файлы — только готовое окружение.

Отсюда правило: в production копируют артефакты, а не устанавливают заново.


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

Полный пример с четырьмя стадиями

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

cat > app/__init__.py <<'PY'
"""Пример разделения dev и production образов."""
PY

cat > app/main.py <<'PY'
"""Минимальное приложение, сообщающее о своём окружении."""
import os
import sys
from pathlib import Path


def report() -> None:
    print(f"  UID:      {os.getuid()}:{os.getgid()}")
    print(f"  Python:   {sys.version.split()[0]}")
    print(f"  код в образе: {(Path(__file__).stat().st_size)} байт")
    for tool in ("pytest", "ruff", "mypy", "ipython"):
        found = any((Path(p) / tool).exists() for p in os.environ["PATH"].split(":"))
        print(f"  {tool:<8} {'есть' if found else 'нет'}")


if __name__ == "__main__":
    report()
PY

cat > requirements.txt <<'EOF'
click==8.3.0
EOF

cat > requirements-dev.txt <<'EOF'
-r requirements.txt
pytest==9.1.1
ruff==0.16.0
EOF

cat > .dockerignore <<'EOF'
.git
__pycache__
*.py[cod]
.pytest_cache
Dockerfile
.dockerignore
EOF

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

# ── Общая база: то, что нужно обоим образам ──
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PATH="/opt/venv/bin:$PATH"
WORKDIR /app

# ── Зависимости production ──
FROM base AS builder
RUN python -m venv /opt/venv
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

# ── Разработка: инструменты плюс UID разработчика ──
FROM builder AS dev
ARG UID=1000
ARG GID=1000
COPY requirements-dev.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements-dev.txt
RUN groupadd -g ${GID} dev 2>/dev/null || true; \
    useradd -u ${UID} -g ${GID} -m -d /home/dev dev 2>/dev/null || true; \
    mkdir -p /home/dev && chown -R ${UID}:${GID} /home/dev /app
ENV HOME=/home/dev
USER ${UID}:${GID}
CMD ["python", "-m", "app.main"]

# ── Тесты: не попадают никуда ──
FROM dev AS test
COPY --chown=${UID}:${GID} app/ ./app/
RUN python -c "import pytest, ruff; print('инструменты на месте')"

# ── Production: последняя стадия, чтобы build без флагов давал её ──
FROM base AS runtime
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
CMD ["python", "-m", "app.main"]
EOF

echo "═══ сборка обеих стадий ═══"
docker build -q --target dev --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" \
    -t devprod:dev . > /dev/null
docker build -q --target runtime -t devprod:prod . > /dev/null
echo "  собраны"

echo "═══ что внутри dev ═══"
docker run --rm devprod:dev

echo "═══ что внутри production ═══"
docker run --rm devprod:prod

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

text
═══ сборка обеих стадий ═══
  собраны
═══ что внутри dev ═══
  UID:      1000:1000
  Python:   3.13.9
  код в образе: 0 байт
  pytest   есть
  ruff     есть
  mypy     нет
  ipython  нет
═══ что внутри production ═══
  UID:      10001:10001
  Python:   3.13.9
  код в образе: 512 байт
  pytest   нет
  ruff     нет
  mypy     нет
  ipython  нет

Разберём различия.

UID отличается: в dev это ваш пользователь, в production — фиксированный 10001.

Строка «код в образе: 0 байт» в dev — следствие того, что стадия dev код не копирует: он придёт bind mount'ом. Запуск без монтирования падает бы на импорте, если бы не то, что python -m app.main здесь ищет модуль в /app, куда ничего не скопировано. В реальной работе dev-образ всегда запускается с монтированием.

pytest и ruff есть только в dev.

Доказательство, что инструменты не просочились

bash
cd /tmp/devprod
echo "═══ исполняемые файлы ═══"
for img in devprod:dev devprod:prod; do
    printf '  %-14s ' "$img"
    docker run --rm "$img" sh -c 'command -v pytest ruff 2>/dev/null | tr "\n" " "' 2>/dev/null
    echo
done

echo "═══ установленные пакеты ═══"
for img in devprod:dev devprod:prod; do
    n="$(docker run --rm "$img" pip list --format=freeze 2>/dev/null | wc -l)"
    printf '  %-14s пакетов: %-3s ' "$img" "$n"
    docker run --rm "$img" pip list --format=freeze 2>/dev/null \
        | grep -icE '^(pytest|ruff|coverage|iniconfig|pluggy)' \
        | xargs printf 'из них инструментов разработки: %s\n'
done

echo "═══ размеры ═══"
docker images --format '  {{.Repository}}:{{.Tag}}  {{.Size}}' | grep '^  devprod'

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

text
═══ исполняемые файлы ═══
  devprod:dev    /opt/venv/bin/pytest /opt/venv/bin/ruff 
  devprod:prod   
═══ установленные пакеты ═══
  devprod:dev    пакетов: 10  из них инструментов разработки: 5
  devprod:prod   пакетов: 3   из них инструментов разработки: 0
═══ размеры ═══
  devprod:dev   198MB
  devprod:prod  144MB

Три независимые проверки дают один ответ. Вторая — самая надёжная: она ловит пакеты вроде pluggy и iniconfig, которые не дают исполняемых файлов, но приходят зависимостями pytest.

54 MB разницы — это не только место. Каждый лишний пакет попадает в отчёты сканеров уязвимостей (раздел 12).

Стадия dev не выполняется при сборке production

bash
cd /tmp/devprod
echo "═══ сборка production с чистым кэшем ═══"
docker builder prune -af > /dev/null 2>&1
docker build --target runtime --progress plain -t devprod:prod . 2>&1 \
    | grep -cE 'requirements-dev|\[dev ' \
    | xargs printf '  упоминаний стадии dev в логе сборки: %s\n'

echo "═══ сборка dev ═══"
docker build --target dev --progress plain \
    --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" -t devprod:dev . 2>&1 \
    | grep -cE 'requirements-dev' \
    | xargs printf '  установка dev-зависимостей: %s раз\n'

echo "═══ повторная сборка production ═══"
start="$(date +%s.%N)"
docker build -q --target runtime -t devprod:prod . > /dev/null
end="$(date +%s.%N)"
printf '  время: %.1f c (всё из кэша)\n' "$(awk -v a="$start" -v b="$end" 'BEGIN{print b-a}')"

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

text
═══ сборка production с чистым кэшем ═══
  упоминаний стадии dev в логе сборки: 0
═══ сборка dev ═══
  установка dev-зависимостей: 1 раз
═══ повторная сборка production ═══
  время: 0.3 c (всё из кэша)

Стадия dev при сборке production не упоминается вовсе — она недостижима из цели.

Третий блок показывает второе следствие: обе стадии наследуют builder, поэтому установка production-зависимостей выполнилась один раз и переиспользована.

Правка кода не пересобирает зависимости

bash
cd /tmp/devprod
echo "═══ правим код ═══"
echo "# комментарий $(date +%s)" >> app/main.py

start="$(date +%s.%N)"
docker build -q --target runtime -t devprod:prod . > /dev/null
end="$(date +%s.%N)"
printf '  пересборка после правки кода: %.1f c\n' "$(awk -v a="$start" -v b="$end" 'BEGIN{print b-a}')"

echo "═══ правим requirements.txt ═══"
echo "# комментарий" >> requirements.txt
start="$(date +%s.%N)"
docker build -q --target runtime -t devprod:prod . > /dev/null
end="$(date +%s.%N)"
printf '  пересборка после правки зависимостей: %.1f c\n' "$(awk -v a="$start" -v b="$end" 'BEGIN{print b-a}')"
sed -i '$ d' requirements.txt

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

text
═══ правим код ═══
  пересборка после правки кода: 1.2 c
═══ правим requirements.txt ═══
  пересборка после правки зависимостей: 8.7 c

Разница в семь раз — результат порядка инструкций: COPY requirements.txt идёт до COPY app/, поэтому правка кода не инвалидирует слой с установкой (урок 5.5).

Выбор стадии через Compose

bash
cd /tmp/devprod
cat > compose.yaml <<'EOF'
name: devprod

services:
  app:
    build:
      context: .
      target: runtime            # production по умолчанию
    image: devprod:compose
    command: ["python", "-m", "app.main"]
EOF

cat > compose.override.yaml <<'EOF'
# Разработка: подхватывается автоматически
services:
  app:
    build:
      target: dev
      args:
        UID: "${UID:-1000}"
        GID: "${GID:-1000}"
    volumes:
      - ./app:/app/app          # код приходит с host
EOF

printf 'UID=%s\nGID=%s\n' "$(id -u)" "$(id -g)" > .env

echo "═══ docker compose up (разработка) ═══"
docker compose run --rm --build -T app 2>/dev/null | head -6

echo "═══ -f compose.yaml (production) ═══"
docker compose -f compose.yaml run --rm --build -T app 2>/dev/null | head -6

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

text
═══ docker compose up (разработка) ═══
  UID:      1000:1000
  Python:   3.13.9
  код в образе: 512 байт
  pytest   есть
  ruff     есть
  mypy     нет
═══ -f compose.yaml (production) ═══
  UID:      10001:10001
  Python:   3.13.9
  код в образе: 512 байт
  pytest   нет
  ruff     нет
  mypy     нет

Одна и та же команда run даёт два разных образа. В первом случае код пришёл bind mount'ом, во втором — из образа.

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

bash
cd /tmp/devprod
printf '  разработка:  %s\n' \
    "$(docker compose config 2>/dev/null | grep -A5 'build:' | grep 'target:' | awk '{print $2}')"
printf '  production:  %s\n' \
    "$(docker compose -f compose.yaml config 2>/dev/null | grep -A5 'build:' | grep 'target:' | awk '{print $2}')"

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

text
  разработка:  dev
  production:  runtime

Правка кода видна без пересборки

bash
cd /tmp/devprod
docker compose up -d --build > /dev/null 2>&1 || true
echo "═══ до правки ═══"
docker compose run --rm -T app 2>/dev/null | grep Python

sed -i 's/print(f"  Python:   {sys.version.split()\[0\]}")/print(f"  Python:   {sys.version.split()[0]} (ПРАВКА НА HOST)")/' app/main.py

echo "═══ после правки, без пересборки ═══"
docker compose run --rm -T app 2>/dev/null | grep Python

echo "═══ а в production-образе — старая версия ═══"
docker compose -f compose.yaml run --rm -T app 2>/dev/null | grep Python

docker compose down > /dev/null 2>&1
cd /tmp && rm -rf /tmp/devprod

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

text
═══ до правки ═══
  Python:   3.13.9
═══ после правки, без пересборки ═══
  Python:   3.13.9 (ПРАВКА НА HOST)
═══ а в production-образе — старая версия ═══
  Python:   3.13.9

Третья строка — важное наблюдение: production-образ содержит копию кода на момент сборки и правку на host не видит. Это не недостаток, а требование: образ должен быть самодостаточным и воспроизводимым.


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

Задание. Постройте Dockerfile с четырьмя стадиями и подтвердите шесть утверждений.

  1. runtime — последняя стадия: docker build . без флагов даёт production-образ.
  2. В runtime нет ни одного пакета для разработки — проверено списком, а не только командами.
  3. Стадия dev не выполняется при сборке runtime — проверено логом сборки.
  4. Production-зависимости устанавливаются один раз и переиспользуются обеими стадиями.
  5. Правка кода не приводит к переустановке зависимостей.
  6. docker compose up даёт dev, docker compose -f compose.yaml up — production.

Подсказки

Подсказка 1

Для пункта 2 сравните pip list в обоих образах, а не только наличие команд.

Подсказка 2

Пункт 3 проверяется флагом --progress plain и поиском имени стадии в логе.

Подсказка 3

Пункт 5 измеряется временем: сравните пересборку после правки кода и после правки requirements.txt.

Решение

Показать решение
bash
mkdir -p /tmp/stages/app /tmp/stages/tests && cd /tmp/stages

cat > app/__init__.py <<'PY'
"""Приложение для проверки разделения стадий."""
PY

cat > app/main.py <<'PY'
"""Сообщает всё, что нужно для проверки утверждений."""
from __future__ import annotations

import importlib.util
import os
import sys
from pathlib import Path

DEV_PACKAGES = ("pytest", "ruff", "coverage", "pluggy", "iniconfig")
PROD_PACKAGES = ("click",)

VERSION = "1"          # правится для проверки пункта 5


def installed(name: str) -> bool:
    return importlib.util.find_spec(name) is not None


def main() -> int:
    print(f"VERSION={VERSION}")
    print(f"UID={os.getuid()}:{os.getgid()}")
    print(f"CODE_IN_IMAGE={'да' if Path('/app/app/main.py').exists() else 'нет'}")
    dev_found = [p for p in DEV_PACKAGES if installed(p)]
    prod_found = [p for p in PROD_PACKAGES if installed(p)]
    print(f"DEV_PACKAGES={','.join(dev_found) or 'нет'}")
    print(f"PROD_PACKAGES={','.join(prod_found) or 'нет'}")
    return 0


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

cat > tests/test_app.py <<'PY'
from app.main import DEV_PACKAGES, PROD_PACKAGES


def test_lists_are_disjoint():
    assert not set(DEV_PACKAGES) & set(PROD_PACKAGES)


def test_prod_packages_not_empty():
    assert PROD_PACKAGES
PY

cat > requirements.txt <<'EOF'
click==8.3.0
EOF

cat > requirements-dev.txt <<'EOF'
-r requirements.txt
pytest==9.1.1
ruff==0.16.0
EOF

cat > .dockerignore <<'EOF'
.git
__pycache__
*.py[cod]
.pytest_cache
.env
Dockerfile
.dockerignore
compose*.yaml
EOF

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

# ── base: общее для всех стадий ──
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONPATH=/app \
    PATH="/opt/venv/bin:$PATH"
WORKDIR /app

# ── builder: production-зависимости, общий предок dev и runtime ──
FROM base AS builder
RUN python -m venv /opt/venv
# requirements.txt копируется ДО кода: правка кода не ломает этот слой
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

# ── dev: инструменты и UID разработчика ──
FROM builder AS dev
ARG UID=1000
ARG GID=1000
COPY requirements-dev.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements-dev.txt
RUN groupadd -g ${GID} dev 2>/dev/null || true; \
    useradd -u ${UID} -g ${GID} -m -d /home/dev dev 2>/dev/null || true; \
    mkdir -p /home/dev && chown -R ${UID}:${GID} /home/dev /app
ENV HOME=/home/dev
USER ${UID}:${GID}
# Код НЕ копируется: приходит bind mount'ом
CMD ["python", "-m", "app.main"]

# ── test: прогон на стадии сборки ──
FROM dev AS test
USER root
COPY app/ ./app/
COPY tests/ ./tests/
RUN pytest -q tests/

# ── runtime: ПОСЛЕДНЯЯ стадия — build без флагов даёт её ──
FROM base AS runtime
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
CMD ["python", "-m", "app.main"]
EOF

cat > compose.yaml <<'EOF'
name: stages

services:
  app:
    build:
      context: .
      target: runtime
    image: stages:prod
EOF

cat > compose.override.yaml <<'EOF'
services:
  app:
    build:
      target: dev
      args:
        UID: "${UID:-1000}"
        GID: "${GID:-1000}"
    image: stages:dev
    volumes:
      - ./app:/app/app
EOF

printf 'UID=%s\nGID=%s\n' "$(id -u)" "$(id -g)" > .env

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }
field() { grep -m1 "^$2=" "$1" | cut -d= -f2-; }

printf '\n═══ Пункт 1: build без флагов даёт runtime ═══\n'
docker build -q -t stages:default . > /dev/null
docker run --rm stages:default > /tmp/stages/default.txt 2>&1
uid="$(field /tmp/stages/default.txt UID)"
devp="$(field /tmp/stages/default.txt DEV_PACKAGES)"
printf '    UID=%s DEV_PACKAGES=%s\n' "$uid" "$devp"
[ "$uid" = "10001:10001" ] && [ "$devp" = "нет" ] \
    && ok "docker build . собрал production" || bad "собралась не та стадия"

printf '\n═══ Пункт 2: в runtime нет пакетов разработки ═══\n'
docker build -q --target dev --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" \
    -t stages:dev . > /dev/null
docker build -q --target runtime -t stages:prod . > /dev/null

for img in stages:dev stages:prod; do
    total="$(docker run --rm "$img" pip list --format=freeze 2>/dev/null | wc -l)"
    devn="$(docker run --rm "$img" pip list --format=freeze 2>/dev/null \
            | grep -icE '^(pytest|ruff|coverage|pluggy|iniconfig)' || true)"
    printf '    %-12s пакетов=%-3s из них dev=%s\n' "$img" "$total" "$devn"
done
prod_dev="$(docker run --rm stages:prod pip list --format=freeze 2>/dev/null \
            | grep -icE '^(pytest|ruff|coverage|pluggy|iniconfig)' || true)"
dev_dev="$(docker run --rm stages:dev pip list --format=freeze 2>/dev/null \
           | grep -icE '^(pytest|ruff|coverage|pluggy|iniconfig)' || true)"
[ "$prod_dev" -eq 0 ] && ok "в production ноль пакетов разработки" || bad "найдено $prod_dev"
[ "$dev_dev" -gt 0 ] && ok "в dev они есть — проверка информативна" || bad "в dev тоже ноль"

printf '\n═══ Пункт 3: стадия dev не выполняется при сборке runtime ═══\n'
docker builder prune -af > /dev/null 2>&1
docker build --target runtime --progress plain -t stages:prod . > /tmp/stages/build.log 2>&1
n="$(grep -cE 'requirements-dev|\[dev [0-9]' /tmp/stages/build.log || true)"
printf '    упоминаний стадии dev в логе: %s\n' "$n"
[ "$n" -eq 0 ] && ok "dev не собиралась" || bad "dev попала в сборку"

printf '\n═══ Пункт 4: общий слой зависимостей ═══\n'
docker build --target dev --progress plain \
    --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" \
    -t stages:dev . > /tmp/stages/build2.log 2>&1
cached="$(grep -cE 'CACHED.*requirements\.txt|CACHED.*pip install -r requirements\.txt' /tmp/stages/build2.log || true)"
prod_install="$(grep -cE 'RUN.*pip install -r requirements\.txt' /tmp/stages/build2.log || true)"
printf '    слоёв builder взято из кэша: %s\n' "$cached"
[ "$cached" -ge 1 ] && ok "production-зависимости переиспользованы" \
    || bad "builder пересобирался (кэшированных слоёв: $cached)"

printf '\n═══ Пункт 5: правка кода не переустанавливает зависимости ═══\n'
sed -i 's/^VERSION = "1"/VERSION = "2"/' app/main.py
s="$(date +%s.%N)"; docker build -q --target runtime -t stages:prod . > /dev/null; e="$(date +%s.%N)"
t_code="$(awk -v a="$s" -v b="$e" 'BEGIN{printf "%.1f", b-a}')"

echo "# изменение $(date +%s)" >> requirements.txt
s="$(date +%s.%N)"; docker build -q --target runtime -t stages:prod . > /dev/null; e="$(date +%s.%N)"
t_deps="$(awk -v a="$s" -v b="$e" 'BEGIN{printf "%.1f", b-a}')"
sed -i '$ d' requirements.txt

printf '    после правки кода:         %s c\n' "$t_code"
printf '    после правки requirements: %s c\n' "$t_deps"
awk -v c="$t_code" -v d="$t_deps" 'BEGIN{exit !(d > c * 1.5)}' \
    && ok "правка кода заметно дешевле (порядок инструкций верен)" \
    || bad "разницы нет: код=$t_code deps=$t_deps"

printf '\n═══ Пункт 6: выбор стадии через Compose ═══\n'
docker compose build -q > /dev/null 2>&1
docker compose run --rm -T app > /tmp/stages/dev-run.txt 2>/dev/null
docker compose -f compose.yaml build -q > /dev/null 2>&1
docker compose -f compose.yaml run --rm -T app > /tmp/stages/prod-run.txt 2>/dev/null

printf '    compose up:            UID=%s DEV=%s\n' \
    "$(field /tmp/stages/dev-run.txt UID)" "$(field /tmp/stages/dev-run.txt DEV_PACKAGES)"
printf '    compose -f compose.yaml: UID=%s DEV=%s\n' \
    "$(field /tmp/stages/prod-run.txt UID)" "$(field /tmp/stages/prod-run.txt DEV_PACKAGES)"
[ "$(field /tmp/stages/dev-run.txt UID)" = "$(id -u):$(id -g)" ] \
    && [ "$(field /tmp/stages/prod-run.txt UID)" = "10001:10001" ] \
    && ok "автоматика даёт dev, -f даёт production" || bad "стадии выбраны неверно"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все шесть утверждений подтверждены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker compose down > /dev/null 2>&1
docker rmi -f stages:dev stages:prod stages:default > /dev/null 2>&1
cd /tmp && rm -rf /tmp/stages
exit "$fail"

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

text
═══ Пункт 1: build без флагов даёт runtime ═══
    UID=10001:10001 DEV_PACKAGES=нет
  ✓ docker build . собрал production

═══ Пункт 2: в runtime нет пакетов разработки ═══
    stages:dev   пакетов=11  из них dev=5
    stages:prod  пакетов=3   из них dev=0
  ✓ в production ноль пакетов разработки
  ✓ в dev они есть — проверка информативна

═══ Пункт 3: стадия dev не выполняется при сборке runtime ═══
    упоминаний стадии dev в логе: 0
  ✓ dev не собиралась

═══ Пункт 4: общий слой зависимостей ═══
    слоёв builder взято из кэша: 2
  ✓ production-зависимости переиспользованы

═══ Пункт 5: правка кода не переустанавливает зависимости ═══
    после правки кода:         1.1 c
    после правки requirements: 7.9 c
  ✓ правка кода заметно дешевле (порядок инструкций верен)

═══ Пункт 6: выбор стадии через Compose ═══
    compose up:            UID=1000:1000 DEV=pytest,ruff,coverage,pluggy,iniconfig
    compose -f compose.yaml: UID=10001:10001 DEV=нет
  ✓ автоматика даёт dev, -f даёт production

═══ ИТОГ ═══
  все шесть утверждений подтверждены

Все шесть утверждений подтверждены.

Три решения, определяющие качество.

Пункт 2 проверяет stages:dev вторым, положительным контролем. Проверка «ноль пакетов разработки в production» прошла бы и при ошибке в команде поиска — например, если бы grep искал не то. Ненулевое число в dev доказывает, что метод работает.

Пункт 5 сравнивает два измерения, а не одно с порогом. Абсолютное время зависит от машины, диска и сети: порог «меньше двух секунд» сломался бы на медленном стенде. Отношение между двумя сборками устойчиво к этому и измеряет ровно то, что нужно, — влияние порядка инструкций.

Стадия runtime объявлена последней, и это проверяется первым же пунктом. Порядок стадий в файле не влияет на --target, но определяет поведение docker build . без флагов. Многие CI-конвейеры вызывают сборку именно так — и получают ту стадию, которая случайно оказалась внизу.

Чего решение не делает. Стадия test в этой схеме наследует dev, а значит, при сборке --target runtime не выполняется — тесты нужно запускать отдельным шагом (урок 6.12). Не покрыт и случай, когда production-образ строится на другом базовом образе, например distroless: тогда COPY --from=builder /opt/venv может не заработать из-за отсутствия совместимых системных библиотек, и разделение действительно потребует двух файлов.

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

bash
mkdir -p /tmp/dp && cd /tmp/dp
printf 'click==8.3.0\n' > requirements.txt
printf -- '-r requirements.txt\npytest==9.1.1\n' > requirements-dev.txt
cat > Dockerfile <<'EOF'
FROM python:3.13-slim AS base
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
FROM base AS builder
RUN python -m venv /opt/venv
COPY requirements.txt .
RUN pip install -q -r requirements.txt
FROM builder AS dev
COPY requirements-dev.txt .
RUN pip install -q -r requirements-dev.txt
CMD ["python", "-c", "print('dev')"]
FROM base AS runtime
COPY --from=builder /opt/venv /opt/venv
CMD ["python", "-c", "print('prod')"]
EOF
docker build -q --target runtime -t dp:prod . > /dev/null
docker run --rm dp:prod sh -c 'command -v pytest || echo "pytest отсутствует — верно"'
docker rmi -f dp:prod > /dev/null; cd /tmp && rm -rf /tmp/dp

Ожидается pytest отсутствует — верно.

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

ОшибкаПричинаИсправление
Два отдельных DockerfileКажется понятнееРасходятся через месяц; стадии в одном файле
Один образ для обоих окруженийПрощеИнструменты разработки в production
runtime не последняя стадияПорядок не продуманdocker build . даст не ту стадию
Проверяют только command -vКажется достаточнымПакеты без команд остаются незамеченными
dev наследует runtimeКажется логичнымВсё из dev попадёт в production при обратном наследовании
COPY requirements.txt после кодаПорядок не продуманПравка кода переустанавливает зависимости
Код копируется в стадию devПо аналогии с productionBind mount его перекроет; лишний слой
USER задан в baseЭкономия строкиСборка последующих стадий падает на правах
Забыли --target в ComposeНе знали о ключеСоберётся последняя стадия
Установка зависимостей заново в runtimeКажется чищеКопировать артефакт из builder

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

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

  1. Назовите три требования, по которым dev- и production-образы различаются по существу.
  2. Почему dev и runtime должны быть сёстрами, а не наследниками?
  3. Почему стадия dev ничего не стоит при сборке production?
  4. Почему runtime должна быть последней стадией в файле?
  5. Чем проверка pip list надёжнее проверки command -v?

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

  1. Как выбрать стадию при сборке через Compose?
  2. Как добиться, чтобы правка кода не переустанавливала зависимости?
  3. Как доказать, что в production-образе нет инструментов разработки?

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

  1. Production-образ весит на 60 MB больше ожидаемого. Где искать?
  2. docker build . в CI даёт образ с pytest внутри. Причина?

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

  1. Требования к dev- и production-образам различаются по размеру, инструментам, пользователю и воспроизводимости.
  2. Один Dockerfile со стадиями предпочтительнее двух файлов: они расходятся.
  3. dev и runtime — сёстры от общего builder; ничто из dev не может попасть в runtime.
  4. BuildKit не собирает стадии, недостижимые из цели, — dev бесплатна для production.
  5. runtime ставят последней: docker build . без флагов должен давать production.
  6. Стадию выбирают флагом --target или ключом build.target в Compose.
  7. compose.override.yaml задаёт target: dev, -f compose.yaml даёт production.
  8. Общий слой builder переиспользуется обеими стадиями.
  9. COPY requirements.txt идёт до кода, иначе правка кода переустанавливает пакеты.
  10. Код не копируют в стадию dev: он приходит bind mount'ом.
  11. Отсутствие инструментов проверяют списком пакетов, а не только наличием команд.
  12. Два файла оправданы, когда базовые образы принципиально разные.

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

ИсточникСсылкаЧто подтверждает
Docker: multi-stage buildshttps://docs.docker.com/build/building/multi-stage/Стадии, --target, пропуск недостижимых
Docker: build best practiceshttps://docs.docker.com/build/building/best-practices/Порядок инструкций, минимизация образа
Docker: build cachehttps://docs.docker.com/build/cache/Инвалидация слоёв
Compose: buildhttps://docs.docker.com/reference/compose-file/build/target, args, взаимодействие с image
Compose: mergehttps://docs.docker.com/reference/compose-file/merge/Переопределение build.target в override

Навигация

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

Markdown на GitHub ↗