production-fastapi
FastAPI-сервис, доведённый до требований эксплуатации: три пробы с разной логикой, структурированные логи, мягкое завершение, проверка конфигурации при старте.
Отличается от fastapi-basic тем, что там показан минимальный корректный сервис, а здесь — то, что добавляется перед выводом в эксплуатацию.
Используется в разделе 11 и в проекте 2.
Что демонстрирует
- три пробы —
/startupz,/healthz,/readyz— с разной логикой; - конфигурация из окружения с проверкой при старте, включая опечатки в именах переменных;
- структурированные логи: все строки JSON, включая строки uvicorn — что требует
python -m uvicorn, а неfastapi run; - идентификатор запроса, очищаемый перед попаданием в журнал;
- мягкое завершение с учётом активных запросов и ограничением по времени;
- хранилище за протоколом — заменяемое на PostgreSQL в проекте 3;
- тесты на запущенном процессе, а не только через
TestClient.
Запуск
docker compose up -d
curl -s localhost:8000/healthz
curl -s -X POST localhost:8000/events -H 'content-type: application/json' \
-d '{"path":"/api/items","method":"GET","status":200,"duration_ms":12}'
curl -s 'localhost:8000/stats?top=3'
Без Docker:
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
EVENTAPI_HOST=127.0.0.1 .venv/bin/python -m uvicorn app.main:app --port 8000
Тесты
pytest -q --cov=app --cov-report=term-missing
63 passed
Name Stmts Miss Cover
-------------------------------------------
app/logging_config.py 36 0 100%
app/main.py 109 8 93%
app/models.py 49 1 98%
app/settings.py 36 0 100%
app/storage.py 44 2 95%
-------------------------------------------
TOTAL 275 11 96%
Три вещи, ради которых стоит смотреть этот пример
1. Опечатку в имени переменной окружения библиотека не ловит
EVENTAPI_LOG_LEVE=info python3 -c "
from app.settings import Settings, load, UnknownSettingError
print('pydantic:', Settings().log_level, '← опечатка не замечена')
try:
load()
except UnknownSettingError as e:
print('своя проверка:', str(e)[:60], '…')"
pydantic: info ← опечатка не замечена
своя проверка: неизвестные переменные окружения: EVENTAPI_LOG_LEVE; изв …
extra="forbid" в pydantic-settings здесь не помогает: переменную, которой не соответствует поле, библиотека просто не видит. Сверка имён с полями модели написана отдельно (app/settings.py, функция check_env).
Тест test_unknown_variable_is_rejected фиксирует оба факта: что pydantic опечатку пропускает и что своя проверка её ловит. Если библиотека когда-нибудь начнёт ловить сама, тест об этом сообщит.
2. Настройка логирования в lifespan даёт смешанный поток
python3 -m uvicorn app.main:app --port 8765 > run.log 2>&1 &
sleep 2 && curl -s localhost:8765/healthz > /dev/null && kill %1
python3 -c "
import json
bad = 0
lines = [l for l in open('run.log') if l.strip()]
for l in lines:
try: json.loads(l)
except Exception: bad += 1
print(f'строк: {len(lines)}, не JSON: {bad}')"
строк: 13, не JSON: 0
Первая редакция вызывала configure() внутри lifespan, и первые две строки оказывались в формате uvicorn:
INFO: Started server process [3576249]
INFO: Waiting for application startup.
{"ts": "…", "level": "info", "logger": "eventapi", …}
Uvicorn печатает их до входа в lifespan. Настройка перенесена в create_app() — то есть выполняется при импорте модуля.
3. Код возврата после docker stop — 0, а не 143
docker run -d --name t eventapi && sleep 3
time docker stop t
docker inspect t --format '{{.State.ExitCode}}'
docker logs t 2>&1 | grep shutdown
real 0m0.9s
0
{"…","event":"shutdown_begin","inflight":0}
{"…","event":"shutdown_done","inflight":0,"drained":true}
Код 0, а не 143 — и это стоит понять.
Uvicorn после мягкого завершения повторно посылает себе тот же сигнал (Server.capture_signals → signal.raise_signal), чтобы родитель увидел настоящую причину смерти. Вне container'а это даёт -15. Но внутри container'а uvicorn — PID 1, а для PID 1 ядро не применяет действие по умолчанию: повторный SIGTERM просто игнорируется, и процесс выходит штатно с нулём.
Проверено запуском на Docker 29.7.1 (verify/FACTS.md).
Отсюда: код возврата непригоден как признак успешного завершения. Отличать мягкое от жёсткого нужно так:
| Признак | Мягкое | Жёсткое (SIGKILL) |
|---|---|---|
| Время | Доли секунды | Ровно grace period |
| Записи в журнале | shutdown_begin, shutdown_done | Нет |
| Код возврата | 0 (или 143 при --init) | 137 |
Пробы: чем они различаются
docker compose up -d && sleep 5
curl -s localhost:8000/healthz # {"status":"alive",…}
curl -s localhost:8000/readyz # {"ready":true,"checks":{…}}
curl -s localhost:8000/startupz # {"initialized":true,…}
Проверка того, что они действительно расходятся:
docker run -d --name slow -p 8001:8000 -e EVENTAPI_READY_AFTER_SECONDS=3600 eventapi
sleep 3
curl -s -o /dev/null -w 'healthz: %{http_code}\n' localhost:8001/healthz # 200
curl -s -o /dev/null -w 'readyz: %{http_code}\n' localhost:8001/readyz # 503
docker rm -f slow
Если обе дают одинаковый код — разделения нет, сколько бы эндпоинтов ни было объявлено. Тогда отказ зависимости приведёт к перезапуску всех экземпляров, а после перезапуска зависимость останется недоступной.
Тесты на запущенном процессе
tests/test_process.py поднимает настоящий uvicorn подпроцессом. TestClient этого не делает и потому не покажет двух вещей:
| Что проверяется | Почему TestClient не покажет |
|---|---|
| Все строки лога — JSON | Не поднимает сервер, его строк нет |
| Отсутствие ANSI-кодов | То же |
Реакция на SIGTERM | Нет процесса, которому послать сигнал |
| Код возврата | Нет процесса |
Состав
app/settings.py окружение, проверка значений И имён
app/models.py схемы; extra="forbid", время к UTC
app/storage.py протокол Storage + реализация в памяти
app/logging_config.py JSON-формат, идентификатор запроса, очистка
app/main.py приложение, три пробы, учёт активных запросов
Чего пример не делает
Образ собран, размер измерен: 66 MB (Docker 29.7.1, 2026-08-04) — вдвое с лишним ниже ориентира в 200 MB.
Ограничение размера тела запроса объявлено, но не применяется. Настройка max_body_bytes есть, middleware — нет: обработка должна происходить до чтения тела, что требует работы на уровне ASGI.
Нет метрик. Только логи.
Хранилище не переживает перезапуск. Так и задумано: постоянное хранение — задача проекта 3. Протокол Storage подготовлен именно для этой замены.
Работа под read_only: true не проверена. compose.yaml это задаёт, но запуск не выполнялся.
Навигация
Все примеры
fastapi-basic — минимальный вариант
Раздел 11. Production
Проект 2. FastAPI service