Перейти к содержимому

Монолит → Микросервисы: как не убить команду в процессе

Константин Потапов
22 мин

Когда реально пора разделять, Strangler Fig на практике, distributed tracing с первого дня и куда девать shared код. Real-world кейс миграции с метриками и честный разговор о граблях.

Монолит → Микросервисы: как не убить команду в процессе

"Мы разделили монолит на 47 микросервисов. Теперь на деплой уходит 4 часа вместо 20 минут, команда ненавидит меня, а CEO спрашивает, когда закончится 'этот эксперимент'."

Реальная цитата CTO компании, которая обратилась ко мне в начале 2024 года. Они потратили 8 месяцев на миграцию, $200k бюджета и теперь хотели вернуться обратно на монолит.

Спойлер: Мы не вернулись. Мы починили архитектуру за 6 недель, убили 32 из 47 сервисов и получили систему, которая работает лучше монолита. Но цена ошибок была высока.

В этой статье — честный разговор о миграции с монолита на микросервисы. Без евангелизма, без слепой веры в "Netflix делают так, значит и нам надо". Только практика, грабли и реальные цифры.

Правда о микросервисах, которую не говорят на конференциях

Начну с неудобной правды: микросервисы — это не эволюция монолита. Это другой класс проблем.

Монолит — это как жить в однокомнатной квартире. Тесно, всё под рукой, уборка занимает час.

Микросервисы — это как управлять многоквартирным домом. Каждая квартира независима, но теперь у вас проблемы с отоплением, электричеством, канализацией, и жильцы жалуются друг на друга.

История из жизни: "Мы стали как Google!"

2023 год. Стартап на Django, 12 человек команда, 50k DAU, стабильный монолит. На конференции CTO услышал доклад про микросервисы. Вернулся вдохновлённым.

План:

  • Разделить монолит на 15 микросервисов
  • Внедрить Kubernetes
  • Service mesh (Istio)
  • Event-driven архитектура (Kafka)
  • "Стать как Netflix"

Реальность через 6 месяцев:

  • Деплой с 15 минут вырос до 2 часов
  • Новый feature занимает 3 недели вместо 1 (нужно координировать 5 команд)
  • Debugging — кошмар (запрос проходит через 7 сервисов, непонятно где падает)
  • Расходы на инфраструктуру выросли в 3 раза
  • 3 senior разработчика уволились

Итог: Через 9 месяцев они вернулись к модульному монолиту. Потеряли $400k и лучших людей.

Микросервисы — это не серебряная пуля. Это обмен одного набора проблем (сложность монолита) на другой (сложность распределённой системы). Убедитесь, что вы готовы платить эту цену.

Когда реально пора разделять: чеклист без демагогии

После 15 миграций (в обе стороны) я вывел простое правило: микросервисы нужны, когда боль монолита дороже боли микросервисов.

Признаки, что монолит душит вас

1. Deploy Bottleneck (узкое горлышко деплоя)

Симптом: Деплой занимает > 30 минут и блокирует всю команду
Пример: 5 команд выстраиваются в очередь на деплой в пятницу вечером
Цена: Время разработчиков + риск конфликтов + slow time-to-market

Реальный кейс: SaaS-платформа, 80 разработчиков. Деплой монолита — 45 минут. Каждая команда могла делать релиз раз в неделю. Конфликты мерджей — еженедельно.

После разделения на 8 сервисов: Каждая команда деплоит независимо, 10-15 раз в день. Time-to-market упал с недели до дня.

2. Scaling Hell (ад масштабирования)

Симптом: Один endpoint жрёт 90% ресурсов, но приходится масштабировать весь монолит
Пример: API для мобилки генерирует PDF-отчёты (CPU-intensive)
         Остальные 50 endpoints простые, но масштабируются вместе с PDF
Цена: $5k/месяц на серверы вместо $1k

Математика:

Монолит: 10 инстансов × 8GB RAM × $100/месяц = $1000
         (Из них 8 инстансов нужны только для PDF-генератора)

Микросервисы: API (2 инстанса × 2GB × $50) + PDF Service (8 × 8GB × $100) = $900
              Экономия: $100/месяц (или $1200/год)

3. Team Collision (столкновения команд)

Симптом: 3+ команды работают в одном репозитории и мешают друг другу
Пример: Team A меняет auth, Team B ломает заказы, Team C дебажит всю пятницу
Цена: Конфликты мерджей + медленные PR reviews + нервы

Признак: Если вы делаете daily sync meetings между командами, чтобы "не наступать на ноги" — вам нужны границы.

4. Technology Lock-in (технологическая тюрьма)

