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

6.10. FastAPI

Цели

После этого материала вы сможете:

  • запустить FastAPI в container официально рекомендуемым способом;
  • объяснить, почему связка Gunicorn с UvicornWorker больше не рекомендуется;
  • реализовать graceful shutdown через lifespan вместо устаревших on_event;
  • разделить liveness и readiness и объяснить, почему это разные проверки;
  • решить, сколько процессов запускать в одном container;
  • настроить конфигурацию с валидацией при старте.

Предварительные знания

Рабочий пример — resources/examples/fastapi-basic/.

Ключевые термины

ТерминОбъяснение
ASGIАсинхронный интерфейс между сервером и Python-приложением
UvicornASGI-сервер, реализация на uvloop и httptools
lifespanМеханизм ASGI для действий при старте и остановке
fastapi runКоманда CLI, запускающая приложение через Uvicorn
livenessПроверка «жив ли процесс»
readinessПроверка «готов ли принимать трафик»

Теория

ASGI против WSGI

Различие определяет всё остальное в этом уроке.

WSGI (Flask)ASGI (FastAPI)
МодельСинхроннаяАсинхронная
ПараллелизмПроцессы или потокиЦикл событий плюс процессы
Ожидание ввода-выводаБлокирует workerОсвобождает цикл событий
СерверGunicornUvicorn
События старта и остановкиНет стандарта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 fastapi command) support using --workers, there's no reason to use a base Docker image instead of building your own.

Официальный образ tiangolo/uvicorn-gunicorn-fastapi объявлен устаревшим.

Текущая рекомендация — команда fastapi run:

dockerfile
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 делает две вещи, несовместимые с машинным разбором логов:

text
 ⚡️ 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": "старт приложения"}
  1. Печатает декоративный баннер до импорта приложения.
  2. Перенастраивает логирование uvicorn после импорта, отменяя вашу программную настройку. Поэтому строки INFO: Started server process остаются в формате uvicorn, хотя configure() уже отработал.

Второй пункт коварнее первого: локально, запуская python -m uvicorn, вы увидите чистый JSON и решите, что настройка работает. В образе с fastapi run она отменяется.

Замер на одном и том же образе (verify/FACTS.md):

Точка входаСтрок логаНе JSON
fastapi run107
python -m uvicorn90

Отсюда правило: fastapi run — для разработки и для случаев, где логи читает человек. Там, где их читает сборщик, точкой входа должен быть python -m uvicorn.

Проверять нужно все строки, а не первые:

bash
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'ов допустимы.

dockerfile
CMD ["fastapi", "run", "app/main.py", "--port", "8000", "--workers", "4"]

Здесь оркестратора нет, и управление процессами кому-то нужно. Uvicorn умеет это сам.

Курс использует Compose, поэтому оба варианта уместны. Практическое правило:

РазвёртываниеПроцессов в containerМасштабирование
Kubernetes1Числом реплик
Docker Swarm1Числом реплик
Compose на одном сервере1 или несколько--workers или deploy.replicas
Одиночный docker runнесколько--workers

Даже в Compose предпочтительнее один процесс плюс deploy.replicas: это даёт независимые healthcheck и постепенное обновление.

lifespan вместо on_event

Декораторы @app.on_event("startup") и @app.on_event("shutdown") объявлены устаревшими. Замена — контекстный менеджер lifespan:

python
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 только при использовании как контекстного менеджера:

python
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-*, иначе будет видеть адрес прокси вместо адреса клиента и формировать неверные ссылки.

dockerfile
CMD ["fastapi", "run", "app/main.py", "--proxy-headers", "--port", "8000"]

Флаг включает разбор заголовков. Включать его следует только когда перед приложением действительно есть доверенный прокси: иначе клиент сможет подделать свой адрес.


Внутренний механизм

