Multi-tenancy для SaaS веб-приложения
Multi-tenancy — архитектура, при которой одна инсталляция приложения обслуживает несколько независимых организаций (tenant'ов). Каждый tenant видит только свои данные. Три принципиальные модели отличаются по уровню изоляции, стоимости и сложности.
Три модели multi-tenancy
Pool (Shared Everything): все tenant'ы в одной базе, в каждой таблице колонка tenant_id. Дёшево, легко масштабировать по числу tenant'ов, но изоляция только на уровне приложения.
Silo (Database per Tenant): каждый tenant — отдельная база. Полная изоляция, простая миграция данных конкретного tenant'а, compliance (GDPR right to erasure — просто удалить базу). Дорого при большом числе tenant'ов.
Bridge (Schema per Tenant): один PostgreSQL кластер, отдельные схемы (tenant_acme, tenant_globex). Компромисс: хорошая изоляция, один процесс PostgreSQL, но управление схемами сложнее.
Pool-модель: Row-Level Security
Самый распространённый подход для SaaS. Защита на уровне PostgreSQL, а не только на уровне ORM:
-- Таблица с tenant_id
ALTER TABLE articles ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenants(id);
CREATE INDEX articles_tenant_id_idx ON articles(tenant_id);
-- RLS политика
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON articles
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Установка контекста перед запросами
SET app.tenant_id = '550e8400-e29b-41d4-a716-446655440000';
SELECT * FROM articles; -- автоматически видит только свои
Laravel Tenancy интеграция:
// TenantScope — глобальный scope для всех моделей
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where($model->getTable() . '.tenant_id', tenant()->id);
}
}
// HasTenant trait
trait HasTenant
{
protected static function bootHasTenant(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function ($model) {
$model->tenant_id ??= tenant()->id;
});
}
}
// Инициализация tenant из запроса
class InitializeTenancy
{
public function handle(Request $request, Closure $next)
{
$subdomain = explode('.', $request->getHost())[0];
$tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();
app()->instance('tenant', $tenant);
// Устанавливаем PostgreSQL context для RLS
DB::statement("SET app.tenant_id = '{$tenant->id}'");
return $next($request);
}
}
Идентификация tenant'а
Поддомен: acme.app.example.com — самый удобный способ.
// Роутинг по поддомену
Route::domain('{tenant}.example.com')->group(function () {
Route::middleware([InitializeTenancy::class])->group(function () {
// Все защищённые роуты
});
});
Кастомный домен: app.acme.com → wildcard SSL (Let's Encrypt, Caddy automatic HTTPS) + запись в DNS + запись в базе.
Path-based: example.com/acme/... — нет SSL-проблем, но URL выглядит менее профессионально.
Silo-модель: Dynamic Database Connections
// Динамическое переключение соединения
class TenantDatabaseManager
{
public function connectTenant(Tenant $tenant): void
{
$config = [
'driver' => 'pgsql',
'host' => $tenant->db_host ?? config('database.connections.pgsql.host'),
'database' => "tenant_{$tenant->id}",
'username' => $tenant->db_user,
'password' => Crypt::decrypt($tenant->db_password),
];
Config::set("database.connections.tenant", $config);
DB::purge('tenant');
DB::reconnect('tenant');
DB::setDefaultConnection('tenant');
}
}
Миграции для всех tenant'ов:
// Artisan command: tenants:migrate
Tenant::each(function (Tenant $tenant) {
app(TenantDatabaseManager::class)->connectTenant($tenant);
Artisan::call('migrate', ['--force' => true]);
});
Schema-модель на PostgreSQL
-- Создание схемы для нового tenant'а
CREATE SCHEMA tenant_acme;
-- Копирование структуры из шаблонной схемы
SELECT clone_schema('tenant_template', 'tenant_acme');
-- Подключение к схеме
SET search_path TO tenant_acme, public;
// search_path как tenant-контекст
DB::statement("SET search_path TO tenant_{$tenant->slug}, public");
Onboarding нового tenant'а
class ProvisionTenantJob implements ShouldQueue
{
public function handle(Tenant $tenant): void
{
// 1. Создать базу/схему
TenantDatabaseManager::create($tenant);
// 2. Запустить миграции
Artisan::call('tenants:migrate', ['--tenant' => $tenant->id]);
// 3. Сиды: роли по умолчанию, настройки
Artisan::call('tenants:seed', ['--tenant' => $tenant->id]);
// 4. DNS если нужно (Cloudflare API)
CloudflareDNS::createSubdomain($tenant->subdomain);
// 5. Email приветствия
Mail::to($tenant->owner_email)->send(new TenantWelcome($tenant));
$tenant->update(['status' => 'active']);
}
}
Cross-tenant данные
Некоторые данные глобальные — не привязаны к tenant:
// Модели без TenantScope
class Country extends Model { } // нет HasTenant
class Plan extends Model { } // тарифные планы — глобальные
// Суперадмин видит все tenant'ы
class SuperAdminScope
{
public function apply(Builder $builder, Model $model): void
{
if (!auth()->user()?->isSuperAdmin()) {
$builder->where('tenant_id', tenant()->id);
}
}
}
Изоляция файлов
// S3/MinIO — отдельный prefix per tenant
Storage::disk('s3')->put(
"tenants/{$tenant->id}/uploads/{$filename}",
$fileContent
);
// Или отдельные бакеты для enterprise-tier
$bucket = $tenant->plan === 'enterprise'
? "tenant-{$tenant->id}"
: "tenants-shared";
Feature flags per tenant
// Включение фич на уровне tenant'а
class TenantFeature extends Model
{
// tenant_id, feature, enabled, config (JSON)
}
// Использование
if (tenant()->hasFeature('advanced_analytics')) {
// Показать BI дашборд
}
// Или через Laravel Pennant
Feature::for($tenant)->active('advanced_analytics');
Сроки
Pool-модель с RLS, TenantScope, subdomain routing, provisioning job: 2–3 недели. Silo с dynamic connections, custom domains, wildcard SSL, cross-tenant аналитика для superadmin: 1–2 месяца.