Симптом: Хотите попробовать новый язык/фреймворк, но это требует переписать весь монолит
Пример: Монолит на Django 2.2, хотите async FastAPI для WebSocket
         Миграция всего монолита = 6 месяцев
Цена: Упущенные возможности + технический долг

Чеклист готовности к микросервисам

Ответьте честно:

  • У вас 3+ команды, работающих в монолите?
  • Деплой занимает > 30 минут?
  • Различные части системы имеют разные требования к масштабированию?
  • У вас есть dedicated DevOps/Platform team?
  • Вы готовы внедрить distributed tracing и централизованное логирование?
  • У вас нет проблем с наймом (микросервисы требуют senior-разработчиков)?
  • Бизнес понимает, что миграция займёт 6-12 месяцев?

Если ответили "ДА" на 5+ вопросов — микросервисы имеют смысл.

Если меньше 5 — попробуйте модульный монолит сначала.

Модульный монолит как промежуточный шаг

Что это: Монолит, разделённый на независимые модули с чёткими границами.

monolith/
  ├── modules/
  │   ├── auth/          # Отдельный модуль
  │   │   ├── api/
  │   │   ├── models/
  │   │   └── services/
  │   ├── orders/        # Отдельный модуль
  │   │   ├── api/
  │   │   ├── models/
  │   │   └── services/
  │   └── payments/      # Отдельный модуль
  │       └── ...
  └── shared/            # Общий код

Правила:

  1. Модули общаются только через публичные API (не прямые импорты)
  2. Каждый модуль можно вынести в сервис за неделю
  3. Shared код минимален и стабилен

Преимущества:

  • ✅ Деплой всё ещё простой (один сервис)
  • ✅ Debugging простой (один процесс)
  • ✅ Команды работают независимо (разные модули)
  • ✅ Готовность к миграции (границы уже есть)

80% компаний, с которыми я работал, решили свои проблемы модульным монолитом. Микросервисы понадобились только 20%. Не переоцениваемте сложность ваших проблем.

Strangler Fig Pattern: как мигрировать без downtime

Strangler Fig (удушающая фига) — паттерн постепенной миграции. Новая система растёт вокруг старой, постепенно заменяя её части. Как фиговое дерево обвивает старое дерево и в итоге заменяет его.

Почему не Big Bang Rewrite

Big Bang Rewrite — это когда вы останавливаете разработку и переписываете всё заново.

Проблемы:

  • 📉 6-12 месяцев без новых features → бизнес теряет деньги
  • 🐛 Вы забудете про edge cases старой системы → баги в продакшене
  • 😰 Команда выгорает → увольнения
  • 💸 Риск провала проекта → зря потраченные миллионы

Известные провалы:

  • Netscape (1998) — переписали браузер с нуля, потеряли рынок
  • Knight Capital (2012) — новая система запустилась с багом, потеряли $440M за 45 минут

Strangler Fig на практике

Идея: Новая система стоит рядом со старой. Постепенно переключаем трафик с монолита на микросервисы. Когда монолит опустеет — выключаем.

Архитектура миграции

                    ┌─────────────────────────┐
                    │   API Gateway / Proxy   │
                    │    (Nginx/Envoy)        │
                    └───────────┬─────────────┘
                                │
                ┌───────────────┴───────────────┐
                │                               │
                ▼                               ▼
    ┌────────────────────┐          ┌──────────────────┐
    │   Монолит (Django) │          │  Микросервисы    │
    │                    │          │                  │
    │  /api/orders  ✅   │          │  Auth Service ✅ │
    │  /api/users   ✅   │          │  Orders Service  │
    │  /api/auth    ❌   │◄─────────│  (в процессе)    │
    │                    │   читает │                  │
    └────────┬───────────┘   данные └─────────┬────────┘
             │                                 │
             └────────────┬────────────────────┘
                          ▼
                  ┌───────────────┐
                  │   PostgreSQL  │
                  │ (shared DB)   │
                  └───────────────┘

Этапы:

  1. Прокси впереди всех — весь трафик идёт через API Gateway
  2. Первый сервис — выносим самый простой/изолированный модуль
  3. Переключаем трафик — меняем роутинг в прокси (без изменения кода)
  4. Мониторим — смотрим на метрики, латентность, ошибки
  5. Откатываемся если проблемы — переключаем обратно за 5 секунд
  6. Повторяем для следующего модуля

Пример: выносим Auth Service

Шаг 1: Дублируем функциональность

# Новый Auth Service (FastAPI)
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy import select
from database import async_session
 
app = FastAPI()
 