Как Uvicorn обрабатывает SIGTERM

  1. Получает сигнал (как PID 1 в container).
  2. Прекращает принимать новые соединения.
  3. Ждёт завершения активных запросов.
  4. Вызывает shutdown-часть lifespan.
  5. Завершается с кодом 0.

Свой обработчик signal.signal в приложении перехватит сигнал раньше и нарушит эту последовательность (урок 6.5).

Почему --reload только для разработки

Автоперезагрузка требует отслеживания файловой системы: дополнительный процесс, наблюдающий за изменениями. В production это лишний расход и риск неожиданного перезапуска при записи временного файла.

Команда fastapi run не включает --reload; для разработки существует fastapi dev.


Команды и примеры

Рабочий пример

bash
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
text
образ собран
{
    "pid": 1,
    "service": "fastapi-basic"
}

pid: 1 — приложение является главным процессом, значит получит SIGTERM напрямую.

bash
docker exec api ps -o pid,args
text
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

bash
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
text
cli      HTTP 200   PID приложения: 1
uvicorn  HTTP 200   PID приложения: 1

Результат одинаков. Различие — в удобстве: fastapi run сам определяет объект приложения в файле и применяет настройки для production.

Посмотрим, что выводит CLI при старте:

bash
docker logs f-cli 2>&1 | head -6
text
FastAPI   Starting production server 🚀
 
             Searching for package file structure from directories with
             __init__.py files
             Importing from /app
 
    module   🐍 app/main.py

CLI явно сообщает, что запущен production-сервер — это защищает от случайного запуска сервера разработки.

bash
docker rm -f f-cli f-uvicorn > /dev/null

Почему не Gunicorn с UvicornWorker

bash
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
text
=== работает, но: ===
  HTTP 200
  процессов: 3
=== размер образов ===
  fapi:gunicorn  268MB
  fapi:cli       255MB

Связка работает, но добавляет зависимость Gunicorn и второй уровень управления процессами. С тех пор как Uvicorn научился управлять worker'ами сам, выгоды в этом нет.

Документация FastAPI называет такой подход устаревшим — новые проекты его использовать не должны.

lifespan и graceful shutdown

