Микросервисы #
1. Почему shared database считается плохой практикой для микросервисов? #
Что означает Shared Database #
Shared Database — это подход, при котором несколько микросервисов напрямую работают с одной схемой или с общими таблицами:
Order Service ───┐
Payment Service ─┼──> Общая БД
User Service ────┘
Проблема не обязательно в том, что сервисы используют один физический сервер PostgreSQL. Они могут находиться на одном сервере, но владеть разными схемами или базами. Плохой вариант — когда несколько сервисов напрямую читают и изменяют одни и те же таблицы. Microsoft отдельно отмечает, что совместное использование физического сервера допустимо; связь возникает именно при общей схеме и общих таблицах.
1. Сервисы перестают быть независимыми #
Главная идея микросервисов — возможность независимо изменять, тестировать, развертывать и масштабировать каждый сервис.
Представим общую таблицу:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
username VARCHAR(100),
email VARCHAR(255)
);
Её используют:
User Service
Order Service
Notification Service
Payment Service
User Service решает переименовать поле:
ALTER TABLE users
RENAME COLUMN username TO login;
Теперь необходимо проверить и одновременно обновить все остальные сервисы, которые обращаются к users.username.
Получается:
изменение одной таблицы
↓
изменение нескольких сервисов
↓
координированное развертывание
Это называется связностью на этапе разработки и развертывания. AWS отмечает, что при общей базе изменения схемы приходится координировать между сервисами, поэтому команды не получают полной независимости.
2. База данных превращается в скрытый API #
В нормальной архитектуре взаимодействие выглядит так:
Order Service
│
│ HTTP / gRPC / событие
▼
User Service
│
▼
User Database
User Service контролирует:
правила доступа;
валидацию;
бизнес-логику;
формат данных;
совместимость API.
При общей базе другой сервис может обойти владельца данных:
Order Service ──────> таблица users
Например, вместо вызова:
GET /users/42
Order Service делает:
SELECT * FROM users WHERE id = 42;
В результате таблица фактически становится неформальным публичным интерфейсом. Любое изменение структуры таблицы может сломать потребителей, даже если официальный API сервиса не изменился.
Microsoft рекомендует считать данные собственностью конкретного сервиса и разрешать другим сервисам получать к ним доступ только через его API.
3. Нарушается инкапсуляция бизнес-логики #
Допустим, Payment Service должен проводить платёж только через метод:
payment_service.process_payment(...)
Внутри находятся проверки:
баланс достаточен
аккаунт не заблокирован
валюта поддерживается
операция не является дубликатом
лимит не превышен
Но другой сервис может напрямую выполнить:
UPDATE accounts
SET balance = balance - 100
WHERE id = 42;
Он изменит данные в обход:
доменных правил;
аудита;
идемпотентности;
проверки блокировок;
событий;
разрешений.
То есть сервис формально существует, но не контролирует собственное состояние.
4. Возникает runtime coupling #
Сервисы становятся связаны не только кодом, но и во время выполнения.
Например, Order Service запускает долгую транзакцию:
BEGIN;
UPDATE customers
SET status = 'active'
WHERE id = 42;
-- Длительная обработка
COMMIT;
В это время Customer Service может ждать освобождения блокировки той же строки или таблицы.
Order Service держит блокировку
↓
Customer Service ждёт
↓
увеличивается время ответа
AWS прямо приводит подобный пример: длительная транзакция одного сервиса может заблокировать операции другого сервиса, когда они используют одну базу.
5. Общая БД становится общей точкой отказа #
┌─ User Service
Общая БД ──────┼─ Order Service
├─ Payment Service
└─ Notification Service
Если база:
недоступна;
перегружена;
исчерпала пул подключений;
столкнулась с проблемой репликации;
выполняет тяжёлую миграцию;
достигла предела CPU или I/O,
то одновременно начинают деградировать все сервисы.
AWS указывает, что разделение хранилищ повышает устойчивость и не позволяет одной базе становиться единой точкой отказа всей системы. Azure также рассматривает общую перегруженную базу как потенциальное узкое место.
6. Невозможно независимо масштабировать хранение данных #
Допустим:
Payment Service — 1 000 запросов/с
Catalog Service — 20 000 запросов/с
Notification Service — 200 запросов/с
При общей базе нагрузка смешивается:
общие CPU
общие IOPS
общий connection pool
общий кеш
общие блокировки
Интенсивные запросы Catalog Service могут ухудшить работу Payment Service, хотя они принадлежат разным бизнес-доменам.
При раздельных хранилищах можно масштабировать только нужный сервис:
Catalog Service → отдельные read replicas
Payment Service → надёжная транзакционная БД
Notification Service → небольшая БД
AWS относит независимое и более детальное масштабирование к преимуществам подхода Database per Service.
7. Все сервисы привязываются к одной технологии #
Общая база обычно заставляет всю систему использовать один тип хранилища:
все сервисы → PostgreSQL
Но разные сервисы могут иметь разные требования:
Payment Service → PostgreSQL для ACID-транзакций
Catalog Service → документная БД
Cache Service → Redis
Search Service → поисковый движок
Analytics → колоночное хранилище
Один сервис может нуждаться в связях и ограничениях внешних ключей, другой — в большом количестве простых операций чтения, третий — в полнотекстовом поиске.
Изоляция данных позволяет сервису выбрать модель хранения под конкретную нагрузку. Общая база ограничивает такую оптимизацию.
8. Усложняются миграции #
При отдельной базе сервис может самостоятельно выполнить:
версия сервиса 1 → миграция → версия сервиса 2
При общей базе миграция должна учитывать все версии всех сервисов.
Например, нельзя сразу удалить колонку:
ALTER TABLE users DROP COLUMN old_email;
Потому что одна из старых версий другого сервиса ещё может её использовать.
Поэтому миграция обычно становится многоэтапной:
1. Добавить новую колонку
2. Обновить запись в обе колонки
3. Обновить всех потребителей
4. Дождаться завершения старых версий
5. Перенести данные
6. Удалить старую колонку
AWS указывает, что изменения общей схемы должны быть обратно совместимыми с текущими и предыдущими версиями всех зависимых микросервисов.
9. Усложняется разделение ответственности команд #
Допустим:
Team A владеет User Service
Team B владеет Order Service
Обе команды изменяют таблицу users.
Возникают вопросы:
кто владеет таблицей;
кто утверждает миграции;
кто отвечает за производительность;
кто может добавлять индексы;
кто отвечает при повреждении данных;
какой сервис определяет бизнес-правила.
Формально сервисы разделены, но команды всё равно должны постоянно согласовывать изменения общей модели данных. AWS отмечает, что shared database не устраняет зависимости между командами.
10. Сложнее обеспечить безопасность #
При отдельной базе можно выдать сервису минимальные права:
Payment Service → только payment_db
Order Service → только order_db
При общей базе сервис нередко получает доступ к данным, которые ему не принадлежат:
Order Service → users, payments, audit_logs, credentials
Даже если права ограничены на уровне таблиц, модель становится сложнее:
GRANT SELECT ON users TO order_service;
GRANT SELECT, UPDATE ON orders TO order_service;
GRANT INSERT ON audit_log TO order_service;
Чем больше сервисов и таблиц, тем сложнее контролировать принцип минимальных привилегий.
Почему тогда Shared Database вообще используют #
У подхода есть преимущества:
проще выполнять SQL JOIN между данными разных доменов;
проще обеспечивать ACID-транзакции;
меньше инфраструктуры;
проще резервное копирование;
проще начать декомпозицию монолита;
меньше сложностей с eventual consistency;
не требуются Saga, Outbox и асинхронные события.
AWS допускает Shared Database как компромисс, например, при постепенном разделении монолита, необходимости сохранить общие ACID-транзакции или нежелании сразу полностью перерабатывать слой данных.
Поэтому Shared Database — не безусловно запрещённый вариант. Он считается плохой практикой, когда система называется микросервисной, но сервисы не могут независимо изменяться и развертываться из-за общей схемы.
Предпочтительный подход: Database per Service #
User Service ─────────> User DB
Order Service ────────> Order DB
Payment Service ──────> Payment DB
Notification Service ─> Notification DB
Другие сервисы не обращаются к этим базам напрямую:
Order Service ──API──> User Service
Order Service ──event─> Payment Service
При этом Database per Service не обязательно означает отдельный физический сервер для каждого сервиса. Возможны варианты:
Один PostgreSQL-сервер
├── user_db
├── order_db
└── payment_db
или:
Одна база PostgreSQL
├── user_schema
├── order_schema
└── payment_schema
Ключевое требование — исключительное владение данными:
каждая таблица имеет одного владельца;
другие сервисы не обращаются к ней напрямую.
Цена Database per Service #
Разделение баз создаёт собственные сложности:
нельзя выполнять обычный
JOINмежду сервисами;распределённые транзакции становятся сложнее;
появляется eventual consistency;
требуется синхронизация данных;
нужны Saga, Transactional Outbox, CQRS или API Composition;
усложняется аналитика и отчётность;
приходится эксплуатировать несколько хранилищ.
AWS прямо указывает сложность межсервисных транзакций и запросов как недостаток Database per Service. Для агрегированных запросов рекомендуются API Composition, CQRS или Event Sourcing.
Итог #
Shared Database противоречит главным свойствам микросервисов:
независимое изменение
независимое развертывание
независимое масштабирование
инкапсуляция бизнес-логики
изолированные отказы
владение данными
Главная проблема формулируется так:
Сервисы разделены на уровне кода,
но остаются монолитом на уровне данных.
Практическое правило:
Один физический сервер БД → допустимо
Отдельная схема/база на сервис → допустимо
Общие таблицы с прямым доступом → сильная связанность
Доступ к чужим данным через API/event → предпочтительно
2. Как организуют аутентификацию и авторизацию между микросервисами? #
Основная идея #
В микросервисной системе обычно существуют две разные личности:
1. Пользователь, от имени которого выполняется операция
2. Сервис, который фактически отправил запрос
Поэтому проверка часто состоит из двух уровней:
Аутентификация:
кто отправил запрос?
Авторизация:
имеет ли этот пользователь или сервис право выполнить операцию?
Типичная архитектура использует:
Пользователи → OpenID Connect
Доступ к API → OAuth 2.0 Access Token
Сервис → сервис → OAuth 2.0 Client Credentials и/или mTLS
Права → scopes, roles, permissions, политики
OpenID Connect добавляет аутентификацию пользователя поверх OAuth 2.0, тогда как OAuth 2.0 управляет делегированным доступом к защищённым ресурсам.
Общая схема #
┌──────────────────────┐
│ Identity Provider │
│ Keycloak / Auth0 / │
│ Entra ID / собственный│
└──────────┬───────────┘
│
выдаёт токены
│
▼
Пользователь ──> API Gateway ──> Order Service ──> Payment Service
│ │ │
│ │ │
проверка JWT проверка JWT проверка JWT
rate limit бизнес-права бизнес-права
При этом API Gateway не должен быть единственным местом проверки. Конечный сервис также должен проверять, что токен предназначен именно ему и разрешает конкретную операцию.
1. Аутентификация пользователя #
Пользователь проходит вход через Identity Provider:
Пользователь
│
▼
Identity Provider
│
├── проверяет пароль, MFA и сессию
│
└── выдаёт токены
Обычно клиент получает:
ID Token — информация о факте аутентификации пользователя
Access Token — право обращаться к API
Refresh Token — получение новых Access Token
В API необходимо передавать именно Access Token:
Authorization: Bearer <access-token>
Использование bearer-токена в заголовке Authorization стандартизировано RFC 6750. Любой, кто завладеет bearer-токеном, сможет применить его, поэтому токен необходимо защищать при передаче и хранении.
ID Token не следует использовать вместо Access Token: он предназначен прежде всего для клиента, который выполнял вход пользователя, а не для произвольных микросервисов.
2. Вызов сервиса от имени пользователя #
Допустим, пользователь создаёт заказ:
Пользователь
│ Access Token пользователя
▼
Order Service
Упрощённое содержимое JWT может выглядеть так:
{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "order-api",
"scope": "orders:read orders:create",
"roles": ["customer"],
"exp": 1785100000
}
Здесь:
iss — кто выдал токен
sub — субъект, обычно пользователь
aud — для какого API предназначен токен
scope — разрешённые операции
roles — роли пользователя
exp — срок действия
Order Service должен проверить:
подпись;
issuer;
audience;
срок действия;
тип токена;
необходимые scopes;
доменные ограничения.
Например, наличие:
scope = orders:create
ещё не означает, что пользователь может изменить любой заказ. Сервис дополнительно проверяет владение ресурсом:
if order.user_id != current_user.id:
raise ForbiddenError()
То есть токен предоставляет общую возможность, а окончательное решение принимает бизнес-логика сервиса.
3. Технический вызов Service-to-Service #
Представим фоновую задачу:
Order Service ──> Notification Service
Пользователя в этом запросе может вообще не быть. Order Service действует от собственного имени.
Для этого часто используется OAuth 2.0 Client Credentials Grant:
Order Service
│ client authentication
▼
Authorization Server
│
└── Access Token для Notification Service
Запрос токена концептуально выглядит так:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&scope=notifications:send
После чего:
POST /internal/notifications
Authorization: Bearer <service-access-token>
Пример содержимого токена:
{
"iss": "https://auth.example.com",
"sub": "order-service",
"client_id": "order-service",
"aud": "notification-api",
"scope": "notifications:send",
"exp": 1785100000
}
OAuth 2.0 определяет Client Credentials для случаев, когда клиент действует от собственного имени либо получает доступ на основании заранее настроенных разрешений.
Где хранить credentials сервиса #
Простейший вариант:
client_id
client_secret
Но один долгоживущий секрет сложнее безопасно хранить, ротировать и отзывать.
Более сильные варианты:
private_key_jwt;
mTLS-сертификаты;
cloud workload identity;
SPIFFE/SPIRE;
короткоживущие ServiceAccount-токены Kubernetes.
Актуальная рекомендация OAuth Security BCP — по возможности применять асимметричную аутентификацию клиента, например mTLS или подписанный private_key_jwt, вместо обычного общего секрета.
В Kubernetes ServiceAccount может предоставлять workload identity процессам Pod; современные projected-токены являются короткоживущими и автоматически обновляются.
4. Вызов от имени пользователя через несколько сервисов #
Более сложный случай:
Пользователь
│
▼
Order Service
│
▼
Payment Service
Payment Service может нуждаться одновременно в информации:
какой пользователь инициировал платёж;
какой сервис отправил внутренний запрос.
Существуют три основных подхода.
Вариант 1. Передать исходный пользовательский токен #
User Token
│
├──> Order Service
└──> Payment Service
Это допустимо, только когда токен изначально предназначен и для Payment Service.
Например:
{
"aud": ["order-api", "payment-api"]
}
Но слишком широкая audience увеличивает область применения украденного токена и связывает сервисы общей моделью прав.
Обычно лучше выдавать токен для конкретного ресурса. RFC 8707 позволяет клиенту указывать целевой защищённый ресурс, чтобы Authorization Server ограничил audience токена. ( RFC Editor)
Вариант 2. Token Exchange #
Order Service обменивает пользовательский токен на новый токен для Payment Service:
User Access Token
│
▼
Authorization Server
│
▼
Payment-specific Access Token
Пример итогового токена:
{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "payment-api",
"scope": "payments:create",
"act": {
"sub": "order-service"
},
"exp": 1785100000
}
Здесь:
sub — пользователь, от имени которого выполняется действие
act — сервис, непосредственно действующий от его имени
OAuth 2.0 Token Exchange стандартизирует обмен одного токена на другой, включая сценарии делегирования и impersonation.
Это наиболее аккуратный вариант для цепочки:
User → Service A → Service B
Потому что Service B получает:
свою audience;
ограниченный scope;
короткий срок жизни;
информацию о пользователе;
информацию о вызывающем сервисе.
Вариант 3. Сервисный токен плюс пользовательский контекст #
Order Service использует собственный сервисный токен:
Authorization: Bearer <order-service-token>
А идентификатор пользователя передаёт отдельно:
X-User-ID: user-123
Так делать безопасно только при строгих условиях:
Payment Service доверяет Order Service;
соединение аутентифицировано через mTLS;
пользовательский контекст нельзя принять извне напрямую;
Order Service имеет явное право действовать от имени пользователя.
Простой заголовок X-User-ID без криптографически подтверждённой личности сервиса нельзя считать надёжным: внешний клиент может подделать его.
5. mTLS между сервисами #
При обычном TLS сервер показывает сертификат клиенту:
клиент проверяет сервер
При mutual TLS сертификаты предъявляют обе стороны:
Service A проверяет Service B
Service B проверяет Service A
Order Service
│ сертификат Order Service
│
│ защищённое соединение
│
▼
Payment Service
сертификат Payment Service
mTLS обеспечивает:
шифрование канала;
целостность трафика;
аутентификацию вызывающего workload;
аутентификацию принимающего workload.
RFC 8705 также определяет применение mTLS для OAuth-аутентификации клиентов и привязки токена к сертификату клиента. Такой токен нельзя полноценно использовать без соответствующего приватного ключа.
Но mTLS сам по себе не отвечает на вопрос:
может ли Order Service списать 10 000 рублей
с конкретного счёта пользователя?
Он подтверждает только:
запрос действительно пришёл от Order Service.
Поэтому обычно применяют комбинацию:
mTLS → подтверждает сервис и защищает канал
Access Token → передаёт разрешения и пользовательский контекст
Бизнес-логика → проверяет конкретный ресурс и действие
Документация Istio также отдельно подчёркивает, что mTLS обеспечивает аутентификацию, но одного mTLS недостаточно для полноценной авторизации.
6. Workload Identity через SPIFFE/SPIRE #
Вместо ручной выдачи сертификатов каждому сервису можно использовать workload identity.
Пример SPIFFE ID:
spiffe://example.com/prod/order-service
SPIRE определяет, какой workload запущен, и выдаёт ему короткоживущий документ идентичности — SVID:
X.509-SVID → для mTLS
JWT-SVID → токен-подтверждение личности
SPIRE Agent
│
├── идентифицирует Pod или процесс
├── выдаёт короткоживущий сертификат
└── автоматически обновляет его
SPIFFE стандартизирует идентичность workload, SVID и Workload API, через который workload получает свои идентификационные документы.
Это позволяет не хранить в переменных окружения постоянные секреты вида:
PAYMENT_SERVICE_CLIENT_SECRET=...
7. Как выполняется авторизация #
Аутентификация сервиса ещё не означает разрешение на любое действие.
Например:
Order Service успешно аутентифицирован
Но его права могут быть ограничены:
разрешено:
payments:create
payments:read-status
запрещено:
payments:refund
payments:delete
admin:accounts
Используют несколько моделей.
RBAC #
Права назначаются ролям:
role: order-service
permissions:
- payments:create
- payments:read
Scopes #
Токен содержит конкретные возможности:
{
"scope": "payments:create payments:status"
}
ABAC #
Решение зависит от атрибутов:
service = order-service
environment = production
tenant = tenant-42
operation = payment:create
amount < configured_limit
Domain authorization #
Сам сервис проверяет бизнес-правила:
allowed = (
caller == "order-service"
and "payments:create" in scopes
and payment.order_id == requested_order_id
and order.user_id == token.subject
and not account.is_blocked
)
На практике эти модели комбинируют:
Gateway → грубые ограничения
Service mesh → разрешённые связи между workload
OAuth scopes → права токена
Микросервис → бизнес-правила и владение ресурсом
8. Где проверять токен #
На API Gateway #
Gateway может проверять:
подпись;
issuer;
expiration;
общий audience;
базовые scopes;
rate limits.
Но только этого недостаточно. Внутренний сервис не должен считать любой запрос, прошедший через сеть, доверенным.
В каждом микросервисе #
Целевой сервис должен проверить токен относительно собственных требований:
aud == идентификатор текущего API;
есть необходимый scope;
вызывающий client разрешён;
пользователь имеет доступ к ресурсу.
Например, токен для:
{
"aud": "order-api"
}
не должен приниматься Payment Service.
JWT Best Current Practices рекомендует разделять разные назначения JWT и проверять aud, iss, допустимые алгоритмы и другие контекстные ограничения, чтобы токен нельзя было использовать не по назначению.
9. JWT или opaque token #
JWT #
eyJhbGciOiJSUzI1NiIs...
Содержит подписанные claims. Сервис обычно проверяет его локально по публичному ключу Authorization Server:
быстрая проверка;
не нужен сетевой запрос на каждый вызов;
сложнее немедленно отозвать уже выданный токен.
Opaque token #
rN8c7uF3kL2...
Сам по себе ничего не сообщает сервису. Сервис вызывает introspection endpoint:
POST /oauth/introspect
token=rN8c7uF3kL2
Ответ:
{
"active": true,
"client_id": "order-service",
"scope": "payments:create",
"sub": "user-123",
"aud": "payment-api",
"exp": 1785100000
}
RFC 7662 определяет introspection endpoint, через который защищённый ресурс получает состояние токена и его метаданные, включая active, scopes, client и временные ограничения.
10. Что именно проверять в JWT #
Недостаточно просто декодировать JWT:
payload = jwt.decode(token, options={"verify_signature": False})
Это не аутентификация.
Необходимо проверить:
1. Криптографическую подпись
2. Разрешённый алгоритм
3. iss — доверенный issuer
4. aud — текущий сервис
5. exp — токен не истёк
6. nbf — токен уже действует
7. typ — ожидаемый тип токена
8. scope / permissions
9. client_id или azp
10. tenant и другие доменные claims
Условный пример:
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
issuer="https://auth.example.com",
audience="payment-api",
)
scopes = set(payload.get("scope", "").split())
if "payments:create" not in scopes:
raise ForbiddenError()
Публичный ключ обычно получают через JWKS Authorization Server и кешируют с учётом ротации ключей.
11. Типовая рекомендуемая архитектура #
Identity Provider
/ \
/ \
User Access Token Service Access Token
│ │
▼ ▼
API Gateway Internal Service
│ │
▼ │
Order Service ─────────────┘
│
│ Token Exchange
▼
Payment Service
Для внешних запросов:
OIDC login;
короткоживущий Access Token;
audience конкретного API;
scopes пользователя.
Для внутренних технических запросов:
Client Credentials;
workload identity;
mTLS;
отдельные scopes для каждого сервиса.
Для запросов от имени пользователя:
Token Exchange;
сохранение user subject;
указание вызывающего сервиса;
новый audience для downstream-сервиса.
Практические правила #
Не использовать один общий JWT-секрет для всех сервисов.
Не выдавать всем сервисам одинаковые права.
Не доверять запросу только потому, что он пришёл
из внутренней сети.
Не передавать ID Token как Access Token.
Не принимать токен с чужим audience.
Не полагаться только на API Gateway.
Не хранить долгоживущие credentials без необходимости.
Не передавать X-User-ID без подтверждённой
идентичности вызывающего сервиса.
Предпочтительная комбинация:
OAuth 2.0 / OIDC
+
короткоживущие Access Token
+
mTLS или workload identity
+
проверка прав внутри конечного сервиса
+
принцип минимальных привилегий
3. Монолит vs Микросервисы #
Главное отличие #
Монолит — приложение, в котором функциональные модули собираются и развёртываются как единая система.
Клиент
│
▼
┌─────────────────────────────┐
│ Монолит │
│ │
│ Users │
│ Orders │
│ Payments │
│ Notifications │
└──────────────┬──────────────┘
│
▼
Общая БД
Микросервисы — система из независимо развёртываемых сервисов, каждый из которых отвечает за определённую бизнес-возможность и взаимодействует с остальными через API или сообщения.
┌──────────────┐
Клиент ──> Gateway ─┤ User Service │──> User DB
├──────────────┤
│Order Service │──> Order DB
├──────────────┤
│Payment │──> Payment DB
├──────────────┤
│Notification │──> Notification DB
└──────────────┘
Ключевая разница не в количестве репозиториев или контейнеров, а в границах развёртывания, владении данными и независимости команд. Микросервис должен иметь определённый контракт и возможность развиваться независимо от остальных компонентов.
Сравнение #
| Критерий | Монолит | Микросервисы |
|---|---|---|
| Развёртывание | Всё приложение целиком | Каждый сервис отдельно |
| Процессы | Обычно один процесс или один deployable unit | Отдельный процесс на сервис |
| Вызовы | Обычные вызовы функций | HTTP, gRPC, очереди, события |
| Данные | Часто одна общая БД | Обычно хранилище на сервис |
| Транзакции | Обычные ACID-транзакции | Saga, события, eventual consistency |
| Масштабирование | Масштабируется всё приложение | Можно масштабировать отдельные сервисы |
| Отказы | Ошибка может затронуть весь процесс | Возможна изоляция отказов |
| Отладка | Обычно проще | Нужны логи, метрики и tracing |
| Локальный запуск | Относительно простой | Может требовать много инфраструктуры |
| Командная независимость | Ограниченная на большом проекте | Высокая при правильных границах |
| Начальная стоимость | Ниже | Выше |
| Операционная сложность | Ниже | Значительно выше |
Развёртывание #
В монолите обычно существует один артефакт:
backend:1.7.0
Даже если изменился только модуль уведомлений, развёртывается всё приложение:
Users + Orders + Payments + Notifications
В микросервисной архитектуре можно отдельно выпустить:
notification-service:2.1.0
не развёртывая Order Service и Payment Service.
Это позволяет сервисам выпускаться независимо, но требует управления версиями API, совместимостью контрактов, конфигурацией, маршрутизацией и инфраструктурой каждого сервиса. Microsoft и AWS относят независимое тестирование и развёртывание к основным преимуществам микросервисов, одновременно подчёркивая рост сложности всей системы.
Масштабирование #
Монолит можно горизонтально масштабировать:
Load Balancer
├── Monolith 1
├── Monolith 2
└── Monolith 3
Поэтому утверждение «монолит невозможно масштабировать» неверно.
Проблема заключается в том, что обычно масштабируется всё приложение целиком. Например, если больше ресурсов требует только каталог, приходится создавать дополнительные экземпляры, содержащие также пользователей, платежи и уведомления.
Нагрузка:
Catalog 20 000 запросов/с
Payments 500 запросов/с
Notifications 100 запросов/с
Монолит:
масштабируется весь backend
В микросервисной системе можно увеличить только количество экземпляров Catalog Service:
Catalog Service
├── instance 1
├── instance 2
├── instance 3
├── instance 4
└── instance 5
Payment Service
└── instance 1
Такая гранулярность полезна при сильно различающихся профилях нагрузки. Но для небольшого приложения она часто не окупает расходы на дополнительную инфраструктуру. Azure указывает, что монолит обычно масштабируется целиком, тогда как микросервисы позволяют выбирать платформу и масштабирование для каждого домена отдельно.
Производительность #
В монолите модули вызывают друг друга как обычные функции:
user = user_service.get_user(user_id)
order = order_service.create_order(user)
Это быстро и надёжно по сравнению с сетевым вызовом.
В микросервисах аналогичная операция может выглядеть так:
Order Service
│ HTTP/gRPC
▼
User Service
Теперь появляются:
сетевые задержки;
тайм-ауты;
недоступность сервиса;
сериализация данных;
повторные запросы;
ограничение соединений;
частичные ошибки.
Поэтому микросервисы не делают приложение автоматически быстрее. Внутренний вызов функции почти всегда дешевле сетевого запроса. Преимущество микросервисов заключается не в скорости отдельного вызова, а в независимом масштабировании, развёртывании и владении бизнес-доменами.
Отказоустойчивость #
В монолите ошибка может повлиять на весь процесс:
Ошибка в генерации PDF
↓
исчерпание памяти
↓
падает весь экземпляр приложения
В микросервисах генерацию PDF можно вынести отдельно:
Order Service — работает
Payment Service — работает
PDF Service — временно недоступен
Но изоляция отказов не появляется автоматически. Если Order Service синхронно зависит от пяти других сервисов, отказ одного из них может нарушить весь запрос:
Order Service
├── User Service OK
├── Inventory Service OK
├── Pricing Service TIMEOUT
└── Payment Service не вызван
Поэтому микросервисам нужны:
timeouts;
retries;
circuit breakers;
bulkheads;
идемпотентность;
очереди;
fallback;
health checks.
Circuit Breaker, например, предотвращает бесконечные обращения к сервису, который уже отвечает ошибками или тайм-аутами.
Работа с данными #
В монолите можно провести одну транзакцию:
async with transaction:
create_order()
reserve_product()
create_payment()
Если операция не завершилась:
ROLLBACK
Все изменения отменяются атомарно.
В микросервисах данные могут быть распределены:
Order Service → Order DB
Inventory Service → Inventory DB
Payment Service → Payment DB
Одна обычная транзакция PostgreSQL уже не может охватить все эти базы.
Тогда бизнес-процесс разбивается на локальные транзакции:
1. Создать заказ
2. Зарезервировать товар
3. Провести платёж
4. Подтвердить заказ
Если платёж не прошёл:
1. Отменить резерв
2. Перевести заказ в cancelled
Это Saga с компенсирующими операциями. Она сложнее обычной транзакции: шаги должны быть идемпотентными, события могут приходить повторно, а система некоторое время находится в промежуточном состоянии. Microsoft отмечает, что стандартные ACID-гарантии напрямую не распространяются на несколько независимо управляемых хранилищ, поэтому применяются Saga и компенсации.
Согласованность данных #
В монолите проще получить строго согласованный результат:
SELECT users.username,
orders.total,
payments.status
FROM users
JOIN orders ON ...
JOIN payments ON ...
В микросервисах выполнить такой JOIN напрямую обычно нельзя, потому что данные принадлежат разным сервисам.
Используются другие способы:
API Composition;
CQRS;
репликация read-моделей;
события;
data warehouse;
eventual consistency.
Например, Order Service может хранить локальную копию имени покупателя:
{
"order_id": 500,
"user_id": 42,
"customer_name": "Alex"
}
Если пользователь изменит имя, данные обновятся после обработки события:
UserNameChanged
В течение небольшого времени копия может быть устаревшей. Такая eventual consistency является нормальной частью многих микросервисных систем, но она усложняет логику и тестирование.
Разработка и тестирование #
Монолит проще запустить локально:
docker compose up postgres redis backend
Интеграционный тест может обращаться к одному приложению и одной базе.
Для микросервисного сценария могут понадобиться:
API Gateway
Auth Service
Order Service
Payment Service
Inventory Service
PostgreSQL × 3
Redis
Kafka или RabbitMQ
Tracing
Service discovery
Полностью поднять систему на ноутбуке становится сложнее. Приходится использовать:
contract testing;
mocks и stubs;
тестовые окружения;
контейнеры;
consumer-driven contracts;
end-to-end тесты;
тестовые брокеры сообщений.
Отдельный сервис тестируется проще, но проверка всей распределённой цепочки сложнее. Azure отдельно относит межсервисную коммуникацию, согласованность данных, транзакции и большое количество компонентов к главным сложностям микросервисной архитектуры.
Наблюдаемость и отладка #
В монолите запрос обычно проходит внутри одного процесса:
POST /orders
↓
controller
↓
service
↓
repository
↓
database
В микросервисах цепочка может выглядеть так:
Gateway
↓
Order Service
↓
Inventory Service
↓
Kafka
↓
Payment Service
↓
Notification Service
Обычного текстового лога уже недостаточно. Нужны:
централизованные логи;
метрики;
distributed tracing;
trace_id;
correlation_id;
алерты;
дашборды;
SLO и SLA.
Иначе сложно определить, где именно потерялся или замедлился запрос. Операционная сложность и необходимость связывать, защищать и наблюдать сеть сервисов являются типичной ценой микросервисов.
Безопасность #
В монолите многие взаимодействия являются внутренними вызовами функций.
В микросервисах каждый сетевой интерфейс становится потенциальной границей безопасности:
Order Service ──> Payment Service
Нужно решать:
как сервисы аутентифицируют друг друга;
какие scopes имеет каждый сервис;
как ротировать credentials;
где проверять JWT;
нужен ли mTLS;
как ограничивать сетевой доступ;
как хранить секреты;
как вести аудит.
Чем больше сервисов, тем больше API, сертификатов, токенов, политик доступа и конфигураций необходимо контролировать.
Команды и ответственность #
Монолит хорошо подходит одной небольшой или средней команде:
Team
├── Users
├── Orders
├── Payments
└── Notifications
На большом проекте множество команд начинают менять один репозиторий и один deployable unit:
Team A ─┐
Team B ─┼──> общий монолит
Team C ─┘
Релизы приходится координировать, а изменения одной команды способны заблокировать выпуск остальных.
Микросервисы позволяют разделить ответственность:
Team A → Order Service
Team B → Payment Service
Team C → Catalog Service
Каждая команда владеет кодом, данными, эксплуатацией и API своего сервиса. Такой подход приносит пользу только при реальной организационной независимости. Если все микросервисы изменяет одна небольшая команда и они всегда выпускаются одновременно, архитектура может превратиться в распределённый монолит. AWS рекомендует закреплять сервис за одной командой и строить границы вокруг бизнес-возможностей или поддоменов.
Преимущества монолита #
простая разработка;
простой локальный запуск;
простые транзакции;
низкие сетевые накладные расходы;
простая отладка;
меньше инфраструктуры;
дешевле эксплуатация;
проще выполнить рефакторинг между модулями;
быстрее создать первую версию продукта.
Небольшое приложение может быть быстрее доставлено как монолит; AWS также отмечает, что монолит часто является естественным первым шагом даже при ожидаемом дальнейшем росте.
Недостатки монолита #
При росте проекта могут появиться:
долгая сборка и тестирование;
координация релизов;
сильная связанность модулей;
сложное владение кодом;
масштабирование всего приложения;
общая область отказа;
сложность замены технологического стека;
большой объём контекста для разработчика.
Но эти проблемы часто возникают не потому, что приложение является монолитом, а потому что оно стало плохо структурированным монолитом.
Преимущества микросервисов #
независимое развёртывание;
независимое масштабирование;
изоляция частей системы;
ясное владение доменами;
автономность команд;
разные технологии хранения;
небольшие границы ответственности;
возможность отдельно оптимизировать критические сервисы.
Эти преимущества проявляются при корректно определённых границах и зрелой инфраструктуре.
Недостатки микросервисов #
сетевые ошибки;
распределённые транзакции;
eventual consistency;
сложная отладка;
сложное тестирование;
API versioning;
service discovery;
повторная доставка сообщений;
идемпотентность;
мониторинг;
оркестрация;
управление секретами;
высокие инфраструктурные расходы;
больше DevOps-работы.
Каждый отдельный сервис может быть проще монолита, но система в целом становится сложнее. Это прямо отмечается в рекомендациях Azure.
Модульный монолит #
На практике лучший начальный вариант для многих систем — не обычный «большой комок кода», а модульный монолит:
┌────────────────────────────────┐
│ Backend │
│ │
│ ┌─────────┐ ┌──────────────┐ │
│ │ Users │ │ Orders │ │
│ └─────────┘ └──────────────┘ │
│ │
│ ┌─────────┐ ┌──────────────┐ │
│ │Payment │ │Notification │ │
│ └─────────┘ └──────────────┘ │
└────────────────────────────────┘
Все модули развёртываются вместе, но имеют строгие внутренние границы:
отдельные модели;
отдельные use cases;
явные интерфейсы;
запрет прямого доступа к внутренностям другого модуля;
владение таблицами;
слабая связанность.
Например:
orders/
├── domain/
├── application/
├── infrastructure/
└── api/
payments/
├── domain/
├── application/
├── infrastructure/
└── api/
Это даёт большую часть простоты монолита и облегчает последующее выделение отдельных модулей в сервисы. Хорошо спроектированные монолиты также могут иметь bounded contexts и чёткие интерфейсы; разница в том, что вызовы остаются внутрипроцессными, а развёртывание — общим.
Когда выбирать монолит #
Монолит обычно предпочтительнее, когда:
продукт только создаётся;
бизнес-требования ещё часто меняются;
команда небольшая;
нет зрелой DevOps-инфраструктуры;
требуется быстро выпустить MVP;
транзакции охватывают много связанных данных;
нагрузка умеренная;
нет необходимости независимо выпускать отдельные части;
границы бизнес-доменов ещё неясны.
В такой ситуации микросервисы могут преждевременно закрепить неправильные границы и повысить стоимость разработки.
Когда микросервисы оправданы #
Микросервисы полезны, когда одновременно присутствует несколько условий:
система большая и долгоживущая;
работают несколько автономных команд;
разные части имеют сильно разную нагрузку;
сервисы должны выпускаться независимо;
требуется изоляция отдельных критических компонентов;
доменные границы хорошо понятны;
организация умеет эксплуатировать распределённые системы;
есть централизованные логи, метрики, tracing и автоматизированный CI/CD;
стоимость монолита уже измеримо тормозит разработку.
Microsoft рекомендует перед переходом оценить зрелость приложения, инфраструктуры, DevOps-процессов и модели разработки, поскольку сама миграция занимает время и требует организационной готовности.
Практический выбор #
Для нового проекта с одной командой:
модульный монолит
Для крупной системы с несколькими независимыми командами и разными профилями нагрузки:
микросервисы
Для существующего монолита, в котором проблема возникает только в одном модуле:
не переписывать всё;
выделить проблемный модуль постепенно.
Обычно применяется Strangler Fig:
Старый монолит
│
├── старые модули
│
└── вызов нового сервиса
│
▼
Новый сервис
AWS и Azure рекомендуют постепенную декомпозицию по бизнес-возможностям или поддоменам, а не полное одномоментное переписывание системы.
Итог #
Монолит:
прост в разработке и эксплуатации,
но со временем может ограничивать независимость команд.
Микросервисы:
дают независимость развёртывания и масштабирования,
но превращают приложение в распределённую систему
со всеми её проблемами.
Модульный монолит:
часто является лучшим стартовым компромиссом.
Главное практическое правило:
Не переходить на микросервисы потому,
что они считаются современной архитектурой.
Переходить тогда, когда конкретные проблемы монолита
дороже сложности распределённой системы.
4. Что такое Микросервисы? #
Определение #
Микросервисы — архитектурный подход, при котором приложение состоит из набора слабо связанных сервисов. Каждый сервис отвечает за отдельную бизнес-возможность, самостоятельно выполняется и может развёртываться независимо от остальных. Сервисы взаимодействуют через явно определённые контракты: HTTP API, gRPC или сообщения в брокере.
Пример интернет-магазина:
┌──────────────────────┐
Клиент ──> Gateway ─┤ User Service │
├──────────────────────┤
│ Catalog Service │
├──────────────────────┤
│ Order Service │
├──────────────────────┤
│ Payment Service │
└──────────────────────┘
Вместо одного большого backend-приложения существуют отдельные сервисы:
User Service — пользователи и профили
Catalog Service — товары и категории
Order Service — оформление заказов
Payment Service — проведение платежей
Delivery Service — доставка
Основные признаки микросервиса #
1. Отдельная бизнес-ответственность #
Сервис создаётся вокруг бизнес-возможности или ограниченного доменного контекста, а не просто вокруг технического слоя.
Правильное разделение:
Order Service
Payment Service
Delivery Service
Сомнительное разделение:
Controller Service
Repository Service
Database Service
Границы сервисов обычно определяют по бизнес-доменам и bounded contexts.
2. Независимое развёртывание #
Изменение Payment Service не должно требовать обязательного выпуска Order Service и других компонентов:
payment-service: 2.4.0 → 2.5.0
order-service: остаётся 1.8.0
Независимое развёртывание считается одним из определяющих свойств микросервисной архитектуры. Если все компоненты всегда приходится тестировать и выпускать одновременно, система начинает напоминать распределённый монолит.
3. Владение своими данными #
Обычно сервис самостоятельно владеет своей доменной моделью и данными:
User Service → User DB
Order Service → Order DB
Payment Service → Payment DB
Order Service не должен напрямую изменять таблицы Payment Service. Он вызывает его API или отправляет сообщение:
Order Service ──POST /payments──> Payment Service
Ключевое требование — не обязательно отдельный физический сервер БД, а исключительное владение таблицами и схемой со стороны одного сервиса.
4. Явный контракт взаимодействия #
В монолите модули могут вызывать обычные функции:
payment_service.create_payment(order)
В микросервисах вызов пересекает границу процесса:
POST /payments
Content-Type: application/json
{
"order_id": 125,
"amount": 1500
}
Либо используется сообщение:
{
"event": "OrderCreated",
"order_id": 125,
"amount": 1500
}
API и форматы событий становятся контрактами, которые необходимо версионировать и сохранять совместимыми.
5. Независимое масштабирование #
Если высокую нагрузку испытывает только каталог, можно увеличить число экземпляров именно этого сервиса:
Catalog Service
├── instance 1
├── instance 2
├── instance 3
└── instance 4
Payment Service
└── instance 1
Микросервисная архитектура позволяет отдельно масштабировать компоненты с разными профилями нагрузки.
Как проходит один запрос #
Пользователь оформляет заказ:
1. Клиент отправляет POST /orders
2. API Gateway передаёт запрос в Order Service
3. Order Service обращается к Inventory Service
4. Inventory Service резервирует товар
5. Order Service отправляет команду Payment Service
6. Payment Service проводит оплату
7. Notification Service отправляет уведомление
Схематично:
Client
│
▼
API Gateway
│
▼
Order Service ───> Inventory Service
│
├──────────────> Payment Service
│
└── event ─────> Notification Service
Вызовы могут быть:
Синхронными:
HTTP, REST, gRPC
Асинхронными:
Kafka, RabbitMQ, NATS, SQS
Микросервис — не обязательно маленький сервис #
Слово micro не определяет количество строк кода, таблиц или разработчиков.
Главное:
чёткая бизнес-ответственность;
независимое развёртывание;
собственное владение данными;
слабая связанность;
явный контракт.
Один сервис может содержать значительный объём кода, если он остаётся внутри одного связного бизнес-контекста.
Что само по себе не делает систему микросервисной #
Недостаточно:
разбить проект на несколько репозиториев;
запустить компоненты в разных Docker-контейнерах;
создать отдельные FastAPI-приложения;
дать каждому компоненту отдельный порт;
использовать Kubernetes;
Например:
Service A ─┐
Service B ─┼──> одна общая схема БД
Service C ─┘
Если сервисы напрямую изменяют общие таблицы, выпускаются только вместе и знают внутреннее устройство друг друга, это скорее распределённый монолит, а не полноценная микросервисная архитектура. Владение данными и независимость развёртывания являются более важными признаками, чем контейнеризация.
Преимущества #
Микросервисы позволяют:
независимо выпускать части системы;
масштабировать только нагруженные компоненты;
разделить ответственность между командами;
изолировать часть технических отказов;
выбирать подходящие технологии для разных сервисов;
постепенно изменять крупную систему.
Команда, владеющая сервисом, может самостоятельно разрабатывать, тестировать, развёртывать и масштабировать его, согласовывая с другими командами главным образом API-контракты.
Недостатки #
Микросервисы превращают приложение в распределённую систему. Возникают:
сетевые ошибки и задержки;
тайм-ауты и повторные запросы;
сложная аутентификация между сервисами;
eventual consistency;
распределённые бизнес-транзакции;
версионирование API;
централизованные логи;
метрики и distributed tracing;
сложный локальный запуск;
более дорогая инфраструктура.
Поэтому микросервисы не являются автоматически более простой или более производительной архитектурой. Они обменивают простоту одного приложения на независимость его частей.
Микросервисы и монолит #
Монолит:
одно приложение;
обычно одно развёртывание;
внутренние вызовы функций;
простые локальные транзакции.
Микросервисы:
несколько отдельных приложений;
независимые развёртывания;
сетевое взаимодействие;
распределённые данные и процессы.
Итог #
Микросервисы — это не просто множество небольших приложений. Это архитектура, в которой каждый сервис:
отвечает за отдельную бизнес-возможность;
владеет своей логикой и данными;
общается через явный контракт;
может независимо тестироваться и развёртываться;
не требует синхронного выпуска всей системы.
Для небольшого нового проекта чаще практичнее начать с хорошо разделённого модульного монолита. Микросервисы оправданы, когда независимость команд, развёртывания и масштабирования действительно компенсирует сложность распределённой системы.
5. Как DDD помогает выделять микросервисы? | Как выделить микросервис из монолита? #
Как DDD помогает выделять микросервисы #
DDD — Domain-Driven Design — помогает делить систему не по техническим слоям и таблицам, а по бизнес-смыслам, правилам и ответственности.
DDD не говорит: «создай сервис размером в 5 классов». Он помогает определить, где проходит естественная граница модели, данных и бизнес-логики.
Неправильное деление:
User Service
Controller Service
Repository Service
Database Service
Это технические слои, а не бизнес-возможности.
Деление по бизнес-домену:
Customer Service
Order Service
Payment Service
Delivery Service
Azure рекомендует проектировать сервисы вокруг бизнес-возможностей, добиваясь высокой внутренней связности и слабой связанности между сервисами.
1. Сначала определяется домен #
Домен — предметная область, для которой создаётся система.
Для интернет-магазина это может быть:
Продажа товаров
├── Каталог
├── Корзина
├── Оформление заказа
├── Оплата
├── Доставка
└── Возвраты
DDD требует сначала понять:
какие бизнес-процессы существуют;
какие правила действуют;
какие понятия используют специалисты;
какие части системы изменяются по разным причинам;
какие данные принадлежат каждой части.
На этой стадии обычно работают не только разработчики, но и бизнес-эксперты. Для исследования домена могут использоваться схемы процессов, интервью и Event Storming. Главная цель — получить общее понимание предметной области до выбора конкретных технологий.
2. Домен разделяется на поддомены #
DDD выделяет несколько типов поддоменов.
Core Domain #
Ключевая часть бизнеса, создающая конкурентное преимущество:
Для маркетплейса:
алгоритм подбора предложений;
управление заказами;
динамическое ценообразование.
Supporting Subdomain #
Поддерживает основной бизнес, но сам не является главным конкурентным преимуществом:
возвраты;
внутренняя отчётность;
управление доставкой.
Generic Subdomain #
Типовая функциональность, которая встречается во многих системах:
аутентификация;
отправка электронной почты;
хранение файлов;
аудит.
Разделение на core, supporting и generic subdomains помогает понять, какие части требуют собственной сложной модели, а какие можно реализовать проще или передать готовому внешнему решению. AWS использует такое деление как один из подходов к декомпозиции монолита.
3. Для каждого поддомена ищут Bounded Context #
Bounded Context, или ограниченный контекст, — граница, внутри которой определённая модель и терминология имеют однозначное значение.
Например, понятие Customer может означать разные вещи.
В контексте продаж:
class Customer:
id: int
discount_level: int
loyalty_points: int
В контексте доставки:
class Recipient:
id: int
full_name: str
phone: str
delivery_address: str
В контексте платежей:
class Payer:
id: int
payment_profile_id: str
risk_level: str
Формально это один человек, но разные части бизнеса рассматривают его по-разному. Поэтому не обязательно создавать одну глобальную сущность User, которую используют все компоненты.
Sales Context:
Customer — покупатель и участник программы лояльности
Delivery Context:
Recipient — получатель посылки
Payment Context:
Payer — участник финансовой операции
Bounded Context определяет область действия конкретной доменной модели. Функциональность одного микросервиса обычно не должна охватывать несколько несвязанных bounded contexts.
4. Bounded Context становится кандидатом в микросервис #
Упрощённое соответствие выглядит так:
Bounded Context Возможный сервис
Ordering Order Service
Payments Payment Service
Shipping Delivery Service
Notifications Notification Service
Но это не жёсткое правило:
Bounded Context ≠ обязательно отдельный микросервис
Bounded Context может быть:
модулем внутри модульного монолита;
отдельным микросервисом;
группой тесно связанных сервисов;
логической границей без отдельного процесса.
DDD определяет границу модели, но не требует обязательно превращать каждую такую границу в отдельное сетевое приложение.
5. Внутри Bounded Context определяются агрегаты #
Aggregate — группа объектов, изменения которой должны контролироваться через один корневой объект и обычно согласовываться в рамках одной локальной транзакции.
Например:
Order Aggregate
├── Order
├── OrderItem
├── DeliveryAddress
└── OrderStatus
Корнем является Order:
class Order:
def add_item(self, product_id: int, price: Decimal) -> None:
...
def confirm(self) -> None:
...
def cancel(self) -> None:
...
Внешний код не должен напрямую менять OrderItem, обходя правила Order.
Агрегаты помогают понять:
какие данные должны изменяться атомарно;
где располагаются бизнес-инварианты;
какие сущности тесно связаны;
где нежелательно проводить границу сервиса.
Практическая рекомендация Azure: микросервис обычно не должен быть меньше агрегата и не должен охватывать больше одного bounded context. При этом отдельный агрегат — только кандидат на сервис, а не обязательный отдельный сервис.
6. Context Map показывает связи между контекстами #
После выделения bounded contexts нужно описать их отношения:
Ordering
│
├── запрашивает цену ──> Catalog
├── создаёт платёж ────> Payments
└── публикует событие ─> Delivery
Context Map показывает:
кто владеет моделью;
кто является поставщиком данных;
кто зависит от кого;
где требуется перевод одной модели в другую;
какие связи синхронные, а какие асинхронные.
Например, Order Service не должен использовать внутреннюю модель Payment Service:
# Плохо
from payment_service.models import PaymentTransaction
Вместо этого используется публичный контракт:
POST /payments
{
"order_id": "order-125",
"amount": "1500.00",
"currency": "RUB"
}
Для защиты новой модели от особенностей старого монолита может применяться Anti-Corruption Layer — адаптер, переводящий данные и понятия между двумя моделями.
Когда Bounded Context стоит выделять в сервис #
Граница выглядит подходящей, когда у части системы есть несколько признаков:
собственная бизнес-терминология;
собственные правила и модель;
явный владелец данных;
отдельный жизненный цикл изменений;
возможность независимого развёртывания;
отдельные требования к нагрузке и доступности;
понятный контракт с остальной системой;
одна ответственная команда.
AWS рекомендует закреплять микросервис за одной командой, которая отвечает за его разработку, поддержку и выпуск.
Когда граница выбрана плохо #
Сервисы постоянно вызывают друг друга #
Order Service
↓
Customer Service
↓
Pricing Service
↓
Order Service
↓
Inventory Service
Один пользовательский запрос превращается в длинную цепочку синхронных вызовов.
Один бизнес-процесс требует общей транзакции #
Service A изменяет таблицу A
Service B изменяет таблицу B
Service C изменяет таблицу C
Если практически каждое изменение должно быть атомарным во всех трёх сервисах, вероятно, границу провели слишком мелко.
Все сервисы выпускаются одновременно #
service-a:2.0
service-b:2.0
service-c:2.0
Всегда должны развёртываться вместе
Это признак распределённого монолита.
Сервисы используют общие таблицы #
Order Service ───┐
Payment Service ─┼──> orders, payments, users
Delivery Service ┘
Общая схема создаёт связанность на уровне разработки и миграций: изменение одной таблицы приходится координировать между несколькими сервисами.
Как выделить микросервис из монолита #
Выделение микросервиса — это не простое копирование папки в отдельный репозиторий.
Нужно отделить:
код;
бизнес-модель;
данные;
контракты;
транзакции;
развёртывание;
эксплуатационную ответственность.
Шаг 1. Сначала определить причину выделения #
Нельзя начинать с формулировки:
Нам нужны микросервисы.
Нужна конкретная проблема:
модуль требует независимого масштабирования;
его релизы блокируют другие команды;
у него отдельные требования к доступности;
его разработка тормозится связанностью монолита;
модуль часто изменяется независимо;
ошибки модуля влияют на всё приложение;
для него требуется отдельный технологический стек.
Без измеримой причины выделение сервиса может только увеличить сложность.
Шаг 2. Выбрать подходящий первый кандидат #
Для первого выделения лучше выбирать модуль:
с понятной ответственностью;
с небольшим количеством зависимостей;
с чётким входом и выходом;
без множества общих транзакций;
с относительно самостоятельными данными;
который можно откатить без остановки основного бизнеса.
Часто хорошими первыми кандидатами бывают:
уведомления;
генерация документов;
поиск;
обработка изображений;
аудит;
экспорт отчётов.
Более сложные кандидаты:
платежи;
балансы;
оформление заказа;
управление остатками;
идентификация пользователей.
У сложных доменов больше транзакционных и бизнес-зависимостей.
Шаг 3. Исследовать текущие зависимости #
Перед выделением нужно построить карту модуля.
Например, для уведомлений:
Notification Module
├── вызывается после регистрации
├── вызывается после оплаты
├── вызывается после отмены заказа
├── читает users.email
├── читает notification_templates
├── записывает notification_history
├── запускает периодические задачи
└── использует SMTP
Необходимо найти:
все точки вызова;
импорты и зависимости кода;
таблицы и запросы;
фоновые задачи;
хранимые процедуры и триггеры;
общие транзакции;
внешние интеграции;
отчёты, читающие эти данные;
требования к задержке и надёжности.
Без этого легко вынести только код, оставив скрытые связи с монолитом.
Шаг 4. Сначала создать модульную границу внутри монолита #
До физического выделения полезно превратить функциональность в изолированный модуль:
monolith/
├── users/
├── orders/
├── payments/
└── notifications/
├── domain/
├── application/
├── infrastructure/
└── public_api.py
Другие части монолита должны работать только через публичный интерфейс:
notification_api.send_order_confirmation(
user_id=user.id,
order_id=order.id,
)
А не обращаться к внутренним классам и таблицам:
# Плохо
NotificationModel.objects.create(...)
Это создаёт seam — шов, по которому модуль затем можно физически отделить.
Шаг 5. Определить контракт нового сервиса #
Нужно решить, какие операции сервис предоставляет наружу.
Синхронный вариант:
POST /notifications
{
"recipient_id": "user-42",
"template": "order-confirmed",
"parameters": {
"order_id": "order-125"
}
}
Асинхронный вариант:
{
"event_id": "evt-981",
"event_type": "OrderConfirmed",
"order_id": "order-125",
"user_id": "user-42",
"occurred_at": "2026-07-27T00:30:00Z"
}
Контракт должен описывать:
обязательные поля;
семантику операции;
формат ошибок;
идемпотентность;
версионирование;
тайм-ауты;
правила повторной доставки;
аутентификацию и авторизацию.
Новый сервис не должен принимать внутренние ORM-модели монолита как контракт.
Шаг 6. Определить владельца данных #
После выделения сервис должен стать владельцем своих данных.
Notification Service
├── notification_templates
├── notification_jobs
├── notification_attempts
└── notification_preferences
Другие компоненты не должны напрямую выполнять:
INSERT INTO notification_jobs ...
Они должны обращаться через API или публиковать событие.
Database per Service означает именно эксклюзивное владение хранилищем. При этом базы могут физически находиться на одном сервере, но права доступа и схемы должны быть разделены. Межсервисные запросы к данным после этого требуют API Composition, событий, read-моделей или других распределённых подходов.
Шаг 7. Подготовить миграцию данных #
Допустим, таблица находится в монолите:
notification_history
Миграция может выполняться так:
1. Создать таблицы в базе нового сервиса
2. Перенести исторические данные
3. Проверить количество и контрольные суммы
4. Синхронизировать новые изменения
5. Переключить запись на новый сервис
6. Переключить чтение
7. Запретить старой системе изменять таблицу
8. Удалить старую таблицу после периода наблюдения
Нужно заранее определить:
что является источником истины;
кто может писать данные на каждом этапе;
как обрабатываются изменения во время миграции;
как выполнить откат;
как сравнивать старые и новые результаты.
Не использовать неконтролируемую двойную запись #
Опасный вариант:
save_to_monolith_db(data)
save_to_microservice_db(data)
Между двумя операциями возможен сбой:
Монолитная БД обновлена
Новая БД не обновлена
Для публикации событий после изменения БД обычно применяется Transactional Outbox:
Одна локальная транзакция
├── изменить бизнес-данные
└── записать событие в outbox
Отдельный worker
└── отправить событие в брокер
Outbox решает проблему dual write, когда нужно одновременно сохранить данные и уведомить другую систему, но эти действия невозможно объединить одной распределённой транзакцией.
Шаг 8. Добавить Anti-Corruption Layer #
Монолит может использовать старую модель:
send_email(
user_db_id=42,
email_kind=3,
payload={"number": 125},
)
Новый сервис может иметь более осмысленный контракт:
SendNotification(
recipient_id="user-42",
template="order-confirmed",
variables={"order_id": "order-125"},
)
Адаптер переводит старую модель в новую:
class NotificationAdapter:
def send_order_confirmation(
self,
user_id: int,
order_id: int,
) -> None:
client.send_notification(
recipient_id=f"user-{user_id}",
template="order-confirmed",
variables={"order_id": f"order-{order_id}"},
)
Это не позволяет устаревшей модели монолита проникнуть внутрь нового сервиса. Anti-Corruption Layer используется как фасад или адаптер между системами с разной семантикой.
Шаг 9. Применить Strangler Fig #
Наиболее безопасный способ — постепенно перенаправлять функциональность из монолита в новый сервис.
Этап 1:
Клиент ──> Монолит
Этап 2:
┌──> Монолит
Клиент ──> Gateway ─┤
└──> Новый сервис
Этап 3:
Клиент ──> Gateway ──> Новый сервис
Strangler Fig состоит из трёх общих фаз:
Transform — создать новую реализацию
Coexist — старая и новая версии работают параллельно
Eliminate — удалить старую реализацию
Постепенный подход снижает риск по сравнению с одномоментным переписыванием и оставляет возможность вернуть трафик в монолит.
Шаг 10. Переключать трафик постепенно #
Пример:
1% запросов → новый сервис
10% → новый сервис
50% → новый сервис
100% → новый сервис
Либо переключать по группам:
внутренние пользователи;
тестовые клиенты;
один регион;
один тип уведомления;
один бизнес-сценарий.
На каждом этапе сравниваются:
ответы старой и новой системы;
количество ошибок;
задержки;
созданные записи;
доставленные события;
бизнес-метрики.
Шаг 11. Обеспечить идемпотентность #
В распределённой системе запрос или сообщение может быть доставлено повторно.
{
"event_id": "evt-981",
"event_type": "OrderConfirmed"
}
Сервис должен хранить обработанные идентификаторы:
CREATE TABLE processed_events (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL
);
Логика:
if event_already_processed(event.id):
return
process_event(event)
mark_event_as_processed(event.id)
Иначе одно повторно доставленное событие может отправить два уведомления или создать две операции.
Шаг 12. Подготовить эксплуатацию до переключения #
До запуска нового сервиса должны существовать:
централизованные логи;
метрики;
distributed tracing;
health checks;
тайм-ауты;
повторные попытки;
circuit breaker;
алерты;
резервное копирование;
управление секретами;
процедура rollback.
Нельзя сначала выделить сервис, а затем начинать думать, как понять, работает ли он.
Шаг 13. Удалить старую реализацию #
После окончательного переключения необходимо удалить:
старые обработчики;
прямые SQL-запросы;
старые таблицы;
фоновые задачи;
устаревшие feature flags;
временную синхронизацию;
неиспользуемые API;
обходные адаптеры.
Пока старый и новый код остаются активными, миграция фактически не завершена.
Пример выделения Notification Service #
До выделения #
Монолит
├── Users
├── Orders
├── Payments
├── Notifications
└── Общая БД
После оплаты код напрямую вызывает уведомления:
with transaction:
payment.mark_succeeded()
Notification.objects.create(
user_id=payment.user_id,
template="payment-succeeded",
)
Промежуточное состояние #
В той же транзакции записывается событие:
with transaction:
payment.mark_succeeded()
outbox.add(
event_type="PaymentSucceeded",
payload={
"payment_id": str(payment.id),
"user_id": str(payment.user_id),
},
)
Outbox worker отправляет событие:
Monolith DB
│
▼
Outbox Worker
│
▼
Message Broker
│
▼
Notification Service
После выделения #
Payment Module
│ PaymentSucceeded
▼
Message Broker
│
▼
Notification Service
│
├── выбирает шаблон
├── получает разрешённые контактные данные
├── отправляет сообщение
└── сохраняет историю попыток
Монолит больше не знает, каким SMTP-провайдером пользуется сервис, сколько попыток отправки выполняется и как устроена его база.
Как понять, что выделение завершено #
Микросервис действительно выделен, когда:
его можно развернуть независимо;
он владеет своими таблицами;
монолит не читает его БД напрямую;
он не читает таблицы монолита напрямую;
между ними существует явный контракт;
его отказ обрабатывается предсказуемо;
есть отдельные метрики и логи;
существует процедура отката;
старая реализация удалена.
Просто вынести код в отдельный контейнер недостаточно:
Отдельный контейнер
+
общая база
+
общие модели
+
совместный релиз
=
распределённый монолит
Итоговая схема #
DDD:
домен
↓
поддомены
↓
bounded contexts
↓
агрегаты и бизнес-инварианты
↓
карта взаимодействий
↓
кандидаты в микросервисы
Выделение сервиса:
выбрать бизнес-границу
↓
изолировать модуль в монолите
↓
определить API и события
↓
передать сервису владение данными
↓
добавить адаптер и маршрутизацию
↓
постепенно переключить трафик
↓
удалить старую реализацию
Главный принцип:
DDD помогает определить правильную бизнес-границу.
Strangler Fig, Anti-Corruption Layer, Outbox
и миграция данных помогают физически отделить эту границу
от работающего монолита.
Основные источники #
- Microsoft Learn: определение границ микросервисов через bounded contexts и агрегаты. ( Microsoft Learn)
- Microsoft Learn: анализ домена и стратегический DDD. ( Microsoft Learn)
- AWS Prescriptive Guidance: декомпозиция по поддоменам и бизнес-возможностям. ( Документация AWS)
- AWS и Microsoft: постепенная миграция по Strangler Fig. ( Документация AWS)
- AWS: Transactional Outbox для надёжной публикации событий. ( Документация AWS)
6. Какие есть риски безопасности при выделении микросервиса? #
Главный риск #
При выделении микросервиса внутренняя граница монолита превращается в сетевую границу:
До:
Orders Module ──вызов функции──> Payments Module
После:
Order Service ──HTTP / gRPC / broker──> Payment Service
Появляются новые API, сетевые соединения, сервисные учётные данные, отдельный CI/CD, контейнеры, логи и инфраструктурные настройки. Поэтому поверхность атаки обычно увеличивается, даже если бизнес-логика почти не изменилась.
1. Доверие ко «внутренней сети» #
Одна из самых опасных ошибок:
Запрос пришёл из внутренней сети
↓
значит, ему можно доверять
Злоумышленник, получивший доступ к одному сервису, контейнеру или узлу, сможет обращаться к другим внутренним API.
Каждый запрос между сервисами нужно отдельно:
аутентифицировать;
авторизовать;
проверять по конкретному действию и ресурсу;
ограничивать на сетевом уровне.
Zero Trust исключает автоматическое доверие только на основании сетевого расположения. NIST рекомендует защищать конкретные ресурсы и сервисы, а не считать внутренний сегмент доверенной зоной.
Предпочтительная схема:
Service A
│
│ mTLS + короткоживущий токен
▼
Service B
│
├── проверяет личность Service A
├── проверяет audience токена
├── проверяет scope
└── проверяет бизнес-права
2. Потеря авторизации при переносе логики #
В монолите проверка могла неявно выполняться выше по стеку:
def endpoint(current_user):
check_user_permissions(current_user)
payment_service.refund(payment_id)
При выделении сервиса разработчики иногда переносят только нижнюю операцию:
POST /payments/42/refund
Но забывают перенести проверку:
может ли этот пользователь вернуть данный платёж;
принадлежит ли платёж его организации;
имеет ли вызывающий сервис право запускать возврат;
разрешён ли возврат в текущем состоянии.
В результате возникает Broken Object Level Authorization или Broken Function Level Authorization: атакующий меняет идентификатор объекта либо вызывает административную операцию без достаточных прав. Эти категории входят в OWASP API Security Top 10.
Проверка должна выполняться в конечном сервисе, а не только в Gateway:
async def refund_payment(
payment_id: UUID,
caller: ServiceIdentity,
subject: UserIdentity,
) -> None:
payment = await repository.get(payment_id)
if "payments:refund" not in caller.scopes:
raise ForbiddenError()
if payment.customer_id != subject.id:
raise ForbiddenError()
if not payment.can_be_refunded():
raise InvalidPaymentState()
3. Неправильная передача пользовательского контекста #
Цепочка может выглядеть так:
User → Order Service → Payment Service
Payment Service должен понимать:
кто пользователь;
какой сервис вызвал операцию;
для какого API предназначен токен;
что именно разрешено.
Опасный вариант:
X-User-ID: 42
Если Payment Service принимает такой заголовок без аутентификации вызывающего сервиса, его можно подделать.
Другой риск — передавать один универсальный JWT всем сервисам с широкими ролями:
{
"aud": "*",
"roles": ["admin"]
}
Безопаснее использовать:
отдельную identity сервиса;
токены с конкретным
aud;минимальные scopes;
короткий срок жизни;
token exchange для downstream-вызовов;
проверку прав в каждом сервисе.
OWASP рекомендует централизовать выдачу идентичностей, но не переносить всё решение об авторизации в одну точку: конечный сервис должен контролировать доступ к собственным ресурсам.
4. Слабая аутентификация Service-to-Service #
После выделения сервису требуются собственные credentials. Частая временная реализация:
SERVICE_TOKEN=super-secret-static-token
Проблемы:
один секрет используют несколько сервисов;
невозможно определить реального вызывающего;
секрет редко ротируется;
утечка даёт длительный доступ;
права обычно слишком широкие.
Более безопасные варианты:
OAuth 2.0 Client Credentials;
workload identity;
короткоживущие ServiceAccount-токены;
mTLS;
SPIFFE/SPIRE;
private_key_jwt.
При mTLS обе стороны подтверждают свою идентичность, но одного mTLS недостаточно: валидный сертификат идентифицирует сервис, однако не определяет, какие операции ему разрешены. Поэтому mTLS необходимо сочетать с authorization policy или токенами прав.
5. Незашифрованный внутренний трафик #
После декомпозиции данные начинают передаваться по сети:
Order Service → Payment Service
В трафике могут находиться:
JWT;
персональные данные;
номера заказов;
внутренние идентификаторы;
платёжные сведения;
служебные команды.
Даже внутренние HTTP-вызовы следует защищать TLS. Для чувствительных или привилегированных взаимодействий применяется mTLS, при котором клиент также предъявляет сертификат. OWASP требует использовать TLS для защищённых веб-сервисов и отдельно указывает на возможность клиентских сертификатов для высокопривилегированных API.
6. Слишком широкие сетевые разрешения #
После выделения нередко получается:
любой Pod → любой Pod → любой порт
Тогда компрометация одного небольшого сервиса даёт возможность сканировать и атаковать всю внутреннюю систему.
Нужен принцип default deny:
Order Service:
может обращаться к Inventory Service и Payment Service
Notification Service:
не может обращаться к Payment DB
External Gateway:
не может напрямую обращаться к внутренней БД
В Kubernetes для этого используются NetworkPolicy, если сетевой плагин поддерживает их. Kubernetes относит NetworkPolicy к штатным механизмам ограничения сетевого взаимодействия между Pod.
Пример логики:
По умолчанию всё запрещено
↓
явно разрешены только необходимые связи
7. Слишком широкие права к базе данных #
Во время миграции новый сервис часто временно получает доступ ко всей базе монолита:
notification-service
└── доступ: SELECT, INSERT, UPDATE, DELETE ко всей main_db
Если сервис будет взломан, атакующий получит доступ не только к уведомлениям, но и к пользователям, заказам или платежам.
Нужно выдавать отдельную учётную запись БД:
GRANT SELECT, INSERT, UPDATE
ON notification_schema.notification_jobs
TO notification_service;
Не следует выдавать:
GRANT ALL PRIVILEGES ON DATABASE main_db;
На переходном этапе особенно важно определить:
какие таблицы принадлежат монолиту;
какие — новому сервису;
кто может читать;
кто может писать;
когда временные права будут отозваны.
8. Shared Database сохраняет боковой доступ к данным #
Иногда сервис отделили на уровне кода, но оставили ему прямой доступ к таблицам монолита:
Payment Service ──SQL──> users
Payment Service ──SQL──> orders
Payment Service ──SQL──> audit_logs
Это не только архитектурная, но и security-проблема:
сервис обходит авторизацию владельца данных;
можно изменить данные без бизнес-проверок;
трудно настроить минимальные привилегии;
компрометация одного сервиса затрагивает несколько доменов.
Целевой вариант:
Payment Service → собственная БД
Order Service → собственная БД
Order Service ──API/event──> Payment Service
9. Небезопасная миграция и копирование данных #
Во время выделения данные часто:
экспортируются в файлы;
копируются через временные скрипты;
загружаются в новую БД;
дублируются в старой и новой системах;
сравниваются через технические логи.
Риски:
дамп БД остался на сервере;
временный bucket открыт;
миграционный пользователь имеет избыточные права;
данные передаются без TLS;
персональные данные попали в логи;
тестовая среда получила production-данные.
Для миграции нужно определить отдельный security-план:
шифрование при передаче и хранении;
временные credentials;
минимальные права;
аудит доступа;
срок удаления дампов;
проверка контрольных сумм;
маскирование чувствительных данных;
процедура отзыва доступов после миграции.
10. Dual write и подмена состояния #
Опасная переходная схема:
save_to_monolith(data)
save_to_microservice(data)
Кроме потери согласованности, она создаёт security-неопределённость:
где находится источник истины;
какая система принимает решение о правах;
что произойдёт при конфликтующих значениях;
можно ли изменить старую запись и обойти новый сервис.
Например:
В новой системе пользователь заблокирован
В старой системе пользователь активен
Если часть запросов всё ещё использует старую таблицу, блокировку можно фактически обойти.
Во время сосуществования необходимо явно определить:
один источник истины;
одного владельца записи;
порядок переключения чтения и записи;
запрет обратной записи после cutover;
автоматическую проверку расхождений.
11. Небезопасные события и брокер сообщений #
После выделения синхронный вызов часто заменяют событием:
{
"type": "PaymentSucceeded",
"user_id": "42",
"amount": 5000
}
Нужно защищать не только HTTP API, но и брокер:
аутентифицировать producer и consumer;
разделять права публикации и чтения;
ограничивать доступ по topic или queue;
проверять схему сообщения;
не доверять полям события автоматически;
защищаться от повторной доставки;
применять идемпотентность;
шифровать чувствительные данные.
Опасная конфигурация:
любой сервис может публиковать PaymentSucceeded
Тогда скомпрометированный сервис сможет отправить поддельное финансовое событие.
Предпочтительно:
Только Payment Service:
publish → payments.succeeded
Notification Service:
consume → payments.succeeded
не может publish
12. Повторная доставка и replay-атаки #
HTTP-запрос или событие может быть отправлено повторно:
PaymentRequested
PaymentRequested
PaymentRequested
Причиной может быть нормальный retry или намеренная replay-атака.
Для денежных и других критичных операций нужны:
idempotency_key;
уникальный event_id;
timestamp;
ограниченный срок действия;
защита от повторного nonce;
таблица обработанных сообщений.
Пример:
CREATE TABLE processed_messages (
message_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL
);
Без идемпотентности повторная доставка может привести к повторному списанию, отправке или созданию ресурса.
13. Отсутствие ограничений ресурсов #
Вызов функции внутри монолита редко был публичной точкой потребления ресурсов. После выделения появляется API:
POST /reports/generate
POST /images/process
POST /notifications/send
Без ограничений атакующий или неисправный сервис может исчерпать:
CPU;
память;
пул подключений;
очередь;
дисковое пространство;
платные внешние API.
OWASP относит unrestricted resource consumption к основным API-рискам.
Необходимы:
rate limiting;
лимиты размера запроса;
тайм-ауты;
ограничения concurrency;
квоты;
лимит количества элементов в batch;
лимит числа retries;
circuit breaker;
лимиты CPU и памяти контейнера.
14. SSRF и неограниченный исходящий трафик #
Новый сервис может принимать URL:
{
"callback_url": "http://..."
}
или обращаться к внешним ресурсам:
webhook;
объектное хранилище;
платёжный провайдер;
внешний REST API.
При SSRF атакующий заставляет сервис обращаться к внутренним адресам, metadata endpoint облака или административным API.
Нужны:
allowlist допустимых доменов;
запрет loopback и внутренних диапазонов;
повторная проверка после DNS resolution;
ограничение редиректов;
egress-фильтрация;
отдельные credentials для внешних интеграций.
SSRF входит в OWASP API Security Top 10, наряду с небезопасным потреблением внешних API.
15. Утечка секретов #
При выделении появляются новые:
пароли БД;
API keys;
OAuth client secrets;
JWT private keys;
сертификаты;
ключи брокера;
cloud credentials.
Риски:
секрет записан в Git;
секрет находится в Dockerfile;
секрет попал в image layer;
секрет напечатан в CI-логе;
все Pod читают общий Secret;
секрет не ротируется.
Для Kubernetes рекомендуется:
шифровать Secrets at rest;
применять минимальные RBAC-права;
ограничивать доступ конкретными ServiceAccount;
не выдавать
listиwatchбез необходимости;использовать внешнее хранилище секретов, когда это оправдано;
регулярно ротировать credentials.
Kubernetes прямо рекомендует шифрование Secrets at rest и least-privilege-доступ.
16. Избыточные Kubernetes-права #
Типичная ошибка:
ServiceAccount микросервиса
↓
cluster-admin
Тогда уязвимость в приложении может позволить:
читать Secrets;
создавать привилегированные Pod;
изменять RBAC;
атаковать другие namespaces;
получить контроль над кластером.
Каждому сервису нужен отдельный ServiceAccount и минимальный набор разрешений.
Kubernetes отдельно предупреждает, что выдача всем ServiceAccount прав cluster-admin позволяет приложениям читать секреты и изменять разрешения всего кластера.
17. Небезопасная конфигурация контейнера #
Новый сервис часто впервые контейнеризируется. Опасные настройки:
securityContext:
privileged: true
или:
запуск от root;
hostNetwork;
hostPID;
mount docker.sock;
записываемая корневая файловая система;
добавленные Linux capabilities;
неограниченный доступ к hostPath.
Для большинства сервисов предпочтительно:
runAsNonRoot;
readOnlyRootFilesystem;
allowPrivilegeEscalation: false;
drop всех ненужных capabilities;
seccomp profile;
ограничения CPU и памяти.
Kubernetes Pod Security Standards определяют уровни Baseline и Restricted, направленные на предотвращение известных способов повышения привилегий и усиление изоляции Pod.
18. Уязвимости цепочки поставки #
Каждый выделенный сервис получает собственные:
Dockerfile;
base image;
зависимости;
CI/CD pipeline;
registry;
deploy manifests.
Чем больше сервисов, тем больше артефактов и зависимостей нужно обновлять и контролировать.
Риски:
уязвимый base image;
dependency confusion;
скомпрометированный пакет;
подмена контейнерного образа;
утечка CI/CD-токена;
незафиксированные версии зависимостей;
deployment непроверенного артефакта.
OWASP относит к угрозам цепочки поставки компрометацию upstream-инфраструктуры, dependency confusion и атаки на CI/CD. Рекомендуются автоматическое сканирование, SBOM, проверка provenance и подпись артефактов.
19. Утечка данных через логи и tracing #
В распределённой системе обычно добавляются централизованные логи и tracing. Разработчики могут случайно записать:
Authorization header;
JWT;
пароль;
номер карты;
email;
полное тело запроса;
персональные данные;
connection string.
Теперь данные копируются в отдельную систему логирования, к которой может иметь доступ больше сотрудников и сервисов.
Нужно:
маскировать чувствительные поля;
не логировать access/refresh tokens;
фильтровать заголовки;
ограничивать доступ к логам;
определить срок хранения;
шифровать лог-хранилище;
не записывать payload целиком по умолчанию.
20. Старый и новый endpoint работают одновременно #
При Strangler Fig некоторое время существуют две реализации:
/api/payments → монолит
/internal/payments → новый сервис
Риски:
старая версия имеет слабую авторизацию;
один endpoint забыли отключить;
правила в старой и новой версиях различаются;
внутренний endpoint случайно опубликован наружу;
feature flag можно обойти;
старый сервис продолжает изменять данные после cutover.
После переключения необходимо удалить или заблокировать:
старые маршруты;
старые credentials;
временные DB-права;
двойную запись;
миграционные учётные записи;
временные очереди;
отладочные endpoint;
feature flags.
Что проверить до выделения #
Минимальный security-чек-лист:
[ ] Определена threat model нового сервиса
[ ] Все входящие соединения перечислены
[ ] Все исходящие соединения перечислены
[ ] У сервиса есть отдельная identity
[ ] Межсервисный трафик защищён TLS/mTLS
[ ] Каждый endpoint проверяет authorization
[ ] Проверяются issuer, audience, expiry и scopes токена
[ ] Настроен default-deny на сетевом уровне
[ ] У БД отдельная учётная запись с минимальными правами
[ ] Определён единственный владелец данных
[ ] Secrets не находятся в Git и контейнерных образах
[ ] Сообщения брокера аутентифицированы и авторизованы
[ ] Критические операции идемпотентны
[ ] Настроены rate limits и resource limits
[ ] Контейнер запускается без root и лишних capabilities
[ ] Зависимости и образы сканируются
[ ] Артефакты подписываются или проверяется provenance
[ ] Логи не содержат токены и персональные данные
[ ] Есть аудит действий и алерты
[ ] Временные доступы удаляются после миграции
[ ] Есть безопасный rollback
Итог #
Наиболее критичные риски при выделении микросервиса:
1. Внутренний API становится новой точкой атаки
2. Теряется или дублируется авторизация монолита
3. Сервисы получают чрезмерные права
4. Появляются новые секреты и credentials
5. Данные небезопасно мигрируют или дублируются
6. Брокер принимает поддельные или повторные события
7. Сетевое доверие позволяет lateral movement
8. Контейнер и CI/CD создают новую supply-chain поверхность
9. Логи и tracing начинают раскрывать чувствительные данные
10. Старая и новая реализации расходятся по правилам безопасности
Главный принцип:
Выделение микросервиса должно создавать
не только новую границу развёртывания,
но и новую явно защищённую границу доверия.
API Gateway, внутренняя сеть или Kubernetes namespace сами по себе не являются достаточной защитой. Каждый сервис должен иметь собственную идентичность, минимальные права и самостоятельно проверять доступ к принадлежащим ему операциям и данным.
7. Стоит ли жертвовать независимостью БД микросервиса при нехватке ресурсов? #
Основной вывод #
При нехватке ресурсов можно пожертвовать физической изоляцией БД, но нежелательно жертвовать логическим владением данными.
То есть допустимо:
Один PostgreSQL-сервер или кластер
├── order_db / order_schema
├── payment_db / payment_schema
└── notification_db / notification_schema
Но нежелательно:
Order Service ───┐
Payment Service ─┼──> общие таблицы users, orders, payments
Notification ────┘
AWS прямо указывает, что при использовании общей реляционной БД данные можно сохранять приватными за счёт отдельных таблиц или схем для каждого микросервиса. Microsoft также формулирует основной принцип как владение сервисом своими доменными данными, а не обязательное наличие отдельного физического сервера.
Уровни изоляции #
От наиболее сильной к наиболее компромиссной:
| Вариант | Изоляция | Стоимость | Оценка |
|---|---|---|---|
| Отдельный DB-кластер на сервис | Максимальная | Высокая | Нужен не всегда |
| Отдельная база в общем кластере | Высокая | Средняя | Хороший компромисс |
| Отдельная схема в общей базе | Достаточная | Низкая | Часто оптимальный вариант |
| Отдельные таблицы без схем | Средняя | Низкая | Допустимо при строгих правах |
| Общие таблицы с прямым доступом | Низкая | Сначала низкая | Обычно плохой вариант |
Наиболее практичный вариант #
При ограниченных ресурсах:
Один PostgreSQL
├── order_schema
├── payment_schema
└── notification_schema
Для каждого сервиса создаётся отдельная роль:
CREATE ROLE order_service LOGIN PASSWORD '...';
CREATE ROLE payment_service LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA order_schema TO order_service;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA order_schema
TO order_service;
При этом order_service не получает доступ к payment_schema.
Microsoft описывает аналогичную схему: отдельная схема и отдельная роль для каждого микросервиса позволяют сохранить разделение при использовании общего PostgreSQL-кластера.
Что должно оставаться независимым #
Даже на общей физической БД у каждого сервиса должны быть собственные:
таблицы или схема;
учётная запись БД;
миграции;
репозитории и ORM-модели;
право изменять данные;
владелец бизнес-правил;
жизненный цикл схемы.
Например:
Order Service владеет:
order_schema.orders
order_schema.order_items
Payment Service владеет:
payment_schema.payments
payment_schema.refunds
Order Service не должен выполнять:
SELECT *
FROM payment_schema.payments;
Вместо этого:
Order Service ──API──> Payment Service
или:
Payment Service ──PaymentSucceeded──> брокер сообщений
Принцип Database per Service означает прежде всего приватность данных и слабую связанность. Он не требует отдельного сервера для каждой базы.
Чем вы жертвуете при общем кластере #
Даже при отдельных схемах сохраняется инфраструктурная связанность.
Общая точка отказа #
PostgreSQL недоступен
↓
Order Service недоступен
Payment Service недоступен
Notification Service недоступен
Конкуренция за ресурсы #
Один сервис может перегрузить:
CPU;
диск и IOPS;
буферный кеш;
число подключений;
WAL;
репликацию.
Общие эксплуатационные операции #
Обновление PostgreSQL, резервное копирование или проблема репликации затрагивает сразу несколько сервисов.
Ограниченный выбор технологий #
Нельзя независимо перевести один сервис на MongoDB, Cassandra или другое хранилище без отдельной миграции.
Поэтому общий кластер уменьшает инфраструктурную независимость, но при правильной изоляции не обязательно разрушает архитектурную независимость.
Когда отдельная схема является нормальным решением #
Такой компромисс разумен, когда:
проект небольшой или средний;
сервисов немного;
нагрузка умеренная;
одна команда обслуживает инфраструктуру;
отдельные managed-базы слишком дороги;
нет разных требований по безопасности и compliance;
нет необходимости масштабировать базы независимо;
отказ общего DB-кластера допустим для всей системы.
AWS рекомендует отдельное хранилище на сервис, когда требуется раздельное масштабирование, повышенная устойчивость либо разные требования безопасности и соответствия. Из этого следует, что при отсутствии таких требований общая инфраструктура с приватными схемами может быть обоснованным компромиссом.
Когда общий кластер уже опасен #
Лучше разделить базы физически, если:
Payment Service требует повышенной защиты;
один сервис создаёт основную нагрузку на I/O;
сервис должен масштабироваться отдельно;
для сервисов нужны разные версии или настройки СУБД;
нужны независимые backup и restore;
разные требования к доступности;
разные команды администрируют данные;
компрометация одной DB-роли не должна затрагивать другие домены.
Особенно это касается:
платежей;
балансов;
персональных и медицинских данных;
аудита;
систем с разными регуляторными требованиями.
Чего делать не следует #
Общие таблицы #
Service A и Service B оба изменяют customers
Это создаёт:
связанность миграций;
обход бизнес-правил владельца данных;
конфликты блокировок;
невозможность независимого релиза;
неясную ответственность;
широкие права доступа.
AWS предупреждает, что при shared database нужно избегать таблиц, в которые пишут несколько сервисов, а любые изменения схемы приходится делать совместимыми со всеми версиями зависимых сервисов.
Общий пользователь БД #
Плохо:
DATABASE_USER=application
DATABASE_PASSWORD=common-password
для всех сервисов.
Тогда невозможно:
ограничить права;
установить реального инициатора запросов;
отозвать доступ одного сервиса;
оценить последствия компрометации.
Межсервисные JOIN
#
Плохо:
SELECT *
FROM order_schema.orders o
JOIN payment_schema.payments p
ON p.order_id = o.id;
Такой запрос связывает схемы и жизненные циклы сервисов. Для межсервисных данных применяют API Composition, события, CQRS/read-модели или денормализацию. AWS рекомендует отдельные паттерны для запросов, охватывающих несколько сервисных хранилищ.
Если ресурсов совсем мало #
Если невозможно обеспечить даже:
отдельные схемы;
отдельные DB-роли;
независимые миграции;
запрет прямого доступа к чужим таблицам;
то стоит рассмотреть модульный монолит, а не создавать формальные микросервисы с общей моделью данных.
Модульный монолит:
один процесс;
одна БД;
явные модули;
явное владение таблицами;
простые локальные транзакции.
Это обычно безопаснее и проще, чем распределённый монолит:
много сервисов
+
сетевая сложность
+
общая БД
+
общие таблицы
+
совместные релизы
Практическая рекомендация #
При ограниченном бюджете:
1. Использовать один PostgreSQL-кластер.
2. Создать отдельную схему или базу на сервис.
3. Создать отдельную DB-роль на сервис.
4. Запретить доступ к чужим схемам.
5. Вести миграции отдельно.
6. Не использовать внешние ключи между схемами сервисов.
7. Не выполнять межсервисные JOIN.
8. Общаться через API или события.
9. Установить лимиты подключений и ресурсов.
10. Заранее предусмотреть возможность вынести схему
на отдельный сервер.
Итоговая граница:
Можно разделять сервер:
Order DB и Payment DB работают в одном PostgreSQL.
Нельзя разделять владение:
Order Service не изменяет данные Payment Service.
Физическая база на каждый сервис — оптимизация изоляции. Частное владение данными — фундаментальное требование. При нехватке ресурсов сокращать следует количество серверов, а не размывать владельцев таблиц и бизнес-правил.
8. Какие существуют способы решения проблемы распределённых транзакций? #
Главная проблема #
Распределённая транзакция возникает, когда одна бизнес-операция должна изменить данные в нескольких независимых местах:
создать заказ
списать деньги
уменьшить остаток товара
создать доставку
Если всё лежит в одной БД, это обычно решается обычной ACID-транзакцией. Если данные находятся в разных сервисах, разных БД или ещё и в брокере сообщений, обычной локальной транзакции уже недостаточно.
Главный вопрос: что делать, если часть операции уже успешно выполнена, а следующая часть упала.
1. Не делать распределённую транзакцию #
Самый надёжный вариант — спроектировать систему так, чтобы одна строгая транзакция не пересекала границы нескольких сервисов.
Например, вместо этого:
OrderService пишет в orders
PaymentService пишет в payments
InventoryService пишет в stock
и всё это должно быть одной ACID-транзакцией
лучше, когда критически связанные данные находятся внутри одного сервиса или одной БД:
OrderService владеет заказом и его статусом
PaymentService отдельно проводит платёж
InventoryService отдельно резервирует товар
Тогда каждый сервис делает свою локальную транзакцию, а общая согласованность достигается через статусы, события и компенсации. В микросервисной архитектуре это нормальный подход: каждый сервис владеет своими данными, а согласованность между сервисами становится отдельной архитектурной задачей. AWS описывает Saga как способ обеспечивать целостность в распределённых транзакциях через последовательность локальных транзакций и компенсации.
2. Two-Phase Commit / 2PC / XA #
Это классический вариант настоящей распределённой ACID-транзакции.
Идея:
Фаза 1: prepare
координатор спрашивает все участники:
"Вы готовы зафиксировать изменения?"
Фаза 2: commit / rollback
если все ответили "готов" — координатор отправляет commit
если кто-то не готов — координатор отправляет rollback
Примерно:
Transaction Coordinator
|
| prepare
v
DB1 DB2 DB3
если все OK:
Transaction Coordinator
|
| commit
v
DB1 DB2 DB3
PostgreSQL поддерживает двухфазный commit через PREPARE TRANSACTION, COMMIT PREPARED и ROLLBACK PREPARED; документация отдельно указывает, что 2PC предназначен для внешних transaction manager’ов. (
PostgreSQL) MySQL поддерживает XA-транзакции, где несколько transactional resources могут участвовать в одной global transaction.
Плюсы:
строгая атомарность
понятная модель "всё или ничего"
подходит для некоторых enterprise-систем
Минусы:
сложно администрировать
участники могут держать блокировки
при сбоях возможны подвисшие prepared transactions
хуже масштабируется
плохо подходит для долгих бизнес-процессов
не все хранилища и брокеры нормально поддерживают XA/2PC
На практике 2PC используют осторожно. В микросервисах его часто избегают, потому что он сильно связывает сервисы и инфраструктуру.
3. Saga #
Saga — самый частый подход для микросервисов.
Смысл: большая распределённая операция разбивается на цепочку локальных транзакций. Каждая локальная транзакция фиксируется в своей БД. Если один из шагов падает, запускаются компенсирующие действия. AWS описывает Saga именно как последовательность локальных транзакций, где при ошибке выполняются compensating transactions для отката уже сделанных изменений.
Пример покупки:
1. Создать заказ → order_created
2. Зарезервировать товар → stock_reserved
3. Списать оплату → payment_charged
4. Подтвердить заказ → order_confirmed
Если оплата не прошла:
payment_failed
→ отменить резерв товара
→ перевести заказ в статус cancelled
Важно: это не rollback в смысле SQL-транзакции. Компенсация — это новая бизнес-операция, которая логически отменяет предыдущий шаг.
Например:
не "откатить INSERT payment"
а "создать refund / отменить платёж / снять резерв"
Есть два основных варианта Saga.
4. Saga Choreography #
При choreography нет центрального координатора. Сервисы реагируют на события друг друга.
OrderService:
создал заказ
опубликовал OrderCreated
InventoryService:
получил OrderCreated
зарезервировал товар
опубликовал StockReserved
PaymentService:
получил StockReserved
списал деньги
опубликовал PaymentCompleted
AWS описывает saga choreography как вариант, где участники публикуют и обрабатывают события, а при сбое выполняются compensatory transactions для восстановления согласованности.
Плюсы:
нет единого координатора
сервисы слабо связаны
хорошо подходит для event-driven архитектуры
Минусы:
сложнее понимать общий процесс
бизнес-логика размазана по сервисам
сложнее отлаживать
при росте количества событий легко получить хаос
Подходит, когда процесс простой и хорошо ложится на события.
5. Saga Orchestration #
При orchestration есть центральный координатор — orchestrator. Он явно управляет шагами процесса.
OrderSagaOrchestrator:
1. вызвать InventoryService.reserve()
2. вызвать PaymentService.charge()
3. вызвать DeliveryService.create()
4. если ошибка — вызвать компенсации
Схема:
Orchestrator
├── reserve stock
├── charge payment
├── create delivery
└── compensate on failure
AWS описывает saga orchestration как подход с центральным координатором, который помогает сохранять целостность распределённой транзакции между несколькими сервисами.
Плюсы:
процесс виден в одном месте
проще контролировать порядок шагов
проще отлаживать сложные сценарии
удобно хранить состояние процесса
Минусы:
orchestrator становится важным компонентом
сервисы сильнее завязаны на команды orchestrator'а
нужно аккуратно проектировать retry, timeout, compensation
Для сложных бизнес-процессов orchestration обычно понятнее, чем choreography.
6. Transactional Outbox #
Outbox решает частую проблему: нужно одновременно изменить данные в БД и отправить событие в брокер.
Плохой вариант:
1. записали заказ в БД
2. отправили событие в Kafka/RabbitMQ
Проблема: между шагами может быть сбой.
БД обновилась, но событие не отправилось
или событие отправилось, но БД откатилась
Transactional Outbox решает это так:
в одной локальной транзакции:
1. записать бизнес-данные
2. записать событие в outbox-таблицу
после commit:
отдельный процесс читает outbox
и публикует событие в брокер
Пример:
BEGIN;
INSERT INTO orders (id, status)
VALUES (100, 'created');
INSERT INTO outbox_events (event_type, payload)
VALUES ('OrderCreated', '{"order_id": 100}');
COMMIT;
AWS описывает transactional outbox как паттерн для решения проблемы dual write, когда одна операция включает запись в БД и отправку сообщения/события, а сбой одной из операций может привести к несогласованности.
Плюсы:
не нужен 2PC между БД и брокером
событие не теряется после commit в БД
хорошо сочетается с Saga
Минусы:
нужен отдельный publisher
нужно чистить outbox
возможны дубли событий
потребители должны быть идемпотентными
AWS отдельно указывает, что при outbox возможны дубли событий, поэтому consuming service рекомендуется делать идемпотентным.
7. Inbox / Idempotent Consumer #
В распределённых системах часто нельзя полагаться на то, что сообщение будет обработано ровно один раз. При retry одно и то же событие может прийти повторно.
Поэтому потребитель должен быть идемпотентным: повторная обработка того же сообщения не должна менять результат сверх первого применения. Microservices.io описывает Idempotent Consumer именно так: при at-least-once delivery потребитель может получить одно сообщение несколько раз, поэтому результат повторной обработки должен быть таким же, как после одной обработки.
Пример inbox-таблицы:
CREATE TABLE processed_messages (
message_id uuid PRIMARY KEY,
processed_at timestamp NOT NULL DEFAULT now()
);
Логика:
1. получил сообщение
2. проверил message_id
3. если уже обработано — пропустить
4. если новое — выполнить действие
5. записать message_id как обработанный
Это особенно важно вместе с Outbox и Saga, потому что retry и повторная доставка сообщений — нормальная часть такой архитектуры.
8. TCC: Try / Confirm / Cancel #
TCC — это подход, где операция явно делится на три шага:
Try — предварительно зарезервировать ресурс
Confirm — окончательно подтвердить
Cancel — отменить резерв
Пример с оплатой и складом:
Try:
зарезервировать товар
заблокировать сумму на карте
Confirm:
окончательно списать деньги
подтвердить списание товара
Cancel:
снять резерв товара
разблокировать деньги
TCC хорошо подходит, когда бизнес-операция естественно поддерживает резервирование. Например: бронь, лимиты, билеты, складские остатки, платёжная авторизация.
Минус: не каждую операцию можно красиво разложить на Try/Confirm/Cancel. Нужно заранее проектировать модель данных под резервы и отмены.
9. Компенсирующие операции #
Компенсации — это не отдельная технология, а общий принцип.
Примеры:
деньги списаны ошибочно → сделать refund
товар зарезервирован → снять резерв
заказ создан → перевести в cancelled
доставка создана → отменить доставку
баланс начислен → создать обратную операцию списания
Компенсация нужна потому, что в распределённой системе часто нельзя физически “откатить всё назад”, как в одной SQL-транзакции. Уже отправленное письмо, внешний платёж или событие в другом сервисе нельзя просто откатить через ROLLBACK.
Поэтому правильнее хранить состояние процесса:
pending
reserved
paid
confirmed
cancelled
compensation_failed
А не надеяться, что все шаги всегда завершатся успешно.
10. Eventual Consistency #
Во многих системах вместо строгой мгновенной согласованности используют eventual consistency.
Это значит:
сразу после операции разные сервисы могут видеть разные состояния,
но через некоторое время система должна прийти к согласованному состоянию
Например:
OrderService уже создал заказ
PaymentService ещё не обработал оплату
InventoryService ещё не обновил резерв
В этот момент заказ может быть в статусе:
payment_pending
Потом события доедут, и статус станет:
confirmed
или:
cancelled
Saga, Outbox, Inbox, retry и idempotency обычно работают именно в модели eventual consistency, а не в модели одной глобальной ACID-транзакции.
11. CDC вместо прямой отправки событий #
CDC — Change Data Capture. Идея: приложение пишет только в свою БД, а изменения из БД потом считываются из журнала изменений и публикуются наружу.
Упрощённо:
Service → Database → CDC → Kafka/Event Bus → Other Services
Это похоже на Outbox, но события могут забираться не ручным polling’ом outbox-таблицы, а через лог изменений БД.
Плюсы:
приложение не делает dual write
можно надёжно публиковать события после commit
меньше риска потерять событие
Минусы:
усложняется инфраструктура
нужно следить за схемой событий
сложнее локальная разработка и отладка
Что выбрать на практике #
Для обычной микросервисной архитектуры чаще всего используют такую комбинацию:
1. Локальная транзакция внутри каждого сервиса.
2. Transactional Outbox для надёжной публикации событий.
3. Saga для длинного бизнес-процесса.
4. Idempotent Consumer / Inbox для защиты от дублей.
5. Retry + timeout + dead-letter queue.
6. Компенсирующие операции вместо глобального rollback.
2PC/XA имеет смысл, когда действительно нужна строгая атомарность между несколькими transactional resources и вся инфраструктура это поддерживает. Но для микросервисов, очередей, внешних API, платежей и долгих бизнес-процессов чаще выбирают Saga/Outbox/компенсации, потому что они лучше переживают частичные сбои и не требуют удерживать одну глобальную транзакцию.