Поделиться
Поделиться

«Давайте перепишем на микросервисы» — одна из самых дорогостоящих фраз в истории стартапов. Команды из 5 человек тратят год на разбиение монолита, получают распределённую систему со всеми её сложностями, но без команды, инфраструктуры и опыта для её поддержки.

Эта статья — практический гайд: когда микросервисы действительно нужны, как переходить безопасно и какие ловушки расставлены на каждом шагу.

Когда монолит — правильный выбор

Прежде чем говорить о декомпозиции, честно ответьте на эти вопросы:

| Вопрос | Если «нет» → стоит подождать | |---|---| | Есть ли >5 независимых команд? | Команды < 5 чел. не выиграют от изоляции | | Есть ли части системы с кардинально разной нагрузкой? | Монолит можно масштабировать горизонтально | | Есть ли независимые требования к деплою? | CI/CD для монолита проще | | Достигли ли вы product-market fit? | Частые изменения требований убьют API-контракты | | Есть ли Platform-команда (DevOps/SRE)? | Без неё микросервисы будут «тикающей бомбой» |

Правило: не разбивайте то, что не болит. Монолит с хорошей модульной структурой обслуживает большинство продуктов до первых десятков миллионов пользователей.

Strangler Fig Pattern: безопасная миграция

Не переписывайте монолит целиком — это всегда заканчивается провалом. Паттерн «инжир-душитель» позволяет мигрировать постепенно: новые фичи пишутся как сервисы, старые модули переносятся по мере необходимости.