@app.post("/api/auth/login")
async def login(credentials: LoginRequest):
    async with async_session() as db:
        # Читаем из ОБЩЕЙ БД (та же, что у монолита)
        result = await db.execute(
            select(User).where(User.email == credentials.email)
        )
        user = result.scalar_one_or_none()
 
        if not user or not verify_password(credentials.password, user.password):
            raise HTTPException(401, "Invalid credentials")
 
        token = create_jwt_token(user.id)
        return {"access_token": token}

Шаг 2: Настраиваем роутинг (Nginx)

upstream auth_service {
    server auth-service:8000;
}
 
upstream monolith {
    server django-app:8000;
}
 
server {
    listen 80;
 
    # 10% трафика на новый сервис (canary deployment)
    location /api/auth/ {
        if ($request_id ~* "[0-9]$") {  # 10% запросов (заканчиваются на 0-9)
            proxy_pass http://auth_service;
        }
        proxy_pass http://monolith;
    }
 
    # Остальное на монолит
    location / {
        proxy_pass http://monolith;
    }
}

Шаг 3: Постепенное увеличение трафика

День 1-3:   10% трафика → Auth Service
День 4-7:   50% трафика → Auth Service
День 8-10: 100% трафика → Auth Service

Шаг 4: Мониторим метрики

# Добавляем метрики в Auth Service
from prometheus_client import Counter, Histogram
 
auth_requests = Counter('auth_requests_total', 'Total auth requests', ['status'])
auth_latency = Histogram('auth_request_duration_seconds', 'Auth latency')
 
@app.post("/api/auth/login")
@auth_latency.time()
async def login(credentials: LoginRequest):
    try:
        # ... логика
        auth_requests.labels(status='success').inc()
        return response
    except Exception as e:
        auth_requests.labels(status='error').inc()
        raise

Шаг 5: Сравниваем с монолитом

# Grafana Dashboard
# Латентность монолита vs микросервиса
histogram_quantile(0.95,
  rate(django_http_request_duration_seconds_bucket{endpoint="/api/auth/login"}[5m])
)
vs
histogram_quantile(0.95,
  rate(auth_request_duration_seconds_bucket[5m])
)
 
# Error rate
rate(django_http_errors_total{endpoint="/api/auth/login"}[5m])
vs
rate(auth_requests_total{status="error"}[5m])
Монолит
Auth Service
P95 latency
120ms
45ms
62%
Error rate
0.02%
0.01%
50%
Throughput
500 req/s
1200 req/s
140%

Шаг 6: Убираем код из монолита

После 2 недель стабильной работы Auth Service на 100% трафика:

# Django монолит - удаляем auth views
# git rm apps/auth/views.py
# git rm apps/auth/serializers.py
# git commit -m "Remove auth - migrated to auth-service"

Грабли Strangler Fig

Грабля #1: Shared Database

Во время миграции у вас общая БД. Это создаёт coupling.

# ❌ Плохо: Auth Service меняет схему
ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(15);
 
# Монолит падает: Unknown column 'last_login_ip'

Решение: Database View Pattern

-- Auth Service работает через VIEW
CREATE VIEW auth_users AS
SELECT id, email, password_hash, created_at
FROM users;
 
GRANT SELECT ON auth_users TO auth_service;
 
-- Монолит продолжает работать с таблицей
-- Auth Service работает с view
-- Миграция схемы не ломает монолит

Грабля #2: Транзакции между сервисами

# ❌ Это НЕ сработает
def create_order(user_id, items):
    with transaction.atomic():
        # Вызов Auth Service
        user = auth_service.get_user(user_id)  # HTTP-запрос
 
        # Создание заказа в монолите
        order = Order.objects.create(user=user)
 
        # Если здесь ошибка → откат не затронет Auth Service!

Решение: Saga Pattern или eventual consistency

# ✅ Event-driven подход
def create_order(user_id, items):
    # 1. Создаём заказ в статусе PENDING
    order = Order.objects.create(user_id=user_id, status='PENDING')
 
    # 2. Отправляем событие в очередь
    event_bus.publish('order.created', {
        'order_id': order.id,
        'user_id': user_id
    })
 
    # 3. Auth Service слушает события и обновляет статистику
    # 4. Если что-то пошло не так → compensating transaction

Distributed transactions не работают в микросервисах. Примите eventual consistency как реальность. Используйте Saga, event sourcing или живите с дублированием данных.

Distributed Tracing с первого дня

Главная боль микросервисов: "Где упал запрос, если он прошёл через 7 сервисов?"

В монолите: смотрим стектрейс, видим всю цепочку вызовов.

В микросервисах: смотрим логи 7 сервисов, пытаемся найти запрос по timestamp. Удачи.

