Главная/Python внутри Container/Практика

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

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

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

Подготовка:

bash
mkdir -p ~/docker-course/06-python && cd ~/docker-course/06-python
# docker pull принимает РОВНО один образ:
# `docker pull a b` отвечает «docker pull requires 1 argument»
for img in python:3.13-slim python:3.13-alpine redis:8-alpine; do docker pull -q "$img"; done
docker system df      # зафиксируйте исходное состояние

Задание 1. Цена Alpine для Python [обяз.]

Постановка. Соберите один и тот же образ с зависимостью, имеющей C-расширение (например, pydantic), на python:3.13-slim и на python:3.13-alpine. Измерьте время установки и итоговый размер.

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

Ожидаемый результат. Два измерения и объяснение через колёса manylinux и musllinux.

Проверка:

bash
docker images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' | grep -E 'slim|alpine'

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


Задание 2. Порядок слоёв и кэш зависимостей [обяз.]

Постановка. Соберите два варианта Dockerfile:

  • вариант A — COPY . . затем pip install -r requirements.txt;
  • вариант B — COPY requirements.txt ., pip install, затем COPY . ..

Измерьте время пересборки после правки одной строки в исходном коде.

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

Проверка:

bash
# после правки исходника
time docker build -q -f Dockerfile.a -t la . > /dev/null
time docker build -q -f Dockerfile.b -t lb . > /dev/null

Разбор — в уроке 6.2 и уроке 5.5.


Задание 3. Пропавшие логи [обяз.]

Постановка. Напишите скрипт, печатающий строку раз в секунду в течение минуты. Запустите его в container без PYTHONUNBUFFERED и с ним. Покажите разницу в docker logs в первые секунды.

Затем убейте container через docker kill в обоих вариантах и сравните, сколько строк дошло до docker logs.

Ожидаемый результат. Без переменной вывод появляется блоками по 4–8 KB и теряется при SIGKILL; с переменной — построчно и без потерь.

Проверка:

bash
docker logs <container> | wc -l

Разбор — в уроке 6.4 и уроке 6.6.


Задание 4. Таблица форм CMD [обяз.]

Постановка. Соберите четыре container'а с одним и тем же Python-приложением, различающиеся только формой запуска:

  1. CMD python app.py (shell form);
  2. CMD ["python", "app.py"] (exec form);
  3. shell form плюс docker run --init;
  4. shell form с exec python app.py.

Для каждого измерьте время docker stop и код выхода. Заполните таблицу и объясните каждую строку.

Ожидаемый результат. Варианты 2, 3 и 4 останавливаются быстро с кодом 0; вариант 1 — за 10 секунд с кодом 137.

Проверка:

bash
time docker stop <c>; docker inspect <c> --format '{{.State.ExitCode}}'
docker exec <c> ps -o pid,args     # кто PID 1

Разбор — в уроке 6.5 и уроке 5.4.


Задание 5. Non-root и запись в volume [обяз.]

Постановка. Соберите образ с USER 10001, приложение пишет в /data. Примонтируйте туда named volume и bind mount с хоста. В одном случае запись пройдёт, в другом — нет.

Объясните разницу и почините bind mount без chmod 777 и без запуска от root.

Ожидаемый результат. Запись работает в обоих случаях; проверка id -u внутри container'а возвращает 10001.

Проверка:

bash
docker run --rm -v "$PWD/data:/data" <образ> sh -c 'touch /data/probe && echo ok'
ls -ln data/

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


Задание 6. Multi-stage с виртуальным окружением [доп.]

Постановка. Переведите однослойный Dockerfile на multi-stage: сборочная стадия ставит зависимости в /opt/venv, финальная копирует только его. Зависимость должна требовать компиляции (например, установка из sdist).

Измерьте размеры до и после, покажите отсутствие компилятора в финальном образе.

Ожидаемый результат. Сокращение размера минимум вдвое; gcc в финальном образе отсутствует.

Проверка:

bash
docker run --rm <образ> sh -c 'command -v gcc || echo "компилятора нет"'

Разбор — в уроке 6.3 и уроке 5.7.


Задание 7. Структурированные логи [доп.]

