Главная/Development workflow/Практика

Раздел 10. Практические задания

Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.

Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.

Подготовка:

bash
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. Требуется:

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

Ожидаемый результат. Четыре подтверждения, каждое командой.

Проверка:

bash
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 применялась без пересборки. Покажите:

  1. изменение видно после правки;
  2. PID процесса изменился;
  3. в логах есть сообщение watcher'а о перезагрузке.

Объясните, почему пункт 2 доказывает больше, чем пункт 1.

Ожидаемый результат. Три наблюдения и объяснение механизма.

Проверка:

bash
curl -s localhost:<порт>/ | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["version"], d["pid"])'

Разбор — в уроке 10.2.


Задание 3. Исчезающие пакеты [обяз.]

Постановка. Воспроизведите ModuleNotFoundError при монтировании кода и устраните проблему тремя разными способами:

  1. виртуальное окружение вне монтируемого пути;
  2. anonymous volume поверх каталога окружения;
  3. монтирование подкаталога вместо корня.

Сравните способы и обоснуйте выбор для реального проекта.

Ожидаемый результат. Воспроизведение ошибки, три рабочих решения, таблица сравнения.

Проверка:

bash
docker run --rm -v "$PWD:/app" <образ> python -c "import fastapi" 2>&1 | tail -1

Разбор — в уроке 10.2 и уроке 7.3.


Задание 4. Миграции отдельным сервисом [обяз.]

Постановка. Вынесите миграции в отдельный сервис Compose. Требуется показать:

  1. migrate стартует после service_healthy у базы;
  2. приложение стартует после service_completed_successfully у migrate;
  3. при провале миграции container приложения не создаётся вовсе;
  4. повторный up не применяет ничего и завершается с кодом 0.

Ожидаемый результат. Четыре подтверждения; пункт 3 — подсчётом container'ов.

Проверка:

bash
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. Требования:

  1. make help перечисляет цели с описаниями, взятыми из самого файла;
  2. все цели объявлены в .PHONY;
  3. в командах нет флагов конфигурации инструментов — они в pyproject.toml;
  4. make test возвращает ненулевой код при падении теста.

Ожидаемый результат. Работающий Makefile и подтверждение каждого требования.

Проверка:

bash
make help
grep -E '(--line-length|--strict|--cov-fail-under)' Makefile || echo "флагов конфигурации нет"

Разбор — в уроке 10.5.


Задание 6. Отладчик IDE [доп.]

Постановка. Подключите отладчик к процессу внутри container через debugpy:

  1. порт отладчика опубликован и принимает соединения;
  2. приложение обслуживает запросы без подключённого отладчика;
  3. с --wait-for-client приложение не стартует — показать последствие для healthcheck;
  4. debugpy присутствует только в dev-образе.

Дополнительно объясните назначение pathMappings.

Ожидаемый результат. Четыре подтверждения и рабочая конфигурация IDE.

Проверка:

bash
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. Требуется:

  1. быстрые проверки (ruff) — из готового repo с зафиксированной версией;
  2. проверки, требующие окружения (mypy) — через docker compose run;
  3. версия ruff в .pre-commit-config.yaml совпадает с requirements-dev.txt — проверить скриптом;
  4. хуки, работающие в container, объявляют pass_filenames: false — объяснить почему.

Ожидаемый результат. Конфигурация и скрипт проверки совпадения версий.

Проверка:

bash
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, которая полностью пересоздаёт тестовую базу с нуля: удаляет данные, поднимает базу, применяет миграции.

Требования:

  1. база работает на tmpfs — данные не переживают перезапуск;
  2. включены небезопасные, но быстрые настройки PostgreSQL;
  3. измерено ускорение относительно дисковой базы;
  4. команда идемпотентна: повторный вызов даёт тот же результат.

Ожидаемый результат. Работающая команда и измерение ускорения.

Проверка:

bash
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. Приложение падает:

text
ModuleNotFoundError: No module named 'httpx'

Конфигурация:

yaml
name: diag10

services:
  api:
    build:
      context: .
      target: dev
    volumes:
      - .:/app
    command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--reload"]

Найдите все причины, по которым пакет мог не появиться, объясните каждую и предложите исправление.

Ожидаемый результат. Список причин с доказательством каждой и рабочая конфигурация.

