Подписывайтесь:

Блог AST-SoftPro

Case Study: проектируем SaaS-платформу на Python

05.07.2026 22 мин чтения
Case Study: проектируем SaaS-платформу на Python

Case Study: проектируем SaaS-платформу на Python — от требований до деплоя

В этой статье мы пройдём весь путь проектирования SaaS-платформы: от обсуждения требований и архитектурных решений до деплоя. В качестве стека выберем Python + PostgreSQL + Redis + Celery — комбинацию, которая покрывает 90% бизнес-задач.

Этап 1: обсуждение требований

Любая архитектура начинается с требований. Без них проектирование — это гадание.

Функциональные требования

Наш клиент — компания, которая хочет запустить SaaS-платформу для автоматизации отчётности. Основные функции:

  1. Мульти-тенантность — каждый клиент (tenant) работает в своём пространстве, данные изолированы.

  2. Импорт данных — загрузка файлов (CSV, Excel, JSON) из разных источников.

  3. Обработка данных — агрегация, трансформация, расчёт метрик.

  4. Генерация отчётов — PDF, Excel, визуализации.

  5. Расписание — автоматическая генерация отчётов по cron.

  6. Уведомления — email и Telegram при завершении обработки.

  7. Платежи — подписка с несколькими тарифами.

  8. API — REST API для интеграции с внешними системами.

Нефункциональные требования

Требование Значение Обоснование
Load (пиковый) 500 RPS Ожидается 1000 пользователей, 30% активных
Latency (P95) < 200 мс Интерактивные запросы должны быть быстрыми
Availability 99.9% Допустимо 43 минуты простоя в месяц
Data retention 2 года Требуется для аудита
Backup Ежедневно Восстановление после аварии
Multi-region Нет (на старте) Один регион, масштабирование при росте

Constraints (ограничения)

  • Бюджет на инфраструктуру — до $500/мес на старте.

  • Команда — 2 бэкенд-разработчика, 1 фронтенд.

  • Стек — Python (основной), Node.js (при необходимости).

  • Облако — не привязаны, но предпочитаем managed-сервисы.

Этап 2: архитектурные решения

Выбор паттерна: монолит vs микросервисы

Для старта — модульный монолит. Причины:

  1. Малая команда — микросервисы требуют больше DevOps.

  2. Быстрый старт — один репозиторий, один деплой.

  3. При росте — выделение сервисов через Strangler-fig паттерн.

# Структура модульного монолита
project/
├── app/
   ├── tenants/          # Мульти-тенантность
   ├── imports/          # Импорт данных
   ├── processing/       # Обработка данных
   ├── reports/          # Генерация отчётов
   ├── billing/          # Платежи и подписки
   ├── notifications/    # Уведомления
   └── api/              # REST API
├── celery_app.py         # Celery
├── config.py             # Настройки
└── main.py               # FastAPI

Мульти-тенантность: схема или база?

Два основных подхода:

  1. Shared database, shared schema — все данные в одной БД, tenant_id в каждой таблице.

  2. Shared database, separate schema — отдельная схема PostgreSQL для каждого tenant.

Выбираем shared schema с tenant_id — проще на старте, достаточно для 1000 tenants. При росте можно перейти на separate schema.

from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship, declarative_base

Base = declarative_base()

class Tenant(Base):
    __tablename__ = "tenants"

    id = Column(Integer, primary_key=True)
    name = Column(String(255), nullable=False)
    slug = Column(String(100), unique=True, nullable=False)
    plan = Column(String(50), default="free")  # free, pro, enterprise
    is_active = Column(Integer, default=1)

    reports = relationship("Report", back_populates="tenant")

class Report(Base):
    __tablename__ = "reports"

    id = Column(Integer, primary_key=True)
    tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
    title = Column(String(500), nullable=False)
    status = Column(String(30), default="pending")  # pending, processing, done, failed
    file_url = Column(String(1000))
    created_at = Column(String(30), default="2026-01-01")

    tenant = relationship("Tenant", back_populates="reports")