Постановка. Настройте вывод логов приложения в JSON: одна строка — один объект с полями ts, level, logger, message. Логи используемого сервера приложений (Gunicorn или Uvicorn) должны идти через тот же обработчик.

Дополнительно: исключение должно попадать в поле exception одной строкой, а не разбиваться на несколько записей.

Ожидаемый результат. docker logs выдаёт валидный JSON построчно, включая строки сервера.

Проверка:

bash
docker logs <c> 2>&1 | python3 -c '
import json, sys
bad = [l for l in sys.stdin if l.strip() and not l.startswith("{")]
print("не-JSON строк:", len(bad))
'

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


Задание 8. Диагностика: порт открыт, ответа нет [диаг.]

Постановка. Дан Dockerfile:

dockerfile
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir flask==3.1.3 gunicorn==26.0.0
COPY app.py .
EXPOSE 8000
CMD ["gunicorn", "-b", "127.0.0.1:8000", "app:app"]

Запуск с -p 8000:8000 проходит, container в статусе running, в логах ошибок нет. Но curl localhost:8000 возвращает Empty reply или Connection reset.

Найдите причину, докажите её измерением и исправьте.

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

Разбор

Причина. Gunicorn слушает 127.0.0.1 — loopback внутри network namespace container'а. Это отдельный интерфейс, недоступный снаружи. Публикация порта пробрасывает трафик на eth0 container'а, где никто не слушает.

Доказательство:

bash
docker exec <c> sh -c 'apt-get update -qq && apt-get install -y -qq iproute2 curl > /dev/null'

echo "═══ где слушает процесс ═══"
docker exec <c> ss -tlnp | grep 8000

echo "═══ изнутри через loopback ═══"
docker exec <c> curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1:8000/

echo "═══ изнутри через собственный IP ═══"
ip="$(docker inspect <c> --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')"
docker exec <c> curl -s -m 3 -o /dev/null -w 'HTTP %{http_code}\n' "http://$ip:8000/" \
    || echo "недоступно — вот и подтверждение"

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

text
═══ где слушает процесс ═══
LISTEN 0  2048  127.0.0.1:8000  0.0.0.0:*  users:(("gunicorn",pid=1,fd=5))
═══ изнутри через loopback ═══
HTTP 200
═══ изнутри через собственный IP ═══
недоступно — вот и подтверждение

Строка 127.0.0.1:8000 вместо 0.0.0.0:8000 — прямая улика. Приложение отвечает через loopback и не отвечает через собственный сетевой интерфейс, куда и приходит проброшенный трафик.

Исправление:

dockerfile
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]

Почему это не дыра в безопасности. Привязка к 0.0.0.0 внутри container'а не делает сервис доступным всему миру: снаружи он виден только через явно опубликованные порты. Ограничивать доступ следует на уровне публикации:

bash
docker run -p 127.0.0.1:8000:8000 ...    # только с хоста

Как узнать быстрее. Симптом «container работает, логи чистые, ответа нет» почти всегда означает одно из двух: сервис слушает loopback либо слушает не тот порт. Обе версии проверяются одной командой ss -tlnp внутри container'а.

Разбор темы — в уроке 6.9 и разделе 08.


Задание 9. Диагностика: ModuleNotFoundError [диаг.]

Постановка. Приложение работает на хосте (python -m app.main), но в container'е падает:

text
ModuleNotFoundError: No module named 'app'

Известно: COPY . . присутствует, WORKDIR /app задан, зависимости установлены успешно.

Найдите все возможные причины (их несколько), определите, какая действует в вашем случае, и исправьте.

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

Разбор

Четыре причины, дающие один и тот же текст ошибки.

ПричинаКак проверить
Каталог исключён в .dockerignoredocker run --rm <образ> ls -la /app
Отсутствует __init__.py при запуске через -mdocker run --rm <образ> find /app -name "__init__.py"
Пакет установлен, но src-layout не учтёнdocker run --rm <образ> python -c "import sys; print(sys.path)"
WORKDIR задан после COPYdocker history <образ> — порядок инструкций

Порядок диагностики — от общего к частному:

bash
echo "═══ 1. что вообще попало в образ ═══"
docker run --rm <образ> find /app -maxdepth 2 -type f -name "*.py" | head -20

