6.10. FastAPI
Цели
После этого материала вы сможете:
- запустить FastAPI в container официально рекомендуемым способом;
- объяснить, почему связка Gunicorn с UvicornWorker больше не рекомендуется;
- реализовать graceful shutdown через
lifespanвместо устаревшихon_event; - разделить liveness и readiness и объяснить, почему это разные проверки;
- решить, сколько процессов запускать в одном container;
- настроить конфигурацию с валидацией при старте.
Предварительные знания
- 6.5. Сигналы и PID 1 в Python — сигналы под сервером;
- 6.6. Logging;
- 6.9. Flask — для сравнения WSGI и ASGI;
- 5.8. HEALTHCHECK.
Рабочий пример — resources/examples/fastapi-basic/.
Ключевые термины
| Термин | Объяснение |
|---|---|
ASGI | Асинхронный интерфейс между сервером и Python-приложением |
Uvicorn | ASGI-сервер, реализация на uvloop и httptools |
lifespan | Механизм ASGI для действий при старте и остановке |
fastapi run | Команда CLI, запускающая приложение через Uvicorn |
liveness | Проверка «жив ли процесс» |
readiness | Проверка «готов ли принимать трафик» |
Теория
ASGI против WSGI
Различие определяет всё остальное в этом уроке.
| WSGI (Flask) | ASGI (FastAPI) | |
|---|---|---|
| Модель | Синхронная | Асинхронная |
| Параллелизм | Процессы или потоки | Цикл событий плюс процессы |
| Ожидание ввода-вывода | Блокирует worker | Освобождает цикл событий |
| Сервер | Gunicorn | Uvicorn |
| События старта и остановки | Нет стандарта | lifespan |
| WebSocket | Нет | Есть |
Ключевое следствие: один процесс Uvicorn обрабатывает тысячи одновременных соединений, ожидающих ответа от базы, тогда как один sync-worker Gunicorn — ровно одно.
Официальный способ запуска изменился
Исторически рекомендовалась связка Gunicorn с UvicornWorker: Gunicorn управлял процессами, Uvicorn обрабатывал ASGI.
Сейчас документация FastAPI прямо от этого отговаривает:
The Docker image was created when Uvicorn didn't support managing and restarting dead workers, so it was needed to use Gunicorn with Uvicorn… But now that Uvicorn (and the
fastapicommand) support using--workers, there's no reason to use a base Docker image instead of building your own.
Официальный образ tiangolo/uvicorn-gunicorn-fastapi объявлен устаревшим.
Текущая рекомендация — команда fastapi run:
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
Она запускает Uvicorn с настройками для production: без автоперезагрузки, с привязкой к 0.0.0.0.
Три способа запуска и их применимость:
| Способ | Когда |
|---|---|
fastapi run | Разработка и простые случаи; официальная рекомендация |
python -m uvicorn app.main:app --host 0.0.0.0 | Эксплуатация со сборщиком логов — см. ниже |
gunicorn -k UvicornWorker | Не рекомендуется; только унаследованные конфигурации |
Команда fastapi run требует пакета fastapi[standard] — обычный fastapi не содержит CLI.
Почему для эксплуатации берут python -m uvicorn
Это не вкусовое предпочтение, а измеренное различие. fastapi run делает две вещи, несовместимые с машинным разбором логов:
⚡️ Starting FastAPI in production mode
🐍 Using import string: app.main:app
🌐 Server started at http://0.0.0.0:8000
Documentation at http://0.0.0.0:8000/docs
Logs:
INFO: Started server process [1]
{"ts": "…", "level": "info", "logger": "app", "message": "старт приложения"}
- Печатает декоративный баннер до импорта приложения.
- Перенастраивает логирование uvicorn после импорта, отменяя вашу программную настройку. Поэтому строки
INFO: Started server processостаются в формате uvicorn, хотяconfigure()уже отработал.
Второй пункт коварнее первого: локально, запуская python -m uvicorn, вы увидите чистый JSON и решите, что настройка работает. В образе с fastapi run она отменяется.
Замер на одном и том же образе (verify/FACTS.md):
| Точка входа | Строк лога | Не JSON |
|---|---|---|
fastapi run | 10 | 7 |
python -m uvicorn | 9 | 0 |
Отсюда правило: fastapi run — для разработки и для случаев, где логи читает человек. Там, где их читает сборщик, точкой входа должен быть python -m uvicorn.
Проверять нужно все строки, а не первые:
docker logs имя 2>&1 | python3 -c '
import json, sys
lines = [l for l in sys.stdin if l.strip()]
bad = sum(1 for l in lines if not l.lstrip().startswith("{"))
print(f"строк: {len(lines)}, не JSON: {bad}")'
Сколько процессов в container
Здесь FastAPI даёт рекомендацию, зависящую от способа развёртывания.
При оркестраторе (Kubernetes, Swarm): один процесс на container.
In this type of scenario, you probably would want to have a single (Uvicorn) process per container, as you would already be handling replication at the cluster level.
Логика: репликацию и балансировку уже выполняет оркестратор. Второй уровень управления процессами внутри container добавляет сложность без выгоды — и мешает: оркестратор видит один container как одну единицу, а внутри их несколько.
При одиночном сервере или Compose: несколько worker'ов допустимы.
CMD ["fastapi", "run", "app/main.py", "--port", "8000", "--workers", "4"]
Здесь оркестратора нет, и управление процессами кому-то нужно. Uvicorn умеет это сам.
Курс использует Compose, поэтому оба варианта уместны. Практическое правило:
| Развёртывание | Процессов в container | Масштабирование |
|---|---|---|
| Kubernetes | 1 | Числом реплик |
| Docker Swarm | 1 | Числом реплик |
| Compose на одном сервере | 1 или несколько | --workers или deploy.replicas |
Одиночный docker run | несколько | --workers |
Даже в Compose предпочтительнее один процесс плюс deploy.replicas: это даёт независимые healthcheck и постепенное обновление.
lifespan вместо on_event
Декораторы @app.on_event("startup") и @app.on_event("shutdown") объявлены устаревшими. Замена — контекстный менеджер lifespan:
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
app.state.pool = await create_pool()
yield
# shutdown — вызывается при SIGTERM
await app.state.pool.close()
app = FastAPI(lifespan=lifespan)
Преимущества:
| Свойство | Следствие |
|---|---|
| Единый блок для старта и остановки | Ресурс создаётся и закрывается рядом — видна пара |
| Работает как обычный контекстный менеджер | Естественная обработка исключений через try/finally |
| Гарантированный порядок | Вложенные ресурсы закрываются в обратном порядке |
Совместим с TestClient | Тесты выполняют startup и shutdown |
Последний пункт практически важен: TestClient вызывает lifespan только при использовании как контекстного менеджера:
with TestClient(app) as client: # lifespan выполняется
...
Без with startup не произойдёт, и тесты readiness будут падать.
Liveness против readiness
Две проверки, отвечающие на разные вопросы (урок 5.8).
/healthz (liveness) | /readyz (readiness) | |
|---|---|---|
| Вопрос | Процесс жив? | Готов принимать трафик? |
| Проверяет | Только себя | Себя и зависимости |
| Отказ означает | Перезапустить | Убрать из балансировки |
| Проверяет базу данных | нет | да |
Используется в HEALTHCHECK | да | нет |
Причина, по которой liveness не должна касаться базы, — каскадный отказ: при недоступности базы все реплики станут unhealthy одновременно и пойдут на перезапуск, создав лавину переподключений к восстанавливающейся базе.
Приложение при этом работоспособно: оно корректно возвращает 503 на запросы, требующие базы, и обслуживает остальные.
Конфигурация
Для FastAPI естественный выбор — pydantic-settings: типизация, валидация и чтение из окружения из коробки.
Но зависимость не обязательна: обычный dataclass с проверками при старте покрывает большинство случаев (урок 6.4).
Главное требование одинаково: валидация при старте, а не при первом запросе.
Прокси и заголовки
Если приложение стоит за reverse proxy, оно должно доверять заголовкам X-Forwarded-*, иначе будет видеть адрес прокси вместо адреса клиента и формировать неверные ссылки.
CMD ["fastapi", "run", "app/main.py", "--proxy-headers", "--port", "8000"]
Флаг включает разбор заголовков. Включать его следует только когда перед приложением действительно есть доверенный прокси: иначе клиент сможет подделать свой адрес.
Внутренний механизм
Как Uvicorn обрабатывает SIGTERM
- Получает сигнал (как PID 1 в container).
- Прекращает принимать новые соединения.
- Ждёт завершения активных запросов.
- Вызывает shutdown-часть
lifespan. - Завершается с кодом
0.
Свой обработчик signal.signal в приложении перехватит сигнал раньше и нарушит эту последовательность (урок 6.5).
Почему --reload только для разработки
Автоперезагрузка требует отслеживания файловой системы: дополнительный процесс, наблюдающий за изменениями. В production это лишний расход и риск неожиданного перезапуска при записи временного файла.
Команда fastapi run не включает --reload; для разработки существует fastapi dev.
Команды и примеры
Рабочий пример
cd resources/examples/fastapi-basic
docker build -q -t fastapi-basic . > /dev/null && echo "образ собран"
docker run -d --name api -p 8000:8000 fastapi-basic > /dev/null
sleep 6
curl -s localhost:8000/ | python3 -m json.tool
образ собран
{
"pid": 1,
"service": "fastapi-basic"
}
pid: 1 — приложение является главным процессом, значит получит SIGTERM напрямую.
docker exec api ps -o pid,args
PID COMMAND
1 /opt/venv/bin/python /opt/venv/bin/fastapi run app/main.py --port 8000
14 ps -o pid,args
Один процесс — в отличие от Gunicorn с его master и worker'ами.
fastapi run против прямого Uvicorn
mkdir -p /tmp/fapi/app && cd /tmp/fapi
cat > app/main.py <<'PY'
import os
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"pid": os.getpid()}
PY
cat > requirements.txt <<'EOF'
fastapi[standard]==0.141.1
EOF
cat > Dockerfile.cli <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
EOF
cat > Dockerfile.uvicorn <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
EOF
for v in cli uvicorn; do
docker build -q -f "Dockerfile.$v" -t "fapi:$v" . > /dev/null
docker run -d --name "f-$v" -p "80$([ "$v" = cli ] && echo 41 || echo 42):8000" "fapi:$v" > /dev/null
done
sleep 6
for v in cli uvicorn; do
port=$([ "$v" = cli ] && echo 8041 || echo 8042)
printf '%-8s HTTP %s PID приложения: %s\n' "$v" \
"$(curl -s -o /dev/null -w '%{http_code}' localhost:$port/)" \
"$(curl -s localhost:$port/ | python3 -c 'import json,sys; print(json.load(sys.stdin)["pid"])')"
done
cli HTTP 200 PID приложения: 1
uvicorn HTTP 200 PID приложения: 1
Результат одинаков. Различие — в удобстве: fastapi run сам определяет объект приложения в файле и применяет настройки для production.
Посмотрим, что выводит CLI при старте:
docker logs f-cli 2>&1 | head -6
FastAPI Starting production server 🚀
Searching for package file structure from directories with
__init__.py files
Importing from /app
module 🐍 app/main.py
CLI явно сообщает, что запущен production-сервер — это защищает от случайного запуска сервера разработки.
docker rm -f f-cli f-uvicorn > /dev/null
Почему не Gunicorn с UvicornWorker
cat > requirements-gunicorn.txt <<'EOF'
fastapi[standard]==0.141.1
gunicorn==26.0.0
EOF
cat > Dockerfile.gunicorn <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements-gunicorn.txt .
RUN pip install --no-cache-dir -r requirements-gunicorn.txt
COPY app/ ./app/
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", \
"--bind", "0.0.0.0:8000", "--workers", "2"]
EOF
docker build -q -f Dockerfile.gunicorn -t fapi:gunicorn . > /dev/null
docker run -d --name f-gun -p 8043:8000 fapi:gunicorn > /dev/null
sleep 6
echo "=== работает, но: ==="
curl -s -o /dev/null -w ' HTTP %{http_code}\n' localhost:8043/
printf ' процессов: %s\n' "$(docker exec f-gun sh -c 'for f in /proc/[0-9]*/cmdline; do tr "\0" " " < "$f"; echo; done | grep -c "[g]unicorn"')"
echo
echo "=== размер образов ==="
docker images --format ' {{.Repository}}:{{.Tag}} {{.Size}}' | grep -E 'fapi:(cli|gunicorn)'
docker rm -f f-gun > /dev/null
=== работает, но: ===
HTTP 200
процессов: 3
=== размер образов ===
fapi:gunicorn 268MB
fapi:cli 255MB
Связка работает, но добавляет зависимость Gunicorn и второй уровень управления процессами. С тех пор как Uvicorn научился управлять worker'ами сам, выгоды в этом нет.
Документация FastAPI называет такой подход устаревшим — новые проекты его использовать не должны.
lifespan и graceful shutdown
cat > app/lifecycle.py <<'PY'
"""Демонстрация lifespan: создание и закрытие ресурсов."""
import asyncio
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
class FakePool:
"""Имитация пула соединений."""
def __init__(self) -> None:
self.closed = False
async def close(self) -> None:
print("[pool] закрываю соединения...", flush=True)
await asyncio.sleep(0.5)
self.closed = True
print("[pool] соединения закрыты", flush=True)
_ready = False
@asynccontextmanager
async def lifespan(app: FastAPI):
global _ready
print(f"[lifespan] startup, PID={os.getpid()}", flush=True)
app.state.pool = FakePool()
_ready = True
print("[lifespan] готов принимать запросы", flush=True)
yield
_ready = False
print("[lifespan] shutdown начат", flush=True)
await app.state.pool.close()
print("[lifespan] shutdown завершён", flush=True)
app = FastAPI(lifespan=lifespan)
@app.get("/")
async def root():
return {"pid": os.getpid(), "ready": _ready}
@app.get("/slow")
async def slow(seconds: float = 4.0):
await asyncio.sleep(min(seconds, 20.0))
return {"slept": seconds}
PY
cat > Dockerfile.lifespan <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["fastapi", "run", "app/lifecycle.py", "--port", "8000"]
EOF
docker build -q -f Dockerfile.lifespan -t fapi:lifespan . > /dev/null
docker run -d --name f-life -p 8044:8000 fapi:lifespan > /dev/null
sleep 6
echo "=== логи старта ==="
docker logs f-life 2>&1 | grep lifespan
echo
echo "=== graceful shutdown с активным запросом ==="
curl -s -m 20 "localhost:8044/slow?seconds=4" > /tmp/life-result.txt &
CURL_PID=$!
sleep 1
s="$(date +%s.%N)"; docker stop --timeout 20 f-life > /dev/null; e="$(date +%s.%N)"
wait $CURL_PID 2>/dev/null
printf ' время stop: %.1f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect f-life --format '{{.State.ExitCode}}')"
printf ' ответ клиенту: %s\n' "$(cat /tmp/life-result.txt)"
echo " логи завершения:"
docker logs f-life 2>&1 | grep -E 'shutdown|pool' | sed 's/^/ /'
docker rm f-life > /dev/null; rm -f /tmp/life-result.txt
=== логи старта ===
[lifespan] startup, PID=1
[lifespan] готов принимать запросы
=== graceful shutdown с активным запросом ===
время stop: 4.1 c, код: 0
ответ клиенту: {"slept":4.0}
логи завершения:
[lifespan] shutdown начат
[pool] закрываю соединения...
[pool] соединения закрыты
[lifespan] shutdown завершён
Последовательность корректна: сервер дождался запроса, клиент получил ответ, затем закрылись ресурсы. Кода signal.signal в приложении нет — всё сделал Uvicorn.
Обратите внимание на --timeout 20: без него grace period 10 секунд мог бы не хватить при более долгом запросе.
Свой обработчик мешает
cat > app/bad_signal.py <<'PY'
"""ОШИБКА: свой обработчик перехватывает сигнал раньше Uvicorn."""
import asyncio
import signal
import sys
from fastapi import FastAPI
app = FastAPI()
def handle(signum, _frame):
print("[мой обработчик] завершаю немедленно", flush=True)
sys.exit(0)
signal.signal(signal.SIGTERM, handle)
@app.get("/slow")
async def slow():
await asyncio.sleep(5)
return {"done": True}
PY
cat > Dockerfile.badsignal <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["fastapi", "run", "app/bad_signal.py", "--port", "8000"]
EOF
docker build -q -f Dockerfile.badsignal -t fapi:badsignal . > /dev/null
docker run -d --name f-bad -p 8045:8000 fapi:badsignal > /dev/null
sleep 6
curl -s -m 15 localhost:8045/slow > /tmp/bad-result.txt 2>&1 &
CURL_PID=$!
sleep 1
docker stop --timeout 20 f-bad > /dev/null
wait $CURL_PID 2>/dev/null
echo -n "ответ клиенту: "
[ -s /tmp/bad-result.txt ] && cat /tmp/bad-result.txt || echo "(пусто — соединение оборвано)"
echo
docker logs f-bad 2>&1 | grep 'мой обработчик'
docker rm f-bad > /dev/null; rm -f /tmp/bad-result.txt
ответ клиенту: (пусто — соединение оборвано)
[мой обработчик] завершаю немедленно
Активный запрос оборван. Правило из урока 6.5 подтверждается: под сервером приложений свой обработчик вреден.
Liveness и readiness
cat > app/probes.py <<'PY'
"""Раздельные пробы: liveness не зависит от базы, readiness зависит."""
import os
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Response
_started_at = time.monotonic()
_ready = False
# Имитация недоступности базы через переменную окружения
DB_DOWN_AFTER = float(os.environ.get("DB_DOWN_AFTER", "0"))
def db_available() -> bool:
if DB_DOWN_AFTER and (time.monotonic() - _started_at) > DB_DOWN_AFTER:
return False
return True
@asynccontextmanager
async def lifespan(_app: FastAPI):
global _ready
_ready = True
yield
_ready = False
app = FastAPI(lifespan=lifespan)
@app.get("/healthz")
async def healthz():
"""Liveness: только процесс. О базе НЕ знает."""
return {"status": "ok"}
@app.get("/readyz")
async def readyz(response: Response):
"""Readiness: процесс и зависимости."""
if not _ready:
response.status_code = 503
return {"status": "starting"}
if not db_available():
response.status_code = 503
return {"status": "database unavailable"}
return {"status": "ready"}
@app.get("/data")
async def data(response: Response):
"""Запрос, которому нужна база."""
if not db_available():
response.status_code = 503
return {"error": "database unavailable"}
return {"items": [1, 2, 3]}
@app.get("/ping")
async def ping():
"""Запрос, которому база не нужна — работает всегда."""
return {"pong": True}
PY
cat > Dockerfile.probes <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --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)"
CMD ["fastapi", "run", "app/probes.py", "--port", "8000"]
EOF
docker build -q -f Dockerfile.probes -t fapi:probes . > /dev/null
docker run -d --name f-probes -p 8046:8000 -e DB_DOWN_AFTER=8 fapi:probes > /dev/null
sleep 20
echo "=== база недоступна (прошло больше 8 секунд) ==="
printf ' /healthz (liveness): HTTP %s\n' \
"$(curl -s -o /dev/null -w '%{http_code}' localhost:8046/healthz)"
printf ' /readyz (readiness): HTTP %s\n' \
"$(curl -s -o /dev/null -w '%{http_code}' localhost:8046/readyz)"
printf ' /data (нужна база): HTTP %s\n' \
"$(curl -s -o /dev/null -w '%{http_code}' localhost:8046/data)"
printf ' /ping (база не нужна): HTTP %s\n' \
"$(curl -s -o /dev/null -w '%{http_code}' localhost:8046/ping)"
echo
printf ' статус healthcheck: %s\n' "$(docker inspect f-probes --format '{{.State.Health.Status}}')"
printf ' перезапусков: %s\n' "$(docker inspect f-probes --format '{{.RestartCount}}')"
docker rm -f f-probes > /dev/null
=== база недоступна (прошло больше 8 секунд) ===
/healthz (liveness): HTTP 200
/readyz (readiness): HTTP 503
/data (нужна база): HTTP 503
/ping (база не нужна): HTTP 200
статус healthcheck: healthy
перезапусков: 0
Разделение работает как задумано: приложение живо и обслуживает запросы, не требующие базы; readiness сообщает балансировщику, что трафик слать не стоит; healthcheck остаётся healthy и не вызывает перезапуска.
Если бы HEALTHCHECK проверял /readyz, container был бы помечен unhealthy, и в оркестраторе все реплики пошли бы на перезапуск одновременно.
Один процесс или несколько
cat > Dockerfile.workers <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
ARG WORKERS=1
ENV WORKERS=${WORKERS}
CMD ["sh", "-c", "exec fastapi run app/main.py --port 8000 --workers ${WORKERS}"]
EOF
docker build -q -f Dockerfile.workers -t fapi:workers . > /dev/null
for n in 1 3; do
docker run -d --name "f-w$n" -e WORKERS="$n" -p "805$n:8000" fapi:workers > /dev/null
sleep 7
printf 'WORKERS=%s → процессов: %s, PID ответов: ' "$n" \
"$(docker exec "f-w$n" sh -c 'ps -o pid= | wc -l')"
for _ in 1 2 3 4; do
curl -s "localhost:805$n/" | python3 -c 'import json,sys; print(json.load(sys.stdin)["pid"], end=" ")'
done
echo
docker rm -f "f-w$n" > /dev/null
done
WORKERS=1 → процессов: 2, PID ответов: 1 1 1 1
WORKERS=3 → процессов: 5, PID ответов: 8 9 10 8
При одном worker'е все запросы обрабатывает PID 1. При трёх — запросы распределяются между процессами.
Обратите внимание: с --workers 3 приложение больше не является PID 1 — им становится процесс-менеджер Uvicorn. Это не проблема (менеджер корректно обрабатывает сигналы), но меняет картину при отладке.
Обёртка sh -c ... exec здесь нужна для подстановки переменной; exec сохраняет корректную доставку сигналов (урок 5.4).
Валидация конфигурации
cd resources/examples/fastapi-basic
sed -n '1,30p' app/settings.py
"""Конфигурация через переменные окружения с валидацией при старте."""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
"""Приложение падает при старте, если конфигурация некорректна."""
app_name: str = "fastapi-basic"
log_level: str = "INFO"
shutdown_delay: float = 0.0
@classmethod
def from_env(cls) -> Settings:
raw_delay = os.environ.get("SHUTDOWN_DELAY", "0")
try:
delay = float(raw_delay)
except ValueError as exc:
raise ValueError(f"SHUTDOWN_DELAY должен быть числом, получено: {raw_delay!r}") from exc
Проверим поведение при некорректном значении:
docker build -q -t fastapi-basic . > /dev/null
echo "=== корректная конфигурация ==="
docker run -d --name cfg-ok -p 8060:8000 fastapi-basic > /dev/null
sleep 6
curl -s -o /dev/null -w ' HTTP %{http_code}\n' localhost:8060/
docker rm -f cfg-ok > /dev/null
echo "=== некорректная конфигурация ==="
docker run --rm -e SHUTDOWN_DELAY=не-число fastapi-basic 2>&1 | tail -3
=== корректная конфигурация ===
HTTP 200
=== некорректная конфигурация ===
ValueError: SHUTDOWN_DELAY должен быть числом, получено: 'не-число'
Приложение падает при старте с понятным сообщением, а не при первом запросе.
Тесты с lifespan
cd resources/examples/fastapi-basic
sed -n '1,20p' tests/test_api.py
"""Тесты API через TestClient."""
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
# Контекстный менеджер запускает lifespan: без него readiness останется False
with TestClient(app) as c:
yield c
Запуск тестов как стадии сборки:
docker build -q --target test -t fastapi-basic:test . > /dev/null && echo "тесты прошли"
Проверим важность with:
mkdir -p /tmp/fapi-test && cd /tmp/fapi-test
cat > demo_test.py <<'PY'
"""Разница между TestClient с контекстным менеджером и без."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
_ready = False
@asynccontextmanager
async def lifespan(_app: FastAPI):
global _ready
_ready = True
yield
_ready = False
app = FastAPI(lifespan=lifespan)
@app.get("/readyz")
async def readyz():
return {"ready": _ready}
# БЕЗ контекстного менеджера: lifespan не выполняется
client_no_ctx = TestClient(app)
print("без with:", client_no_ctx.get("/readyz").json())
# С контекстным менеджером: lifespan выполняется
with TestClient(app) as client:
print("с with: ", client.get("/readyz").json())
PY
docker run --rm -v "$PWD/demo_test.py:/t.py:ro" \
-e PYTHONUNBUFFERED=1 \
python:3.13-slim sh -c \
'pip install -q "fastapi[standard]==0.141.1" 2>/dev/null && python /t.py'
без with: {'ready': False}
с with: {'ready': True}
Без with startup не выполнился — тесты readiness падали бы без видимой причины.
Уборка
cd /tmp
docker rm -f api 2>/dev/null || true
docker rmi -f $(docker images -q --filter 'reference=fapi:*') fastapi-basic fastapi-basic:test 2>/dev/null || true
rm -rf /tmp/fapi /tmp/fapi-test
Практическое упражнение
Задание. Контейнеризируйте FastAPI-сервис, удовлетворяющий восьми требованиям.
- Запуск Uvicorn напрямую (
python -m uvicorn), не через Gunicorn — и не черезfastapi run, см. требование 6. lifespanвместоon_event; ресурсы закрываются при остановке.- Раздельные
/healthzи/readyzс разной логикой. HEALTHCHECKпроверяет liveness, не readiness.- Конфигурация валидируется при старте.
- Structured logging в JSON, включая логи Uvicorn.
- Работа от непривилегированного пользователя, тесты как стадия сборки.
docker stopдозавершает активный запрос и возвращает код0.
Докажите каждое требование командой, а требования 2 и 3 — ещё и тестом.
Подсказки
Подсказка 1
Для требования 3 нужен способ имитировать недоступность зависимости — например, переменная окружения.
Подсказка 2
Требование 8 проверяется запросом, идущим в момент docker stop; понадобится --timeout больше длительности запроса.
Подсказка 3
TestClient нужно использовать как контекстный менеджер, иначе lifespan не выполнится.
Решение
Сначала выполните задание самостоятельно.
Показать решение
mkdir -p /tmp/fapi-ex/app /tmp/fapi-ex/tests && cd /tmp/fapi-ex
cat > app/__init__.py <<'PY'
"""FastAPI-сервис."""
PY
cat > app/settings.py <<'PY'
"""Конфигурация с валидацией при старте (требование 5)."""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
VALID_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR"})
@dataclass(frozen=True)
class Settings:
app_name: str = "fastapi-ex"
log_level: str = "INFO"
shutdown_delay: float = 0.0
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 VALID_LEVELS:
errors.append(f"LOG_LEVEL должен быть одним из {sorted(VALID_LEVELS)}, получено: {level}")
def _float(name: str, default: str) -> float:
raw = os.environ.get(name, default)
try:
value = float(raw)
except ValueError:
errors.append(f"{name} должен быть числом, получено: {raw!r}")
return 0.0
if value < 0:
errors.append(f"{name} не может быть отрицательным")
return value
delay = _float("SHUTDOWN_DELAY", "0")
db_fail = _float("DB_FAIL_AFTER", "0")
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,
shutdown_delay=delay,
db_fail_after=db_fail,
)
PY
cat > app/logging_config.py <<'PY'
"""Structured logging в JSON, включая логи Uvicorn (требование 6)."""
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 k, v in getattr(record, "extra_fields", {}).items():
payload.setdefault(k, v)
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)
# Логи Uvicorn — через тот же обработчик
for name in ("uvicorn", "uvicorn.access", "uvicorn.error"):
lg = logging.getLogger(name)
lg.handlers.clear()
lg.propagate = True
PY
cat > app/main.py <<'PY'
"""FastAPI-сервис: lifespan, раздельные пробы, JSON-логи."""
from __future__ import annotations
import asyncio
import logging
import os
import time
from contextlib import asynccontextmanager
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_at = time.monotonic()
_ready = False
class Pool:
"""Имитация пула соединений с базой."""
def __init__(self) -> None:
self.closed = False
def available(self) -> bool:
if settings.db_fail_after and (time.monotonic() - _started_at) > settings.db_fail_after:
return False
return not self.closed
async def close(self) -> None:
logger.info("закрываю пул соединений")
await asyncio.sleep(0.2)
self.closed = True
logger.info("пул закрыт")
# Требование 2: lifespan вместо on_event
@asynccontextmanager
async def lifespan(app: FastAPI):
global _ready
logger.info("startup", extra={"extra_fields": {"pid": os.getpid()}})
app.state.pool = Pool()
_ready = True
logger.info("готов принимать запросы")
yield
_ready = False
logger.info("shutdown начат")
if settings.shutdown_delay:
await asyncio.sleep(settings.shutdown_delay)
await app.state.pool.close()
logger.info("shutdown завершён")
app = FastAPI(title=settings.app_name, lifespan=lifespan)
# Требование 3: liveness — только процесс
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
# Требование 3: readiness — процесс и зависимости
@app.get("/readyz")
async def readyz(response: Response) -> dict[str, str]:
if not _ready:
response.status_code = 503
return {"status": "starting"}
if not app.state.pool.available():
response.status_code = 503
return {"status": "database unavailable"}
return {"status": "ready"}
@app.get("/")
async def root() -> dict[str, object]:
return {"service": settings.app_name, "pid": os.getpid()}
@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_api.py <<'PY'
"""Тесты требований 2 и 3."""
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
# Контекстный менеджер выполняет lifespan — без него readiness будет False
with TestClient(app) as c:
yield c
# ── Требование 2: lifespan ──
def test_lifespan_startup_sets_ready(client):
assert client.get("/readyz").json()["status"] == "ready"
def test_lifespan_creates_pool(client):
assert app.state.pool is not None
assert not app.state.pool.closed
def test_lifespan_shutdown_closes_pool():
with TestClient(app) as c:
c.get("/")
# после выхода из контекста shutdown выполнен
assert app.state.pool.closed
def test_without_context_manager_lifespan_not_run():
"""Без with startup не выполняется — типичная ошибка в тестах."""
bare = TestClient(app)
assert bare.get("/readyz").json()["status"] in {"starting", "ready"}
# ── Требование 3: раздельные пробы ──
def test_healthz_always_ok(client):
assert client.get("/healthz").status_code == 200
def test_readyz_ok_when_healthy(client):
assert client.get("/readyz").status_code == 200
def test_healthz_ok_when_db_down(client, monkeypatch):
"""Ключевая проверка: liveness НЕ зависит от базы."""
monkeypatch.setattr(app.state.pool, "closed", True)
assert client.get("/healthz").status_code == 200
def test_readyz_fails_when_db_down(client, monkeypatch):
"""Readiness зависит от базы."""
monkeypatch.setattr(app.state.pool, "closed", True)
assert client.get("/readyz").status_code == 503
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'
Dockerfile*
.dockerignore
.git
.venv
__pycache__
*.py[cod]
.pytest_cache
EOF
# tests и requirements-dev.txt НЕ исключены: их копирует стадия test,
# а .dockerignore действует на всю сборку сразу
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=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
# Требование 7: тесты как стадия сборки
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/
# python -m pytest, а не pytest: консольная команда не кладёт
# текущий каталог в sys.path, и `from app.main import app` не находит пакет
RUN python -m pytest -q tests/
FROM base AS runtime
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
# Требование 7: non-root
USER 10001:10001
EXPOSE 8000
# Требование 4: HEALTHCHECK проверяет liveness, НЕ readiness
HEALTHCHECK --interval=15s --timeout=3s --start-period=15s --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)"
# Требование 1: Uvicorn напрямую.
# fastapi run здесь не годится: он печатает баннер до импорта приложения
# и перенастраивает логирование uvicorn после импорта — требование 6
# перестало бы выполняться.
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
EOF
# ── Проверка требований ──
echo "═══ Требование 7: тесты в сборке ═══"
docker build -q --target test -t fapi-ex:test . > /dev/null && echo " тесты прошли"
docker build -q -t fapi-ex . > /dev/null
docker run -d --name fex -p 8070:8000 -e DB_FAIL_AFTER=10 -e SHUTDOWN_DELAY=1 fapi-ex > /dev/null
sleep 18
echo
echo "═══ Требование 1: способ запуска ═══"
# ps в slim-образе нет — читаем /proc. Перенаправление выполняется
# на хосте, поэтому читать файл должна команда внутри: cat, не tr.
printf ' PID 1: %s\n' "$(docker exec fex cat /proc/1/cmdline | tr '\0' ' ' | cut -c1-46)"
echo
echo "═══ Требования 3 и 4: пробы при недоступной базе ═══"
printf ' /healthz (liveness): HTTP %s\n' "$(curl -s -o /dev/null -w '%{http_code}' localhost:8070/healthz)"
printf ' /readyz (readiness): HTTP %s\n' "$(curl -s -o /dev/null -w '%{http_code}' localhost:8070/readyz)"
printf ' healthcheck: %s (перезапусков: %s)\n' \
"$(docker inspect fex --format '{{.State.Health.Status}}')" \
"$(docker inspect fex --format '{{.RestartCount}}')"
echo
echo "═══ Требование 6: JSON-логи, включая Uvicorn ═══"
# Считаем ВСЕ строки, а не только начинающиеся с {: проверка,
# которая заранее отбросила бы не-JSON, не смогла бы провалиться.
docker logs fex 2>&1 | python3 -c "
import json, sys
lines = [l for l in sys.stdin.read().splitlines() if l.strip()]
bad = []
for l in lines:
try:
r = json.loads(l)
except ValueError:
bad.append(l)
print(f' строк: {len(lines)}, не JSON: {len(bad)}')
for l in bad[:3]:
print(' не JSON:', l[:50])
for l in lines[-2:]:
try:
r = json.loads(l)
print(f\" [{r['logger']:<15}] {r['message'][:45]}\")
except ValueError:
pass
"
echo
echo "═══ Требование 7: пользователь ═══"
printf ' UID: %s\n' "$(docker exec fex id -u)"
echo
echo "═══ Требование 8: graceful shutdown ═══"
curl -s -m 25 "localhost:8070/slow?seconds=4" > /tmp/fex.txt &
CURL_PID=$!
sleep 1
s="$(date +%s.%N)"; docker stop --timeout 25 fex > /dev/null; e="$(date +%s.%N)"
wait $CURL_PID 2>/dev/null
printf ' время stop: %.1f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect fex --format '{{.State.ExitCode}}')"
printf ' ответ клиенту: %s\n' "$(cat /tmp/fex.txt)"
docker logs fex 2>&1 | grep '^{' | python3 -c "
import json, sys
for line in sys.stdin:
r = json.loads(line)
if 'shutdown' in r['message'] or 'пул' in r['message']:
print(f\" {r['message']}\")
"
echo
echo "═══ Требование 5: валидация конфигурации ═══"
docker run --rm -e LOG_LEVEL=НЕВЕРНЫЙ -e SHUTDOWN_DELAY=abc fapi-ex 2>&1 | head -4 | sed 's/^/ /'
docker rm -f fex > /dev/null 2>&1; rm -f /tmp/fex.txt
cd /tmp && docker rmi -f fapi-ex fapi-ex:test > /dev/null 2>&1; rm -rf /tmp/fapi-ex
Ожидаемый вывод:
═══ Требование 7: тесты в сборке ═══
тесты прошли
═══ Требование 1: способ запуска ═══
PID 1: python -m uvicorn app.main:app --host 0.0.0.0
═══ Требования 3 и 4: пробы при недоступной базе ═══
/healthz (liveness): HTTP 200
/readyz (readiness): HTTP 503
healthcheck: healthy (перезапусков: 0)
═══ Требование 6: JSON-логи, включая Uvicorn ═══
строк: 10, не JSON: 0
[uvicorn.access ] 172.17.0.1:54174 - "GET /healthz HTTP/1.1" 20
[uvicorn.access ] 172.17.0.1:54188 - "GET /readyz HTTP/1.1" 503
═══ Требование 7: пользователь ═══
UID: 10001
═══ Требование 8: graceful shutdown ═══
время stop: 4.7 c, код: 0
ответ клиенту: {"slept":4.0}
Waiting for application shutdown.
shutdown начат
закрываю пул соединений
пул закрыт
shutdown завершён
Application shutdown complete.
═══ Требование 5: валидация конфигурации ═══
ОШИБКА КОНФИГУРАЦИИ:
- LOG_LEVEL должен быть одним из ['DEBUG', 'ERROR', 'INFO', 'WARNING'], получено: НЕВЕРНЫЙ
- SHUTDOWN_DELAY должен быть числом, получено: 'abc'
Все восемь требований выполнены.
Три решения, определяющие качество.
Тест test_healthz_ok_when_db_down — самый важный в наборе. Он фиксирует в коде правило, которое иначе легко нарушить при рефакторинге: liveness не должна зависеть от базы. Без теста кто-нибудь однажды «улучшит» /healthz, добавив проверку соединения, и получит каскадный отказ в production.
Проверка 3 и 4 выполняется при недоступной базе. При работающей базе обе пробы возвращают 200, и разница не видна. Только имитация отказа показывает, что разделение реально работает.
Требование 5 собирает все ошибки конфигурации. Вывод показывает две ошибки сразу, а не первую — иначе исправление потребовало бы двух перезапусков (урок 6.4).
Что осталось за рамками. Сервис запускается одним процессом. Для Compose с несколькими репликами это правильно — репликацию задаёт deploy.replicas. Для одиночного docker run под нагрузкой понадобился бы --workers; расчёт разбирается в уроке 6.11.
Проверка результата
cd resources/examples/fastapi-basic
docker build -q -t fb . > /dev/null
docker run -d --name fbt -p 8080:8000 fb > /dev/null
sleep 8
curl -s -o /dev/null -w 'healthz: HTTP %{http_code}\n' localhost:8080/healthz
# ps в slim-образе нет — читаем /proc внутри container'а
docker exec fbt cat /proc/1/cmdline | tr '\0' ' ' | cut -c1-40; echo
time docker stop fbt
docker inspect fbt --format 'код: {{.State.ExitCode}}'
docker rm -f fbt > /dev/null; docker rmi -f fb > /dev/null
healthz: HTTP 200
python -m uvicorn app.main:app --host 0.
код: 0
Ожидается HTTP 200, python -m uvicorn в PID 1, остановка меньше чем за секунду с кодом 0.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Gunicorn с UvicornWorker в новом проекте | Устаревшие руководства | Uvicorn сам управляет worker'ами; запускать python -m uvicorn |
Образ tiangolo/uvicorn-gunicorn-fastapi | Был популярен | Объявлен устаревшим; собирать свой образ |
Обычный fastapi вместо fastapi[standard] | Меньше зависимостей | Команда fastapi run отсутствует |
@app.on_event("startup") | Старые примеры | Устарело; использовать lifespan |
Свой обработчик SIGTERM | Кажется надёжнее | Перехватывает сигнал раньше Uvicorn и рвёт запросы |
HEALTHCHECK проверяет /readyz | Кажется более полной проверкой | Каскадный отказ при недоступности базы |
| Одна проба вместо двух | Проще | Liveness и readiness — разные вопросы |
TestClient(app) без with | Работает для простых тестов | lifespan не выполняется; тесты readiness падают |
--reload в production | Осталось из разработки | Лишний расход, неожиданные перезапуски |
--proxy-headers без прокси | Кажется безобидным | Клиент может подделать свой адрес |
Контрольные вопросы
На понимание:
- Почему связка Gunicorn с
UvicornWorkerбольше не рекомендуется? - Чем ASGI отличается от WSGI по модели обработки запросов?
- Почему
lifespanлучшеon_event? Назовите три причины. - Почему
HEALTHCHECKдолжен проверять liveness, а не readiness? - Почему при оркестраторе рекомендуется один процесс на container?
На применение:
- Как реализовать закрытие пула соединений при остановке?
- Как проверить, что liveness не зависит от базы данных?
- Как запустить несколько worker'ов при развёртывании без оркестратора?
На диагностику:
fastapi: command not foundпри запуске образа. Причина?- Тесты readiness падают, хотя приложение работает. Что проверить?
Краткое резюме
- Официальный способ запуска FastAPI —
fastapi run, требующийfastapi[standard]; для container'а, чьи логи читает сборщик, точкой входа должен бытьpython -m uvicorn—fastapi runотменяет программную настройку логирования. - Gunicorn с
UvicornWorkerобъявлен устаревшим: Uvicorn сам управляет worker'ами. - При оркестраторе — один процесс на container, репликация на уровне кластера.
- При Compose или одиночном сервере допустимы
--workers, но реплики предпочтительнее. lifespanзаменяет устаревшиеon_eventи даёт парность создания и закрытия ресурсов.- Uvicorn сам обрабатывает
SIGTERM; свой обработчик вреден. - Liveness проверяет только процесс, readiness — процесс и зависимости.
HEALTHCHECKдолжен использовать liveness, иначе возможен каскадный отказ.TestClientвыполняетlifespanтолько как контекстный менеджер.- Конфигурация валидируется при старте и сообщает все ошибки сразу.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| FastAPI in Containers | https://fastapi.tiangolo.com/deployment/docker/ | fastapi run как рекомендация, отказ от Gunicorn с UvicornWorker, один процесс на container при оркестраторе |
| FastAPI: lifespan events | https://fastapi.tiangolo.com/advanced/events/ | lifespan вместо устаревших on_event |
| FastAPI: server workers | https://fastapi.tiangolo.com/deployment/server-workers/ | Когда применять --workers, взаимодействие с оркестратором |
| FastAPI: testing | https://fastapi.tiangolo.com/advanced/testing-events/ | TestClient как контекстный менеджер для выполнения lifespan |
| FastAPI: behind a proxy | https://fastapi.tiangolo.com/advanced/behind-a-proxy/ | --proxy-headers и условия применения |
| Uvicorn: deployment | https://www.uvicorn.org/deployment/ | Обработка сигналов, управление worker'ами |
| Uvicorn: settings | https://www.uvicorn.org/settings/ | Параметры запуска, --workers, --host |
| ASGI specification | https://asgi.readthedocs.io/en/latest/specs/lifespan.html | Протокол lifespan |
| Kubernetes: probes | https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ | Различие liveness и readiness |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Worker processes
Главное оглавление