Разбор

Здесь четыре независимые причины. Любая из них по отдельности даёт тот же симптом.

Причина 1. restart не пересобирает образ.

bash
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'

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

text
  ✔ 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):

bash
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'

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

text
  перезагрузок watcher: 3
  httpx после перезагрузок: 0

Три перезагрузки — и ноль эффекта для зависимостей. Watcher перезапускает процесс в том же окружении.

Причина 3. Монтирование .:/app скрывает окружение, если оно внутри /app.

bash
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 скрыт монтированием"'

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

text
═══ где виртуальное окружение ═══
  /app/.venv
═══ что видно с монтированием ═══
  /app/.venv скрыт монтированием

Если окружение собрано в /app/.venv, монтирование корня проекта его перекрывает — и ни один пакет не найдётся, не только httpx (урок 7.3).

Исправление: python -m venv /opt/venv либо монтирование подкаталога.

Причина 4. Кэш сборки не инвалидировался.

Если COPY requirements.txt идёт после COPY . ., порядок слоёв нарушен, но кэш всё равно должен сброситься. А вот если файл скопирован под другим именем или каталог исключён в .dockerignore — установка выполнится со старым списком:

bash
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 "  отсутствует"

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

text
═══ requirements.txt в контексте сборки ═══
  fastapi[standard]==0.141.1
  httpx==0.28.1
═══ .dockerignore ═══
  .git
  __pycache__

Здесь файл в порядке. Но если бы .dockerignore содержал requirements*.txt или широкий шаблон, COPY взял бы устаревшую версию или упал бы.

Порядок диагностики. Симптом один, причин четыре — проверять нужно по порядку, от дешёвого к дорогому:

bash
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

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

text
1. Пакет вообще есть в requirements.txt?
   вхождений: 1
2. Пакет попал в образ?
   в образе: 1
3. Пакет виден в работающем container?
   в container: 0
4. Где ищет интерпретатор?
   /usr/local/bin/python

Расхождение между шагами 2 и 3 указывает точно: пакет в образе есть, но работающий container использует старый образ либо не то окружение.

Шаг 4 добавляет решающую деталь: интерпретатор системный (/usr/local/bin/python), а не из виртуального окружения — значит, PATH указывает на скрытый монтированием каталог.

Исправленная конфигурация:

yaml
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
dockerfile
FROM python:3.13-slim AS base
ENV PATH="/opt/venv/bin:$PATH"
RUN python -m venv /opt/venv         # ВНЕ /app
WORKDIR /app

Проверка исправления:

bash
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

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

text
  интерпретатор: /opt/venv/bin/python
  httpx доступен: True

Сводка:

ПричинаСимптом-отличиеИсправление
1restart вместо up --buildПакет есть в образе, нет в containerup -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. Окружение без чужих файлов [★]

Постановка. Организуйте рабочий процесс, удовлетворяющий девяти требованиям.

  1. Один Dockerfile со стадиями dev и runtime; в production нет инструментов разработки.
  2. Hot reload работает; правка кода видна без пересборки.
  3. Пакеты образа не скрываются монтированием — доказать.
  4. Все файлы, созданные приложением и инструментами в bind mount, принадлежат вашему UID.
  5. Эти файлы удаляются с host без sudo.
  6. Миграции выполняются отдельным сервисом до старта приложения.
  7. make check выполняет линтер, типы и тесты; CI вызывает ту же цель.
  8. Конфигурация инструментов только в pyproject.toml.
  9. Решение работает у коллеги с другим UID без правки файлов репозитория.

Скрипт проверки возвращает ненулевой код при любом расхождении.

Подсказки

Подсказка 1

Требования 4 и 9 вместе означают, что UID приходит из окружения, а не записан в файлы.

Подсказка 2

Требование 4 проверяйте на файле, созданном приложением, и на файле, изменённом ruff format.

Подсказка 3

Требование 3 проверяется монтированием и попыткой импорта пакета, установленного в образе.

Подсказка 4

Для требования 9 достаточно собрать образ с UID=4242 и убедиться, что файлы репозитория не менялись.

Решение

Показать решение
bash
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"

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

text
═══ Требование 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.


Очистка после раздела

bash
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
Главное оглавление

Markdown на GitHub ↗