echo "═══ 2. видит ли Python каталог ═══"
docker run --rm <образ> python -c "import sys; [print(' ', p) for p in sys.path]"

echo "═══ 3. есть ли __init__.py ═══"
docker run --rm <образ> sh -c 'ls -la /app/app/__init__.py 2>&1'

echo "═══ 4. что говорит сам импорт ═══"
docker run --rm <образ> python -c "import app" 2>&1 | tail -2

Ожидаемый вывод при самой частой причине:

text
═══ 1. что вообще попало в образ ═══
/app/requirements.txt
═══ 2. видит ли Python каталог ═══
  /app
  /usr/local/lib/python313.zip
  ...
═══ 3. есть ли __init__.py ═══
ls: cannot access '/app/app/__init__.py': No such file or directory
═══ 4. что говорит сам импорт ═══
ModuleNotFoundError: No module named 'app'

Первая команда всё объясняет: в образе нет ни одного файла приложения. Значит, каталог исключён из контекста сборки.

Проверка .dockerignore:

bash
grep -nE '^\s*(app|src|\*|\.)' .dockerignore

Классическая ошибка — строка * с последующими исключениями !..., где нужный каталог забыли вернуть. Вторая по частоте — . или слишком широкий шаблон вроде *.py[cod], записанный как *.py*.

Если файлы на месте, но импорт не работает — причина в sys.path. Для src-layout нужен либо установленный пакет:

dockerfile
COPY pyproject.toml .
COPY src/ ./src/
RUN pip install .

либо явное указание пути:

dockerfile
ENV PYTHONPATH=/app/src

Первый вариант предпочтительнее: он проверяет, что пакет вообще устанавливается, и работает одинаково внутри и снаружи container'а.

Почему на хосте работает. При запуске из каталога проекта Python добавляет текущий каталог в sys.path, а установленный в режиме pip install -e . пакет виден отовсюду. В container'е ни того, ни другого может не быть.

Разбор — в уроке 6.3 и уроке 5.1.


Задание 10. Диагностика: OOM под нагрузкой [диаг.]

Постановка. Сервис на Gunicorn развёрнут с --memory 512m. Число worker'ов рассчитано формулой из документации:

python
workers = 2 * os.cpu_count() + 1

Под нагрузкой container перезапускается, docker logs показывает Worker was sent SIGKILL! Perhaps out of memory?.

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

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

Разбор

Диагноз. os.cpu_count() возвращает число CPU хоста, а не квоту container'а. На 16-ядерной машине формула даёт 33 worker'а. При 512 MB это гарантированный OOM.

Подтверждение 1 — что видит Python:

bash
docker exec <c> python -c "
import os
from pathlib import Path
print('os.cpu_count():', os.cpu_count())
print('cpu.max:       ', Path('/sys/fs/cgroup/cpu.max').read_text().strip())
print('формула даёт:  ', 2 * os.cpu_count() + 1, 'worker(ов)')
"

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

text
os.cpu_count(): 16
cpu.max:        150000 100000
формула даёт:   33 worker(ов)

Расхождение налицо: 33 worker'а при квоте 1.5 CPU.

Подтверждение 2 — сколько процессов запущено на самом деле:

bash
docker exec <c> sh -c 'ps -o pid,rss,args | grep "[g]unicorn" | wc -l'
docker exec <c> sh -c 'ps -o rss= | awk "{s+=\$1} END {print s/1024 \" MiB суммарно\"}"'

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

text
34
689 MiB суммарно

689 MiB при лимите 512 — превышение подтверждено арифметически.

Подтверждение 3 — счётчик OOM в cgroup:

bash
docker exec <c> grep oom /sys/fs/cgroup/memory.events
docker inspect <c> --format 'OOMKilled={{.State.OOMKilled}} Exit={{.State.ExitCode}}'

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

text
oom oom_kill 7
OOMKilled=false Exit=0

Ключевая деталь: oom_kill 7 при OOMKilled=false. OOM killer убивал worker'ов, а не master — поэтому container не помечен как убитый по памяти. Полагаясь только на docker inspect, проблему пропустили бы (урок 6.13).

Корректный расчёт. Ограничение идёт по двум ресурсам сразу — берётся меньшее:

text
по CPU:    2 × 1.5 + 1 = 4 worker'а
по памяти: 512 MiB × 0.7 / 60 MiB ≈ 5 worker'ов
итог:      4

Исправление — задать число явно, а не вычислять:

yaml
services:
  api:
    environment:
      WEB_CONCURRENCY: "4"
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 512M

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

bash
sleep 60   # под нагрузкой
docker exec <c> grep oom_kill /sys/fs/cgroup/memory.events

Ожидается oom_kill 0 — счётчик не растёт.

Почему явное число лучше расчёта. Даже правильный расчёт по cgroup скрывает важное число в коде. Значение в конфигурации видно рядом с лимитами памяти и CPU, меняется без пересборки и одинаково понимается всеми, кто читает Compose-файл.

Разбор — в уроке 6.11 и уроке 6.13.


Задание 11. Диагностика: тесты «проходят», но не выполняются [диаг.]

Постановка. В Dockerfile есть стадия с RUN pytest. CI собирает образ командой docker build -t app . и сообщает об успехе. При этом в коде заведомо сломанный тест.

Объясните, почему сборка проходит, и предложите два способа исправления с оценкой каждого.

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

Разбор

Причина. BuildKit собирает только стадии, достижимые из цели сборки. Если финальная стадия не зависит от стадии test, последняя не попадает в граф и не выполняется.

Доказательство:

bash
echo "═══ обычная сборка ═══"
docker build -t app . > /dev/null 2>&1
echo "  код: $?"

echo "═══ явно стадия test ═══"
docker build --target test -t app:test . > /dev/null 2>&1
echo "  код: $?"

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

text
═══ обычная сборка ═══
  код: 0
═══ явно стадия test ═══
  код: 1

Один и тот же Dockerfile, один и тот же код — разные результаты. Второй показывает правду.

Ещё нагляднее — сравнить план сборки:

bash
docker build --progress plain -t app . 2>&1 | grep -c "RUN pytest"
docker build --progress plain --target test -t app:test . 2>&1 | grep -c "RUN pytest"

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

text
0
1

Способ 1 — отдельный шаг (рекомендуется):

bash
docker build --target test -t app:test .     # падение здесь останавливает CI
docker build --target runtime -t app .       # стадии переиспользуются из кэша

Плюсы: явно, работает везде, позволяет собрать образ без тестов при необходимости. Минус: требует контроля над командой сборки.

Способ 2 — искусственная зависимость:

dockerfile
FROM builder AS test
COPY tests/ ./tests/
RUN pytest -q && touch /app/.tests-passed

FROM base AS runtime
COPY --from=test /app/.tests-passed /app/.tests-passed

Плюс: тесты невозможно обойти. Минусы: production-образ нельзя собрать без прогона тестов даже когда это нужно; в образе остаётся бессмысленный файл.

Рекомендация — способ 1. Способ 2 применяют, когда команда сборки задаётся чужой системой и повлиять на неё нельзя.

Дополнительная ловушка. Даже с --target test тесты могут не выполниться повторно: слой RUN pytest кэшируется. Перед релизом:

bash
docker build --target test --no-cache-filter test -t app:test .

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


Задание 12. Полная контейнеризация сервиса [★]

Постановка. Контейнеризируйте FastAPI-сервис, удовлетворяющий одновременно всем требованиям раздела:

  1. Multi-stage: сборочная стадия с зависимостями, стадия тестов, финальная без того и другого.
  2. Запуск через fastapi run; приложение — PID 1.
  3. USER 10001, запись в volume работает.
  4. PYTHONUNBUFFERED=1, логи в JSON, включая логи сервера.
  5. Конфигурация из окружения с валидацией при старте; все ошибки сообщаются сразу.
  6. lifespan закрывает ресурсы при остановке.
  7. Раздельные /healthz и /readyz; HEALTHCHECK использует liveness.
  8. docker stop дозавершает активный запрос, код выхода 0.
  9. Тесты выполняются в стадии сборки; в финальном образе pytest отсутствует.
  10. Лимиты --memory и --cpus заданы по измерению; oom_kill остаётся нулевым под нагрузкой.

Каждое требование подтвердите командой.

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

Подсказки

Подсказка 1