Базы данных: PostgreSQL как основа

PostgreSQL — универсальный выбор для SaaS:

  • Реляционные данные — пользователи, подписки, отчёты.

  • JSONB — гибкие конфигурации tenant-а без миграций.

  • Row-level security — дополнительная изоляция tenant-ов.

  • Partitioning — разбиение больших таблиц по tenant_id.

-- Partitioning по tenant_id для больших таблиц
CREATE TABLE report_events (
    id BIGSERIAL,
    tenant_id INTEGER NOT NULL,
    event_type VARCHAR(50),
    payload JSONB,
    created_at TIMESTAMP DEFAULT NOW()
) PARTITION BY LIST (tenant_id);

-- Отдельная партиция для каждого tenant-а (при большом количестве)
CREATE TABLE report_events_tenant_1 PARTITION OF report_events
    FOR VALUES IN (1);

Кэширование: Redis

Redis используется для:

  1. Кэш запросов — результаты частых запросов.

  2. Сессии — хранение сессий пользователей.

  3. Rate limiting — ограничение запросов по tenant-у.

  4. Брокер Celery — очередь задач.

import redis
from fastapi import Request

redis_client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)

# Cache-aside паттерн
async def get_tenant_stats(request: Request, tenant_id: int):
    cache_key = f"tenant_stats:{tenant_id}"

    # Попытка получить из кэша
    cached = redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    # Запрос к базе
    stats = await db.get_tenant_stats(tenant_id)

    # Кэширование на 5 минут
    redis_client.setex(cache_key, 300, json.dumps(stats))

    return stats

Асинхронная обработка: Celery

Тяжёлые операции (импорт данных, генерация отчётов) выполняются в фоне через Celery:

from celery import Celery

celery_app = Celery(
    "saas_platform",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/1",
)

celery_app.conf.update(
    task_serializer="json",
    result_serializer="json",
    accept_content=["json"],
    task_acks_late=True,  # Подтверждение после выполнения
    worker_prefetch_multiplier=1,
)

@celery_app.task(bind=True, max_retries=3, default_retry_delay=60)
def import_data_task(self, tenant_id: int, file_url: str):
    """Импорт данных из файла"""
    try:
        # Загрузка файла
        data = download_file(file_url)

        # Парсинг и валидация
        parsed = parse_data(data)

        # Сохранение в БД
        save_to_db(tenant_id, parsed)

        # Уведомление
        notify_tenant(tenant_id, "Данные успешно импортированы")

        return {"status": "completed", "rows": len(parsed)}
    except Exception as exc:
        raise self.retry(exc=exc)

@celery_app.task
def generate_report_task(tenant_id: int, report_id: int):
    """Генерация отчёта"""
    report = get_report(report_id)

    # Агрегация данных
    data = aggregate_data(tenant_id, report.filters)

    # Генерация PDF
    pdf_url = generate_pdf(data, report.template)

    # Сохранение результата
    update_report(report_id, status="done", file_url=pdf_url)

    # Уведомление
    notify_tenant(tenant_id, f"Отчёт '{report.title}' готов")

    return {"status": "completed", "url": pdf_url}

Этап 3: API-дизайн

REST API на FastAPI

FastAPI выбран за асинхронность, валидацию через Pydantic и автогенерацию документации.

from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel

app = FastAPI(title="SaaS Reporting Platform")

# --- Сchemas ---
class ReportCreate(BaseModel):
    title: str
    filters: dict
    template: str

class ReportResponse(BaseModel):
    id: int
    tenant_id: int
    title: str
    status: str
    file_url: str | None = None

# --- Middleware: tenant isolation ---
@app.middleware("http")
async def tenant_middleware(request, call_next):
    tenant_id = request.headers.get("X-Tenant-ID")
    if not tenant_id:
        raise HTTPException(400, "Missing X-Tenant-ID header")
    request.state.tenant_id = int(tenant_id)
    response = await call_next(request)
    return response

