Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
🌐
Клиент-сервер
Опубликовано:
18.06.2026
Обновлено:
09.07.2026

Как собрать настоящий backend на FastAPI, Postgres и Docker

Данил Мануйлов

Большинство туториалов по 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 - запрет запуска от root
  • HEALTHCHECK - 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 обязательных настроек

  1. Non-root user - USER appuser в Dockerfile, иначе взломщик получает root на хосте
  2. Отключить Swagger в prod - docs_url=None, redoc_url=None, openapi_url=None
  3. CORS через whitelist - только известные origin, не allow_origins=["*"]
  4. Secrets через env - никаких паролей в Dockerfile или docker-compose.yml в открытую
  5. 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:

  1. Структурные логи (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
  1. Метрики Prometheus
from prometheus_fastapi_instrumentator import Instrumentator

Instrumentator().instrument(app).expose(app)  # /metrics endpoint
  1. 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 от автоматических сканеров.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также