Требования 3 и 9 конфликтуют с наивным COPY . .: тесты нужны в одной стадии и не нужны в другой. Копируйте каталоги по отдельности.

Подсказка 2

Требование 8 проверяется запросом, идущим в момент docker stop. Понадобится --timeout больше длительности запроса.

Подсказка 3

Требование 10 начинается с запуска без лимита: сначала измерьте пик, потом задавайте.

Подсказка 4

Скрипт проверки должен возвращать ненулевой код при провале — иначе он бесполезен в CI.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
mkdir -p ~/docker-course/06-python/final/app ~/docker-course/06-python/final/tests
cd ~/docker-course/06-python/final

cat > app/__init__.py <<'PY'
"""Сервис итогового задания раздела 6."""
PY

cat > app/settings.py <<'PY'
"""Требование 5: конфигурация с валидацией при старте."""
from __future__ import annotations

import os
import sys
from dataclasses import dataclass

LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR"})


@dataclass(frozen=True)
class Settings:
    app_name: str = "final"
    log_level: str = "INFO"
    data_dir: str = "/data"
    db_fail_after: float = 0.0

    @classmethod
    def from_env(cls) -> Settings:
        errors: list[str] = []

        level = os.environ.get("LOG_LEVEL", "INFO").upper()
        if level not in LEVELS:
            errors.append(f"LOG_LEVEL должен быть одним из {sorted(LEVELS)}, получено: {level}")

        raw = os.environ.get("DB_FAIL_AFTER", "0")
        try:
            db_fail = float(raw)
        except ValueError:
            errors.append(f"DB_FAIL_AFTER должен быть числом, получено: {raw!r}")
            db_fail = 0.0

        data_dir = os.environ.get("DATA_DIR", cls.data_dir)

        # Все ошибки сразу, а не первая: иначе исправление потребует
        # нескольких перезапусков
        if errors:
            print("ОШИБКА КОНФИГУРАЦИИ:", file=sys.stderr)
            for e in errors:
                print(f"  - {e}", file=sys.stderr)
            raise SystemExit(1)

        return cls(
            app_name=os.environ.get("APP_NAME", cls.app_name),
            log_level=level,
            data_dir=data_dir,
            db_fail_after=db_fail,
        )
PY

cat > app/logging_config.py <<'PY'
"""Требование 4: JSON-логи, включая логи Uvicorn."""
from __future__ import annotations

import json
import logging
import sys
from datetime import datetime, timezone


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.fromtimestamp(record.created, tz=timezone.utc)
            .isoformat(timespec="milliseconds").replace("+00:00", "Z"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        if record.exc_info:
            # Трассировка одной строкой: иначе сборщик логов разобьёт её на записи
            payload["exception"] = self.formatException(record.exc_info)
        for key, value in getattr(record, "extra_fields", {}).items():
            payload.setdefault(key, value)
        return json.dumps(payload, ensure_ascii=False, default=str)


def configure(level: str = "INFO") -> None:
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JsonFormatter())

    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(handler)
    root.setLevel(level)

    for name in ("uvicorn", "uvicorn.access", "uvicorn.error"):
        lg = logging.getLogger(name)
        lg.handlers.clear()
        lg.propagate = True
PY

cat > app/main.py <<'PY'
"""Требования 2, 4, 6, 7, 8."""
from __future__ import annotations

import asyncio
import logging
import os
import time
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, Response

from .logging_config import configure
from .settings import Settings

settings = Settings.from_env()
configure(settings.log_level)
logger = logging.getLogger("app")

_started = time.monotonic()
_ready = False


class Store:
    """Ресурс, требующий закрытия (требование 6)."""

    def __init__(self, path: Path) -> None:
        self.path = path
        self.closed = False

    def available(self) -> bool:
        if settings.db_fail_after and (time.monotonic() - _started) > settings.db_fail_after:
            return False
        return not self.closed

    def write(self, line: str) -> None:
        # Требование 3: запись в volume от непривилегированного пользователя
        with (self.path / "records.log").open("a", encoding="utf-8") as f:
            f.write(line + "\n")

    async def close(self) -> None:
        logger.info("закрываю хранилище")
        await asyncio.sleep(0.2)
        self.closed = True


