Большинство туториалов по FastAPI заканчиваются на uvicorn main:app --reload и SQLite. До продакшна - пропасть. Эта статья закрывает её: от структуры проекта и многоэтапного Dockerfile до миграций Alembic в контейнере, настройки Gunicorn и трёх уровней мониторинга.
Что такое стек FastAPI + PostgreSQL + Docker и зачем он нужен
Почему FastAPI, а не Flask или Django
FastAPI - ASGI-фреймворк для Python, построенный поверх Starlette. Он работает асинхронно через asyncio, автоматически генерирует OpenAPI-документацию из аннотаций типов и валидирует данные через Pydantic v2.
| Критерий | FastAPI | Flask | Django REST |
|---|---|---|---|
| Асинхронность | Нативная (ASGI) | Через расширения (WSGI) | Частичная (ASGI опционально) |
| Автодокументация | ✅ /docs, /redoc | ❌ нет | ❌ нет |
| Валидация данных | Pydantic v2 встроен | Marshmallow / вручную | DRF serializers |
| Производительность I/O | Высокая | Средняя | Средняя |
| Кривая обучения | Низкая | Очень низкая | Высокая |
Flask прост для микросервисов, но вы сами прикручиваете всё. Django REST - мощный комбайн, но избыточен для API без фронтенда. FastAPI занимает золотую середину.
Зачем контейнеризировать бэкенд с самого начала
Docker решает проблему «у меня работает»: все зависимости, версия Python, системные библиотеки упакованы в образ. Docker Compose поднимает связку FastAPI + PostgreSQL одной командой на любой машине - локально, на CI, на VPS.
Без контейнеров вы рано или поздно столкнётесь с расхождением версий между dev и prod. С Docker это невозможно - prod гоняет ровно тот же слепок, что вы тестировали.
PostgreSQL: почему именно она
PostgreSQL поддерживает JSONB-колонки, full-text search, строгую типизацию и транзакции ACID. asyncpg - нативный асинхронный драйвер для Postgres, написанный на Python + Cython; это самый быстрый способ подключиться к Postgres из asyncio-кода. SQLAlchemy 2.0 использует asyncpg под капотом, когда вы указываете postgresql+asyncpg:// в строке подключения.
Структура проекта: как не запутаться с самого старта
Каноническое дерево файлов
Хорошая структура разделяет ответственности. Ни один файл не делает сразу всё:
project/
├── app/
│ ├── __init__.py
│ ├── main.py # точка входа, lifespan, middleware
│ ├── config.py # pydantic-settings
│ ├── database.py # engine, session factory
│ ├── models/ # SQLAlchemy ORM-модели
│ ├── schemas/ # Pydantic схемы (request/response)
│ ├── routers/ # APIRouter по доменам
│ └── services/ # бизнес-логика
├── alembic/
│ ├── env.py
│ └── versions/
├── tests/
├── .env.example
├── .env # НЕ в git!
├── Dockerfile
├── docker-compose.yml
├── pyproject.toml
└── README.md
Модели - отдельно от схем. Это ключевое разделение: ORM-модели описывают БД, Pydantic-схемы - контракт API. Смешивать их - путь к боли при рефакторинге.
Управление зависимостями: uv
В 2026 году рекомендую uv от Astral: он заменяет pip, pip-tools и virtualenv, работает в 10–100 раз быстрее pip и поддерживает lock-файлы. В Dockerfile это сокращает время сборки с 60–90 секунд до 5–15.
# Устанавливаем uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN uv sync --frozen --no-dev
Если проект использует Poetry - тоже нормально, но uv быстрее.
Конфигурация через pydantic-settings
Никогда не хардкодьте строки подключения. Используйте pydantic-settings:
# app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
DATABASE_URL: str
SECRET_KEY: str
DEBUG: bool = False
ALLOWED_ORIGINS: list[str] = []
DOCS_URL: str | None = None # None = отключить в prod
settings = Settings()
При старте Pydantic проверяет типы всех переменных. Если DATABASE_URL не задан - приложение не стартует с понятной ошибкой, а не падает через 5 минут при первом запросе.
Файл .env.example коммитите в git (без реальных значений). Сам .env - в .gitignore. Это обязательно.
Dockerfile и Docker Compose: production-конфиг с нуля
Multi-stage Dockerfile: уменьшаем образ в 4–5 раз
Многоэтапная сборка (multi-stage build) - стандарт для Python в production. Первый этап устанавливает зависимости и компилирует всё, что нужно. Второй берёт только результат - без компиляторов, кэшей и dev-пакетов.
# ── Stage 1: builder ─────────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /app
# Копируем манифест зависимостей отдельно - кэш сработает при повторной сборке
COPY pyproject.toml uv.lock ./
# Устанавливаем uv и зависимости в /app/.venv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN uv sync --frozen --no-dev
# ── Stage 2: runtime ─────────────────────────────────────
FROM python:3.12-slim AS runtime
WORKDIR /app
# Создаём непривилегированного пользователя
RUN groupadd --gid 1001 appgroup \
&& useradd --uid 1001 --gid appgroup --no-create-home appuser
# Берём только .venv из builder - никаких компиляторов в prod
COPY --from=builder /app/.venv /app/.venv
# Копируем исходники
COPY app/ ./app/
COPY alembic/ ./alembic/
COPY alembic.ini .
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["gunicorn", "app.main:app", \
"-k", "uvicorn.workers.UvicornWorker", \
"--workers", "2", \
"--bind", "0.0.0.0:8000", \
"--access-logfile", "-", \
"--error-logfile", "-"]
Ключевые решения:
python:3.12-slimвместо полного образаUSER appuser- запрет запуска от rootHEALTHCHECK- Docker самостоятельно перезапускает контейнер при зависанииPYTHONUNBUFFERED=1- логи сразу видны в docker logs, без буферизации
docker-compose.yml: сервисы, сети, тома
version: "3.9"
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- backend
migrate:
build: .
command: ["alembic", "upgrade", "head"]
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
depends_on:
db:
condition: service_healthy
networks:
- backend
app:
build: .
ports:
- "8000:8000"
env_file: .env
depends_on:
migrate:
condition: service_completed_successfully
db:
condition: service_healthy
networks:
- backend
restart: unless-stopped
volumes:
postgres_data:
networks:
backend:
driver: bridge
Три сервиса: db, migrate, app. Порядок запуска строгий: сначала ждём health Postgres, потом прогоняем миграции, потом стартует приложение.
Без condition: service_healthy FastAPI стартует раньше Postgres - и падает с connection refused. Эту ошибку допускают 9 из 10 новичков.
Healthcheck для Postgres
pg_isready - встроенная утилита Postgres, которая проверяет готовность сервера принимать соединения. Она надёжнее wget localhost:5432, потому что проверяет именно Postgres-протокол, а не просто открытый порт.
Асинхронная работа с БД: SQLAlchemy 2.0 + asyncpg + Alembic
Настройка async-сессии и пула соединений
# app/database.py
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=10, # базовый размер пула
max_overflow=20, # дополнительные соединения при пике
pool_pre_ping=True, # проверять соединение перед использованием
echo=settings.DEBUG, # SQL-лог только в dev
)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
pool_pre_ping=True - критично для production: если Postgres перезапустился, SQLAlchemy сам переподключится вместо того, чтобы вернуть ошибку клиенту. expire_on_commit=False нужен в async-коде, иначе доступ к атрибутам модели после commit вызывает дополнительный запрос в закрытой сессии.
lifespan: правильная инициализация
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.database import engine
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup: пул уже инициализирован при импорте engine
yield
# shutdown: закрываем все соединения
await engine.dispose()
app = FastAPI(
lifespan=lifespan,
docs_url=None, # Swagger отключён в prod
redoc_url=None,
openapi_url=None,
)
on_event("startup") устарел начиная с FastAPI 0.93+. Используйте lifespan через asynccontextmanager - это официальный способ.
Dependency Injection для сессии
# app/database.py (дополнение)
from typing import AsyncGenerator
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
# app/routers/users.py
from app.database import get_async_session
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/")
async def get_users(session: AsyncSession = Depends(get_async_session)):
# session автоматически закрывается после ответа
...
Этот паттерн гарантирует: сессия открыта ровно на время запроса, коммит при успехе, откат при ошибке, закрытие в любом случае.
Миграции Alembic в контейнере
Alembic требует синхронного соединения в env.py, но модели используют async. Решение - отдельный sync-URL для Alembic:
# alembic/env.py (ключевой фрагмент)
from sqlalchemy import pool, create_engine
from app.models import Base # импортируем все модели для autogenerate
config.set_main_option(
"sqlalchemy.url",
str(settings.DATABASE_URL).replace("+asyncpg", ""), # sync для Alembic
)
def run_migrations_online():
connectable = create_engine(
config.get_main_option("sqlalchemy.url"),
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=Base.metadata)
with context.begin_transaction():
context.run_migrations()
Создание новой миграции:
# В работающем контейнере migrate или локально:
docker compose run --rm migrate alembic revision --autogenerate -m "add_users_table"
docker compose run --rm migrate alembic upgrade head
Никогда не запускайте alembic upgrade head прямо в CMD контейнера приложения. Если два контейнера стартуют одновременно - оба попытаются мигрировать, и вы получите гонку состояний.
Production-запуск: Gunicorn, безопасность и мониторинг
Сколько воркеров запускать
Gunicorn управляет процессами Uvicorn, а Uvicorn обрабатывает запросы через asyncio. Рекомендуемая формула: (2 × CPU_cores) + 1 - но для async-сервисов это завышено. При интенсивных I/O-операциях 2 воркера на 2-ядерной машине часто дают лучший throughput, чем 5.
gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
--workers 2 \
--bind 0.0.0.0:8000 \
--timeout 30 \
--graceful-timeout 10 \
--keep-alive 5
В Kubernetes одного Uvicorn-воркера на pod достаточно - K8s сам масштабирует replicas.
Безопасность: 5 обязательных настроек
- Non-root user -
USER appuserв Dockerfile, иначе взломщик получает root на хосте - Отключить Swagger в prod -
docs_url=None,redoc_url=None,openapi_url=None - CORS через whitelist - только известные origin, не
allow_origins=["*"] - Secrets через env - никаких паролей в Dockerfile или docker-compose.yml в открытую
- SSL/TLS через Nginx или Traefik - Gunicorn/Uvicorn не занимаются терминацией TLS
# Минимальный CORS для prod
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS, # из .env
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
Логирование, метрики, трекинг ошибок
Три уровня observability для production:
- Структурные логи (structlog)
import structlog
logger = structlog.get_logger()
@app.middleware("http")
async def log_requests(request, call_next):
logger.info("request", method=request.method, path=request.url.path)
response = await call_next(request)
logger.info("response", status_code=response.status_code)
return response
- Метрики Prometheus
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app) # /metrics endpoint
- Sentry для ошибок
import sentry_sdk
from sentry_sdk.integrations.starlette import StarletteIntegration
from sentry_sdk.integrations.fastapi import FastApiIntegration
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
integrations=[StarletteIntegration(), FastApiIntegration()],
traces_sample_rate=0.1, # 10% транзакций трейсить
)
Эндпоинт /health - обязателен. Его проверяет и Docker HEALTHCHECK, и Kubernetes readiness probe:
@app.get("/health", tags=["health"])
async def health_check():
return {"status": "ok"}
Альтернативное мнение
Часть команд предпочитает вместо Gunicorn+Uvicorn использовать один Uvicorn-воркер на контейнер и масштабировать через Kubernetes replicas или Docker Swarm. Это проще в управлении и лучше подходит для облачных платформ. Если деплоитесь на Kubernetes - рассмотрите этот подход: убираете Gunicorn, получаете более предсказуемое потребление памяти на pod.
Нетривиальный факт
asyncpg не использует стандартный Python DB-API (PEP 249). Он реализует Postgres wire protocol напрямую, без прослойки. Именно поэтому asyncpg быстрее psycopg2 даже при синхронных операциях: меньше абстракций, меньше аллокаций, нативный бинарный формат обмена данными.
FAQ
Почему FastAPI быстрее Flask?
FastAPI работает через ASGI и asyncio - он обрабатывает тысячи соединений в одном процессе. Flask использует WSGI: каждый запрос блокирует поток до завершения. Для I/O-нагрузок (запросы к БД, HTTP-вызовы) разница кратная.
Как не потерять данные PostgreSQL при docker compose down?
Используйте именованный Docker volume: postgres_data:/var/lib/postgresql/data. Данные живут в volume, а не в контейнере - при остановке не удаляются. Только docker compose down -v удалит volume вместе с данными.
Сколько воркеров Uvicorn запускать?
На 2-ядерной машине - 2 воркера. Формула (2 × CPU) + 1 завышена для async-нагрузок. При I/O asyncio сам мультиплексирует тысячи запросов в одном воркере. Тестируйте под нагрузкой.
Зачем отдельный сервис для миграций Alembic?
При горизонтальном масштабировании несколько экземпляров app попытаются мигрировать одновременно - получите гонку состояний. Отдельный migrate-сервис с condition: service_completed_successfully исключает это.
Что такое pool_pre_ping и зачем он нужен?
pool_pre_ping=True заставляет SQLAlchemy проверять соединение перед использованием. Если Postgres перезапустился, «мёртвое» соединение из пула автоматически заменяется новым.
Нужен ли Nginx перед FastAPI в Docker?
Для production - да. Nginx или Traefik терминируют TLS, отдают статику и балансируют нагрузку. Gunicorn/Uvicorn не предназначены для прямого выхода в интернет без reverse proxy.
Как отключить Swagger UI в production?
Передайте docs_url=None, redoc_url=None, openapi_url=None в FastAPI(). Это закрывает информацию о структуре API от автоматических сканеров.
Источники
FastAPI - Server Workers (официальная документация) - https://fastapi.tiangolo.com/deployment/server-workers/
Better Stack - FastAPI Docker Best Practices (2025) - https://betterstack.com/community/guides/scaling-python/fastapi-docker-best-practices/
Berk Karaal - Setup FastAPI Project with Async SQLAlchemy 2, Alembic, PostgreSQL and Docker - https://berkkaraal.com/blog/2024/09/19/setup-fastapi-project-with-async-sqlalchemy-2-alembic-postgresql-and-docker/
Dev.to / jod35 - Setting Up a Dev Environment for FastAPI, PostgreSQL, SQLAlchemy & Alembic with Docker - https://dev.to/jod35/setting-up-a-dev-environment-for-fastapi-postgresql-sqlalchemy-alembic-with-docker-4nci
Danube Data - FastAPI Production Deployment: The Complete 2025 Guide - https://danubedata.ro/blog/fastapi-production-deployment-guide-2025
Shesh Babu - FastAPI without ORM: Getting started with asyncpg - https://www.sheshbabu.com/posts/fastapi-without-orm-getting-started-with-asyncpg/
David Muraya - Slimmer FastAPI Docker Images with Multi-Stage Builds - https://davidmuraya.com/blog/slimmer-fastapi-docker-images-multistage-builds/
YouTube - Best Way to Deploy FastAPI in Production - https://www.youtube.com/watch?v=zIFqjwuK7Yg
TestDriven.io - Dockerizing FastAPI with Postgres, Uvicorn, and Traefik - https://testdriven.io/blog/fastapi-docker-traefik/
Stack Overflow - asyncpg/fastapi creating more connection than pool limit - https://stackoverflow.com/questions/69967439/asyncpg-fastapi-creating-more-connection-than-pool-limit



.svg.webp)





