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

Multi-tenant архитектура и Strangler Fig pattern

Зачем знать на Middle 3: SaaS-продукты живут в multi-tenant архитектуре, и одна утечка данных между tenants — repunctional конец компании. Tech lead должен понимать модели изоляции (shared everything → dedicated infra) и их trade-offs: cost vs security vs blast radius. Параллельно — Strangler Fig pattern (Martin Fowler) для legacy modernization, без которого «переписать монолит на микросервисы» превращается в годовой проект-зомби.

  1. Концепция: что такое multi-tenant и зачем strangler
  2. Глубже: модели изоляции, RLS, tenant routing, миграции
  3. Gotchas: data leaks, noisy neighbors, strangler pitfalls
  4. Real cases: Atlassian, Salesforce, Amazon, GitHub
  5. Вопросы (20+)
  6. Practice: построить multi-tenant API
  7. Источники

Single-tenant:

  • Каждый клиент = отдельная инсталляция.
  • Свой инфра-стек (или контейнер) на клиента.
  • Pros: isolation, customization, compliance простая.
  • Cons: операционно дорого (каждый upgrade × N клиентов).

Multi-tenant:

  • Один общий код+инфра обслуживает много tenants.
  • Изоляция логическая.
  • Pros: cost-efficient, единый release.
  • Cons: всё сложно (security, performance, customization).
Single-tenant:
[Tenant A] → [App-A] → [DB-A]
[Tenant B] → [App-B] → [DB-B]
[Tenant C] → [App-C] → [DB-C]
Multi-tenant:
[Tenant A] ─┐
[Tenant B] ─┼→ [Shared App] → [Shared DB with tenant_id]
[Tenant C] ─┘
  • Cost: один деплой обслуживает 1000 клиентов вместо 1000 деплоев.
  • Operations: одна версия кода, единый patch.
  • Resource sharing: утилизация инфраструктуры выше.

Когда не подходит:

  • Compliance не позволяет (банки, healthcare без специальной модели).
  • Очень разные SLA per tenant.
  • Сильная кастомизация.

Старая система работает, но не подходит для будущего (язык, архитектура, объёмы). Полный rewrite = high risk. Strangler = постепенная миграция, без перерыва сервиса.


Меньше isolation Больше isolation
─────────────────────────────────────────────────────────────────────────────►
Shared everything Shared DB / separate Separate DB Separate infra
schemas per tenant per tenant
─────────────────────────────────────────────────────────────────────────────►
Меньше cost Больше cost

Один код, одна DB, одна schema, одна таблица. Различаются по tenant_id.

CREATE TABLE orders (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
customer_id UUID NOT NULL,
total DECIMAL,
created_at TIMESTAMPTZ
);
CREATE INDEX ON orders (tenant_id);

Pros:

  • Самый дешёвый.
  • Просто scaling.
  • Аналитика across tenants.

Cons:

  • Все queries обязаны WHERE tenant_id.
  • Один баг → утечка между tenants.
  • Hard to “delete tenant” (тысячи строк).
  • Noisy neighbor.

Подход: RLS обязателен (см. ниже).

Postgres schemas (или MySQL databases). Каждый tenant — schema tenant_<uuid>.

CREATE SCHEMA tenant_abc;
CREATE TABLE tenant_abc.orders (...);
-- В runtime:
SET search_path TO tenant_abc, public;
SELECT * FROM orders; -- читает из tenant_abc.orders

Pros:

  • Логически разделено.
  • Легче удалить tenant (DROP SCHEMA).
  • Можно делать per-schema backup.

Cons:

  • Migration на N schemas (1000 schemas × migration = долго).
  • Connection pool: search_path на сессии.
  • Аналитика across tenants сложнее.

Каждый tenant — отдельная БД (на одном или разных Postgres-серверах).

Pros:

  • Сильная изоляция.
  • Per-tenant tuning (indexes, configs).
  • Per-tenant restore.

Cons:

  • 1000 БД = операционно сложно.
  • Connections × N (pgBouncer обязателен).
  • Дороже.