@asynccontextmanager
async def lifespan(app: FastAPI):
    global _ready
    data_dir = Path(settings.data_dir)
    data_dir.mkdir(parents=True, exist_ok=True)
    app.state.store = Store(data_dir)
    _ready = True
    logger.info("startup завершён", extra={"extra_fields": {"pid": os.getpid()}})

    yield

    _ready = False
    logger.info("shutdown начат")
    await app.state.store.close()
    logger.info("shutdown завершён")


app = FastAPI(title=settings.app_name, lifespan=lifespan)


@app.get("/healthz")
async def healthz() -> dict[str, str]:
    """Liveness: только процесс, о хранилище не знает (требование 7)."""
    return {"status": "ok"}


@app.get("/readyz")
async def readyz(response: Response) -> dict[str, str]:
    """Readiness: процесс и зависимости."""
    if not _ready:
        response.status_code = 503
        return {"status": "starting"}
    if not app.state.store.available():
        response.status_code = 503
        return {"status": "storage unavailable"}
    return {"status": "ready"}


@app.get("/")
async def root() -> dict[str, object]:
    return {"service": settings.app_name, "pid": os.getpid()}


@app.post("/records")
async def write_record(response: Response) -> dict[str, str]:
    if not app.state.store.available():
        response.status_code = 503
        return {"error": "storage unavailable"}
    app.state.store.write(f"{time.time():.3f}")
    return {"status": "written"}


@app.get("/slow")
async def slow(seconds: float = 4.0) -> dict[str, float]:
    await asyncio.sleep(min(seconds, 20.0))
    return {"slept": seconds}
PY

cat > tests/test_app.py <<'PY'
"""Требование 9: тесты выполняются в стадии сборки."""
import pytest
from fastapi.testclient import TestClient

from app.main import app


@pytest.fixture
def client(tmp_path, monkeypatch):
    # TestClient как контекстный менеджер — иначе lifespan не выполнится
    with TestClient(app) as c:
        yield c


def test_root(client):
    assert client.get("/").status_code == 200


def test_healthz(client):
    assert client.get("/healthz").json() == {"status": "ok"}


def test_readyz_after_startup(client):
    assert client.get("/readyz").json()["status"] == "ready"


def test_healthz_independent_of_storage(client, monkeypatch):
    """Ключевой тест: liveness НЕ зависит от хранилища."""
    monkeypatch.setattr(app.state.store, "closed", True)
    assert client.get("/healthz").status_code == 200


def test_readyz_depends_on_storage(client, monkeypatch):
    monkeypatch.setattr(app.state.store, "closed", True)
    assert client.get("/readyz").status_code == 503


def test_write_record(client):
    assert client.post("/records").json() == {"status": "written"}
PY

cat > requirements.txt <<'EOF'
fastapi[standard]==0.141.1
EOF

cat > requirements-dev.txt <<'EOF'
-r requirements.txt
pytest==9.1.1
httpx==0.28.1
EOF

cat > .dockerignore <<'EOF'
.git
.venv
__pycache__
*.py[cod]
.pytest_cache
data
reports
Dockerfile
.dockerignore
check.sh
EOF

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

# ── Требование 1: общая база ──
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONFAULTHANDLER=1 \
    PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser

FROM base AS builder
RUN python -m venv /opt/venv
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

# ── Требование 9: тесты в сборке, в финальный образ не попадают ──
FROM builder AS test
COPY requirements-dev.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements-dev.txt
COPY app/ ./app/
COPY tests/ ./tests/
RUN pytest -q tests/

FROM base AS runtime
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/

# Требование 3: каталог данных принадлежит пользователю приложения
RUN mkdir -p /data && chown 10001:10001 /data
VOLUME ["/data"]

USER 10001:10001
EXPOSE 8000

# Требование 7: HEALTHCHECK использует liveness, не readiness
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --start-interval=2s --retries=3 \
  CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2).status==200 else 1)"

# Требование 2: exec form, приложение — PID 1
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
EOF

cat > check.sh <<'SH'
#!/usr/bin/env bash
# Проверка всех десяти требований. Ненулевой код при любом провале.
set -uo pipefail

IMAGE=final
C=final-check
PORT=8099
fail=0

ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }
eq()  { [ "$2" = "$3" ] && ok "$1: $2" || bad "$1: получено '$2', ожидалось '$3'"; }

