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, без которого «переписать монолит на микросервисы» превращается в годовой проект-зомби.
Содержание
Заголовок раздела «Содержание»- Концепция: что такое multi-tenant и зачем strangler
- Глубже: модели изоляции, RLS, tenant routing, миграции
- Gotchas: data leaks, noisy neighbors, strangler pitfalls
- Real cases: Atlassian, Salesforce, Amazon, GitHub
- Вопросы (20+)
- Practice: построить multi-tenant API
- Источники
1. Концепция
Заголовок раздела «1. Концепция»1.1 Single-tenant vs Multi-tenant
Заголовок раздела «1.1 Single-tenant vs Multi-tenant»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] ─┘1.2 Зачем многоарендность
Заголовок раздела «1.2 Зачем многоарендность»- Cost: один деплой обслуживает 1000 клиентов вместо 1000 деплоев.
- Operations: одна версия кода, единый patch.
- Resource sharing: утилизация инфраструктуры выше.
Когда не подходит:
- Compliance не позволяет (банки, healthcare без специальной модели).
- Очень разные SLA per tenant.
- Сильная кастомизация.
1.3 Зачем Strangler
Заголовок раздела «1.3 Зачем Strangler»Старая система работает, но не подходит для будущего (язык, архитектура, объёмы). Полный rewrite = high risk. Strangler = постепенная миграция, без перерыва сервиса.
2. Глубже: Multi-tenant
Заголовок раздела «2. Глубже: Multi-tenant»2.1 Модели изоляции
Заголовок раздела «2.1 Модели изоляции»Меньше isolation Больше isolation─────────────────────────────────────────────────────────────────────────────►Shared everything Shared DB / separate Separate DB Separate infra schemas per tenant per tenant─────────────────────────────────────────────────────────────────────────────►Меньше cost Больше cost2.2 Модель 1: Shared everything
Заголовок раздела «2.2 Модель 1: Shared everything»Один код, одна 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 обязателен (см. ниже).
2.3 Модель 2: Shared DB, separate schemas
Заголовок раздела «2.3 Модель 2: Shared DB, separate schemas»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.ordersPros:
- Логически разделено.
- Легче удалить tenant (
DROP SCHEMA). - Можно делать per-schema backup.
Cons:
- Migration на N schemas (1000 schemas × migration = долго).
- Connection pool: search_path на сессии.
- Аналитика across tenants сложнее.
2.4 Модель 3: Separate DB per tenant
Заголовок раздела «2.4 Модель 3: Separate DB per tenant»Каждый tenant — отдельная БД (на одном или разных Postgres-серверах).
Pros:
- Сильная изоляция.
- Per-tenant tuning (indexes, configs).
- Per-tenant restore.
Cons:
- 1000 БД = операционно сложно.
- Connections × N (pgBouncer обязателен).
- Дороже.
2.5 Модель 4: Separate infrastructure
Заголовок раздела «2.5 Модель 4: Separate infrastructure»Каждый enterprise клиент — свой kuberntetes namespace или даже свой кластер.
Pros:
- Максимальная изоляция (compliance).
- Per-tenant compute.
Cons:
- Дорого.
- Операционный кошмар (если без правильного automation).
Гибрид (часто):
- 95% клиентов — shared everything.
- 5% enterprise — dedicated.
2.6 Tenant identification
Заголовок раздела «2.6 Tenant identification»Как определить «какой tenant пришёл»?
1. Subdomain (acme.app.com):
host := r.Host // acme.app.comtenant := 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.
2.7 Tenant middleware в Go
Заголовок раздела «2.7 Tenant middleware в Go»type contextKey stringconst 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 ""}2.8 Postgres Row-Level Security (RLS)
Заголовок раздела «2.8 Postgres Row-Level Security (RLS)»Что: 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!
// ...}2.9 Application-level isolation
Заголовок раздела «2.9 Application-level isolation»Если 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 — обязательный параметр.
2.10 Connection pool per tenant vs shared
Заголовок раздела «2.10 Connection pool per tenant vs shared»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.
2.11 Noisy neighbor problem
Заголовок раздела «2.11 Noisy neighbor problem»Один 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.
2.12 Tenant migration (move tenant)
Заголовок раздела «2.12 Tenant migration (move tenant)»Сценарий: один tenant вырос — мигрируем в dedicated DB.
Steps:
- Создать dedicated DB.
- Dual write: каждое изменение пишем в shared + dedicated (через outbox / CDC).
- Backfill historical data (pg_dump + restore).
- Switch reads на dedicated.
- Switch writes на dedicated.
- Удалить tenant из shared.
Сложности:
- Pending events, in-flight transactions.
- Refs from other tenants (если есть) — обычно нет (изоляция).
2.13 Configuration per tenant
Заголовок раздела «2.13 Configuration per tenant»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.
2.14 Data deletion (GDPR right-to-erasure)
Заголовок раздела «2.14 Data deletion (GDPR right-to-erasure)»Tenant решил уйти → удалить все его данные.
Стратегии:
- Soft delete:
deleted_atfield, через 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).
3. Глубже: Strangler Fig pattern
Заголовок раздела «3. Глубже: Strangler Fig pattern»3.1 История термина
Заголовок раздела «3.1 История термина»Martin Fowler ввёл в 2004 термин на основе strangler fig tree — дерева, которое обвивает другое и постепенно его душит.
3.2 Концепция
Заголовок раздела «3.2 Концепция»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%)]3.3 Когда применять
Заголовок раздела «3.3 Когда применять»- Монолит → микросервисы.
- Legacy stack (PHP, Ruby) → новый (Go).
- Старая БД → новая (Oracle → Postgres).
- On-prem → cloud.
- Custom auth → OAuth2/OIDC.
3.4 Phases в деталях
Заголовок раздела «3.4 Phases в деталях»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: 104. Compare / shadow
- Опционально: посылать запросы в оба, сравнивать ответы.
- Логировать разницу.
- Только после успехов — увеличить вес.
5. Gradually move all traffic
- 10% → 25% → 50% → 75% → 100%.
- Monitoring, ready to rollback.
6. Retire old
- Старый код выпиливаем.
- Старые ресурсы (БД, серверы) — decommission.
3.5 Tools для Strangler
Заголовок раздела «3.5 Tools для Strangler»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.
3.6 Strangler в Go: feature flag пример
Заголовок раздела «3.6 Strangler в Go: feature flag пример»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) }}3.7 Anti-corruption layer
Заголовок раздела «3.7 Anti-corruption layer»При интеграции с legacy:
- Domain в новом сервисе — clean.
- Adapter layer обращается к legacy и переводит legacy-модели в новые.
// Anti-corruption layertype 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.
3.8 Data synchronization
Заголовок раздела «3.8 Data synchronization»Если у нового и legacy свои БД — нужен sync.
Подходы:
- Dual write: writes в оба. Риск рассинхрона.
- CDC: Debezium от legacy → синхронизация в new.
- Outbox pattern: writes + event → consumer обновляет other.
- Read-only sync: legacy остаётся source of truth, new — replica.
3.9 Common Strangler pitfalls
Заголовок раздела «3.9 Common Strangler pitfalls»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.
3.10 Big rewrite anti-pattern
Заголовок раздела «3.10 Big rewrite anti-pattern»"Давайте за полгода всё перепишем!"[12 месяцев работы]"Где-то 60% готово..."[18 месяцев]"Ещё надо feature parity..."[24 месяца, проект отменён]Joel Spolsky — Things You Should Never Do, Part I (2000) — про rewrite Netscape, который убил компанию. Strangler — антидот.
4. Real cases
Заголовок раздела «4. Real cases»4.1 Atlassian Cloud (Multi-tenant)
Заголовок раздела «4.1 Atlassian Cloud (Multi-tenant)»- Shared services + separate schemas в Postgres.
- Per-product (Jira/Confluence) — отдельные стэки.
- Enterprise tier — dedicated infra.
- 250K+ tenants на shared platform.
4.2 Salesforce — pioneer multi-tenancy
Заголовок раздела «4.2 Salesforce — pioneer multi-tenancy»- Один codebase, метаданные per tenant в Oracle.
- Heavy use of partitioning.
- Customization via metadata, не код.
4.3 Shopify (Rails multi-tenant)
Заголовок раздела «4.3 Shopify (Rails multi-tenant)»- Pod architecture: группы tenants в pods.
- Каждый pod — отдельный shard.
- Migration tenants между pods.
4.4 GitHub Enterprise Cloud
Заголовок раздела «4.4 GitHub Enterprise Cloud»- Multi-tenant shared.
- GitHub Enterprise Server — dedicated.
- GitHub AE (Azure) — dedicated cloud.
4.5 Amazon — Strangler внутри AWS
Заголовок раздела «4.5 Amazon — Strangler внутри AWS»- Монолит retail → микросервисы (с начала 2000-х).
- Команды двух «пицц», независимое деплоймент.
- API gateway routes.
- 10+ лет миграции, ещё legacy куски остались.
4.6 Netflix — billing modernization
Заголовок раздела «4.6 Netflix — billing modernization»- Mainframe billing → cloud-based.
- Strangler: гetway маршрутизирует часть customers.
- Внутри Netflix есть документация про этот процесс.
4.7 GitHub — Ruby monolith → services
Заголовок раздела «4.7 GitHub — Ruby monolith → services»- Очень осторожно: GitHub Rails monolith ещё есть в 2026.
- Выделяют отдельные сервисы (Pages, Actions).
- Strangler через GraphQL gateway.
4.8 Microsoft Office 365 (Multi-tenant)
Заголовок раздела «4.8 Microsoft Office 365 (Multi-tenant)»- Geographic regions (NA, EU, APAC).
- Внутри региона — multi-tenant.
- Government clouds — completely separate (compliance).
5. Вопросы (20+)
Заголовок раздела «5. Вопросы (20+)»- Чем single-tenant отличается от multi-tenant?
- Какие 4 модели изоляции в multi-tenant? Pros/cons каждой.
- Какие способы tenant identification ты знаешь?
- Что такое Row-Level Security в Postgres? Как использовать?
- Какие минусы RLS?
- Как реализовать tenant context в Go middleware?
- Что такое noisy neighbor? Как решать?
- Опиши процесс tenant migration (shared → dedicated).
- Как соблюдать GDPR в multi-tenant (right-to-erasure)?
- Чем shared pool отличается от per-tenant pool?
- Что такое Strangler Fig pattern? Кто автор?
- Опиши 4 фазы Strangler migration.
- Какие tools используют для routing в Strangler?
- Что такое feature flag и зачем в Strangler?
- Что такое anti-corruption layer?
- Как синхронизировать данные между legacy и new?
- Какие common pitfalls Strangler pattern?
- Почему «big rewrite» — анти-паттерн?
- Опиши пример Strangler из real-world (Amazon/Netflix/etc).
- Как выбирать «первую функцию» для миграции в Strangler?
- Как обеспечить наблюдаемость во время migration?
- Что такое shadow traffic?
- Как защитить multi-tenant систему от cross-tenant утечек?
- Чем pod architecture (Shopify) отличается от shared everything?
- Как делать backup/restore в multi-tenant?
6. Practice
Заголовок раздела «6. Practice»Задача 1: multi-tenant API на Go
Заголовок раздела «Задача 1: multi-tenant API на Go»- JWT с
tenant_id. - Middleware extracts tenant в context.
- Repository layer — обязательный
tenantIDпараметр. - Endpoint
/orders— возвращает только orders своего tenant. - Тест: попытка передать
tenantIDдругого — должна fail.
Задача 2: RLS в Postgres
Заголовок раздела «Задача 2: RLS в Postgres»- Создать таблицу
ordersсtenant_id. - Включить RLS, политика по
current_setting. - На Go: при каждой транзакции
SET LOCAL app.current_tenant. - Тест: без SET — нет результата.
Задача 3: rate limit per tenant
Заголовок раздела «Задача 3: rate limit per tenant»- Redis token bucket per
tenant_id. - Middleware: проверяет, если превышен → 429.
- Per-tenant limits (free: 10 QPS, pro: 100 QPS).
Задача 4: Strangler через Envoy
Заголовок раздела «Задача 4: Strangler через Envoy»- Поднять legacy сервис (Python Flask).
- Поднять новый Go сервис с тем же API.
- Envoy: routing 90/10 → 50/50 → 100/0.
- Метрики latency обоих clusters.
Задача 5: feature flag migration
Заголовок раздела «Задача 5: feature flag migration»- Реализуй feature flag через Unleash (или собственный).
- Endpoint
/notifications— два implementation. - Toggle: для users из конкретной группы — новая логика.
- Метрики per-implementation.
7. Источники
Заголовок раздела «7. Источники»- Martin Fowler — StranglerFigApplication: https://martinfowler.com/bliki/StranglerFigApplication.html
- Sam Newman — Monolith to Microservices (книга, главы про Strangler)
- AWS — SaaS Lens — multi-tenancy patterns: https://docs.aws.amazon.com/wellarchitected/latest/saas-lens/
- Software Engineering at Google (главы про migration patterns)
- Joel Spolsky — Things You Should Never Do: https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/
- PostgreSQL Row-Level Security: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
- Shopify Engineering — pod architecture posts
- Tailscale Multi-tenancy guide: https://tailscale.com/learn/multi-tenancy
- Stripe Atlas — multi-tenant patterns
- Atlassian Cloud architecture posts
- Salesforce multi-tenant whitepapers (легаси, но интересно)
- Building Multi-Tenant SaaS Architectures — Tod Golding (O’Reilly)
- Envoy weighted clusters: https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/
- Unleash docs: https://docs.getunleash.io/
- LaunchDarkly best practices: https://launchdarkly.com/blog/
- Microsoft — Multi-tenant SaaS patterns (Azure docs)
- 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» — рецепт катастрофы.