Главная/Материалы/Материал

production-fastapi

FastAPI-сервис, доведённый до требований эксплуатации: три пробы с разной логикой, структурированные логи, мягкое завершение, проверка конфигурации при старте.

Отличается от fastapi-basic тем, что там показан минимальный корректный сервис, а здесь — то, что добавляется перед выводом в эксплуатацию.

Используется в разделе 11 и в проекте 2.

Что демонстрирует

  • три пробы/startupz, /healthz, /readyz — с разной логикой;
  • конфигурация из окружения с проверкой при старте, включая опечатки в именах переменных;
  • структурированные логи: все строки JSON, включая строки uvicorn — что требует python -m uvicorn, а не fastapi run;
  • идентификатор запроса, очищаемый перед попаданием в журнал;
  • мягкое завершение с учётом активных запросов и ограничением по времени;
  • хранилище за протоколом — заменяемое на PostgreSQL в проекте 3;
  • тесты на запущенном процессе, а не только через TestClient.

Запуск

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

bash
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

Тесты

bash
pytest -q --cov=app --cov-report=term-missing
text
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. Опечатку в имени переменной окружения библиотека не ловит

bash
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], '…')"
text
pydantic: info ← опечатка не замечена
своя проверка: неизвестные переменные окружения: EVENTAPI_LOG_LEVE; изв …

extra="forbid" в pydantic-settings здесь не помогает: переменную, которой не соответствует поле, библиотека просто не видит. Сверка имён с полями модели написана отдельно (app/settings.py, функция check_env).

Тест test_unknown_variable_is_rejected фиксирует оба факта: что pydantic опечатку пропускает и что своя проверка её ловит. Если библиотека когда-нибудь начнёт ловить сама, тест об этом сообщит.

2. Настройка логирования в lifespan даёт смешанный поток

bash
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}')"
text
строк: 13, не JSON: 0

Первая редакция вызывала configure() внутри lifespan, и первые две строки оказывались в формате uvicorn:

text
INFO:     Started server process [3576249]
INFO:     Waiting for application startup.
{"ts": "…", "level": "info", "logger": "eventapi", …}

Uvicorn печатает их до входа в lifespan. Настройка перенесена в create_app() — то есть выполняется при импорте модуля.

3. Код возврата после docker stop — 0, а не 143

bash
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
text
real    0m0.9s
0
{"…","event":"shutdown_begin","inflight":0}
{"…","event":"shutdown_done","inflight":0,"drained":true}

Код 0, а не 143 — и это стоит понять.

Uvicorn после мягкого завершения повторно посылает себе тот же сигнал (Server.capture_signalssignal.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

Пробы: чем они различаются

bash
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,…}

Проверка того, что они действительно расходятся:

bash
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Нет процесса, которому послать сигнал
Код возвратаНет процесса

Состав

text
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

Markdown на GitHub ↗