OpenTelemetry + Jaeger: must-have с первого сервиса

OpenTelemetry — стандарт для distributed tracing. Jaeger — UI для просмотра трейсов.

Архитектура трейсинга

Request ID: 7f3a9c12-4e8d-4f2a-a1b3-8d7e9f2c1a4b

API Gateway (span: 250ms)
  ↓
Auth Service (span: 45ms)
  ├─ DB query (span: 12ms)
  └─ Redis cache (span: 3ms)
  ↓
Order Service (span: 180ms)
  ├─ DB query (span: 50ms)
  ├─ HTTP → Payment Service (span: 120ms)
  │   ├─ DB query (span: 15ms)
  │   └─ HTTP → Stripe API (span: 95ms)  ← Виновник!
  └─ Kafka publish (span: 8ms)

Один взгляд на Jaeger UI — и вы видите, что Stripe тормозит.

Настройка OpenTelemetry в Python

Установка:

pip install opentelemetry-api opentelemetry-sdk \
            opentelemetry-instrumentation-fastapi \
            opentelemetry-exporter-jaeger

Код (FastAPI):

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
 
# Настройка трейсера
trace.set_tracer_provider(TracerProvider())
tracer = trace.get_tracer(__name__)
 
# Экспорт в Jaeger
jaeger_exporter = JaegerExporter(
    agent_host_name="jaeger",
    agent_port=6831,
)
trace.get_tracer_provider().add_span_processor(
    BatchSpanProcessor(jaeger_exporter)
)
 
app = FastAPI()
 
# Автоматическая инструментация
FastAPIInstrumentor.instrument_app(app)
SQLAlchemyInstrumentor().instrument(engine=engine)
RedisInstrumentor().instrument()
 
# Кастомные spans
@app.post("/orders")
async def create_order(order: OrderCreate):
    with tracer.start_as_current_span("create_order"):
        # Span автоматически включает:
        # - request_id
        # - http.method, http.url, http.status_code
        # - duration
 
        with tracer.start_as_current_span("validate_user"):
            user = await auth_service.get_user(order.user_id)
 
        with tracer.start_as_current_span("process_payment"):
            payment = await payment_service.charge(order.total)
 
        with tracer.start_as_current_span("save_to_db"):
            result = await db.execute(insert(Order).values(**order.dict()))
 
        return {"order_id": result.inserted_primary_key[0]}

Межсервисное распространение trace ID:

import httpx
from opentelemetry.propagate import inject
 
async def call_payment_service(amount: float):
    headers = {}
    # Инжектим trace context в HTTP-заголовки
    inject(headers)
 
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "http://payment-service/charge",
            json={"amount": amount},
            headers=headers  # trace_id передаётся дальше
        )
    return response.json()

Принимающая сторона (Payment Service):

from opentelemetry.propagate import extract
 
@app.post("/charge")
async def charge(request: Request, data: ChargeRequest):
    # Извлекаем trace context из заголовков
    context = extract(request.headers)
 
    # Спан автоматически связывается с родительским
    with tracer.start_as_current_span("charge_payment", context=context):
        # ... логика оплаты
        pass

Docker Compose для Jaeger

services:
  jaeger:
    image: jaegertracing/all-in-one:1.52
    environment:
      - COLLECTOR_OTLP_ENABLED=true
    ports:
      - "16686:16686" # Jaeger UI
      - "6831:6831/udp" # Прием traces
      - "4317:4317" # OTLP gRPC
    networks:
      - monitoring
 
  auth-service:
    build: ./auth-service
    environment:
      - JAEGER_AGENT_HOST=jaeger
      - JAEGER_AGENT_PORT=6831
    networks:
      - monitoring
 
  order-service:
    build: ./order-service
    environment:
      - JAEGER_AGENT_HOST=jaeger
      - JAEGER_AGENT_PORT=6831
    networks:
      - monitoring
 
networks:
  monitoring:
    driver: bridge

Что показывает distributed tracing

Сценарий 1: Slow Request

Запрос на /api/orders занял 2.5 секунды. Почему?

Jaeger показывает:
┌─ API Gateway: 2500ms
│  ├─ Auth Service: 50ms ✅
│  └─ Order Service: 2400ms ⚠️
│     ├─ DB query: 2300ms ❌ ← Проблема здесь!
│     └─ Kafka publish: 80ms ✅

Решение: Добавить индекс на orders.user_id

Сценарий 2: Cascading Failures

Order Service возвращает 500. Что случилось?

Jaeger показывает:
┌─ Order Service: 500 Internal Server Error
│  └─ Payment Service: timeout after 30s ❌
│     └─ Stripe API: no response ❌

