Проект 2. FastAPI service
Выполнять после: раздела 08
Опирается на разделы: 05, 06, 07, 08
Ориентировочное время: 8 часов
Оценивание: rubric, проходной результат 70
Задача
Написать и контейнеризовать HTTP-сервис eventapi: приём событий и статистика по ним.
Задача продолжает проект 1. Там события читались из файла инструментом командной строки; здесь они приходят по HTTP, а агрегация та же. Логика подсчёта переносится почти без изменений — меняется всё остальное: конфигурация, журналирование, пробы, завершение.
Сервис из этого проекта встраивается в стек проекта 3, поэтому одно решение стоит принять заранее: хранилище должно быть отделено интерфейсом. В проекте 3 хранение в памяти заменяется на PostgreSQL, и заменяться должен один класс, а не обработчики запросов.
Endpoint'ы
| Метод | Путь | Назначение |
|---|---|---|
POST | /events | Принять событие с проверкой полей |
GET | /events | Список с фильтром и страницами |
GET | /stats | Агрегация по полю |
GET | /healthz | Жив ли процесс |
GET | /readyz | Готов ли обслуживать |
GET | /startupz | Завершена ли инициализация |
Требования
Обязательные
| № | Требование | Проверяется |
|---|---|---|
| 1 | Валидация тела запроса; неверные данные дают 422 с указанием поля | Checklist 1 |
| 2 | Три пробы с разной логикой | Checklist 2 |
| 3 | Конфигурация из переменных окружения, проверяется при старте | Checklist 3 |
| 4 | Опечатка в имени переменной приводит к отказу, а не игнорируется | Checklist 3 |
| 5 | Структурированные логи в stdout: каждая строка — объект JSON | Checklist 4 |
| 6 | Идентификатор запроса проходит через все записи одного запроса | Checklist 4 |
| 7 | Мягкое завершение: активные запросы дорабатываются | Checklist 5 |
| 8 | Запуск не от root, идентификатор числовой | Checklist 6 |
| 9 | Multi-stage; инструментов сборки в итоговом образе нет | Checklist 7 |
| 10 | Тесты; покрытие не ниже 85 %; провал останавливает сборку | Checklist 8 |
| 11 | Сервис доступен снаружи container'а | Checklist 9 |
Требования, невыполнение которых снимает работу
- приложение слушает
127.0.0.1— снаружи container'а недоступно; SIGTERMне завершает процесс в пределах grace period;- конфигурация с ошибкой не останавливает старт, а проявляется при первом запросе;
- запуск от
rootбез обоснования.
Архитектура
Три пробы, а не одна
Это главное содержательное требование проекта. Три вопроса — три ответа:
| Проба | Вопрос | Что проверяет | Что делает оркестратор при отказе |
|---|---|---|---|
/startupz | Инициализация завершена? | Готовность внутренних структур | Ждёт, не убивая |
/healthz | Процесс жив? | Только сам процесс | Перезапускает |
/readyz | Готов обслуживать? | Зависимости, прогрев, завершение | Убирает из балансировки |
Ошибка, которую требование призвано предотвратить: один и тот же путь в liveness и readiness. Тогда отказ зависимости приводит к перезапуску всех экземпляров разом — а после перезапуска зависимость по-прежнему недоступна (урок 11.3).
Отсюда правило: /healthz не должен обращаться к зависимостям.
Конфигурация проверяется при старте
Сервис, стартовавший с испорченной настройкой, выглядит здоровым и отвечает ошибками. Это худший из исходов: пробы зелёные, трафик идёт, ошибки в логе.
Требование 4 отдельное и не сводится к требованию 3. Опечатка в имени переменной (EVENTAPI_LOG_LEVE) не даёт неверного значения — она даёт тихое умолчание. Библиотеки такое обычно не ловят, и проверку приходится делать явно.
Хранилище за интерфейсом
обработчики запросов
│
▼
Storage (протокол)
│
┌────┴─────┐
▼ ▼
MemoryStorage PostgresStorage ← проект 3
Обязательное свойство хранилища в памяти — предел размера. Сервис, принимающий события без ограничения, исчерпает память, и отказ придёт от OOM killer, без записи в лог приложения (checkpoint 3).
Структура файлов
eventapi/
├── Dockerfile
├── .dockerignore
├── compose.yaml
├── requirements.txt
├── requirements-dev.txt
├── pytest.ini
├── README.md
├── app/
│ ├── __init__.py
│ ├── settings.py # окружение и проверка при старте
│ ├── models.py # схемы запросов и ответов
│ ├── storage.py # протокол и реализация в памяти
│ ├── logging_config.py # JSON-формат и идентификатор запроса
│ └── main.py # приложение, пробы, endpoint'ы
└── tests/
├── conftest.py
├── test_settings.py
├── test_api.py
├── test_probes.py
├── test_logging.py
├── test_shutdown.py
└── test_process.py # проверки на запущенном процессе
Последний файл отличает проект от упражнения: TestClient не поднимает сервер, поэтому две вещи он показать не может — формат строк, которые печатает сам uvicorn, и реакцию на сигнал. Обе проверяются только запуском процесса.
Пошаговый план
Шаг 1. Модели и валидация
Схемы запроса и ответа. Решить сразу:
extra="forbid"или молчаливое игнорирование лишних полей;- что делать со временем без часового пояса.
Опора: урок 6.10.
Шаг 2. Конфигурация
Переменные окружения с префиксом, проверка значений и проверка имён.
Шаг 3. Хранилище
Протокол и реализация в памяти с пределом размера. Порядок при равных счётчиках должен быть воспроизводим.
Шаг 4. Журналирование
JSON в stdout, идентификатор запроса из контекстной переменной, подчинение логгеров uvicorn общему формату.
Шаг 5. Приложение и пробы
lifespan вместо устаревших обработчиков событий, middleware с учётом активных запросов, три пробы.
Шаг 6. Тесты
Отдельно — тесты на запущенном процессе. Именно они ловят то, что не видит TestClient.
Шаг 7. Образ
Multi-stage, стадия тестов, HEALTHCHECK, числовой USER, exec-форма точки входа.
Критерии завершения
curl -s localhost:8000/healthzотвечает 200 при недоступных зависимостях.curl -s -o /dev/null -w '%{http_code}' localhost:8000/readyzдаёт 503 во время завершения.- Неверное тело запроса даёт 422 с именем поля.
docker run -e EVENTAPI_PORT=0 …завершается с ошибкой при старте, а не при запросе.docker run -e EVENTAPI_LOG_LEVE=info …завершается с ошибкой: имя переменной неизвестно.- Каждая строка
docker logsразбирается как JSON — включая строки uvicorn. time docker stopукладывается менее чем в 3 секунды.docker build --target testпадает при сломанном тесте.- Сервис отвечает на запрос с хоста после
docker run -p.
О размере образа: сначала договоритесь, что вы меряете. Под Docker 29 два очевидных способа дают числа, различающиеся вчетверо:
docker images eventapi --format '{{.Size}}' # DISK USAGE
docker image inspect eventapi --format '{{.Size}}' | numfmt --to=si # CONTENT SIZE
293MB
69M
Первое — сколько образ занимает на узле, второе — сколько качается из registry (урок 3.4).
Ориентир «меньше 350 MB по DISK USAGE». Он выбран измерением, а не на глаз: python:3.13-slim сам по себе занимает 178 MB, и требование «меньше 200 MB» на диске для FastAPI-приложения недостижимо — на всё приложение осталось бы 22 MB. Эталонное решение даёт 293 MB / 69 MB (Docker 29.7.1, измерено 2026-08-04).
Если не укладываетесь — обоснованный путь: перейти на fastapi без [standard] плюс uvicorn[standard]. Это измерено и даёт 247 MB / 57 MB — минус 55 MB на диске. Точка входа python -m uvicorn нужна независимо от размера (требование 5). Записать в README, что именно было сделано и сколько дало.
Дополнительные задания
| № | Задание | Что добавляет |
|---|---|---|
| 1 | Ограничить размер тела запроса и проверить поведение при превышении | Защита от исчерпания памяти |
| 2 | Очистить идентификатор запроса, приходящий из заголовка | Значение от клиента попадает в журнал |
| 3 | Проверить, что все строки лога — JSON, тестом на запущенном процессе | Ловит смешение форматов |
| 4 | Сравнить размер образа с fastapi[standard] и без | Измерение вместо предположения |
| 5 | Добавить метрики в формате Prometheus | Раздел 13 |
| 6 | Проверить поведение при --read-only | Урок 11.2 |
Задания 1–3 реализованы в эталонном решении; 4–6 — нет.
Навигация
Список проверок →
Эталонное решение → — открывать после собственной попытки
← Предыдущий проект: Python CLI
Вернуться к проектам
Главное оглавление