10.1. Development и production образы
Цели
После этого материала вы сможете:
- назвать, чем требования к dev- и production-образу различаются по существу;
- организовать оба в одном
Dockerfileчерез стадии, не дублируя код; - выбрать стадию через
--targetиcompose.override.yaml; - доказать, что инструменты разработки не попали в production-образ;
- объяснить, почему стадия
devничего не стоит при сборке production; - понимать, когда разделение всё же требует двух файлов.
Предварительные знания
- 5.7. Multi-stage builds;
- 6.12. Тестирование в container — стадия тестов;
- 9.6. Несколько файлов и profiles.
Ключевые термины
| Термин | Объяснение |
|---|---|
стадия | Именованный этап сборки: FROM ... AS имя |
--target | Флаг: собрать до указанной стадии |
base | Общая стадия-предок для dev и production |
поверхность атаки | Совокупность установленного, что может содержать уязвимость |
Теория
Требования различаются по существу
| Свойство | Development | Production |
|---|---|---|
| Размер | Не важен | Важен: скачивание, хранение, запуск |
| Скорость пересборки | Критична | Не важна |
| Инструменты отладки | Нужны | Не нужны и вредны |
| Компиляторы | Часто нужны | Не нужны |
| Исходный код в образе | Приходит с host | Обязан быть внутри |
| Пользователь | UID разработчика | Фиксированный non-root |
| Права на запись | Шире | Минимальные |
| Воспроизводимость | Желательна | Обязательна |
| Поверхность атаки | Терпима | Минимальна |
Три строки объясняют, почему один образ не годится для обоих случаев.
Инструменты отладки в production — это не только лишние мегабайты. Каждый пакет — потенциальная уязвимость, а curl, git и компилятор в образе облегчают жизнь атакующему, получившему выполнение кода.
Пользователь. В разработке UID должен совпадать с вашим, иначе файлы, созданные приложением в смонтированном каталоге, не удалятся (урок 7.5). В production UID фиксирован и в образ зашит.
Исходный код. В разработке он приходит bind mount'ом, и копия внутри образа только мешает. В production копия обязательна: образ должен быть самодостаточным.
Один Dockerfile против двух
| Один файл, стадии | Два файла | |
|---|---|---|
| Дублирование | Нет | Есть |
| Расхождение через месяц | Невозможно | Практически неизбежно |
| Общий слой зависимостей | Да | Нет |
| Читаемость | Ниже при многих стадиях | Выше |
| Разные базовые образы | Возможно, но неудобно | Естественно |
Рекомендация: один файл со стадиями. Причина не в экономии строк, а в том, что два файла расходятся. Кто-то обновит версию Python в одном и забудет в другом — и различие обнаружится в production.
Два файла оправданы, когда образы принципиально разные: например, production на distroless, а разработка на полном Debian с оболочкой.
Схема стадий
base (общее: python, venv, пользователь, переменные)
│
├──► builder (зависимости production)
│ │
│ ├──► test (плюс dev-зависимости, прогон pytest)
│ │
│ └──► runtime (production: только venv и код)
│
└──► dev (плюс инструменты, UID разработчика)
Ключевое свойство: dev и runtime — сёстры, а не наследники друг друга. Ничто из dev физически не может попасть в runtime.
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
# compose.yaml — база, production по умолчанию
services:
api:
build:
context: .
target: runtime
image: myapp:local
# 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 копируют артефакты, а не устанавливают заново.
Команды и примеры
Полный пример с четырьмя стадиями
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
Ожидаемый вывод:
═══ сборка обеих стадий ═══
собраны
═══ что внутри 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.
Доказательство, что инструменты не просочились
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'
Ожидаемый вывод:
═══ исполняемые файлы ═══
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
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}')"
Ожидаемый вывод:
═══ сборка production с чистым кэшем ═══
упоминаний стадии dev в логе сборки: 0
═══ сборка dev ═══
установка dev-зависимостей: 1 раз
═══ повторная сборка production ═══
время: 0.3 c (всё из кэша)
Стадия dev при сборке production не упоминается вовсе — она недостижима из цели.
Третий блок показывает второе следствие: обе стадии наследуют builder, поэтому установка production-зависимостей выполнилась один раз и переиспользована.
Правка кода не пересобирает зависимости
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
Ожидаемый вывод:
═══ правим код ═══
пересборка после правки кода: 1.2 c
═══ правим requirements.txt ═══
пересборка после правки зависимостей: 8.7 c
Разница в семь раз — результат порядка инструкций: COPY requirements.txt идёт до COPY app/, поэтому правка кода не инвалидирует слой с установкой (урок 5.5).
Выбор стадии через Compose
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
Ожидаемый вывод:
═══ 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'ом, во втором — из образа.
Проверим, какая стадия использована:
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}')"
Ожидаемый вывод:
разработка: dev
production: runtime
Правка кода видна без пересборки
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
Ожидаемый вывод:
═══ до правки ═══
Python: 3.13.9
═══ после правки, без пересборки ═══
Python: 3.13.9 (ПРАВКА НА HOST)
═══ а в production-образе — старая версия ═══
Python: 3.13.9
Третья строка — важное наблюдение: production-образ содержит копию кода на момент сборки и правку на host не видит. Это не недостаток, а требование: образ должен быть самодостаточным и воспроизводимым.
Практическое упражнение
Задание. Постройте Dockerfile с четырьмя стадиями и подтвердите шесть утверждений.
runtime— последняя стадия:docker build .без флагов даёт production-образ.- В
runtimeнет ни одного пакета для разработки — проверено списком, а не только командами. - Стадия
devне выполняется при сборкеruntime— проверено логом сборки. - Production-зависимости устанавливаются один раз и переиспользуются обеими стадиями.
- Правка кода не приводит к переустановке зависимостей.
docker compose upдаёт dev,docker compose -f compose.yaml up— production.
Подсказки
Подсказка 1
Для пункта 2 сравните pip list в обоих образах, а не только наличие команд.
Подсказка 2
Пункт 3 проверяется флагом --progress plain и поиском имени стадии в логе.
Подсказка 3
Пункт 5 измеряется временем: сравните пересборку после правки кода и после правки requirements.txt.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Пункт 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 может не заработать из-за отсутствия совместимых системных библиотек, и разделение действительно потребует двух файлов.
Проверка результата
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 | По аналогии с production | Bind mount его перекроет; лишний слой |
USER задан в base | Экономия строки | Сборка последующих стадий падает на правах |
Забыли --target в Compose | Не знали о ключе | Соберётся последняя стадия |
Установка зависимостей заново в runtime | Кажется чище | Копировать артефакт из builder |
Контрольные вопросы
На понимание:
- Назовите три требования, по которым dev- и production-образы различаются по существу.
- Почему
devиruntimeдолжны быть сёстрами, а не наследниками? - Почему стадия
devничего не стоит при сборке production? - Почему
runtimeдолжна быть последней стадией в файле? - Чем проверка
pip listнадёжнее проверкиcommand -v?
На применение:
- Как выбрать стадию при сборке через Compose?
- Как добиться, чтобы правка кода не переустанавливала зависимости?
- Как доказать, что в production-образе нет инструментов разработки?
На диагностику:
- Production-образ весит на 60 MB больше ожидаемого. Где искать?
docker build .в CI даёт образ сpytestвнутри. Причина?
Краткое резюме
- Требования к dev- и production-образам различаются по размеру, инструментам, пользователю и воспроизводимости.
- Один
Dockerfileсо стадиями предпочтительнее двух файлов: они расходятся. devиruntime— сёстры от общегоbuilder; ничто изdevне может попасть вruntime.- BuildKit не собирает стадии, недостижимые из цели, —
devбесплатна для production. runtimeставят последней:docker build .без флагов должен давать production.- Стадию выбирают флагом
--targetили ключомbuild.targetв Compose. compose.override.yamlзадаётtarget: dev,-f compose.yamlдаёт production.- Общий слой
builderпереиспользуется обеими стадиями. COPY requirements.txtидёт до кода, иначе правка кода переустанавливает пакеты.- Код не копируют в стадию
dev: он приходит bind mount'ом. - Отсутствие инструментов проверяют списком пакетов, а не только наличием команд.
- Два файла оправданы, когда базовые образы принципиально разные.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: multi-stage builds | https://docs.docker.com/build/building/multi-stage/ | Стадии, --target, пропуск недостижимых |
| Docker: build best practices | https://docs.docker.com/build/building/best-practices/ | Порядок инструкций, минимизация образа |
| Docker: build cache | https://docs.docker.com/build/cache/ | Инвалидация слоёв |
| Compose: build | https://docs.docker.com/reference/compose-file/build/ | target, args, взаимодействие с image |
| Compose: merge | https://docs.docker.com/reference/compose-file/merge/ | Переопределение build.target в override |
Навигация
← Вернуться к разделу
Следующий материал → Hot reload
Главное оглавление