Решение: Stripe лежит. Добавить circuit breaker.

Сценарий 3: N+1 Problem в распределённой системе

Запрос на /api/orders?user_id=123 медленный

Jaeger показывает:
┌─ Order Service: 3200ms
│  ├─ DB query (orders): 50ms ✅
│  ├─ HTTP → Product Service: 150ms (×20 раз!) ❌
│  │  └─ DB query: 5ms ×20
│
Решение: Батчинг запросов к Product Service

Distributed tracing экономит часы debugging. Внедрите его ДО того, как запустите второй микросервис. Ретроспективное внедрение болезненно.

OpenTelemetryJaegerGrafana TempoZipkin

Куда девать shared код: 4 стратегии

Самый больной вопрос микросервисов: "У нас есть общий код (модели, утилиты, валидация). Что с ним делать?"

Стратегия 1: Shared Library (библиотека)

Идея: Общий код в отдельный пакет. Каждый сервис подключает как зависимость.

shared-lib/
  ├── models/
  │   ├── user.py
  │   └── order.py
  ├── utils/
  │   ├── validators.py
  │   └── formatters.py
  └── setup.py

# Публикуем в private PyPI или Artifactory

Использование:

# requirements.txt каждого сервиса
company-shared-lib==1.2.3
 
# В коде
from company_shared.models import User
from company_shared.utils import validate_email

