Раздел 10. Практические задания
Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.
Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.
Подготовка:
mkdir -p ~/docker-course/10-workflow && cd ~/docker-course/10-workflow
# docker pull принимает РОВНО один образ:
# `docker pull a b` отвечает «docker pull requires 1 argument»
for img in python:3.13-slim postgres:17-alpine; do docker pull -q "$img"; done
make --version | head -1
docker compose version
Задание 1. Две стадии в одном Dockerfile [обяз.]
Постановка. Напишите Dockerfile со стадиями dev и runtime, наследующими общий builder. Требуется:
runtime— последняя стадия:docker build .без флагов даёт production-образ.- В
runtimeнет ни одного пакета для разработки — проверено спискомpip list, а не только командами. - Стадия
devне выполняется при сборкеruntime— проверено логом сборки. - Правка кода не приводит к переустановке зависимостей — проверено временем.
Ожидаемый результат. Четыре подтверждения, каждое командой.
Проверка:
docker run --rm <образ:prod> pip list --format=freeze | grep -icE '^(pytest|ruff|mypy)'
docker build --target runtime --progress plain . 2>&1 | grep -c 'requirements-dev'
Разбор — в уроке 10.1.
Задание 2. Hot reload для FastAPI [обяз.]
Постановка. Настройте окружение так, чтобы правка .py-файла на host применялась без пересборки. Покажите:
- изменение видно после правки;
- PID процесса изменился;
- в логах есть сообщение watcher'а о перезагрузке.
Объясните, почему пункт 2 доказывает больше, чем пункт 1.
Ожидаемый результат. Три наблюдения и объяснение механизма.
Проверка:
curl -s localhost:<порт>/ | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["version"], d["pid"])'
Разбор — в уроке 10.2.
Задание 3. Исчезающие пакеты [обяз.]
Постановка. Воспроизведите ModuleNotFoundError при монтировании кода и устраните проблему тремя разными способами:
- виртуальное окружение вне монтируемого пути;
- anonymous volume поверх каталога окружения;
- монтирование подкаталога вместо корня.
Сравните способы и обоснуйте выбор для реального проекта.
Ожидаемый результат. Воспроизведение ошибки, три рабочих решения, таблица сравнения.
Проверка:
docker run --rm -v "$PWD:/app" <образ> python -c "import fastapi" 2>&1 | tail -1
Разбор — в уроке 10.2 и уроке 7.3.
Задание 4. Миграции отдельным сервисом [обяз.]
Постановка. Вынесите миграции в отдельный сервис Compose. Требуется показать:
migrateстартует послеservice_healthyу базы;- приложение стартует после
service_completed_successfullyуmigrate; - при провале миграции container приложения не создаётся вовсе;
- повторный
upне применяет ничего и завершается с кодом0.
Ожидаемый результат. Четыре подтверждения; пункт 3 — подсчётом container'ов.
Проверка:
docker ps -a --filter label=com.docker.compose.service=app -q | wc -l
docker inspect $(docker compose ps -aq migrate) --format '{{.State.ExitCode}}'
Разбор — в уроке 10.4 и уроке 9.4.
Задание 5. Makefile проекта [обяз.]
Постановка. Напишите Makefile с целями up, down, test, lint, migrate, shell и help. Требования:
make helpперечисляет цели с описаниями, взятыми из самого файла;- все цели объявлены в
.PHONY; - в командах нет флагов конфигурации инструментов — они в
pyproject.toml; make testвозвращает ненулевой код при падении теста.
Ожидаемый результат. Работающий Makefile и подтверждение каждого требования.
Проверка:
make help
grep -E '(--line-length|--strict|--cov-fail-under)' Makefile || echo "флагов конфигурации нет"
Разбор — в уроке 10.5.
Задание 6. Отладчик IDE [доп.]
Постановка. Подключите отладчик к процессу внутри container через debugpy:
- порт отладчика опубликован и принимает соединения;
- приложение обслуживает запросы без подключённого отладчика;
- с
--wait-for-clientприложение не стартует — показать последствие для healthcheck; debugpyприсутствует только в dev-образе.
Дополнительно объясните назначение pathMappings.
Ожидаемый результат. Четыре подтверждения и рабочая конфигурация IDE.
Проверка:
python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('127.0.0.1',5678)); print('отладчик доступен')"
docker run --rm <образ:prod> pip list --format=freeze | grep -c debugpy
Разбор — в уроке 10.3.
Задание 7. pre-commit с версиями из CI [доп.]
Постановка. Настройте pre-commit так, чтобы версии инструментов совпадали с теми, что использует CI. Требуется:
- быстрые проверки (
ruff) — из готового repo с зафиксированной версией; - проверки, требующие окружения (
mypy) — черезdocker compose run; - версия
ruffв.pre-commit-config.yamlсовпадает сrequirements-dev.txt— проверить скриптом; - хуки, работающие в container, объявляют
pass_filenames: false— объяснить почему.
Ожидаемый результат. Конфигурация и скрипт проверки совпадения версий.
Проверка:
grep -oP 'rev: v\K[0-9.]+' .pre-commit-config.yaml
grep -oP '^ruff==\K[0-9.]+' requirements-dev.txt
Разбор — в уроке 10.5.
Задание 8. Пересоздание тестовой базы [доп.]
Постановка. Сделайте команду make db-reset, которая полностью пересоздаёт тестовую базу с нуля: удаляет данные, поднимает базу, применяет миграции.
Требования:
- база работает на
tmpfs— данные не переживают перезапуск; - включены небезопасные, но быстрые настройки PostgreSQL;
- измерено ускорение относительно дисковой базы;
- команда идемпотентна: повторный вызов даёт тот же результат.
Ожидаемый результат. Работающая команда и измерение ускорения.
Проверка:
time make db-reset
docker compose exec -T db-test psql -U postgres -d testdb -tAc 'SELECT max(version) FROM schema_version'
Разбор — в уроке 10.4.
Задание 9. Диагностика: пакет не появился [диаг.]
Постановка. Разработчик добавил httpx в requirements.txt и перезапустил стек командой docker compose restart. Приложение падает:
ModuleNotFoundError: No module named 'httpx'
Конфигурация:
name: diag10
services:
api:
build:
context: .
target: dev
volumes:
- .:/app
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--reload"]
Найдите все причины, по которым пакет мог не появиться, объясните каждую и предложите исправление.
Ожидаемый результат. Список причин с доказательством каждой и рабочая конфигурация.
Разбор
Здесь четыре независимые причины. Любая из них по отдельности даёт тот же симптом.
Причина 1. restart не пересобирает образ.
cd ~/docker-course/10-workflow && mkdir -p diag9/app && cd diag9
# (файлы из условия созданы)
docker compose restart api 2>&1 | tail -1 | sed 's/^/ /'
docker compose exec -T api pip list --format=freeze 2>/dev/null | grep -c httpx \
| xargs printf ' httpx в container после restart: %s\n'
Ожидаемый вывод:
✔ Container diag10-api-1 Started
httpx в container после restart: 0
docker compose restart останавливает и запускает тот же container из того же образа. Ни сборки, ни пересоздания не происходит.
| Команда | Пересобирает образ | Пересоздаёт container |
|---|---|---|
restart | Нет | Нет |
up | Только при изменении конфигурации | При изменении |
up --build | Да | Да |
build | Да | Нет |
Исправление: docker compose up -d --build.
Причина 2. --reload не подхватывает зависимости.
Даже если бы разработчик надеялся на автоматическую перезагрузку — она перезапускает процесс, но не переустанавливает пакеты (урок 10.2):
cd ~/docker-course/10-workflow/diag9
docker compose logs api --no-log-prefix 2>/dev/null | grep -ci 'reloading' \
| xargs printf ' перезагрузок watcher: %s\n'
docker compose exec -T api pip list --format=freeze 2>/dev/null | grep -c httpx \
| xargs printf ' httpx после перезагрузок: %s\n'
Ожидаемый вывод:
перезагрузок watcher: 3
httpx после перезагрузок: 0
Три перезагрузки — и ноль эффекта для зависимостей. Watcher перезапускает процесс в том же окружении.
Причина 3. Монтирование .:/app скрывает окружение, если оно внутри /app.
cd ~/docker-course/10-workflow/diag9
echo "═══ где виртуальное окружение ═══"
docker compose run --rm -T --no-deps api sh -c 'ls -d /app/.venv /opt/venv 2>/dev/null' 2>/dev/null | sed 's/^/ /'
echo "═══ что видно с монтированием ═══"
docker compose exec -T api sh -c 'ls -d /app/.venv 2>/dev/null || echo " /app/.venv скрыт монтированием"'
Ожидаемый вывод:
═══ где виртуальное окружение ═══
/app/.venv
═══ что видно с монтированием ═══
/app/.venv скрыт монтированием
Если окружение собрано в /app/.venv, монтирование корня проекта его перекрывает — и ни один пакет не найдётся, не только httpx (урок 7.3).
Исправление: python -m venv /opt/venv либо монтирование подкаталога.
Причина 4. Кэш сборки не инвалидировался.
Если COPY requirements.txt идёт после COPY . ., порядок слоёв нарушен, но кэш всё равно должен сброситься. А вот если файл скопирован под другим именем или каталог исключён в .dockerignore — установка выполнится со старым списком:
cd ~/docker-course/10-workflow/diag9
echo "═══ requirements.txt в контексте сборки ═══"
docker build -q --target dev -t diag9:check . > /dev/null 2>&1
docker run --rm diag9:check cat /app/requirements.txt 2>/dev/null | sed 's/^/ /' \
|| echo " файла нет в образе"
echo "═══ .dockerignore ═══"
cat .dockerignore 2>/dev/null | sed 's/^/ /' || echo " отсутствует"
Ожидаемый вывод:
═══ requirements.txt в контексте сборки ═══
fastapi[standard]==0.141.1
httpx==0.28.1
═══ .dockerignore ═══
.git
__pycache__
Здесь файл в порядке. Но если бы .dockerignore содержал requirements*.txt или широкий шаблон, COPY взял бы устаревшую версию или упал бы.
Порядок диагностики. Симптом один, причин четыре — проверять нужно по порядку, от дешёвого к дорогому:
cd ~/docker-course/10-workflow/diag9
echo "1. Пакет вообще есть в requirements.txt?"
grep -c httpx requirements.txt | xargs printf ' вхождений: %s\n'
echo "2. Пакет попал в образ?"
docker build -q --target dev -t diag9:probe . > /dev/null 2>&1
docker run --rm diag9:probe pip list --format=freeze 2>/dev/null | grep -c httpx \
| xargs printf ' в образе: %s\n'
echo "3. Пакет виден в работающем container?"
docker compose exec -T api pip list --format=freeze 2>/dev/null | grep -c httpx \
| xargs printf ' в container: %s\n'
echo "4. Где ищет интерпретатор?"
docker compose exec -T api python -c "import sys; print(' ', sys.executable)" 2>/dev/null
Ожидаемый вывод:
1. Пакет вообще есть в requirements.txt?
вхождений: 1
2. Пакет попал в образ?
в образе: 1
3. Пакет виден в работающем container?
в container: 0
4. Где ищет интерпретатор?
/usr/local/bin/python
Расхождение между шагами 2 и 3 указывает точно: пакет в образе есть, но работающий container использует старый образ либо не то окружение.
Шаг 4 добавляет решающую деталь: интерпретатор системный (/usr/local/bin/python), а не из виртуального окружения — значит, PATH указывает на скрытый монтированием каталог.
Исправленная конфигурация:
name: diag10
services:
api:
build:
context: .
target: dev
volumes:
# Монтируем только код: /opt/venv вне /app и не затрагивается
- ./app:/app/app
command:
- uvicorn
- app.main:app
- --host
- 0.0.0.0
- --reload
- --reload-dir
- app
develop:
watch:
- action: sync
path: ./app
target: /app/app
# Изменение зависимостей автоматически пересобирает образ
- action: rebuild
path: ./requirements.txt
FROM python:3.13-slim AS base
ENV PATH="/opt/venv/bin:$PATH"
RUN python -m venv /opt/venv # ВНЕ /app
WORKDIR /app
Проверка исправления:
cd ~/docker-course/10-workflow/diag9
docker compose up -d --build > /dev/null 2>&1
sleep 8
docker compose exec -T api python -c "
import sys, importlib.util
print(' интерпретатор:', sys.executable)
print(' httpx доступен:', importlib.util.find_spec('httpx') is not None)
" 2>/dev/null
docker compose down > /dev/null 2>&1
Ожидаемый вывод:
интерпретатор: /opt/venv/bin/python
httpx доступен: True
Сводка:
| № | Причина | Симптом-отличие | Исправление |
|---|---|---|---|
| 1 | restart вместо up --build | Пакет есть в образе, нет в container | up -d --build |
| 2 | Надежда на --reload | Перезагрузки в логах, эффекта нет | Пересборка обязательна |
| 3 | Монтирование скрыло окружение | Не найден ни один пакет | Venv в /opt/venv |
| 4 | Файл не попал в контекст | Пакета нет и в образе | Проверить .dockerignore |
Третья колонка — то, что позволяет различить причины за одну команду. Если не найден только httpx — это 1, 2 или 4; если не найден и fastapi — это 3.
Как избежать в будущем. Действие rebuild в develop.watch устраняет причины 1 и 2 полностью: изменение requirements.txt автоматически запускает пересборку (урок 10.2).
Задание 10. Окружение без чужих файлов [★]
Постановка. Организуйте рабочий процесс, удовлетворяющий девяти требованиям.
- Один
Dockerfileсо стадиямиdevиruntime; в production нет инструментов разработки. - Hot reload работает; правка кода видна без пересборки.
- Пакеты образа не скрываются монтированием — доказать.
- Все файлы, созданные приложением и инструментами в bind mount, принадлежат вашему UID.
- Эти файлы удаляются с host без
sudo. - Миграции выполняются отдельным сервисом до старта приложения.
make checkвыполняет линтер, типы и тесты; CI вызывает ту же цель.- Конфигурация инструментов только в
pyproject.toml. - Решение работает у коллеги с другим UID без правки файлов репозитория.
Скрипт проверки возвращает ненулевой код при любом расхождении.
Подсказки
Подсказка 1
Требования 4 и 9 вместе означают, что UID приходит из окружения, а не записан в файлы.
Подсказка 2
Требование 4 проверяйте на файле, созданном приложением, и на файле, изменённом ruff format.
Подсказка 3
Требование 3 проверяется монтированием и попыткой импорта пакета, установленного в образе.
Подсказка 4
Для требования 9 достаточно собрать образ с UID=4242 и убедиться, что файлы репозитория не менялись.
Решение
Показать решение
mkdir -p ~/docker-course/10-workflow/final/{app,tests,migrations,scripts,output}
cd ~/docker-course/10-workflow/final
cat > app/__init__.py <<'PY'
"""Приложение итогового задания раздела 10."""
PY
cat > app/settings.py <<'PY'
"""Конфигурация."""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
"""Параметры подключения и вывода."""
db_host: str = os.environ.get("DB_HOST", "db")
db_port: int = int(os.environ.get("DB_PORT", "5432"))
db_name: str = os.environ.get("DB_NAME", "appdb")
db_user: str = os.environ.get("DB_USER", "postgres")
db_password: str = os.environ.get("DB_PASSWORD", "secret")
output_dir: str = os.environ.get("OUTPUT_DIR", "/app/output")
@property
def dsn(self) -> str:
"""Строка подключения к PostgreSQL."""
return (f"postgresql://{self.db_user}:{self.db_password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}")
PY
cat > app/main.py <<'PY'
"""FastAPI-сервис: пишет файлы в bind mount, читает данные из базы."""
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
import psycopg
from fastapi import FastAPI, Response
from .settings import Settings
VERSION = "1" # правится на host для проверки hot reload
settings = Settings()
app = FastAPI()
@app.get("/healthz")
async def healthz() -> dict[str, str]:
"""Liveness: только процесс."""
return {"status": "ok"}
@app.get("/")
async def root() -> dict[str, object]:
"""Сведения об окружении."""
return {
"version": VERSION,
"pid": os.getpid(),
"uid": os.getuid(),
"executable": os.environ.get("PATH", "").split(":")[0],
"psycopg": importlib.util.find_spec("psycopg") is not None,
}
@app.post("/write")
async def write_file() -> dict[str, object]:
"""Требование 4: файл должен принадлежать UID разработчика."""
target = Path(settings.output_dir) / "generated.txt"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"создано процессом {os.getpid()}\n", encoding="utf-8")
st = target.stat()
return {"path": str(target), "uid": st.st_uid, "gid": st.st_gid}
@app.get("/items")
async def items(response: Response) -> dict[str, object]:
"""Читает таблицу, созданную миграциями."""
try:
with psycopg.connect(settings.dsn, connect_timeout=3) as conn, conn.cursor() as cur:
cur.execute("SELECT count(*) FROM items")
count = cur.fetchone()[0]
except Exception as exc:
response.status_code = 503
return {"error": type(exc).__name__}
return {"count": count}
PY
cat > migrations/migrate.py <<'PY'
"""Идемпотентные миграции под консультативной блокировкой."""
from __future__ import annotations
import sys
import time
from pathlib import Path
import psycopg
sys.path.insert(0, "/app")
from app.settings import Settings # noqa: E402
settings = Settings()
LOCK_KEY = 20261031
MIGRATIONS: dict[int, list[str]] = {
1: ["CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, name text NOT NULL)"],
2: ["ALTER TABLE items ADD COLUMN IF NOT EXISTS created_at"
" timestamptz NOT NULL DEFAULT now()"],
}
def connect(attempts: int = 40) -> psycopg.Connection:
"""Повторы с экспоненциальной задержкой."""
delay = 0.25
last: Exception | None = None
for _ in range(attempts):
try:
return psycopg.connect(settings.dsn, connect_timeout=3)
except psycopg.OperationalError as exc:
last = exc
time.sleep(delay)
delay = min(delay * 2, 3.0)
raise RuntimeError(f"база недоступна: {last}")
def main() -> int:
with connect() as conn, conn.cursor() as cur:
cur.execute("SELECT pg_advisory_lock(%s)", (LOCK_KEY,))
cur.execute("""
CREATE TABLE IF NOT EXISTS schema_version (
version integer PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now())
""")
conn.commit()
cur.execute("SELECT coalesce(max(version), 0) FROM schema_version")
current = cur.fetchone()[0]
applied = 0
for version in sorted(MIGRATIONS):
if version <= current:
continue
for stmt in MIGRATIONS[version]:
cur.execute(stmt)
cur.execute("INSERT INTO schema_version (version) VALUES (%s)", (version,))
applied += 1
conn.commit()
cur.execute("SELECT coalesce(max(version), 0) FROM schema_version")
print(f"МИГРАЦИИ: применено {applied}, версия {cur.fetchone()[0]}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
PY
cat > tests/test_settings.py <<'PY'
from app.settings import Settings
def test_dsn_format() -> None:
s = Settings(db_host="h", db_port=1234, db_name="n", db_user="u", db_password="p")
assert s.dsn == "postgresql://u:p@h:1234/n"
def test_defaults_present() -> None:
s = Settings()
assert s.db_port > 0
assert s.output_dir
PY
cat > pyproject.toml <<'EOF'
[project]
name = "final10"
version = "0.1.0"
requires-python = ">=3.13"
# Требование 8: единственный источник правды
[tool.ruff]
line-length = 100
target-version = "py313"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.mypy]
python_version = "3.13"
ignore_missing_imports = true
files = ["app", "tests"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"
EOF
cat > requirements.txt <<'EOF'
fastapi[standard]==0.141.1
psycopg[binary]==3.3.4
EOF
cat > requirements-dev.txt <<'EOF'
-r requirements.txt
pytest==9.1.1
ruff==0.16.0
mypy==1.19.1
EOF
cat > .dockerignore <<'EOF'
.git
__pycache__
*.py[cod]
.pytest_cache
.ruff_cache
.mypy_cache
output
.env
Makefile
scripts
compose*.yaml
Dockerfile
EOF
cat > .gitignore <<'EOF'
.env
output/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.mypy_cache/
EOF
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app \
PATH="/opt/venv/bin:$PATH"
# Требование 3: окружение ВНЕ /app — монтирование его не скроет
RUN python -m venv /opt/venv
WORKDIR /app
FROM base AS builder
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
# ── Разработка: UID приходит снаружи (требование 9) ──
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 /app/output && chown -R ${UID}:${GID} /home/dev /app
ENV HOME=/home/dev
USER ${UID}:${GID}
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
"--reload", "--reload-dir", "app"]
# ── Production: последняя стадия ──
FROM base AS runtime
RUN useradd --create-home --uid 10001 appuser && \
mkdir -p /app/output && chown 10001:10001 /app/output
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
COPY --chown=10001:10001 migrations/ ./migrations/
USER 10001:10001
EXPOSE 8000
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
EOF
cat > compose.yaml <<'EOF'
name: final10
x-app-env: &app-env
DB_HOST: db
DB_NAME: appdb
DB_USER: postgres
DB_PASSWORD: secret
OUTPUT_DIR: /app/output
services:
api:
build:
context: .
target: runtime
environment: *app-env
depends_on:
migrate:
condition: service_completed_successfully
migrate:
build:
context: .
target: runtime
command: ["python", "/app/migrations/migrate.py"]
environment: *app-env
restart: "no"
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_PASSWORD: secret
tmpfs:
- /var/lib/postgresql/data:size=512m
command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 1s
timeout: 3s
retries: 30
start_period: 20s
start_interval: 1s
tools:
build:
context: .
target: dev
args:
UID: "${UID:-1000}"
GID: "${GID:-1000}"
image: final10:dev
working_dir: /app
volumes:
- ./app:/app/app
- ./tests:/app/tests
- ./pyproject.toml:/app/pyproject.toml:ro
profiles: [tools]
EOF
cat > compose.override.yaml <<'EOF'
# Разработка: подхватывается автоматически
services:
api:
build:
target: dev
args:
UID: "${UID:-1000}"
GID: "${GID:-1000}"
ports:
- "127.0.0.1:8400:8000"
volumes:
- ./app:/app/app # требование 2: код с host
- ./output:/app/output # требование 4: файлы видны на host
migrate:
build:
target: dev
args:
UID: "${UID:-1000}"
GID: "${GID:-1000}"
volumes:
- ./app:/app/app
- ./migrations:/app/migrations
EOF
printf 'UID=%s\nGID=%s\n' "$(id -u)" "$(id -g)" > .env
cat > Makefile <<'MAKEFILE'
.DEFAULT_GOAL := help
.PHONY: help build up down lint format typecheck test check migrate shell clean
COMPOSE := docker compose
TOOLS := $(COMPOSE) --profile tools run --rm --no-deps -T tools
help: ## показать доступные команды
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
build: ## собрать образы
@$(COMPOSE) --profile tools build
up: ## поднять стек
@$(COMPOSE) up -d --build
down: ## остановить стек
@$(COMPOSE) down
migrate: ## выполнить миграции
@$(COMPOSE) run --rm migrate
lint: ## проверить стиль
$(TOOLS) ruff check app tests
format: ## отформатировать код
$(TOOLS) ruff format app tests
typecheck: ## проверить типы
$(TOOLS) mypy
test: ## прогнать тесты
$(TOOLS) pytest
check: lint typecheck test ## все проверки — то же, что делает CI
shell: ## оболочка в dev-образе
@$(COMPOSE) --profile tools run --rm --no-deps tools bash
clean: ## убрать за собой
@$(COMPOSE) --profile tools down -v --remove-orphans
@rm -rf .pytest_cache .ruff_cache .mypy_cache output/*
MAKEFILE
mkdir -p .github/workflows
cat > .github/workflows/ci.yaml <<'EOF'
name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make build
- run: make check
EOF
# ── Проверка ──
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
api() { curl -s -m 5 "http://127.0.0.1:8400$1" 2>/dev/null; }
field() { api / | python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])" 2>/dev/null; }
printf '\n═══ Требование 1: стадии и чистота production ═══\n'
make build > /dev/null 2>&1
docker build -q --target runtime -t final10:prod . > /dev/null 2>&1
dev_pkgs="$(docker run --rm final10:dev pip list --format=freeze 2>/dev/null \
| grep -icE '^(pytest|ruff|mypy)' || true)"
prod_pkgs="$(docker run --rm final10:prod pip list --format=freeze 2>/dev/null \
| grep -icE '^(pytest|ruff|mypy)' || true)"
printf ' инструментов в dev: %s, в production: %s\n' "$dev_pkgs" "$prod_pkgs"
[ "$prod_pkgs" -eq 0 ] && [ "$dev_pkgs" -gt 0 ] \
&& ok "production чист, проверка информативна" || bad "dev=$dev_pkgs prod=$prod_pkgs"
printf '\n═══ Требование 6: миграции до приложения ═══\n'
make up > /tmp/final10-up.log 2>&1
for _ in $(seq 90); do [ -n "$(field version)" ] && break; sleep 1; done
mcode="$(docker inspect "$(docker compose ps -aq migrate)" --format '{{.State.ExitCode}}' 2>/dev/null)"
docker compose logs migrate --no-log-prefix 2>/dev/null | grep МИГРАЦИИ | sed 's/^/ /'
printf ' код migrate: %s\n' "$mcode"
items="$(api /items | python3 -c 'import json,sys; print(json.load(sys.stdin).get("count", "ошибка"))' 2>/dev/null)"
printf ' таблица items доступна приложению: count=%s\n' "$items"
[ "$mcode" = "0" ] && [ "$items" = "0" ] \
&& ok "миграции выполнены, приложение видит схему" || bad "migrate=$mcode items=$items"
printf '\n═══ Требования 2–3: hot reload и видимость пакетов ═══\n'
printf ' psycopg доступен: %s, интерпретатор из: %s\n' "$(field psycopg)" "$(field executable)"
[ "$(field psycopg)" = "True" ] && [ "$(field executable)" = "/opt/venv/bin" ] \
&& ok "пакеты образа не скрыты монтированием (требование 3)" \
|| bad "psycopg=$(field psycopg) path=$(field executable)"
pid_before="$(field pid)"
sed -i 's/^VERSION = "1"/VERSION = "2-ПРАВКА"/' app/main.py
for _ in $(seq 25); do [ "$(field version)" = "2-ПРАВКА" ] && break; sleep 1; done
pid_after="$(field pid)"
printf ' version=%s, pid: %s → %s\n' "$(field version)" "$pid_before" "$pid_after"
[ "$(field version)" = "2-ПРАВКА" ] && [ "$pid_before" != "$pid_after" ] \
&& ok "код перезагружен, процесс перезапущен (требование 2)" || bad "перезагрузка не сработала"
printf '\n═══ Требования 4–5: владельцы файлов ═══\n'
rm -f output/generated.txt
api_out="$(curl -s -m 5 -X POST "http://127.0.0.1:8400/write" 2>/dev/null)"
echo "$api_out" | python3 -m json.tool --compact 2>/dev/null | sed 's/^/ /'
owner_app="$(stat -c '%u:%g' output/generated.txt 2>/dev/null)"
printf ' файл приложения: %s (ваш: %s:%s)\n' "$owner_app" "$(id -u)" "$(id -g)"
[ "$owner_app" = "$(id -u):$(id -g)" ] && ok "файл приложения ваш" || bad "владелец $owner_app"
printf 'z = 1\n' >> app/main.py
make format > /dev/null 2>&1
owner_fmt="$(stat -c '%u:%g' app/main.py)"
printf ' файл после ruff format: %s\n' "$owner_fmt"
[ "$owner_fmt" = "$(id -u):$(id -g)" ] && ok "файл инструмента ваш" || bad "владелец $owner_fmt"
sed -i '/^z = 1$/d' app/main.py
rm -f output/generated.txt 2>/dev/null && ok "удаление без sudo (требование 5)" || bad "нужен sudo"
printf '\n═══ Требования 7–8: make check и конфигурация ═══\n'
make check > /tmp/final10-check.log 2>&1
rc=$?
grep -E 'All checks passed|Success|passed in' /tmp/final10-check.log | sed 's/^/ /'
[ "$rc" -eq 0 ] && ok "make check прошёл (требование 7)" || bad "check вернул $rc"
grep -q 'run: make check' .github/workflows/ci.yaml \
&& ok "CI вызывает ту же цель" || bad "CI вызывает другое"
if grep -qE '(--line-length|--strict|--cov-fail-under)' Makefile; then
bad "флаги конфигурации в Makefile"
else
ok "конфигурация только в pyproject.toml (требование 8)"
fi
printf '\n═══ Требование 9: другой UID ═══\n'
UID=4242 GID=4242 docker compose --profile tools build tools > /dev/null 2>&1
other="$(UID=4242 GID=4242 docker compose --profile tools run --rm --no-deps -T tools id -u 2>/dev/null | tr -d '\r')"
printf ' UID в образе при UID=4242: %s\n' "$other"
[ "$other" = "4242" ] && ok "собралось под чужой UID" || bad "получено '$other'"
hardcoded="$(grep -rl "UID=$(id -u)" --include='*.yaml' --include='Dockerfile' --include='Makefile' . 2>/dev/null | wc -l)"
printf ' файлов репозитория с зашитым UID: %s\n' "$hardcoded"
[ "$hardcoded" -eq 0 ] && ok "UID не записан в репозиторий (требование 9)" || bad "UID зашит"
UID="$(id -u)" GID="$(id -g)" docker compose --profile tools build tools > /dev/null 2>&1
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все девять требований выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
make clean > /dev/null 2>&1
docker rmi -f final10:dev final10:prod > /dev/null 2>&1
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: стадии и чистота production ═══
инструментов в dev: 3, в production: 0
✓ production чист, проверка информативна
═══ Требование 6: миграции до приложения ═══
МИГРАЦИИ: применено 2, версия 2
код migrate: 0
таблица items доступна приложению: count=0
✓ миграции выполнены, приложение видит схему
═══ Требования 2–3: hot reload и видимость пакетов ═══
psycopg доступен: True, интерпретатор из: /opt/venv/bin
✓ пакеты образа не скрыты монтированием (требование 3)
version=2-ПРАВКА, pid: 9 → 15
✓ код перезагружен, процесс перезапущен (требование 2)
═══ Требования 4–5: владельцы файлов ═══
{"path":"/app/output/generated.txt","uid":1000,"gid":1000}
файл приложения: 1000:1000 (ваш: 1000:1000)
✓ файл приложения ваш
файл после ruff format: 1000:1000
✓ файл инструмента ваш
✓ удаление без sudo (требование 5)
═══ Требования 7–8: make check и конфигурация ═══
All checks passed!
Success: no issues found in 4 source files
2 passed in 0.05s
✓ make check прошёл (требование 7)
✓ CI вызывает ту же цель
✓ конфигурация только в pyproject.toml (требование 8)
═══ Требование 9: другой UID ═══
UID в образе при UID=4242: 4242
✓ собралось под чужой UID
файлов репозитория с зашитым UID: 0
✓ UID не записан в репозиторий (требование 9)
═══ ИТОГ ═══
все девять требований выполнены
Все девять требований выполнены.
Три решения, определяющие качество.
Требование 4 проверяется на двух файлах: созданном приложением и изменённом инструментом. Это разные процессы в разных container'ах — api и tools. UID мог совпасть у одного и не совпасть у другого, если стадия dev собиралась с разными аргументами. Две проверки закрывают оба пути появления файлов на host.
Требование 1 проверяет и dev-образ тоже. «Ноль инструментов в production» прошло бы и при ошибке в команде подсчёта. Ненулевое значение для dev доказывает, что метод работает и различает образы.
Сервис migrate в базовом файле собирается из стадии runtime, а в override — из dev. Это не случайность: в production миграции должны выполняться тем же образом, что и приложение, без инструментов разработки. В разработке им нужен bind mount кода, чтобы правка миграции применялась без пересборки. Разделение по файлам даёт оба поведения из одного Dockerfile.
Чего решение не делает. Каталог output/ создаётся заранее скриптом; если бы он существовал и принадлежал другому пользователю — например, остался от прежних запусков от root — bind mount владельца не изменил бы, и приложение упёрлось бы в отказ записи. Промышленная обёртка проверяет владельца перед запуском (урок 7.5). Не покрыт и develop.watch: он снял бы проблему UID полностью, но лишил бы возможности видеть файлы, созданные приложением, — а это требование 4.
Очистка после раздела
docker compose down -v --remove-orphans 2>/dev/null || true
# ВНИМАНИЕ: НЕ `docker ps -aq | xargs -r docker rm -f`.
# Такая строка удаляет ВСЕ container'ы на машине, включая чужие:
# базу коллеги, кластер kind, работающий стенд. Удаляем только
# созданные из образов этого раздела.
for img in python:3.13-slim postgres:17-alpine; do
docker ps -aq --filter "ancestor=$img" | xargs -r docker rm -f
done
docker image prune -f
docker builder prune -f
docker system df
Сравните с состоянием, зафиксированным в начале раздела.
Критерии завершения
Раздел закрыт, когда выполнены обязательные задания 1–5 и вы можете без подсказок ответить на вопросы из MAIN.md раздела.
Дальше: Quiz 10.
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий раздел → Production
Главное оглавление