# --- Endpoints ---
@app.post("/reports", response_model=ReportResponse)
async def create_report(
    data: ReportCreate,
    tenant_id: int = Depends(get_tenant_id),
):
    """Создание отчёта (синхронно — быстро)"""
    report = save_report(tenant_id, data)

    # Запуск фоновой генерации
    generate_report_task.delay(tenant_id, report.id)

    return report

@app.get("/reports/{report_id}", response_model=ReportResponse)
async def get_report(
    report_id: int,
    tenant_id: int = Depends(get_tenant_id),
):
    """Статус отчёта"""
    report = get_report_by_id(report_id, tenant_id)
    if not report:
        raise HTTPException(404, "Report not found")
    return report

@app.get("/reports/{report_id}/download")
async def download_report(
    report_id: int,
    tenant_id: int = Depends(get_tenant_id),
):
    """Скачивание готового отчёта"""
    report = get_report_by_id(report_id, tenant_id)
    if report.status != "done":
        raise HTTPException(400, "Report not ready")
    return FileResponse(report.file_url)

@app.post("/imports")
async def import_data(
    file_url: str,
    tenant_id: int = Depends(get_tenant_id),
):
    """Запуск импорта данных"""
    import_data_task.delay(tenant_id, file_url)
    return {"status": "queued"}

Rate limiting по tenant-у

from fastapi import Request, HTTPException
import time

def rate_limiter(max_requests: int = 100, window: int = 60):
    async def limiter(request: Request):
        tenant_id = request.state.tenant_id
        key = f"rate:{tenant_id}"

        current = redis_client.get(key)
        if current and int(current) > max_requests:
            raise HTTPException(429, "Rate limit exceeded")

        redis_client.incr(key)
        if not current:
            redis_client.expire(key, window)

    return limiter

# Применение к роутам
app.add_api_route("/reports", create_report, methods=["POST"], dependencies=[Depends(rate_limiter(100, 60))])

Этап 4: надёжность и мониторинг

Circuit breaker для внешних вызовов

При вызове внешних API (платежи, email) нужен circuit breaker:

import time
from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

class CircuitBreaker:
    def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 60):
        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.last_failure_time = 0
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout

    def call(self, func, *args, **kwargs):
        if self.state == CircuitState.OPEN:
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = CircuitState.HALF_OPEN
            else:
                raise Exception("Circuit is OPEN")

        try:
            result = func(*args, **kwargs)
            self.failure_count = 0
            self.state = CircuitState.CLOSED
            return result
        except Exception as e:
            self.failure_count += 1
            self.last_failure_time = time.time()
            if self.failure_count >= self.failure_threshold:
                self.state = CircuitState.OPEN
            raise e

# Использование
cb = CircuitBreaker(failure_threshold=5, recovery_timeout=60)

def send_email(to: str, subject: str, body: str):
    return cb.call(email_service.send, to, subject, body)

Retry с exponential backoff

import time
import random

def retry_with_backoff(func, max_retries: int = 3, base_delay: float = 1.0):
    for attempt in range(max_retries):
        try:
            return func()
        except Exception as e:
            if attempt == max_retries - 1:
                raise e
            delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
            time.sleep(delay)

Структурированное логирование

import logging
import json
from datetime import datetime

class JsonFormatter(logging.Formatter):
    def format(self, record):
        log_data = {
            "timestamp": datetime.utcnow().isoformat(),
            "level": record.levelname,
            "message": record.getMessage(),
            "module": record.module,
            "function": record.funcName,
        }
        if hasattr(record, "tenant_id"):
            log_data["tenant_id"] = record.tenant_id
        if record.exc_info:
            log_data["exception"] = self.formatException(record.exc_info)
        return json.dumps(log_data)

# Настройка
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logging.getLogger("saas_platform").addHandler(handler)

Этап 5: деплой

Docker Compose для разработки

version: "3.8"