┌─────────────────────────────────────────────┐
│                  API Gateway                │
│  /auth/* → Auth Service (новый)             │
│  /orders/* → Orders Service (новый)         │
│  /* → Monolith (продолжает работать)        │
└─────────────────────────────────────────────┘
          │              │              │
    Auth Service   Orders Service    Monolith

Шаги реализации

  1. Добавьте прокси-слой (API Gateway или Nginx) перед монолитом — без изменений в коде
  2. Выделите первый сервис по понятной границе (auth, notifications, файлы) — не трогайте бизнес-ядро
  3. Перенаправьте трафик через прокси, сохранив fallback на монолит
  4. Убедитесь в стабильности на real traffic несколько недель
  5. Удалите код из монолита только после полного переноса
# nginx.conf — постепенная миграция
upstream monolith { server monolith:3000; }
upstream auth_service { server auth:4001; }
upstream orders_service { server orders:4002; }

server {
  location /api/v1/auth/ {
    proxy_pass http://auth_service;
  }
  
  location /api/v1/orders/ {
    proxy_pass http://orders_service;
  }
  
  location / {
    proxy_pass http://monolith;
  }
}

Sync vs Async: выбор коммуникации

Это самое важное архитектурное решение. Неправильный выбор превращает микросервисы в «распределённый монолит».

Синхронная коммуникация (REST/gRPC)

Подходит для:

  • Запросов, требующих немедленного ответа (получить профиль пользователя)
  • Простых CRUD-операций между сервисами
  • Внешних API
// gRPC: быстрее REST для внутренней коммуникации
// proto/orders.proto
service OrderService {
  rpc GetOrder (GetOrderRequest) returns (Order);
  rpc CreateOrder (CreateOrderRequest) returns (Order);
}

message Order {
  string id = 1;
  string user_id = 2;
  OrderStatus status = 3;
  repeated OrderItem items = 4;
  google.protobuf.Timestamp created_at = 5;
}

Проблема: цепочка синхронных вызовов создаёт coupling. Если Payment Service недоступен, падает весь CheckoutOrdersInventory.

Асинхронная коммуникация (Events/Queues)

Подходит для:

  • Операций, не требующих немедленного ответа
  • Нотификаций, аналитики, обновления вторичных данных
  • Интеграции между сервисами с разной доступностью
// Публикация события через RabbitMQ
import amqp from "amqplib";

interface OrderCreatedEvent {
  eventType: "order.created";
  orderId: string;
  userId: string;
  totalAmount: number;
  items: Array<{ productId: string; qty: number }>;
  timestamp: string;
}

async function publishOrderCreated(order: Order): Promise<void> {
  const connection = await amqp.connect(process.env.RABBITMQ_URL!);
  const channel = await connection.createChannel();
  
  await channel.assertExchange("domain-events", "topic", { durable: true });
  
  const event: OrderCreatedEvent = {
    eventType: "order.created",
    orderId: order.id,
    userId: order.userId,
    totalAmount: order.total,
    items: order.items,
    timestamp: new Date().toISOString()
  };
  
  channel.publish(
    "domain-events",
    "order.created",
    Buffer.from(JSON.stringify(event)),
    { persistent: true, contentType: "application/json" }
  );
  
  await channel.close();
  await connection.close();
}

// Подписчик в Inventory Service
async function consumeOrderEvents(): Promise<void> {
  const channel = await createChannel();
  await channel.assertQueue("inventory.order-created", { durable: true });
  await channel.bindQueue("inventory.order-created", "domain-events", "order.created");
  
  channel.consume("inventory.order-created", async (msg) => {
    if (!msg) return;
    try {
      const event: OrderCreatedEvent = JSON.parse(msg.content.toString());
      await reserveInventory(event.items);
      channel.ack(msg);
    } catch (err) {
      channel.nack(msg, false, false); // dead letter queue
    }
  });
}

Service Discovery

В динамической среде (Kubernetes, Docker Swarm) IP-адреса сервисов меняются. Нужен механизм обнаружения.

В Kubernetes service discovery работает из коробки через DNS:

# orders-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: orders-service
  namespace: production
spec:
  selector:
    app: orders
  ports:
    - port: 80
      targetPort: 4002
// Обращение по DNS-имени сервиса
const response = await fetch("http://orders-service/api/orders/123");
// Внутри кластера резолвится в: orders-service.production.svc.cluster.local

Вне Kubernetes — используйте Consul или Eureka.

Distributed Tracing с OpenTelemetry

Когда запрос проходит через 5 сервисов, найти где произошла задержка без трассировки почти невозможно.

// Инициализация OpenTelemetry (один раз при старте)
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";

const sdk = new NodeSDK({
  resource: new Resource({
    "service.name": "orders-service",
    "service.version": "2.1.0",
    "deployment.environment": process.env.NODE_ENV
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
  })
});

sdk.start();

// Создание кастомных span
import { trace, context, SpanStatusCode } from "@opentelemetry/api";

const tracer = trace.getTracer("orders-service");

async function processOrder(orderId: string): Promise<void> {
  const span = tracer.startSpan("process-order");
  span.setAttribute("order.id", orderId);
  
  try {
    await context.with(trace.setSpan(context.active(), span), async () => {
      await validateOrder(orderId);      // дочерние spans создаются автоматически
      await chargePayment(orderId);
      await notifyWarehouse(orderId);
    });
    span.setStatus({ code: SpanStatusCode.OK });
  } catch (err) {
    span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
    span.recordException(err as Error);
    throw err;
  } finally {
    span.end();
  }
}

Данные отправляются в Jaeger или Tempo, откуда можно видеть весь путь запроса через все сервисы.

Saga Pattern для распределённых транзакций

В микросервисах нет двухфазного коммита. Для операций, охватывающих несколько сервисов (checkout: списать деньги + уменьшить склад + создать доставку), используется Saga.

Choreography-based Saga (через события)

Order Service        Payment Service      Inventory Service    Delivery Service
     │                    │                    │                    │
     │─ order.created ──►│                    │                    │
     │                   │─ payment.charged ─►│                    │
     │                   │                   │─ stock.reserved ──►│
     │                   │                   │                   │─ delivery.scheduled

     │  При ошибке (compensating transactions):
     │─ payment.failed ──────────────────────────────────────────►│
     │                                       │◄─ stock.released ──│
// Compensating transaction при ошибке доставки
async function handleDeliveryFailed(event: DeliveryFailedEvent): Promise<void> {
  // Откатываем резервирование склада
  await publishEvent("inventory.release-stock", {
    orderId: event.orderId,
    reason: "delivery_failed"
  });
  
  // Откатываем платёж
  await publishEvent("payment.refund", {
    orderId: event.orderId,
    amount: event.totalAmount
  });
  
  // Уведомляем пользователя
  await publishEvent("notification.send", {
    userId: event.userId,
    template: "order_cancelled",
    orderId: event.orderId
  });
}

Антипаттерны: чеклист

Перед релизом нового сервиса проверьте, не попали ли вы в одну из ловушек:

  • Distributed Monolith: сервисы деплоятся только вместе, изменение в одном требует изменений в трёх других
  • Chatty services: на один пользовательский запрос уходит 20+ межсервисных вызовов
  • Shared database: два сервиса читают и пишут в одну таблицу — нет изоляции данных
  • Synchronous everywhere: все вызовы синхронные — недоступность одного сервиса каскадирует на все
  • No circuit breaker: без паттерна Circuit Breaker один медленный сервис блокирует весь тред-пул
  • Отсутствие идемпотентности: повторная обработка события создаёт дубли заказов
  • Версионирование API "по договорённости": нет формального versioning, Breaking changes без предупреждения
// Circuit Breaker с opossum
import CircuitBreaker from "opossum";

const paymentBreaker = new CircuitBreaker(callPaymentService, {
  timeout: 3000,           // 3 секунды
  errorThresholdPercentage: 50,  // открыть при 50% ошибок
  resetTimeout: 30000      // попробовать снова через 30 сек
});

paymentBreaker.fallback(() => ({ status: "pending", message: "Payment queued" }));
paymentBreaker.on("open", () => logger.warn("Payment circuit breaker OPEN"));

Итог

Микросервисы — это не архитектурный выбор, это организационный выбор. Разбивайте систему по границам команд и доменов, а не по техническим слоям. Начните со Strangler Fig, избегайте синхронных цепочек, внедрите tracing с первого дня.

Подробнее про Redis как backbone для async-коммуникации между сервисами — Redis: паттерны кеширования. Про Node.js-архитектуру внутри одного сервиса — Node.js backend: архитектура проекта.