15.2. Тесты внутри container
Цели
После этого материала вы сможете:
- объяснить, почему стадия
testв multi-stage сборке может не выполниться, и заставить её выполниться; - собрать образ так, чтобы тесты проверяли то, что действительно поедет в эксплуатацию;
- разделить production- и test-зависимости и доказать, что вторые не попали в образ;
- извлечь отчёт о покрытии из сборки, не запуская container;
- организовать быструю итерацию без пересборки образа на каждое изменение;
- понять, почему пути в отчёте о покрытии не совпадают и как это исправить.
Предварительные знания
- 5.7. Multi-stage сборка;
- 6.12. Тестирование — запуск
pytestв образе; - 15.1. Стратегия тестирования.
Ключевые термины
| Термин | Объяснение |
|---|---|
стадия | Именованный этап multi-stage сборки |
target | Стадия, до которой ведётся сборка |
достижимость | Свойство стадии быть нужной для цели сборки |
--output | Извлечение файлов из сборки без запуска container |
[paths] | Раздел настройки coverage, сопоставляющий пути |
Теория
Главная ловушка: стадия может не выполниться
FROM python:3.13-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
FROM base AS test
RUN pip install --no-cache-dir pytest==9.1.1
COPY tests/ ./tests/
RUN python -m pytest tests/ -q # ← тесты здесь
FROM base AS production
CMD ["python", "-m", "src.main"]
docker build -t app:1.0 .
Тесты не выполнились. BuildKit строит граф зависимостей от целевой стадии и пропускает всё, что до неё не ведёт (урок 5.7). Целевая стадия — последняя, production; она зависит от base, а не от test.
Сборка проходит успешно, вывод выглядит нормально, тесты никогда не запускались. Это одна из самых дорогих ошибок раздела: команда уверена, что тесты в сборке есть.
Три способа сделать стадию достижимой:
| Способ | Как | Когда применять |
|---|---|---|
| Явная цель | docker build --target test . | Отдельный шаг в CI |
| Зависимость по артефакту | COPY --from=test /отчёт /отчёт в production | Тесты обязательны для сборки |
| Отдельная команда | docker build --target test . && docker build . | Просто и явно |
Второй способ — самый надёжный: он делает невозможной сборку production-образа без прохождения тестов.
Тесты должны проверять то, что поедет
Две схемы, различающиеся результатом:
НЕВЕРНО ВЕРНО
base base
/ \ |
test production production
|
тестируется НЕ то, test
что собирается |
тестируется РОВНО то,
что поедет
В левой схеме test и production — независимые ветви от общей базы. Они могут разойтись: в production добавлена инструкция, которой нет в test.
В правой стадия test наследует готовый production-образ и добавляет к нему только тестовые зависимости и сами тесты. Всё, что проверено, — это и есть отгружаемый артефакт плюс наложенный сверху тестовый слой.
FROM base AS production
COPY src/ ./src/
USER app
CMD ["python", "-m", "src.main"]
FROM production AS test
USER root
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN python -m pytest tests/ -q
Обратите внимание на USER root в стадии test: установка пакетов требует прав. Это не влияет на production-образ — стадии независимы после ветвления.
Разделение зависимостей
requirements.txt → production-образ
requirements-dev.txt → только стадия test
Проверка, что разделение соблюдено:
docker run --rm app:1.0 python -c "import pytest" 2>&1 | grep ModuleNotFoundError
Отсутствие вывода означает, что pytest попал в production-образ. Это не только размер — это лишняя поверхность и признак нарушенного разделения.
Типичная причина попадания: COPY . . до установки зависимостей плюс единый requirements.txt, куда сложили всё.
Извлечение отчётов
Отчёт о покрытии создаётся внутри сборки и по умолчанию остаётся там. Три способа достать:
| Способ | Команда | Свойство |
|---|---|---|
--output | docker build --target report --output type=local,dest=./out . | Не создаёт container |
| Через container | docker create + docker cp | Работает везде |
| Монтирование | docker run -v "$PWD/out:/out" | Не часть сборки |
Первый способ — предпочтительный. Он требует отдельной стадии, содержащей только отчёт:
FROM test AS report
RUN python -m pytest tests/ --cov=src --cov-report=xml:/out/coverage.xml -q
FROM scratch AS export
COPY --from=report /out /
docker build --target export --output type=local,dest=./reports .
Стадия export на базе scratch содержит только файлы отчёта. --output type=local выгружает её содержимое в каталог.
Почему пути в отчёте не совпадают
Внутри container код лежит в /app/src, на машине разработчика — в /home/user/проект/src. Отчёт содержит пути из container'а, и инструмент на машине их не находит.
Решение — раздел [paths] в настройке coverage:
[tool.coverage.paths]
source = [
"src/",
"/app/src/",
]
Все перечисленные пути считаются одним и тем же исходником. Это же нужно, когда отчёты объединяются из нескольких прогонов — например, из матрицы версий Python.
Быстрая итерация
Пересборка образа на каждое изменение теста занимает секунды даже при хорошем кэше. Для цикла «правка — прогон» это много.
docker build --target test -t app:test .
docker run --rm -v "$PWD/src:/app/src:ro" -v "$PWD/tests:/app/tests:ro" \
app:test python -m pytest tests/ -q
Образ собирается один раз; код и тесты монтируются. Изменение файла видно немедленно.
Ограничение: так проверяется код, а не образ. Перед публикацией нужен полный прогон в собранном образе — монтирование скрывает ошибки вида «файл не скопирован в образ» (урок 15.1).
Внутренний механизм
Как BuildKit решает, что строить
BuildKit разбирает Dockerfile в граф, где узлы — инструкции, а рёбра — зависимости (FROM, COPY --from). Затем от целевого узла обходит граф назад и строит только достижимое.
Отсюда два следствия:
Стадия без входящих рёбер не строится. Именно это происходит с test, если на неё никто не ссылается.
Порядок стадий в файле не определяет порядок сборки. Независимые стадии могут строиться параллельно; зависимые — в порядке рёбер, а не строк.
Проверить, что действительно выполнялось: docker build --progress=plain показывает каждый шаг с номером стадии.
Почему RUN pytest останавливает сборку
Инструкция RUN завершается неуспешно при ненулевом коде возврата команды. pytest возвращает 0 при успехе и 1 при падении хотя бы одного теста.
Отсюда практическое требование: команда в RUN не должна «проглатывать» код возврата.
RUN python -m pytest tests/ -q # верно
RUN python -m pytest tests/ -q || true # тесты не остановят сборку
RUN python -m pytest tests/ -q | tee /tmp/log # код возврата от tee, не от pytest
Третья строка — распространённая ошибка. Код возврата конвейера в оболочке по умолчанию равен коду последней команды. Решение — set -o pipefail:
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN python -m pytest tests/ -q | tee /tmp/pytest.log
Команды и примеры
Стадия test, которая не выполняется
mkdir -p /tmp/incontainer && cd /tmp/incontainer
mkdir -p src tests
cat > requirements.txt <<'EOF'
click==8.3.0
EOF
cat > requirements-dev.txt <<'EOF'
pytest==9.1.1
pytest-cov==7.1.0
EOF
cat > src/__init__.py <<'PY'
PY
cat > src/calc.py <<'PY'
"""Простой модуль для демонстрации."""
from __future__ import annotations
def total(price: float, quantity: int, discount: float = 0.0) -> float:
"""Сумма заказа со скидкой."""
if price < 0 or quantity < 0:
raise ValueError("отрицательные значения недопустимы")
if not 0.0 <= discount <= 1.0:
raise ValueError("скидка вне диапазона 0..1")
return round(price * quantity * (1 - discount), 2)
PY
cat > tests/test_calc.py <<'PY'
import pytest
from src.calc import total
def test_simple():
assert total(100.0, 3) == 300.0
def test_with_discount():
assert total(100.0, 2, 0.1) == 180.0
@pytest.mark.parametrize("price,qty", [(-1, 1), (1, -1)])
def test_negative_rejected(price, qty):
with pytest.raises(ValueError):
total(price, qty)
def test_bad_discount():
with pytest.raises(ValueError):
total(100.0, 1, 1.5)
PY
cat > Dockerfile.unreachable <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
# Стадия test существует, но НИКТО на неё не ссылается
FROM base AS test
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN echo "=== ТЕСТЫ ВЫПОЛНЯЮТСЯ ===" && python -m pytest tests/ -q
FROM base AS production
CMD ["python", "-c", "from src.calc import total; print(total(10, 2))"]
EOF
echo "═══ обычная сборка ═══"
docker build -f Dockerfile.unreachable -t reach:demo . 2>&1 | tail -6 | sed 's/^/ /'
echo "═══ выполнялись ли тесты? ═══"
if docker build -f Dockerfile.unreachable -t reach:demo --no-cache . 2>&1 \
| grep -q 'ТЕСТЫ ВЫПОЛНЯЮТСЯ'; then
echo " ДА — строка из стадии test найдена в выводе"
else
echo " НЕТ — стадия test пропущена, её вывода в сборке нет"
fi
echo "═══ явная цель ═══"
docker build -f Dockerfile.unreachable --target test -t reach:test --no-cache . 2>&1 \
| grep -E 'ТЕСТЫ ВЫПОЛНЯЮТСЯ|passed|DONE' | head -3 | sed 's/^/ /'
Ожидаемый вывод:
═══ обычная сборка ═══
=> [production 1/1] ...
=> exporting to image
=> => naming to docker.io/library/reach:demo
═══ выполнялись ли тесты? ═══
НЕТ — стадия test пропущена, её вывода в сборке нет
═══ явная цель ═══
#12 0.412 === ТЕСТЫ ВЫПОЛНЯЮТСЯ ===
#12 0.688 5 passed in 0.03s
#12 DONE 0.7s
Первая проверка — то, ради чего написан этот блок. Сборка прошла успешно, образ создан, тесты не выполнялись ни разу.
Ошибку легко не заметить: в выводе сборки нет ничего тревожного, просто отсутствуют строки, которых никто не искал.
Как сделать тесты обязательными
cd /tmp/incontainer
cat > Dockerfile.enforced <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Production-образ: то, что поедет
FROM base AS production
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app && chown -R app:app /app
USER app
CMD ["python", "-c", "from src.calc import total; print(total(10, 2))"]
# Тесты НАСЛЕДУЮТ production: проверяется ровно то, что отгружается
FROM production AS test
USER root
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN echo "=== ТЕСТЫ ВЫПОЛНЯЮТСЯ ===" \
&& python -m pytest tests/ -q \
&& mkdir -p /out && echo "тесты пройдены" > /out/tests-passed.txt
# Финальная стадия ЗАВИСИТ от test — без прохождения тестов сборки не будет
FROM production AS final
COPY --from=test /out/tests-passed.txt /app/.tests-passed
EOF
echo "═══ сборка без указания цели ═══"
docker build -f Dockerfile.enforced -t enforced:1 --no-cache . 2>&1 \
| grep -E 'ТЕСТЫ ВЫПОЛНЯЮТСЯ|passed|naming' | sed 's/^/ /'
echo "═══ проверка: тесты выполнились ═══"
docker run --rm enforced:1 cat /app/.tests-passed 2>/dev/null | sed 's/^/ /'
echo "═══ ломаем тест и пробуем собрать ═══"
cat >> tests/test_calc.py <<'PY'
def test_заведомо_ломающий():
assert total(1.0, 1) == 999.0
PY
build_rc=0
docker build -f Dockerfile.enforced -t enforced:2 --no-cache . > broken.log 2>&1 || build_rc=$?
printf ' код возврата сборки: %s\n' "$build_rc"
grep -E 'assert|failed' broken.log | head -3 | sed 's/^/ /'
echo "═══ создался ли образ ═══"
if docker image inspect enforced:2 > /dev/null 2>&1; then
echo " образ enforced:2 СОЗДАН — тесты не остановили сборку"
else
echo " образ enforced:2 НЕ создан — сборка остановлена падением теста"
fi
# Убираем сломанный тест
python3 - <<'PY'
from pathlib import Path
p = Path("tests/test_calc.py")
text = p.read_text()
marker = "\n\ndef test_заведомо_ломающий():"
if marker in text:
p.write_text(text[:text.index(marker)] + "\n")
print(" сломанный тест удалён")
PY
Ожидаемый вывод:
═══ сборка без указания цели ═══
#14 0.401 === ТЕСТЫ ВЫПОЛНЯЮТСЯ ===
#14 0.702 5 passed in 0.03s
=> => naming to docker.io/library/enforced:1
═══ проверка: тесты выполнились ═══
тесты пройдены
═══ ломаем тест и пробуем собрать ═══
код возврата сборки: 1
E assert 1.0 == 999.0
1 failed, 5 passed in 0.04s
═══ создался ли образ ═══
образ enforced:2 НЕ создан — сборка остановлена падением теста
сломанный тест удалён
Ключевая строка — COPY --from=test /out/tests-passed.txt. Она создаёт ребро в графе: финальная стадия не может быть построена, пока не построена test, а test не строится при падении pytest.
Проверка выполнена в обе стороны: с исправными тестами образ собирается, со сломанным — нет.
Тесты проверяют то, что поедет
cd /tmp/incontainer
echo "═══ от какого пользователя работает production ═══"
docker run --rm enforced:1 sh -c 'id -u; id -un' 2>/dev/null | sed 's/^/ /'
echo "═══ от какого пользователя выполнялись тесты ═══"
docker build -f Dockerfile.enforced --target test -t enforced:test . > /dev/null 2>&1
docker run --rm enforced:test sh -c 'id -u; id -un' 2>/dev/null | sed 's/^/ /'
echo "═══ разбор ═══"
cat <<'TXT'
Стадия test наследует production и переключается на root
только для установки пакетов. Сам production-образ остаётся
с USER app.
Если бы test и production были независимыми ветвями от base,
тесты могли бы проходить в окружении, отличном от отгружаемого:
другой пользователь, другой набор файлов, другой рабочий каталог.
Проверка того, что тесты видят production-окружение:
docker build --target test -t app:test .
docker run --rm app:test ls -la /app
TXT
docker run --rm enforced:test sh -c 'ls /app | tr "\n" " "' 2>/dev/null | sed 's/^/ файлы в test: /'
docker run --rm enforced:1 sh -c 'ls /app | tr "\n" " "' 2>/dev/null | sed 's/^/ файлы в prod: /'
Ожидаемый вывод:
═══ от какого пользователя работает production ═══
10001
app
═══ от какого пользователя выполнялись тесты ═══
0
root
═══ разбор ═══
Стадия test наследует production и переключается на root
только для установки пакетов. Сам production-образ остаётся
с USER app.
...
файлы в test: requirements.txt requirements-dev.txt src tests
файлы в prod: requirements.txt src
Последние две строки показывают разницу: в тестовом образе есть tests/ и requirements-dev.txt, в production — нет.
Общее у них — src/, установленный в одной и той же базовой стадии. Именно это и требуется: проверяется тот код, который отгружается.
Проверка разделения зависимостей
cd /tmp/incontainer
cat > check-deps.sh <<'SH'
#!/usr/bin/env bash
# Проверяет, что test-зависимости не попали в production-образ.
set -uo pipefail
IMAGE="${1:?укажите образ}"
DEV_PACKAGES="${2:-pytest pytest-cov coverage ruff mypy ipython pdbpp}"
found=0
printf '\n Проверка образа: %s\n\n' "$IMAGE"
for pkg in $DEV_PACKAGES; do
module="${pkg//-/_}"
if docker run --rm --entrypoint python "$IMAGE" -c "import $module" > /dev/null 2>&1; then
printf ' ⚠ %-14s ПРИСУТСТВУЕТ в production-образе\n' "$pkg"
found=$((found + 1))
else
printf ' ✓ %-14s отсутствует\n' "$pkg"
fi
done
# Файлы, которых в production быть не должно
printf '\n'
for path in /app/tests /app/requirements-dev.txt /app/.pytest_cache /app/conftest.py; do
if docker run --rm --entrypoint sh "$IMAGE" -c "test -e $path" 2>/dev/null; then
printf ' ⚠ %-26s присутствует\n' "$path"
found=$((found + 1))
else
printf ' ✓ %-26s отсутствует\n' "$path"
fi
done
printf '\n нарушений разделения: %s\n\n' "$found"
[ "$found" -gt 0 ] && exit 1
exit 0
SH
chmod +x check-deps.sh
echo "═══ production-образ ═══"
./check-deps.sh enforced:1 || true
echo "═══ тестовый образ (для сравнения) ═══"
./check-deps.sh enforced:test 2>&1 | tail -4 || true
Ожидаемый вывод:
═══ production-образ ═══
Проверка образа: enforced:1
✓ pytest отсутствует
✓ pytest-cov отсутствует
✓ coverage отсутствует
✓ ruff отсутствует
✓ mypy отсутствует
✓ ipython отсутствует
✓ pdbpp отсутствует
✓ /app/tests отсутствует
✓ /app/requirements-dev.txt отсутствует
✓ /app/.pytest_cache отсутствует
✓ /app/conftest.py отсутствует
нарушений разделения: 0
═══ тестовый образ (для сравнения) ═══
⚠ /app/requirements-dev.txt присутствует
нарушений разделения: 3
Второй блок подтверждает, что проверка вообще работает: на тестовом образе она находит нарушения. Проверка, всегда возвращающая «чисто», бесполезна.
Извлечение отчёта о покрытии
cd /tmp/incontainer
cat > pyproject.toml <<'EOF'
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.paths]
# Один и тот же исходник под разными путями: в container и на машине
source = ["src/", "/app/src/"]
[tool.coverage.report]
show_missing = true
skip_covered = false
EOF
cat > Dockerfile.report <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM base AS production
COPY src/ ./src/
CMD ["python", "-c", "from src.calc import total; print(total(10, 2))"]
FROM production AS test
COPY requirements-dev.txt pyproject.toml ./
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN mkdir -p /out && python -m pytest tests/ -q \
--cov=src \
--cov-report=term \
--cov-report=xml:/out/coverage.xml \
--junit-xml=/out/junit.xml
# Стадия, содержащая ТОЛЬКО отчёты
FROM scratch AS export
COPY --from=test /out/ /
EOF
echo "═══ сборка с выгрузкой отчётов ═══"
rm -rf reports && mkdir -p reports
docker build -f Dockerfile.report --target export \
--output "type=local,dest=./reports" --no-cache . 2>&1 \
| grep -E 'TOTAL|passed|DONE.*export' | head -4 | sed 's/^/ /'
echo "═══ что выгрузилось ═══"
ls -la reports/ 2>/dev/null | tail -3 | sed 's/^/ /'
echo "═══ содержимое отчёта о покрытии ═══"
python3 - <<'PY'
import xml.etree.ElementTree as ET
from pathlib import Path
path = Path("reports/coverage.xml")
if not path.exists():
print(" coverage.xml не выгружен")
else:
root = ET.parse(path).getroot()
rate = float(root.get("line-rate", 0)) * 100
print(f" покрытие строк: {rate:.0f} %")
for cls in root.iter("class"):
name = cls.get("filename")
cls_rate = float(cls.get("line-rate", 0)) * 100
print(f" {name:<24} {cls_rate:.0f} %")
PY
echo "═══ почему нужен раздел paths ═══"
cat <<'TXT'
Внутри container исходники лежат в /app/src, на машине — в ./src.
Отчёт содержит пути из container'а.
Без раздела [tool.coverage.paths] инструмент на машине
не найдёт файлы и покажет 0 % или ошибку.
С разделом:
[tool.coverage.paths]
source = ["src/", "/app/src/"]
Оба пути считаются одним исходником. Это же нужно
при объединении отчётов из нескольких прогонов.
TXT
Ожидаемый вывод:
═══ сборка с выгрузкой отчётов ═══
#16 0.712 5 passed in 0.09s
#16 0.712 TOTAL 8 0 2 0 100%
#18 DONE 0.1s
═══ что выгрузилось ═══
-rw-r--r-- 1 evg evg 1284 июл 31 12:04 coverage.xml
-rw-r--r-- 1 evg evg 1902 июл 31 12:04 junit.xml
═══ содержимое отчёта о покрытии ═══
покрытие строк: 100 %
src/calc.py 100 %
═══ почему нужен раздел paths ═══
Внутри container исходники лежат в /app/src, на машине — в ./src.
Отчёт содержит пути из container'а.
...
Отчёты появились на машине, при этом ни один container не запускался: --output type=local выгружает содержимое стадии напрямую.
Стадия export на базе scratch содержит только файлы отчётов — это важно, иначе в выгрузку попала бы вся файловая система.
Ловушка с кодом возврата
cd /tmp/incontainer
cat > Dockerfile.pipe <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir pytest==9.1.1
COPY src/ ./src/
COPY tests/ ./tests/
RUN echo "def test_упадёт(): assert False" > tests/test_broken.py
# ЛОВУШКА: код возврата берётся от tee, а не от pytest
RUN python -m pytest tests/ -q | tee /tmp/pytest.log
EOF
echo "═══ сборка с конвейером без pipefail ═══"
pipe_rc=0
docker build -f Dockerfile.pipe -t pipe:trap --no-cache . > pipe.log 2>&1 || pipe_rc=$?
printf ' код возврата сборки: %s\n' "$pipe_rc"
grep -E 'failed|passed' pipe.log | tail -1 | sed 's/^/ в логе: /'
if docker image inspect pipe:trap > /dev/null 2>&1; then
echo " образ СОЗДАН несмотря на упавший тест"
else
echo " образ не создан"
fi
echo "═══ исправление: pipefail ═══"
cat > Dockerfile.pipefail <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /app
RUN pip install --no-cache-dir pytest==9.1.1
COPY src/ ./src/
COPY tests/ ./tests/
RUN echo "def test_упадёт(): assert False" > tests/test_broken.py
RUN python -m pytest tests/ -q | tee /tmp/pytest.log
EOF
fix_rc=0
docker build -f Dockerfile.pipefail -t pipe:fixed --no-cache . > pipefix.log 2>&1 || fix_rc=$?
printf ' код возврата сборки: %s\n' "$fix_rc"
if docker image inspect pipe:fixed > /dev/null 2>&1; then
echo " образ создан — исправление не сработало"
else
echo " образ НЕ создан — сборка остановлена, как и должно быть"
fi
echo "═══ три способа потерять код возврата ═══"
cat <<'TXT'
RUN pytest tests/ || true намеренно проглочен
RUN pytest tests/ ; echo готово код от echo
RUN pytest tests/ | tee /tmp/log код от tee
Первый вариант иногда пишут «чтобы посмотреть логи» и забывают убрать.
Третий выглядит безобидно и встречается чаще всего.
Исправление для третьего:
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
Проверка: сломайте один тест и убедитесь, что сборка падает.
Механизм, который никто не проверял, обычно не работает.
TXT
docker rmi -f pipe:trap pipe:fixed reach:demo reach:test enforced:1 enforced:test > /dev/null 2>&1
Ожидаемый вывод:
═══ сборка с конвейером без pipefail ═══
код возврата сборки: 0
в логе: 1 failed, 5 passed in 0.05s
образ СОЗДАН несмотря на упавший тест
═══ исправление: pipefail ═══
код возврата сборки: 1
образ НЕ создан — сборка остановлена, как и должно быть
═══ три способа потерять код возврата ═══
RUN pytest tests/ || true намеренно проглочен
RUN pytest tests/ ; echo готово код от echo
RUN pytest tests/ | tee /tmp/log код от tee
...
1 failed в логе и код возврата сборки: 0 в соседних строках — это и есть ловушка. Сборка сообщила об успехе, хотя тест упал.
Быстрая итерация
cd /tmp/incontainer
docker build -f Dockerfile.report --target test -t iter:test . > /dev/null 2>&1
echo "═══ прогон с монтированием кода ═══"
docker run --rm \
-v "$PWD/src:/app/src:ro" \
-v "$PWD/tests:/app/tests:ro" \
iter:test python -m pytest tests/ -q 2>&1 | tail -2 | sed 's/^/ /'
echo "═══ меняем код без пересборки ═══"
cp src/calc.py src/calc.py.bak
python3 - <<'PY'
from pathlib import Path
p = Path("src/calc.py")
p.write_text(p.read_text().replace("return round(price * quantity", "return round(0 * price * quantity"))
print(" внесена ошибка в src/calc.py")
PY
docker run --rm \
-v "$PWD/src:/app/src:ro" \
-v "$PWD/tests:/app/tests:ro" \
iter:test python -m pytest tests/ -q 2>&1 | tail -2 | sed 's/^/ /'
mv src/calc.py.bak src/calc.py
echo " код восстановлен"
echo "═══ ограничение подхода ═══"
cat <<'TXT'
Монтирование даёт быстрый цикл, но проверяет КОД, а не ОБРАЗ.
Что оно скрывает:
файл не скопирован в образ инструкцией COPY
файл исключён .dockerignore
зависимость не установлена в образ
права на файл в образе не те
Поэтому перед публикацией — полный прогон в собранном образе:
docker build --target test -t app:test . && docker run --rm app:test pytest
TXT
cd /tmp && rm -rf /tmp/incontainer
docker rmi -f iter:test > /dev/null 2>&1
Ожидаемый вывод:
═══ прогон с монтированием кода ═══
..... [100%]
5 passed in 0.04s
═══ меняем код без пересборки ═══
внесена ошибка в src/calc.py
FF... [100%]
2 failed, 3 passed in 0.05s
код восстановлен
═══ ограничение подхода ═══
Монтирование даёт быстрый цикл, но проверяет КОД, а не ОБРАЗ.
Что оно скрывает:
файл не скопирован в образ инструкцией COPY
файл исключён .dockerignore
зависимость не установлена в образ
права на файл в образе не те
Поэтому перед публикацией — полный прогон в собранном образе:
docker build --target test -t app:test . && docker run --rm app:test pytest
Изменение файла на машине немедленно отразилось в прогоне — образ не пересобирался.
Практическое упражнение
Задание. Встройте тесты в сборку так, чтобы их нельзя было обойти.
Требования:
- Показать, что стадия
testбез ссылок на неё не выполняется при обычной сборке. - Сделать тесты обязательными: сборка production-образа невозможна при падении теста.
- Проверить это в обе стороны: с исправными тестами и со сломанным.
- Показать, что стадия
testнаследует production-образ, а не ветвится от базы. - Написать проверку, что test-зависимости не попали в production-образ, и убедиться, что она находит нарушение.
- Выгрузить отчёт о покрытии из сборки, не запуская container.
- Показать ловушку с потерей кода возврата в конвейере и её исправление.
Подсказки
Подсказка 1
Для пункта 1 добавьте в стадию test вывод характерной строки и ищите её в выводе сборки с --no-cache.
Подсказка 2
Пункт 2 решается инструкцией COPY --from=test в финальной стадии: она создаёт ребро в графе.
Подсказка 3
Проверка из пункта 5 должна быть проверена сама: запустите её на тестовом образе, где нарушение заведомо есть.
Решение
Показать решение
mkdir -p /tmp/testlab && cd /tmp/testlab
mkdir -p src tests
# ─── Приложение ───────────────────────────────────────────────────────
cat > requirements.txt <<'EOF'
click==8.3.0
EOF
cat > requirements-dev.txt <<'EOF'
pytest==9.1.1
pytest-cov==7.1.0
EOF
cat > src/__init__.py <<'PY'
PY
cat > src/pricing.py <<'PY'
"""Расчёт стоимости заказа."""
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
VAT = Decimal("0.20")
def line_total(price: Decimal, quantity: int) -> Decimal:
"""Стоимость позиции без НДС."""
if price < 0:
raise ValueError("отрицательная цена")
if quantity < 0:
raise ValueError("отрицательное количество")
return (price * quantity).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def with_vat(amount: Decimal) -> Decimal:
"""Сумма с НДС."""
return (amount * (1 + VAT)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def apply_discount(amount: Decimal, percent: int) -> Decimal:
"""Скидка в процентах."""
if not 0 <= percent <= 100:
raise ValueError("процент вне диапазона 0..100")
factor = (Decimal(100) - Decimal(percent)) / Decimal(100)
return (amount * factor).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
PY
cat > tests/test_pricing.py <<'PY'
from decimal import Decimal
import pytest
from src.pricing import apply_discount, line_total, with_vat
@pytest.mark.parametrize("price,qty,expected", [
("100.00", 3, "300.00"),
("9.99", 7, "69.93"),
("0.01", 1, "0.01"),
])
def test_line_total(price, qty, expected):
assert line_total(Decimal(price), qty) == Decimal(expected)
@pytest.mark.parametrize("price,qty", [("-1", 1), ("1", -1)])
def test_line_total_rejects_negative(price, qty):
with pytest.raises(ValueError):
line_total(Decimal(price), qty)
def test_with_vat():
assert with_vat(Decimal("100.00")) == Decimal("120.00")
@pytest.mark.parametrize("amount,percent,expected", [
("100.00", 10, "90.00"),
("100.00", 0, "100.00"),
("100.00", 100, "0.00"),
])
def test_apply_discount(amount, percent, expected):
assert apply_discount(Decimal(amount), percent) == Decimal(expected)
@pytest.mark.parametrize("percent", [-1, 101])
def test_discount_out_of_range(percent):
with pytest.raises(ValueError):
apply_discount(Decimal("100"), percent)
PY
cat > pyproject.toml <<'EOF'
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.paths]
source = ["src/", "/app/src/"]
[tool.coverage.report]
show_missing = true
EOF
# ─── Dockerfile с обязательными тестами ───────────────────────────────
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Production-образ: ровно то, что отгружается
FROM base AS production
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app && chown -R app:app /app
USER app
CMD ["python", "-c", \
"from decimal import Decimal; from src.pricing import with_vat; \
print(with_vat(Decimal('100')))"]
# Тесты НАСЛЕДУЮТ production: проверяется отгружаемый артефакт
FROM production AS test
USER root
COPY requirements-dev.txt pyproject.toml ./
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN mkdir -p /out \
&& echo "МАРКЕР-СТАДИИ-TEST" \
&& python -m pytest tests/ -q \
--cov=src --cov-report=term \
--cov-report=xml:/out/coverage.xml \
--junit-xml=/out/junit.xml \
&& echo "пройдено" > /out/passed.txt
# Финальная стадия ЗАВИСИТ от test — обойти тесты невозможно
FROM production AS final
COPY --from=test /out/passed.txt /app/.tests-passed
# Только отчёты, для выгрузки
FROM scratch AS export
COPY --from=test /out/ /
EOF
# ─── Dockerfile с недостижимой стадией — для сравнения ────────────────
cat > Dockerfile.unreachable <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
FROM base AS test
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN echo "МАРКЕР-СТАДИИ-TEST" && python -m pytest tests/ -q
FROM base AS production
CMD ["python", "-c", "print('ok')"]
EOF
# ─── Проверка разделения зависимостей ─────────────────────────────────
cat > check-separation.sh <<'SH'
#!/usr/bin/env bash
# Проверяет, что test-зависимости и тестовые файлы не попали в образ.
set -uo pipefail
IMAGE="${1:?укажите образ}"
QUIET="${2:-}"
DEV_MODULES="pytest coverage _pytest pluggy"
DEV_PATHS="/app/tests /app/requirements-dev.txt /app/pyproject.toml /app/.pytest_cache"
found=0
say() { [ -n "$QUIET" ] || printf "$@"; }
say '\n Разделение зависимостей: %s\n\n' "$IMAGE"
for module in $DEV_MODULES; do
if docker run --rm --entrypoint python "$IMAGE" -c "import $module" > /dev/null 2>&1; then
say ' ⚠ модуль %-12s ПРИСУТСТВУЕТ\n' "$module"
found=$((found + 1))
else
say ' ✓ модуль %-12s отсутствует\n' "$module"
fi
done
for path in $DEV_PATHS; do
if docker run --rm --entrypoint sh "$IMAGE" -c "test -e $path" 2>/dev/null; then
say ' ⚠ путь %-26s присутствует\n' "$path"
found=$((found + 1))
else
say ' ✓ путь %-26s отсутствует\n' "$path"
fi
done
say '\n нарушений: %s\n\n' "$found"
echo "$found" > /tmp/.separation_count
[ "$found" -gt 0 ] && exit 1
exit 0
SH
chmod +x check-separation.sh
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Требование 1: недостижимая стадия не выполняется ═══\n'
docker build -f Dockerfile.unreachable -t lab:unreach --no-cache . > unreach.log 2>&1
if grep -q 'МАРКЕР-СТАДИИ-TEST' unreach.log; then
printf ' маркер стадии test в выводе: НАЙДЕН\n'
unreach_ran=1
else
printf ' маркер стадии test в выводе: не найден\n'
unreach_ran=0
fi
printf ' образ собран: %s\n' \
"$(docker image inspect lab:unreach > /dev/null 2>&1 && echo да || echo нет)"
printf ' с явной целью --target test:\n'
docker build -f Dockerfile.unreachable --target test -t lab:unreach-test --no-cache . 2>&1 \
| grep -E 'МАРКЕР|passed' | head -2 | sed 's/^/ /'
[ "$unreach_ran" -eq 0 ] \
&& ok "стадия test пропущена при обычной сборке, хотя образ собрался" \
|| bad "маркер найден — стадия выполнилась"
printf '\n═══ Требования 2-3: тесты обязательны ═══\n'
docker build -t lab:final --no-cache . > build-ok.log 2>&1
build_ok_rc=$?
if grep -q 'МАРКЕР-СТАДИИ-TEST' build-ok.log; then
printf ' маркер стадии test: НАЙДЕН — тесты выполнились\n'
enforced_ran=1
else
printf ' маркер стадии test: не найден\n'
enforced_ran=0
fi
grep -E '[0-9]+ passed' build-ok.log | tail -1 | sed 's/^/ /'
printf ' внутри образа: %s\n' \
"$(docker run --rm --entrypoint cat lab:final /app/.tests-passed 2>/dev/null)"
printf '\n ломаем один тест:\n'
cat >> tests/test_pricing.py <<'PY'
def test_intentionally_broken():
"""Заведомо падающий тест: проверяем, что сборка остановится."""
assert with_vat(Decimal("100.00")) == Decimal("999.99")
PY
broken_rc=0
docker build -t lab:broken --no-cache . > build-broken.log 2>&1 || broken_rc=$?
printf ' код возврата сборки: %s\n' "$broken_rc"
grep -E '[0-9]+ failed' build-broken.log | tail -1 | sed 's/^/ /'
if docker image inspect lab:broken > /dev/null 2>&1; then
printf ' образ lab:broken СОЗДАН\n'
broken_created=1
else
printf ' образ lab:broken не создан\n'
broken_created=0
fi
python3 - <<'PY'
from pathlib import Path
p = Path("tests/test_pricing.py")
text = p.read_text()
marker = "\n\ndef test_intentionally_broken():"
if marker in text:
p.write_text(text[:text.index(marker)] + "\n")
print(" сломанный тест удалён")
PY
[ "$enforced_ran" -eq 1 ] && [ "$build_ok_rc" -eq 0 ] \
&& [ "$broken_rc" -ne 0 ] && [ "$broken_created" -eq 0 ] \
&& ok "проверено в обе стороны: исправные тесты пропускают, сломанный останавливает" \
|| bad "ок=$build_ok_rc сломан=$broken_rc создан=$broken_created"
printf '\n═══ Требование 4: test наследует production ═══\n'
docker build --target test -t lab:test --no-cache . > /dev/null 2>&1
prod_user="$(docker run --rm --entrypoint id lab:final -un 2>/dev/null)"
test_user="$(docker run --rm --entrypoint id lab:test -un 2>/dev/null)"
printf ' пользователь в production: %s\n' "$prod_user"
printf ' пользователь в test: %s (переключён для установки пакетов)\n' "$test_user"
prod_files="$(docker run --rm --entrypoint sh lab:final -c 'ls /app | tr "\n" " "' 2>/dev/null)"
test_files="$(docker run --rm --entrypoint sh lab:test -c 'ls /app | tr "\n" " "' 2>/dev/null)"
printf ' файлы production: %s\n' "$prod_files"
printf ' файлы test: %s\n' "$test_files"
prod_src="$(docker run --rm --entrypoint sh lab:final \
-c 'sha256sum /app/src/pricing.py | cut -c1-16' 2>/dev/null)"
test_src="$(docker run --rm --entrypoint sh lab:test \
-c 'sha256sum /app/src/pricing.py | cut -c1-16' 2>/dev/null)"
printf ' хеш src/pricing.py: production=%s test=%s\n' "$prod_src" "$test_src"
[ "$prod_src" = "$test_src" ] && [ -n "$prod_src" ] && [ "$prod_user" = "app" ] \
&& ok "тесты проверяют тот же код, что отгружается (совпадение хешей)" \
|| bad "хеши: $prod_src и $test_src, пользователь production: $prod_user"
printf '\n═══ Требование 5: разделение зависимостей ═══\n'
./check-separation.sh lab:final || true
prod_violations="$(cat /tmp/.separation_count 2>/dev/null || echo "?")"
./check-separation.sh lab:test quiet > /dev/null 2>&1 || true
test_violations="$(cat /tmp/.separation_count 2>/dev/null || echo "?")"
printf ' нарушений в production: %s\n' "$prod_violations"
printf ' нарушений в тестовом образе (контроль): %s\n' "$test_violations"
[ "$prod_violations" = "0" ] && [ "${test_violations:-0}" -gt 0 ] \
&& ok "production чист, и проверка доказала свою работоспособность на тестовом образе" \
|| bad "production=$prod_violations test=$test_violations"
printf '\n═══ Требование 6: выгрузка отчётов без запуска container ═══\n'
rm -rf reports && mkdir -p reports
docker build --target export --output "type=local,dest=./reports" --no-cache . \
> export.log 2>&1
printf ' выгружено файлов: %s\n' "$(ls reports 2>/dev/null | wc -l)"
ls -1 reports 2>/dev/null | sed 's/^/ /'
python3 - <<'PY'
import xml.etree.ElementTree as ET
from pathlib import Path
cov = Path("reports/coverage.xml")
junit = Path("reports/junit.xml")
if cov.exists():
root = ET.parse(cov).getroot()
print(f" покрытие строк: {float(root.get('line-rate', 0)) * 100:.0f} %")
sources = [s.text for s in root.iter("source")]
print(f" пути в отчёте: {sources}")
if junit.exists():
root = ET.parse(junit).getroot()
suite = root if root.tag == "testsuite" else root.find("testsuite")
print(f" тестов в junit.xml: {suite.get('tests')}, "
f"падений: {suite.get('failures')}")
PY
n_reports="$(ls reports 2>/dev/null | wc -l)"
[ "$n_reports" -ge 2 ] \
&& ok "отчёты выгружены через --output, container не запускался" \
|| bad "выгружено файлов: $n_reports"
printf '\n═══ Требование 7: потеря кода возврата ═══\n'
cat > Dockerfile.trap <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir pytest==9.1.1
COPY src/ ./src/
RUN mkdir -p tests && echo "def test_fails(): assert False" > tests/test_fail.py
RUN python -m pytest tests/ -q | tee /tmp/pytest.log
EOF
cat > Dockerfile.trap-fixed <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /app
RUN pip install --no-cache-dir pytest==9.1.1
COPY src/ ./src/
RUN mkdir -p tests && echo "def test_fails(): assert False" > tests/test_fail.py
RUN python -m pytest tests/ -q | tee /tmp/pytest.log
EOF
trap_rc=0
docker build -f Dockerfile.trap -t lab:trap --no-cache . > trap.log 2>&1 || trap_rc=$?
fixed_rc=0
docker build -f Dockerfile.trap-fixed -t lab:trapfixed --no-cache . > trapfix.log 2>&1 || fixed_rc=$?
printf ' %-34s %-14s %s\n' "вариант" "код сборки" "образ создан"
printf ' %s\n' "──────────────────────────────────────────────────────────────"
printf ' %-34s %-14s %s\n' "pytest | tee (без pipefail)" "$trap_rc" \
"$(docker image inspect lab:trap > /dev/null 2>&1 && echo ДА || echo нет)"
printf ' %-34s %-14s %s\n' "то же с SHELL pipefail" "$fixed_rc" \
"$(docker image inspect lab:trapfixed > /dev/null 2>&1 && echo да || echo НЕТ)"
grep -E '[0-9]+ failed' trap.log | tail -1 | sed 's/^/ в логе первого: /'
[ "$trap_rc" -eq 0 ] && [ "$fixed_rc" -ne 0 ] \
&& ok "ловушка воспроизведена: конвейер скрыл падение, pipefail исправил" \
|| bad "коды: без pipefail=$trap_rc с pipefail=$fixed_rc"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
docker rmi -f lab:final lab:test lab:unreach lab:unreach-test lab:trap lab:trapfixed \
> /dev/null 2>&1
cd /tmp && rm -rf /tmp/testlab
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: недостижимая стадия не выполняется ═══
маркер стадии test в выводе: не найден
образ собран: да
с явной целью --target test:
#12 0.398 МАРКЕР-СТАДИИ-TEST
#12 0.701 11 passed in 0.06s
✓ стадия test пропущена при обычной сборке, хотя образ собрался
═══ Требования 2-3: тесты обязательны ═══
маркер стадии test: НАЙДЕН — тесты выполнились
#14 0.688 11 passed in 0.06s
внутри образа: пройдено
ломаем один тест:
код возврата сборки: 1
#14 0.712 1 failed, 11 passed in 0.07s
образ lab:broken не создан
сломанный тест удалён
✓ проверено в обе стороны: исправные тесты пропускают, сломанный останавливает
═══ Требование 4: test наследует production ═══
пользователь в production: app
пользователь в test: root (переключён для установки пакетов)
файлы production: requirements.txt src
файлы test: pyproject.toml requirements-dev.txt requirements.txt src tests
хеш src/pricing.py: production=8f3a2b1c4d5e6f7a test=8f3a2b1c4d5e6f7a
✓ тесты проверяют тот же код, что отгружается (совпадение хешей)
═══ Требование 5: разделение зависимостей ═══
Разделение зависимостей: lab:final
✓ модуль pytest отсутствует
✓ модуль coverage отсутствует
✓ модуль _pytest отсутствует
✓ модуль pluggy отсутствует
✓ путь /app/tests отсутствует
✓ путь /app/requirements-dev.txt отсутствует
✓ путь /app/pyproject.toml отсутствует
✓ путь /app/.pytest_cache отсутствует
нарушений: 0
нарушений в production: 0
нарушений в тестовом образе (контроль): 7
✓ production чист, и проверка доказала свою работоспособность на тестовом образе
═══ Требование 6: выгрузка отчётов без запуска container ═══
выгружено файлов: 3
coverage.xml
junit.xml
passed.txt
покрытие строк: 100 %
пути в отчёте: ['/app']
тестов в junit.xml: 11, падений: 0
✓ отчёты выгружены через --output, container не запускался
═══ Требование 7: потеря кода возврата ═══
вариант код сборки образ создан
──────────────────────────────────────────────────────────────
pytest | tee (без pipefail) 0 ДА
то же с SHELL pipefail 1 НЕТ
в логе первого: 1 failed in 0.04s
✓ ловушка воспроизведена: конвейер скрыл падение, pipefail исправил
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены.
Требование 1 — самое ценное. Строка «маркер не найден» рядом со строкой «образ собран: да» показывает всю суть проблемы: сборка успешна, тесты не выполнялись, и в выводе нет ничего, что бы на это указало.
Три решения, определяющие качество.
Требование 5 проверяет проверку. Утверждение «в production-образе нет pytest» подтверждается нулём нарушений — но ноль получился бы и у сломанного скрипта, всегда возвращающего успех. Запуск той же проверки на тестовом образе даёт 7 нарушений и доказывает, что она способна их находить. Без контрольного запуска результат «0 нарушений» ничего не значит.
Требование 4 сравнивает хеш файла, а не факт его наличия. Наличие src/pricing.py в обоих образах не доказывает, что это один и тот же файл: стадии могли разойтись. Совпадение sha256 доказывает. Это же выявило бы ситуацию, когда в production попадает файл, изменённый после прогона тестов.
Ловушка с tee воспроизведена в обоих вариантах в одном прогоне. Показать только исправленный вариант — значит попросить поверить на слово. Таблица из двух строк с кодами 0 и 1 при одинаковом падающем тесте делает утверждение проверяемым, а разницу — наглядной.
Чего решение не делает. Отчёт о покрытии выгружен, но не проверено, что раздел [tool.coverage.paths] действительно сопоставляет пути: для этого нужно объединить отчёт из container'а с отчётом с машины, что требует второго прогона вне Docker. Быстрая итерация через монтирование в решении не задействована — она удобна в работе, но проверять её автоматически нечем: результат совпадает с обычным прогоном. Проверка разделения зависимостей перечисляет известные пакеты; неизвестный dev-пакет она не найдёт, для полноты нужен разбор установленного через pip list и сравнение со списком production-зависимостей.
Проверка результата
docker build --no-cache . 2>&1 | grep -c 'passed'
docker build --target test -t app:test . && docker run --rm app:test python -m pytest -q
docker run --rm --entrypoint python ОБРАЗ -c "import pytest" 2>&1 | tail -1
docker build --target export --output type=local,dest=./reports .
Первая команда должна найти строку о пройденных тестах — иначе стадия недостижима.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Стадия test без ссылок | Кажется, что стадии выполняются по порядку | BuildKit её пропустит; COPY --from=test |
test ветвится от base параллельно production | Симметрично выглядит | Тестируется не то, что поедет; FROM production AS test |
RUN pytest | tee log | Нужны логи | Код возврата от tee; SHELL с pipefail |
RUN pytest || true | Оставили после отладки | Тесты не останавливают сборку |
Единый requirements.txt | Проще | Test-зависимости попадают в production |
| Отчёт остаётся внутри образа | Не знают про --output | Отдельная стадия и type=local |
| Пути в отчёте не совпадают | Разные каталоги | Раздел [tool.coverage.paths] |
| Проверка разделения не проверена сама | «Ноль нарушений — значит чисто» | Запустить на образе, где нарушение есть |
| Только монтирование, без прогона в образе | Быстрее | Скрывает «файл не скопирован в образ» |
USER app перед установкой в стадии test | По аналогии с production | Установка требует прав; USER root в test |
Контрольные вопросы
На понимание:
- Почему стадия
testможет не выполниться приdocker build .? - Чем
FROM production AS testлучше, чемFROM base AS test? - Почему
RUN pytest | tee logне останавливает сборку при падении теста? - Что делает
--output type=localи чем это лучшеdocker cp? - Зачем нужен раздел
[tool.coverage.paths]?
На применение:
- Как сделать невозможной сборку production-образа без прохождения тестов?
- Как убедиться, что
pytestне попал в production-образ? - Как организовать быструю итерацию и чем за это платят?
На диагностику:
- Сборка проходит, но в логе нет ни слова о тестах. Гипотеза?
- Отчёт о покрытии показывает 0 % при явно работающих тестах. Причина?
Краткое резюме
- BuildKit строит только достижимые от цели стадии — стадия
testбез ссылок пропускается. - Сборка при этом успешна, и в выводе нет признаков проблемы.
COPY --from=testв финальной стадии делает тесты обязательными.FROM production AS testгарантирует, что проверяется отгружаемый артефакт.- В стадии
testпереключаются наrootдля установки пакетов — на production это не влияет. RUNостанавливает сборку при ненулевом коде; конвейер этот код теряет.SHELL ["/bin/bash", "-o", "pipefail", "-c"]возвращает поведение конвейера к ожидаемому.- Test-зависимости держат в отдельном файле и проверяют их отсутствие в образе.
- Проверку разделения нужно проверить на образе, где нарушение заведомо есть.
--output type=localвыгружает файлы из стадии, не создавая container.- Стадия для выгрузки строится на
scratchи содержит только отчёты. - Монтирование кода ускоряет цикл, но скрывает ошибки сборки образа.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: multi-stage builds | https://docs.docker.com/build/building/multi-stage/ | Стадии, --target |
| Docker: build exporters | https://docs.docker.com/build/exporters/local-tar/ | --output type=local |
Docker: SHELL | https://docs.docker.com/reference/dockerfile/#shell | Смена оболочки, pipefail |
Docker: RUN | https://docs.docker.com/reference/dockerfile/#run | Остановка при ненулевом коде |
| pytest: exit codes | https://docs.pytest.org/en/stable/reference/exit-codes.html | Коды возврата |
coverage.py: [paths] | https://coverage.readthedocs.io/en/latest/config.html#paths | Сопоставление путей |
| pytest-cov | https://pytest-cov.readthedocs.io/ | Формирование отчётов |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Integration tests через Compose
Главное оглавление