bash
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
text
=== логи старта ===
[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 секунд мог бы не хватить при более долгом запросе.

Свой обработчик мешает

bash
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
text
ответ клиенту: (пусто — соединение оборвано)
[мой обработчик] завершаю немедленно

Активный запрос оборван. Правило из урока 6.5 подтверждается: под сервером приложений свой обработчик вреден.

Liveness и readiness

bash
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
text
=== база недоступна (прошло больше 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, и в оркестраторе все реплики пошли бы на перезапуск одновременно.

Один процесс или несколько

bash
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
text
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).

Валидация конфигурации

bash
cd resources/examples/fastapi-basic
sed -n '1,30p' app/settings.py
text
"""Конфигурация через переменные окружения с валидацией при старте."""
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

Проверим поведение при некорректном значении:

bash
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
text
=== корректная конфигурация ===
  HTTP 200
=== некорректная конфигурация ===
ValueError: SHUTDOWN_DELAY должен быть числом, получено: 'не-число'

Приложение падает при старте с понятным сообщением, а не при первом запросе.

Тесты с lifespan

bash
cd resources/examples/fastapi-basic
sed -n '1,20p' tests/test_api.py
text
"""Тесты 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

Запуск тестов как стадии сборки:

bash
docker build -q --target test -t fastapi-basic:test . > /dev/null && echo "тесты прошли"

Проверим важность with:

bash
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'
text
без with: {'ready': False}
с with:  {'ready': True}

Без with startup не выполнился — тесты readiness падали бы без видимой причины.

Уборка

bash
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-сервис, удовлетворяющий восьми требованиям.

  1. Запуск Uvicorn напрямую (python -m uvicorn), не через Gunicorn — и не через fastapi run, см. требование 6.
  2. lifespan вместо on_event; ресурсы закрываются при остановке.
  3. Раздельные /healthz и /readyz с разной логикой.
  4. HEALTHCHECK проверяет liveness, не readiness.
  5. Конфигурация валидируется при старте.
  6. Structured logging в JSON, включая логи Uvicorn.
  7. Работа от непривилегированного пользователя, тесты как стадия сборки.
  8. docker stop дозавершает активный запрос и возвращает код 0.

Докажите каждое требование командой, а требования 2 и 3 — ещё и тестом.

Подсказки

Подсказка 1

Для требования 3 нужен способ имитировать недоступность зависимости — например, переменная окружения.

Подсказка 2

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

Подсказка 3

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

Решение

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

Показать решение
bash
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

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

text
═══ Требование 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.

Проверка результата

bash
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
text
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 без проксиКажется безобиднымКлиент может подделать свой адрес

Контрольные вопросы

На понимание:

  1. Почему связка Gunicorn с UvicornWorker больше не рекомендуется?
  2. Чем ASGI отличается от WSGI по модели обработки запросов?
  3. Почему lifespan лучше on_event? Назовите три причины.
  4. Почему HEALTHCHECK должен проверять liveness, а не readiness?
  5. Почему при оркестраторе рекомендуется один процесс на container?

На применение:

  1. Как реализовать закрытие пула соединений при остановке?
  2. Как проверить, что liveness не зависит от базы данных?
  3. Как запустить несколько worker'ов при развёртывании без оркестратора?

На диагностику:

  1. fastapi: command not found при запуске образа. Причина?
  2. Тесты readiness падают, хотя приложение работает. Что проверить?

Краткое резюме

  1. Официальный способ запуска FastAPI — fastapi run, требующий fastapi[standard]; для container'а, чьи логи читает сборщик, точкой входа должен быть python -m uvicornfastapi run отменяет программную настройку логирования.
  2. Gunicorn с UvicornWorker объявлен устаревшим: Uvicorn сам управляет worker'ами.
  3. При оркестраторе — один процесс на container, репликация на уровне кластера.
  4. При Compose или одиночном сервере допустимы --workers, но реплики предпочтительнее.
  5. lifespan заменяет устаревшие on_event и даёт парность создания и закрытия ресурсов.
  6. Uvicorn сам обрабатывает SIGTERM; свой обработчик вреден.
  7. Liveness проверяет только процесс, readiness — процесс и зависимости.
  8. HEALTHCHECK должен использовать liveness, иначе возможен каскадный отказ.
  9. TestClient выполняет lifespan только как контекстный менеджер.
  10. Конфигурация валидируется при старте и сообщает все ошибки сразу.

Официальные источники

ИсточникСсылкаЧто подтверждает
FastAPI in Containershttps://fastapi.tiangolo.com/deployment/docker/fastapi run как рекомендация, отказ от Gunicorn с UvicornWorker, один процесс на container при оркестраторе
FastAPI: lifespan eventshttps://fastapi.tiangolo.com/advanced/events/lifespan вместо устаревших on_event
FastAPI: server workershttps://fastapi.tiangolo.com/deployment/server-workers/Когда применять --workers, взаимодействие с оркестратором
FastAPI: testinghttps://fastapi.tiangolo.com/advanced/testing-events/TestClient как контекстный менеджер для выполнения lifespan
FastAPI: behind a proxyhttps://fastapi.tiangolo.com/advanced/behind-a-proxy/--proxy-headers и условия применения
Uvicorn: deploymenthttps://www.uvicorn.org/deployment/Обработка сигналов, управление worker'ами
Uvicorn: settingshttps://www.uvicorn.org/settings/Параметры запуска, --workers, --host
ASGI specificationhttps://asgi.readthedocs.io/en/latest/specs/lifespan.htmlПротокол lifespan
Kubernetes: probeshttps://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/Различие liveness и readiness

Навигация

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

Markdown на GitHub ↗