cleanup() { docker rm -f "$C" > /dev/null 2>&1 || true; }
trap cleanup EXIT

printf '\n═══ 9. Тесты в стадии сборки ═══\n'
if docker build --target test -q -t "$IMAGE:test" . > /dev/null; then
    ok "тесты прошли"
else
    bad "тесты упали"; exit 1
fi

docker build --target runtime -q -t "$IMAGE" . > /dev/null

printf '\n═══ 1, 9. Финальный образ без тестового обвеса ═══\n'
docker run --rm "$IMAGE" sh -c 'command -v pytest' > /dev/null 2>&1 \
    && bad "pytest присутствует" || ok "pytest отсутствует"
docker run --rm "$IMAGE" sh -c 'test -d /app/tests' > /dev/null 2>&1 \
    && bad "tests/ в образе" || ok "tests/ отсутствует"

mkdir -p ./data
docker run -d --name "$C" -p "$PORT:8000" \
    -v "$PWD/data:/data" --memory 384m --memory-swap 384m --cpus 1.0 \
    "$IMAGE" > /dev/null
sleep 10

printf '\n═══ 2. PID 1 ═══\n'
eq "PID 1" "$(docker exec "$C" ps -o comm= -p 1 | tr -d ' ')" "python"

printf '\n═══ 3. Пользователь и запись в volume ═══\n'
eq "UID" "$(docker exec "$C" id -u)" "10001"
eq "запись в /data" "$(curl -s -X POST "localhost:$PORT/records" | python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')" "written"
[ -s ./data/records.log ] && ok "файл на хосте создан" || bad "файла на хосте нет"

printf '\n═══ 4. JSON-логи, включая Uvicorn ═══\n'
nonjson="$(docker logs "$C" 2>&1 | grep -cv '^{' || true)"
eq "не-JSON строк" "$nonjson" "0"
docker logs "$C" 2>&1 | grep -q '"logger": "uvicorn' \
    && ok "логи Uvicorn в JSON" || bad "логи Uvicorn не в JSON"

printf '\n═══ 7. Раздельные пробы ═══\n'
eq "/healthz" "$(curl -s -o /dev/null -w '%{http_code}' "localhost:$PORT/healthz")" "200"
eq "/readyz"  "$(curl -s -o /dev/null -w '%{http_code}' "localhost:$PORT/readyz")" "200"
eq "healthcheck" "$(docker inspect "$C" --format '{{.State.Health.Status}}')" "healthy"

printf '\n═══ 10. Лимиты: OOM не сработал ═══\n'
for _ in $(seq 300); do curl -s -o /dev/null "localhost:$PORT/"; done
eq "oom_kill" "$(docker exec "$C" awk '/oom_kill /{print $2}' /sys/fs/cgroup/memory.events)" "0"
printf '     пик памяти: %s MiB при лимите 384\n' \
    "$(docker exec "$C" awk '{printf "%.0f", $1/1048576}' /sys/fs/cgroup/memory.peak 2>/dev/null || echo '?')"
thr="$(docker exec "$C" awk '/nr_throttled/{print $2}' /sys/fs/cgroup/cpu.stat)"
per="$(docker exec "$C" awk '/nr_periods/{print $2}' /sys/fs/cgroup/cpu.stat)"
printf '     throttling: %s из %s периодов\n' "$thr" "$per"

printf '\n═══ 6, 8. Graceful shutdown с активным запросом ═══\n'
curl -s -m 25 "localhost:$PORT/slow?seconds=4" > /tmp/final-slow.txt &
cpid=$!
sleep 1
docker stop --timeout 25 "$C" > /dev/null
wait $cpid 2>/dev/null
eq "код выхода" "$(docker inspect "$C" --format '{{.State.ExitCode}}')" "0"
grep -q "slept" /tmp/final-slow.txt && ok "запрос дозавершён" || bad "запрос оборван"
docker logs "$C" 2>&1 | grep -q "закрываю хранилище" \
    && ok "ресурсы закрыты через lifespan" || bad "lifespan shutdown не отработал"
rm -f /tmp/final-slow.txt