Каждый enterprise клиент — свой kuberntetes namespace или даже свой кластер.

Pros:

  • Максимальная изоляция (compliance).
  • Per-tenant compute.

Cons:

  • Дорого.
  • Операционный кошмар (если без правильного automation).

Гибрид (часто):

  • 95% клиентов — shared everything.
  • 5% enterprise — dedicated.

Как определить «какой tenant пришёл»?

1. Subdomain (acme.app.com):

host := r.Host // acme.app.com
tenant := strings.Split(host, ".")[0]

✅ Понятно клиенту, ✅ branding, ❌ cert wildcard.

2. Path prefix (/tenants/acme/...):

mux.HandleFunc("/tenants/{tenant}/users", handler)

✅ Просто, ❌ дублирование в URL.

3. Header (X-Tenant-ID: abc): ✅ Чисто, ❌ легко забыть, ❌ кеши.

4. JWT claim:

type Claims struct {
UserID string `json:"sub"`
TenantID string `json:"tenant_id"`
jwt.RegisteredClaims
}

✅ Cryptographically signed, ✅ tenant — часть auth. Самый популярный для B2B SaaS.

5. API key prefix: sk_live_acme_xxxxxxxxxx — первый сегмент = tenant.

type contextKey string
const tenantKey contextKey = "tenant"
func TenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
claims, err := parseJWT(token)
if err != nil {
http.Error(w, "unauthorized", 401)
return
}
if claims.TenantID == "" {
http.Error(w, "no tenant", 403)
return
}
ctx := context.WithValue(r.Context(), tenantKey, claims.TenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func TenantFromContext(ctx context.Context) string {
if v, ok := ctx.Value(tenantKey).(string); ok {
return v
}
return ""
}

Что: Postgres-feature, который автоматически фильтрует строки по политике.

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- В runtime каждой сессии:
SET LOCAL app.current_tenant = 'abc-123-uuid';
-- Теперь все queries видят только строки своего tenant:
SELECT * FROM orders; -- автоматически WHERE tenant_id = 'abc...'

Pros:

  • Защита на уровне БД — даже если в коде забыли WHERE.
  • Прозрачно для приложения.

Cons:

  • Нужно проставлять SET LOCAL на каждую транзакцию.
  • Влияет на perfomance (extra filter).
  • Сложно с pgBouncer transaction pooling.

Go с RLS (через pgx):

func (r *OrdersRepo) FindOrders(ctx context.Context) ([]Order, error) {
tenant := TenantFromContext(ctx)
tx, err := r.pool.Begin(ctx)
if err != nil { return nil, err }
defer tx.Rollback(ctx)
// Установить tenant для всей транзакции
_, err = tx.Exec(ctx, "SET LOCAL app.current_tenant = $1", tenant)
if err != nil { return nil, err }
rows, err := tx.Query(ctx, "SELECT id, total FROM orders")
// Автоматически фильтруется RLS!
// ...
}

Если RLS не подходит (например, NoSQL, легаси), всё на стороне приложения:

func (r *OrdersRepo) FindOrders(ctx context.Context) ([]Order, error) {
tenant := TenantFromContext(ctx)
if tenant == "" {
return nil, errors.New("no tenant in context")
}
rows, err := r.db.QueryContext(ctx,
"SELECT id, total FROM orders WHERE tenant_id = $1", tenant)
// ...
}

Best practice: wrap всё в repository layer, где tenant — обязательный параметр.

Shared pool:

  • Все tenants используют один pgxpool.
  • Per-query — SET tenant.

Per-tenant pool:

  • Map[tenant_id]*pgxpool.
  • Каждый tenant имеет свои connections.
  • Лучше для performance/isolation (нет cross-contamination через session vars).
  • Дороже по соединениям.

Hybrid: enterprise tenants — свой pool, остальные — shared.

Один tenant генерит 90% нагрузки → ломает всем.

Решения:

  • Rate limiting per tenant: Redis-based token bucket с key ratelimit:tenant:<id>.
  • Resource quota: max QPS, max storage per tenant.
  • Bulkhead pattern: dedicated thread pool / DB connections для VIP tenants.
  • Cost-based: tier (free/pro/enterprise) с разными limits.

Сценарий: один tenant вырос — мигрируем в dedicated DB.

Steps:

  1. Создать dedicated DB.
  2. Dual write: каждое изменение пишем в shared + dedicated (через outbox / CDC).
  3. Backfill historical data (pg_dump + restore).
  4. Switch reads на dedicated.
  5. Switch writes на dedicated.
  6. Удалить tenant из shared.

Сложности:

  • Pending events, in-flight transactions.
  • Refs from other tenants (если есть) — обычно нет (изоляция).
type TenantConfig struct {
TenantID string
Plan string // "free", "pro", "enterprise"
MaxUsers int
Features map[string]bool
CustomSettings map[string]any
}
// Хранится в БД, кешируется в Redis.

В runtime: middleware подгружает config в context.

Tenant решил уйти → удалить все его данные.

Стратегии:

  • Soft delete: deleted_at field, через N дней — purge.
  • Hard delete: DELETE FROM orders WHERE tenant_id = ? — cascade.
  • Schema drop: если separate schemas — DROP SCHEMA tenant_X CASCADE.
  • DB drop: если separate DB — drop database.

Не забыть:

  • Backups (anonymize / purge по retention).
  • Search indexes (Elasticsearch).
  • S3 файлы.
  • Аналитика (S3 parquet).

Martin Fowler ввёл в 2004 термин на основе strangler fig tree — дерева, которое обвивает другое и постепенно его душит.

Phase 1: legacy работает, новое — рядом, нет трафика
[Legacy System (100%)] [New System (0%)]
Phase 2: новое получает часть трафика
[Legacy System (80%)] [New System (20%)]
Proxy маршрутизирует
Phase 3: миграция
[Legacy System (20%)] [New System (80%)]
Phase 4: финал
[Legacy System (0%, off)] [New System (100%)]
  • Монолит → микросервисы.
  • Legacy stack (PHP, Ruby) → новый (Go).
  • Старая БД → новая (Oracle → Postgres).
  • On-prem → cloud.
  • Custom auth → OAuth2/OIDC.

1. Identify capability

  • Выбрать функцию, которую перенесём (не «весь сервис целиком»).
  • Маленькая, изолированная, нечасто меняется.
  • Пример: /api/notifications/send-email.

2. Build new alongside

  • Реализовать новую версию (Go-сервис, например).
  • Обеспечить feature parity с legacy.
  • Тесты.

3. Route traffic via proxy

  • API gateway (Envoy, Kong, Nginx).
  • Routing rule: % трафика на new.
  • Feature flag для конкретных users.
# Envoy route
- match: { prefix: "/api/notifications" }
route:
weighted_clusters:
clusters:
- name: legacy
weight: 90
- name: new-go-service
weight: 10

4. Compare / shadow

  • Опционально: посылать запросы в оба, сравнивать ответы.
  • Логировать разницу.
  • Только после успехов — увеличить вес.

5. Gradually move all traffic

  • 10% → 25% → 50% → 75% → 100%.
  • Monitoring, ready to rollback.

6. Retire old

  • Старый код выпиливаем.
  • Старые ресурсы (БД, серверы) — decommission.

Routing:

  • Envoy — modern, дёшево для % routing.
  • Kong — gateway с plugins.
  • Nginx — простой, классика.
  • HAProxy — L7 routing.
  • Istio (service mesh) — даёт продвинутые routing rules.

Feature flags:

  • LaunchDarkly — SaaS.
  • Unleash — open source.
  • ConfigCat — SaaS.
  • Custom через Postgres + Redis.

Observability:

  • Metrics: latency, error rate per cluster.
  • Logs: routing decisions.
  • Traces: full pipeline через gateway → service.
import "github.com/Unleash/unleash-client-go/v4"
unleash.Initialize(
unleash.WithUrl("https://unleash.acme.com/api/"),
unleash.WithAppName("api-gateway"),
)
func sendEmailHandler(w http.ResponseWriter, r *http.Request) {
userID := UserFromContext(r.Context())
if unleash.IsEnabled("new-email-service",
unleash.WithContext(unleash.Context{UserId: userID})) {
forwardToNewService(w, r)
} else {
forwardToLegacy(w, r)
}
}

При интеграции с legacy:

  • Domain в новом сервисе — clean.
  • Adapter layer обращается к legacy и переводит legacy-модели в новые.
// Anti-corruption layer
type LegacyUserAdapter struct {
legacyClient *legacy.Client
}
func (a *LegacyUserAdapter) GetUser(ctx context.Context, id string) (*newdomain.User, error) {
raw, err := a.legacyClient.FetchUser(id) // legacy API, JSON XML что угодно
if err != nil { return nil, err }
return &newdomain.User{
ID: raw.UserID, // legacy: UserID, new: ID
Email: strings.ToLower(raw.EMailAddress),
// ...нормализация
}, nil
}

Это защищает новый код от грязи legacy.

Если у нового и legacy свои БД — нужен sync.

Подходы:

  • Dual write: writes в оба. Риск рассинхрона.
  • CDC: Debezium от legacy → синхронизация в new.
  • Outbox pattern: writes + event → consumer обновляет other.
  • Read-only sync: legacy остаётся source of truth, new — replica.

1. Strangler становится новым монолитом. Если просто переписали без рефакторинга — получили тот же монолит на Go вместо Python.

2. Затягивание migration. «Через 6 месяцев допилим». Через 3 года всё ещё legacy.

3. Параллельная разработка. Legacy продолжают допиливать. New догоняет, но никогда не догонит. → Заморозить feature в legacy.

4. Insufficient observability. Не видно, что % traffic идёт куда. Не можно понять regression.

5. Compatibility burden. Новый сервис вынужден копировать quirks legacy (даже баги). Иногда нужно — для backward compatibility.

"Давайте за полгода всё перепишем!"
[12 месяцев работы]
"Где-то 60% готово..."
[18 месяцев]
"Ещё надо feature parity..."
[24 месяца, проект отменён]

Joel Spolsky — Things You Should Never Do, Part I (2000) — про rewrite Netscape, который убил компанию. Strangler — антидот.


  • Shared services + separate schemas в Postgres.
  • Per-product (Jira/Confluence) — отдельные стэки.
  • Enterprise tier — dedicated infra.
  • 250K+ tenants на shared platform.
  • Один codebase, метаданные per tenant в Oracle.
  • Heavy use of partitioning.
  • Customization via metadata, не код.
  • Pod architecture: группы tenants в pods.
  • Каждый pod — отдельный shard.
  • Migration tenants между pods.
  • Multi-tenant shared.
  • GitHub Enterprise Server — dedicated.
  • GitHub AE (Azure) — dedicated cloud.
  • Монолит retail → микросервисы (с начала 2000-х).
  • Команды двух «пицц», независимое деплоймент.
  • API gateway routes.
  • 10+ лет миграции, ещё legacy куски остались.
  • Mainframe billing → cloud-based.
  • Strangler: гetway маршрутизирует часть customers.
  • Внутри Netflix есть документация про этот процесс.
  • Очень осторожно: GitHub Rails monolith ещё есть в 2026.
  • Выделяют отдельные сервисы (Pages, Actions).
  • Strangler через GraphQL gateway.
  • Geographic regions (NA, EU, APAC).
  • Внутри региона — multi-tenant.
  • Government clouds — completely separate (compliance).

  1. Чем single-tenant отличается от multi-tenant?
  2. Какие 4 модели изоляции в multi-tenant? Pros/cons каждой.
  3. Какие способы tenant identification ты знаешь?
  4. Что такое Row-Level Security в Postgres? Как использовать?
  5. Какие минусы RLS?
  6. Как реализовать tenant context в Go middleware?
  7. Что такое noisy neighbor? Как решать?
  8. Опиши процесс tenant migration (shared → dedicated).
  9. Как соблюдать GDPR в multi-tenant (right-to-erasure)?
  10. Чем shared pool отличается от per-tenant pool?
  11. Что такое Strangler Fig pattern? Кто автор?
  12. Опиши 4 фазы Strangler migration.
  13. Какие tools используют для routing в Strangler?
  14. Что такое feature flag и зачем в Strangler?
  15. Что такое anti-corruption layer?
  16. Как синхронизировать данные между legacy и new?
  17. Какие common pitfalls Strangler pattern?
  18. Почему «big rewrite» — анти-паттерн?
  19. Опиши пример Strangler из real-world (Amazon/Netflix/etc).
  20. Как выбирать «первую функцию» для миграции в Strangler?
  21. Как обеспечить наблюдаемость во время migration?
  22. Что такое shadow traffic?
  23. Как защитить multi-tenant систему от cross-tenant утечек?
  24. Чем pod architecture (Shopify) отличается от shared everything?
  25. Как делать backup/restore в multi-tenant?

  1. JWT с tenant_id.
  2. Middleware extracts tenant в context.
  3. Repository layer — обязательный tenantID параметр.
  4. Endpoint /orders — возвращает только orders своего tenant.
  5. Тест: попытка передать tenantID другого — должна fail.
  1. Создать таблицу orders с tenant_id.
  2. Включить RLS, политика по current_setting.
  3. На Go: при каждой транзакции SET LOCAL app.current_tenant.
  4. Тест: без SET — нет результата.
  1. Redis token bucket per tenant_id.
  2. Middleware: проверяет, если превышен → 429.
  3. Per-tenant limits (free: 10 QPS, pro: 100 QPS).
  1. Поднять legacy сервис (Python Flask).
  2. Поднять новый Go сервис с тем же API.
  3. Envoy: routing 90/10 → 50/50 → 100/0.
  4. Метрики latency обоих clusters.
  1. Реализуй feature flag через Unleash (или собственный).
  2. Endpoint /notifications — два implementation.
  3. Toggle: для users из конкретной группы — новая логика.
  4. Метрики per-implementation.

  1. Martin Fowler — StranglerFigApplication: https://martinfowler.com/bliki/StranglerFigApplication.html
  2. Sam Newman — Monolith to Microservices (книга, главы про Strangler)
  3. AWS — SaaS Lens — multi-tenancy patterns: https://docs.aws.amazon.com/wellarchitected/latest/saas-lens/
  4. Software Engineering at Google (главы про migration patterns)
  5. Joel Spolsky — Things You Should Never Do: https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/
  6. PostgreSQL Row-Level Security: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
  7. Shopify Engineering — pod architecture posts
  8. Tailscale Multi-tenancy guide: https://tailscale.com/learn/multi-tenancy
  9. Stripe Atlas — multi-tenant patterns
  10. Atlassian Cloud architecture posts
  11. Salesforce multi-tenant whitepapers (легаси, но интересно)
  12. Building Multi-Tenant SaaS Architectures — Tod Golding (O’Reilly)
  13. Envoy weighted clusters: https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/
  14. Unleash docs: https://docs.getunleash.io/
  15. LaunchDarkly best practices: https://launchdarkly.com/blog/
  16. Microsoft — Multi-tenant SaaS patterns (Azure docs)
  17. Martin Fowler — Patterns of Enterprise Application Architecture

Резюме. Multi-tenancy — это про правильный баланс между cost и isolation. От shared everything (дёшево, требует RLS) до dedicated infra (дорого, безопасно). Tenant identification через JWT — best practice для B2B SaaS. Postgres RLS — мощный инструмент, но требует дисциплины. Strangler Fig pattern — единственный устойчивый способ модернизировать legacy. Tech lead понимает: маленькие incremental миграции, observability, feature flags, anti-corruption layer. «Big rewrite» — рецепт катастрофы.