«Давайте перепишем на микросервисы» — одна из самых дорогостоящих фраз в истории стартапов. Команды из 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
Шаги реализации
- Добавьте прокси-слой (API Gateway или Nginx) перед монолитом — без изменений в коде
- Выделите первый сервис по понятной границе (auth, notifications, файлы) — не трогайте бизнес-ядро
- Перенаправьте трафик через прокси, сохранив fallback на монолит
- Убедитесь в стабильности на real traffic несколько недель
- Удалите код из монолита только после полного переноса
# 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 недоступен, падает весь Checkout → Orders → Inventory.
Асинхронная коммуникация (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: архитектура проекта.