Плюсы:

  • ✅ DRY (Don't Repeat Yourself)
  • ✅ Версионирование (можно откатить)
  • ✅ Единое место для изменений

Минусы:

  • ❌ Coupling между сервисами (все зависят от одной либы)
  • ❌ Обновление либы = деплой всех сервисов
  • ❌ Breaking changes = кошмар

Грабля:

# Кто-то обновил shared-lib с 1.2.3 до 2.0.0
# Breaking change: User.full_name → User.get_full_name()
 
# Auth Service обновился → работает ✅
# Order Service не обновился → падает ❌

Решение: Semantic versioning + deprecation warnings.

# shared-lib 1.3.0 (transitional release)
class User:
    def full_name(self):
        warnings.warn("Use get_full_name() instead", DeprecationWarning)
        return self.get_full_name()
 
    def get_full_name(self):
        return f"{self.first_name} {self.last_name}"
 
# shared-lib 2.0.0 (breaking release)
# Удаляем full_name(), оставляем только get_full_name()

Когда использовать:

  • Stable код (редко меняется)
  • Утилиты, константы, форматтеры
  • Pydantic/Protobuf схемы для контрактов API

Стратегия 2: Code Generation (генерация кода)

Идея: Храним схемы в одном месте (OpenAPI, Protobuf), генерируем клиенты для каждого языка.

api-schemas/
  ├── openapi/
  │   ├── auth.yaml
  │   └── orders.yaml
  └── generate.sh

# generate.sh
openapi-generator generate \
  -i openapi/auth.yaml \
  -g python \
  -o clients/python/auth

openapi-generator generate \
  -i openapi/auth.yaml \
  -g typescript-fetch \
  -o clients/typescript/auth

Результат:

# Auth Service (FastAPI)
# API автоматически генерирует OpenAPI схему
 
# Order Service использует сгенерированный клиент
from auth_client import AuthApi, Configuration
 
config = Configuration(host="http://auth-service")
api = AuthApi(config)
 
user = api.get_user(user_id=123)
print(user.email)  # Type-safe!

Плюсы:

  • ✅ Type safety (IDE подсказывает методы)
  • ✅ Автоматическая валидация
  • ✅ Поддержка разных языков (Python, Go, TypeScript)
  • ✅ Схема API = source of truth

Минусы:

  • ❌ Дополнительный шаг в CI/CD
  • ❌ Сгенерированный код иногда кривой
  • ❌ Нужно синхронизировать схемы

Когда использовать:

  • Polyglot микросервисы (Python + Go + Node.js)
  • Строгие контракты API
  • External API для партнёров

Стратегия 3: Duplication (дублирование)

Идея: Копируем код в каждый сервис. Да, серьёзно.

auth-service/
  └── utils/
      └── validators.py  # Копия

order-service/
  └── utils/
      └── validators.py  # Та же копия

payment-service/
  └── utils/
      └── validators.py  # Та же копия

Плюсы:

  • ✅ Полная независимость сервисов
  • ✅ Нет coupling
  • ✅ Можно изменять без риска сломать другие сервисы

Минусы:

  • ❌ Нарушение DRY
  • ❌ Баг придётся фиксить в 10 местах
  • ❌ Расхождение версий

Когда использовать:

  • Простой код (10-50 строк утилит)
  • Код, который редко меняется
  • Когда coupling дороже дублирования

"Duplication is far cheaper than the wrong abstraction" — Sandi Metz. Иногда скопировать 20 строк кода в 5 сервисов проще, чем поддерживать shared library.


Стратегия 4: Service as Source of Truth

Идея: Нет shared кода. Сервисы общаются только через API.

# ❌ Плохо: Order Service импортирует User из shared-lib
from shared.models import User
 
def create_order(user_id):
    user = User.objects.get(id=user_id)  # Direct DB access!
    order = Order.create(user=user)
 
# ✅ Хорошо: Order Service вызывает Auth Service API
async def create_order(user_id):
    user = await auth_service_client.get_user(user_id)  # HTTP call
    order = await Order.create(user_id=user_id, user_email=user.email)

Плюсы:

  • ✅ Нет coupling
  • ✅ Каждый сервис владеет своими данными
  • ✅ Легко менять реализацию внутри сервиса

Минусы:

  • ❌ Network latency
  • ❌ Нужен fallback если сервис недоступен
  • ❌ Eventual consistency

Когда использовать:

  • Высокая изоляция критична
  • Сервисы на разных языках
  • Разные команды владеют сервисами

Что выбрать: decision tree

Общий код:
├─ Stable, редко меняется?
│  ├─ Да → Shared Library
│  └─ Нет → Duplication или Service API
├─ Нужна type safety?
│  └─ Да → Code Generation
├─ Polyglot микросервисы?
│  └─ Да → Code Generation или Service API
└─ Хотите независимость любой ценой?
   └─ Да → Service API

Мой выбор в 2025:

  • Pydantic models для API контрактов → Shared Library (версионирование)
  • Утилиты (форматтеры, валидаторы) → Duplication
  • Бизнес-логика → Service API (каждый сервис владеет своей доменной логикой)

Real-World кейс: миграция E-commerce с метриками

Компания: E-commerce платформа (B2C)

До миграции: Django монолит, 120k строк кода, 35 разработчиков

Проблема: Деплой 45 минут, 5 команд мешают друг другу, scaling дорогой

Начальное состояние

Архитектура:

Django Monolith (10 инстансов × 8GB RAM)
├── Auth (5% CPU)
├── Catalog (15% CPU)
├── Orders (20% CPU)
├── Payments (10% CPU)
├── Recommendations (40% CPU) ← ML-модель, CPU-intensive
└── Admin Panel (10% CPU)

PostgreSQL (master + 2 read replicas)
Redis (cache + sessions)
Celery (background tasks)

Метрики (до):

МетрикаЗначение
Деплой45 минут
Deploys/неделя2-3 раза
Инстансы10 × c5.2xlarge ($340/мес)
Инфраструктура$3400/мес
P95 latency350ms
Uptime99.5% (3.6 часа downtime/месяц)
Time to market2-3 недели

Боль:

  1. Recommendations (ML) требовал 40% CPU, но приходилось масштабировать весь монолит
  2. 5 команд работали в одном репозитории → конфликты мерджей
  3. Деплой блокировал всех → очередь в пятницу вечером
  4. Изменение в Auth ломало Orders (coupling)

План миграции (6 месяцев)

Этап 1: Подготовка (месяц 1)

  • ✅ Настроили OpenTelemetry + Jaeger
  • ✅ Внедрили feature flags (LaunchDarkly)
  • ✅ Настроили API Gateway (Kong)
  • ✅ Создали shared library для моделей
  • ✅ Разделили команды по доменам

Этап 2: Первый сервис - Recommendations (месяц 2)

Почему первый: Изолированный, CPU-intensive, не критичный для бизнеса.

# Recommendations Service (FastAPI + ML model)
from fastapi import FastAPI
from ml_model import RecommendationModel
 
app = FastAPI()
model = RecommendationModel.load()
 
@app.get("/recommendations/{user_id}")
async def get_recommendations(user_id: int, limit: int = 10):
    # ML inference
    products = await model.predict(user_id, limit=limit)
    return {"products": products}

Результат:

До выноса
После выноса
Инстансы монолита
10 × c5.2xlarge
6 × c5.2xlarge
40%
P95 latency
350ms
280ms (-20%)
20%
Inference time
180ms
95ms (-47%)
47%
Рекомендации
4 × c5.xlarge (GPU)

Экономия: $600/месяц (GPU инстансы дешевле для ML, чем раздувать монолит)

Этап 3: Auth Service (месяц 3)

Почему второй: Критичный, но простой. Чёткие границы.

# Auth Service (FastAPI + JWT)
from fastapi import FastAPI, Depends, HTTPException
from fastapi_jwt_auth import AuthJWT
 
app = FastAPI()
 
@app.post("/auth/login")
async def login(credentials: LoginRequest):
    user = await authenticate(credentials)
    access_token = create_access_token(user.id)
    return {"access_token": access_token}
 
@app.get("/auth/me")
async def get_current_user(Authorize: AuthJWT = Depends()):
    Authorize.jwt_required()
    user_id = Authorize.get_jwt_subject()
    user = await get_user_by_id(user_id)
    return user

Результат:

  • Деплой Auth: 8 минут (был 45)
  • Auth team может релизить 5-10 раз в день
  • Монолит стал легче (убрали 15k строк)

Этап 4: Orders Service (месяц 4)

Сложности: Транзакции с Payments, события для других сервисов.

# Orders Service (FastAPI + Event-driven)
from fastapi import FastAPI
import aio_pika  # RabbitMQ
 
app = FastAPI()
 
@app.post("/orders")
async def create_order(order: OrderCreate):
    # 1. Создаём заказ
    order_entity = await db.create_order(order)
 
    # 2. Отправляем событие
    connection = await aio_pika.connect_robust("amqp://rabbitmq/")
    channel = await connection.channel()
    await channel.default_exchange.publish(
        aio_pika.Message(
            body=json.dumps({
                "order_id": order_entity.id,
                "user_id": order.user_id,
                "total": order.total
            }).encode()
        ),
        routing_key="orders.created"
    )
 
    return order_entity

Этап 5: Payments Service (месяц 5)

Интеграция: Stripe, PayPal, internal wallet.

# Payments Service
@app.post("/payments/charge")
async def charge(payment: PaymentRequest):
    # Слушаем события "orders.created"
    # Списываем деньги
    # Публикуем "payments.completed"
    pass

Этап 6: Catalog Service (месяц 6)

Особенность: Read-heavy (80% GET запросов).

# Catalog Service с агрессивным кешированием
from fastapi_cache import FastAPICache
from fastapi_cache.backends.redis import RedisBackend
 
@app.on_event("startup")
async def startup():
    redis = aioredis.from_url("redis://localhost")
    FastAPICache.init(RedisBackend(redis), prefix="catalog:")
 
@app.get("/products/{product_id}")
@cache(expire=3600)  # 1 час
async def get_product(product_id: int):
    return await db.get_product(product_id)

Финальное состояние

Архитектура (после):

API Gateway (Kong)
├── Auth Service (2 инстансов)
├── Catalog Service (3 инстансов + Redis)
├── Orders Service (4 инстансов)
├── Payments Service (2 инстансов)
├── Recommendations Service (4 GPU инстансов)
└── Django Monolith (Admin Panel только, 2 инстансов)

Event Bus (RabbitMQ)
Distributed Tracing (Jaeger)
Centralized Logging (Loki)

Метрики (после):

МетрикаДоПослеИзменение
Деплой45 мин5-12 мин-73%
Deploys/неделя2-340-50+1500%
Инфраструктура$3400/мес$2100/мес-38%
P95 latency350ms180ms-49%
Uptime99.5%99.9%+0.4%
Time to market2-3 недели3-5 дней-70%

Экономия: $1300/месяц × 12 = $15,600/год только на инфраструктуре.

Выигрыш в productivity: Команды стали релизить в 20 раз чаще → фичи на рынок в 5 раз быстрее → ROI бесценен.

Грабли, которые мы поймали

Грабля #1: Забыли про N+1 в микросервисах

# ❌ Плохо: N+1 HTTP-запросов
async def get_order_details(order_id):
    order = await db.get_order(order_id)
 
    # Для каждого item делаем HTTP-запрос!
    for item in order.items:
        product = await catalog_service.get_product(item.product_id)
        item.product_name = product.name
 
# 10 items = 10 HTTP-запросов × 50ms = 500ms latency!

Решение: Batch API

# ✅ Хорошо: один запрос для всех продуктов
async def get_order_details(order_id):
    order = await db.get_order(order_id)
 
    product_ids = [item.product_id for item in order.items]
    products = await catalog_service.get_products_batch(product_ids)  # Один запрос!
 
    products_map = {p.id: p for p in products}
    for item in order.items:
        item.product_name = products_map[item.product_id].name

Грабля #2: Distributed монолит

После 3 месяцев у нас было 15 сервисов, но они ВСЕ вызывали друг друга синхронно. Это distributed монолит, не микросервисы.

Решение: Event-driven архитектура. Сервисы общаются через события, не HTTP.

Грабля #3: Нет circuit breaker

Payments Service упал → Orders Service ждал 30s timeout на каждый запрос → весь сайт лёг.

Решение: Circuit Breaker Pattern.

from circuitbreaker import circuit
 
@circuit(failure_threshold=5, recovery_timeout=60)
async def call_payment_service(amount):
    async with httpx.AsyncClient() as client:
        response = await client.post("http://payment-service/charge", ...)
        return response.json()
 
# После 5 неудач → circuit открыт → fast fail (не ждём 30s)

Антипаттерны и как их избежать

Антипаттерн #1: Микросервис на каждую таблицу

Плохо:

User Service (таблица users)
Order Service (таблица orders)
Product Service (таблица products)
Cart Service (таблица cart_items)
...

Почему плохо: Создать заказ = 5 HTTP-запросов между сервисами. Транзакции невозможны.

Правильно: Сервисы по бизнес-доменам.

Auth & Users Service (вся auth логика)
Catalog Service (продукты, категории, поиск)
Order Management Service (заказы, корзина, checkout)

Антипаттерн #2: Shared Database

Плохо: Все сервисы пишут в одну PostgreSQL.

Почему плохо:

  • Coupling на уровне схемы БД
  • Один сервис меняет схему → другой падает
  • Scaling проблематичен

Правильно: Database per service (или хотя бы schema per service).

-- Auth Service
CREATE SCHEMA auth;
CREATE TABLE auth.users (...);
 
-- Order Service
CREATE SCHEMA orders;
CREATE TABLE orders.orders (...);

Антипаттерн #3: Нет API Gateway

Плохо: Frontend вызывает 10 микросервисов напрямую.

Почему плохо:

  • CORS на каждом сервисе
  • Auth на каждом сервисе
  • Frontend знает внутреннюю топологию
  • Нельзя изменить routing без изменения frontend

Правильно: API Gateway (Kong, Nginx, Envoy, AWS API Gateway).

# Kong routing
/api/auth/*      → Auth Service
/api/products/*  → Catalog Service
/api/orders/*    → Orders Service

Антипаттерн #4: Distributed монолит

Признаки:

  • Все сервисы синхронно вызывают друг друга
  • Нельзя задеплоить один сервис без других
  • Изменение API одного сервиса → изменения во всех остальных

Правильно: Loose coupling через события.

Чеклист перед стартом миграции

Инфраструктура:

  • API Gateway настроен
  • Service mesh (опционально, но желательно)
  • Distributed tracing (OpenTelemetry + Jaeger)
  • Centralized logging (Loki/ELK)
  • Metrics & Monitoring (Prometheus + Grafana)
  • CI/CD для каждого сервиса
  • Container registry (Docker Hub, ECR, GCR)

Архитектура:

  • Определили границы сервисов (DDD, bounded contexts)
  • Спланировали стратегию для shared кода
  • Выбрали паттерн для eventual consistency (Saga, events)
  • Спроектировали API контракты (OpenAPI, gRPC)
  • Спланировали data migration стратегию

Команда:

  • Все понимают, зачем мигрируем
  • Есть dedicated DevOps/Platform team
  • Разработчики понимают distributed systems
  • Есть владельцы для каждого сервиса

Бизнес:

  • Бизнес понимает, что миграция займёт 6-12 месяцев
  • Есть бюджет на дополнительную инфраструктуру
  • Готовы к временному slowdown в features

Выводы

Микросервисы — это не про технологии. Это про людей, процессы и бизнес-цели.

Когда НЕ нужны микросервисы:

  • Команда < 10 человек
  • Startup в поиске product-market fit
  • Нет проблем с deploy frequency
  • Монолит справляется с нагрузкой

Когда нужны:

  • 3+ команды работают в монолите
  • Deploy bottleneck (> 30 минут)
  • Разные части системы требуют разного scaling
  • Есть DevOps/Platform team

Strangler Fig Pattern:

  • Миграция без downtime
  • Постепенное переключение трафика
  • Возможность отката за секунды
  • Минимизация рисков

Distributed Tracing:

  • Must-have с первого микросервиса
  • Экономит часы debugging
  • OpenTelemetry + Jaeger — стандарт

Shared Code:

  • Stable код → Shared Library
  • API контракты → Code Generation
  • Утилиты → Duplication (иногда это ОК)
  • Бизнес-логика → Service API

Главный урок: Не делайте микросервисы, потому что это модно. Делайте, потому что монолит стал вашей болью. И делайте постепенно, измеряя каждый шаг.


Нужна помощь с архитектурой? Я провожу архитектурные ревью и помогаю командам принимать правильные решения. Пишите на почту — обсудим ваш кейс.

Полезные материалы:

Похожие материалы