services:
  app:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/saas
      - REDIS_URL=redis://redis:6379
    depends_on:
      - db
      - redis

  db:
    image: postgres:16
    environment:
      - POSTGRES_DB=saas
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

  celery-worker:
    build: .
    command: celery -A celery_app worker -l info
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/saas
      - REDIS_URL=redis://redis:6379
    depends_on:
      - db
      - redis

  celery-beat:
    build: .
    command: celery -A celery_app beat -l info
    environment:
      - REDIS_URL=redis://redis:6379
    depends_on:
      - redis

volumes:
  pgdata:

Продакшн-архитектура

┌─────────────────────────────────────────────────────┐
│                    Cloudflare / Nginx               │
│              (SSL, CDN, Rate Limiting)              │
└──────────────────────┬──────────────────────────────┘
                       │
              ┌────────▼────────┐
              │    Load Balancer│
              └────────┬────────┘
                       │
          ┌────────────┴────────────┐
          ▼                          ▼
   ┌─────────────┐          ┌─────────────┐
   │   App 1     │          │   App 2     │
   │  (FastAPI)  │          │  (FastAPI)  │
   └──────┬──────┘          └──────┬──────┘
          │                        │
   ┌──────▼──────┐          ┌──────▼──────┐
   │  Celery W1  │          │  Celery W2  │
   └──────┬──────┘          └──────┬──────┘
          │                        │
   ┌──────▼────────────────────────▼──────┐
   │           Redis (broker + cache)     │
   └──────────────────────────────────────┘
          │
   ┌──────▼──────┐
   │  PostgreSQL │
   │  (Primary)  │
   └─────────────┘
          │
   ┌──────▼──────┐
   │  PostgreSQL │
   │  (Replica)  │
   └─────────────┘

CI/CD через GitHub Actions

name: Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Install dependencies
        run: pip install -r requirements.txt -r requirements-dev.txt
      - name: Run tests
        run: pytest -v --cov=app

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to server
        run: |
          ssh deploy@server "cd /opt/saas && git pull && docker-compose up -d --build"

Этап 6: масштабирование при росте

Когда начинать масштабировать

Сигналы, что монолит нужно разделять:

  1. Команда выросла до 10+ разработчиков — конфликты при мердже.

  2. Один модуль нагружает базу — аналитика тормозит основной API.

  3. Разные требования к масштабированию — отчёты нужны 10 воркеров, API — 2 инстанса.

  4. Разные SLA — платёжный модуль требует 99.99%, остальное — 99.9%.

Стратегия: Strangler-fig

  1. Выделяем отдельный сервис (например, отчёты).

  2. Ставим API-gateway, который маршрутизирует запросы.

  3. Постепенно переносим трафик.

  4. Старый код остаётся до полного переноса.

Бюджет на инфраструктуру

Компонент MVP ($/мес) Рост ($/мес)
PostgreSQL (managed) 25 100
Redis (managed) 15 50
App servers 50 200
Celery workers 25 100
CDN (Cloudflare) 0 20
Monitoring 0 50
Итого 115 520

Заключение

Проектирование SaaS-платформы — это не выбор «идеальной» архитектуры, а последовательность решений, каждое из которых обосновано требованиями и ограничениями. Модульный монолит на Python + PostgreSQL + Redis + Celery — это правильный старт для большинства проектов. Масштабирование и разделение на микросервисы — это следующий этап, когда появится реальная нагрузка.

Главное правило: не проектируйте систему, которая вам не нужна. Начинайте с простого, измеряйте, масштабируйте по факту.

Ключевые моменты:

  1. Начинайте с модульного монолита — проще для малой команды, легче деплоить.

  2. Мульти-тенантность через shared schema с tenant_id — достаточно для 1000 tenants.

  3. PostgreSQL + JSONB — реляционная база с гибкостью NoSQL.

  4. Celery для фоновых задач — импорт данных, генерация отчётов, уведомления.

  5. Circuit breaker и retry с backoff — обязательны для внешних вызовов.

  6. Strangler-fig — паттерн миграции от монолита к микросервисам.

  7. Не масштабируйте преждевременно — измеряйте, потом решайте.

Полезные ссылки

AI-Помощник