printf '\n═══ 5. Валидация конфигурации ═══\n'
out="$(docker run --rm -e LOG_LEVEL=НЕТ -e DB_FAIL_AFTER=abc "$IMAGE" 2>&1)"
echo "$out" | grep -q "LOG_LEVEL" && echo "$out" | grep -q "DB_FAIL_AFTER" \
    && ok "обе ошибки сообщены сразу" || bad "сообщена не вся конфигурация"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЕННЫЕ ПРОВЕРКИ"
exit "$fail"
SH
chmod +x check.sh

./check.sh
echo "КОД: $?"

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

text
═══ 9. Тесты в стадии сборки ═══
  ✓ тесты прошли

═══ 1, 9. Финальный образ без тестового обвеса ═══
  ✓ pytest отсутствует
  ✓ tests/ отсутствует

═══ 2. PID 1 ═══
  ✓ PID 1: python

═══ 3. Пользователь и запись в volume ═══
  ✓ UID: 10001
  ✓ запись в /data: written
  ✓ файл на хосте создан

═══ 4. JSON-логи, включая Uvicorn ═══
  ✓ не-JSON строк: 0
  ✓ логи Uvicorn в JSON

═══ 7. Раздельные пробы ═══
  ✓ /healthz: 200
  ✓ /readyz: 200
  ✓ healthcheck: healthy

═══ 10. Лимиты: OOM не сработал ═══
  ✓ oom_kill: 0
     пик памяти: 118 MiB при лимите 384
     throttling: 12 из 640 периодов

═══ 6, 8. Graceful shutdown с активным запросом ═══
  ✓ код выхода: 0
  ✓ запрос дозавершён
  ✓ ресурсы закрыты через lifespan

═══ 5. Валидация конфигурации ═══
  ✓ обе ошибки сообщены сразу

═══ ИТОГ ═══
  все требования выполнены
КОД: 0

Объяснение выбранных лимитов. Пик составил 118 MiB. Лимит 384 MiB — это пик × 3, а не × 1.3, потому что здесь один процесс, и запас оставлен на рост при добавлении worker'ов. Throttling — 12 периодов из 640 (менее 2 %), что находится в пределах нормы для --cpus 1.0.

Порядок был именно таким, как предписывает урок 6.13: сначала запуск без лимита и измерение, потом назначение. Обратный порядок — назначить лимит и надеяться — даёт либо OOM, либо бессмысленно большое число.

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

chown каталога /data в стадии сборки, а не при старте. Изменение владельца при каждом запуске потребовало бы root внутри container'а — то есть отказа от требования 3. В образе это делается один раз, до USER.

Скрипт использует trap cleanup EXIT. Без него провалившаяся проверка оставила бы работающий container, и следующий запуск упал бы на конфликте имён — проверка стала бы недиагностируемой.

Проверка требования 4 ищет "logger": "uvicorn, а не просто валидный JSON. Собственные логи приложения перевести в JSON легко; логи сервера — та часть, которую забывают. Проверка нацелена именно на неё.

Чего решение не делает. Один процесс без --workers: для Compose с несколькими репликами это правильно, для одиночного docker run под нагрузкой понадобился бы расчёт из урока 6.11. Нет и integration-тестов — у сервиса нет внешних зависимостей; при появлении базы они потребовали бы отдельного сервиса Compose (урок 6.12).


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

bash
# ВНИМАНИЕ: НЕ `docker ps -aq | xargs -r docker rm -f`.
# Такая строка удаляет ВСЕ container'ы на машине, включая чужие:
# базу коллеги, кластер kind, работающий стенд. Удаляем только
# созданные из образов этого раздела.
for img in python:3.13-slim python:3.13-alpine redis:8-alpine; do
    docker ps -aq --filter "ancestor=$img" | xargs -r docker rm -f
done
docker image prune -f
docker builder prune -f
docker volume prune -f
docker system df

Сравните с состоянием, зафиксированным в начале раздела.


Критерии завершения

Раздел закрыт, когда выполнены обязательные задания 1–5 и вы можете без подсказок ответить на вопросы из MAIN.md раздела.

Дальше: Quiz 06.


Навигация

← Предыдущий материал
Вернуться к разделу
Следующий раздел → Storage: volumes и данные
Главное оглавление

Markdown на GitHub ↗