Multi-tenancy w PostgreSQL i Phoenix - jeden system, 50 klientów, zero wycieków danych
Piątek, 17:00. Dzwoni telefon. Klient Twojego systemu SaaS - firma logistyczna z Gdańska - właśnie zobaczył na dashboardzie zamówienia firmy kurierskiej z Wrocławia. Nie swoje zamówienia. Cudze. Z cenami, adresami, danymi kontaktowymi.
Jak to możliwe? Programista zapomniał dodać WHERE tenant_id = ? w jednym zapytaniu. Jednym z dwustu. W piątkowym deployu, o 16:30, żeby zdążyć przed weekendem.
Wyciek danych między tenantami to nie bug. To katastrofa. RODO, utrata zaufania, potencjalne pozwy. I to katastrofa, której nie powinno być w ogóle możliwe wywołać jednym brakującym WHERE.
Istnieją trzy podejścia do izolacji danych w systemach multi-tenant. Każde eliminuje ryzyko wycieku na innym poziomie. Każde ma inne koszty. W Elixirze i PostgreSQL wszystkie trzy działają natywnie.
Trzy podejścia do multi-tenancy
1. Shared tables + Row-Level Security (RLS)
Wszyscy tenanci w tych samych tabelach. Kolumna tenant_id w każdej tabeli. PostgreSQL sam filtruje dane na poziomie bazy danych - nawet jeśli aplikacja zapomni o WHERE.
2. Schema-per-tenant (Ecto prefix)
Każdy tenant ma własny zestaw tabel w osobnym schemacie PostgreSQL. Tenant "acme" ma tenant_acme.orders, tenant_acme.users. Schemat "beta" ma tenant_beta.orders, tenant_beta.users. Fizycznie oddzielone, logicznie w jednej bazie.
3. Database-per-tenant
Każdy tenant ma własną bazę danych. Najsilniejsza izolacja. Niezależne backupy, niezależna wydajność, niezależne skalowanie.
| Wymiar | RLS (shared tables) | Schema-per-tenant | Database-per-tenant |
|---|---|---|---|
| Izolacja | Logiczna (policy PostgreSQL) | Logiczna (schemat) | Fizyczna (osobna baza) |
| Skala tenantów | 10 000+ | 10-500 | 10-100 |
| Overhead per tenant | Zero (jedna tabela) | Metadane schematu | Pula połączeń + pamięć |
| Migracje | Jedna migracja dla wszystkich | N migracji (jedna per schemat) | N migracji (jedna per bazę) |
| Backup/restore | Cała baza (wszyscy tenanci) | Per schemat (pg_dump -n) | Per baza (niezależny) |
| Customizacja per tenant | Minimalna | Osobne indeksy/rozszerzenia | Pełna swoboda |
| Ryzyko wycieku danych | Niskie (policy chroni) | Bardzo niskie (oddzielne tabele) | Zerowe (oddzielna baza) |
| Złożoność wdrożenia | Niska | Średnia | Wysoka |
Podejście 1: Row-Level Security
RLS to mechanizm PostgreSQL, który dodaje niewidoczny filtr do każdego zapytania. Programista pisze SELECT * FROM orders - PostgreSQL wykonuje SELECT * FROM orders WHERE tenant_id = 42. Automatycznie. Bez możliwości pominięcia.
Migracja - włączenie RLS
defmodule MyApp.Repo.Migrations.EnableRLS do
use Ecto.Migration
def up do
create table(:orders) do
add :title, :string, null: false
add :total, :decimal
add :tenant_id, references(:tenants, on_delete: :delete_all), null: false
timestamps()
end
create index(:orders, [:tenant_id])
# Włącz RLS
execute "ALTER TABLE orders ENABLE ROW LEVEL SECURITY"
# Wymuszaj RLS nawet dla właściciela tabeli
execute "ALTER TABLE orders FORCE ROW LEVEL SECURITY"
# Policy: wiersz widoczny tylko gdy tenant_id = bieżący tenant z sesji
execute """
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::bigint)
WITH CHECK (tenant_id = current_setting('app.current_tenant')::bigint)
"""
end
def down do
execute "DROP POLICY IF EXISTS tenant_isolation ON orders"
execute "ALTER TABLE orders DISABLE ROW LEVEL SECURITY"
drop table(:orders)
end
endUstawianie kontekstu tenanta w Ecto
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
alias Ecto.Adapters.SQL
def with_tenant(tenant_id, fun) when is_function(fun, 0) do
transaction(fn ->
# set_config z trzecim argumentem true = ustawienie lokalne dla transakcji
# Automatycznie czyszczone po zakończeniu transakcji
SQL.query!(__MODULE__, "SELECT set_config('app.current_tenant', $1, true)", [
to_string(tenant_id)
])
fun.()
end)
end
endUżycie w kontekście biznesowym
defmodule MyApp.Orders do
alias MyApp.{Repo, Order}
def list_orders(tenant_id) do
Repo.with_tenant(tenant_id, fn ->
# RLS automatycznie filtruje - zwraca TYLKO zamówienia tego tenanta
Repo.all(Order)
end)
end
def create_order(tenant_id, attrs) do
Repo.with_tenant(tenant_id, fn ->
%Order{}
|> Order.changeset(attrs)
|> Ecto.Changeset.put_change(:tenant_id, tenant_id)
|> Repo.insert()
end)
end
endProgramista pisze Repo.all(Order). Nie dodaje WHERE tenant_id = ?. I nie musi - PostgreSQL doda to sam. Nawet jeśli ktoś w przyszłości napisze surowe SQL w Repo.query!/2, RLS ochroni dane. To jest obrona na poziomie bazy danych, nie na poziomie kodu aplikacji.
Kluczowe: indeksy kompozytowe
RLS dołącza WHERE tenant_id = X do każdego zapytania. Bez odpowiednich indeksów to sequential scan:
# W migracji - indeksy MUSZĄ zaczynać się od tenant_id
create index(:orders, [:tenant_id, :inserted_at])
create index(:orders, [:tenant_id, :status, :inserted_at])
# Indeks TYLKO na :inserted_at NIE pomoże w zapytaniach z RLS
# PostgreSQL nie użyje go, bo filtr tenant_id jest pierwszyKiedy wybrać RLS
- SaaS z tysiącami małych tenantów (darmowe plany, trial, self-service)
- Jedna migracja dla wszystkich - zero fan-out
- Tenant isolation jako "safety net" obok aplikacyjnego filtrowania
- Gdy potrzebujesz cross-tenant raportów (admin dashboard z agregatami)
Podejście 2: Schema-per-tenant (Ecto prefix)
Ecto ma wbudowaną obsługę schematów PostgreSQL przez opcję :prefix. Każde zapytanie, insert, update czy delete może celować w konkretny schemat.
Automatyczne wstrzykiwanie prefiksu
Zamiast przekazywać prefix: do każdego wywołania Repo, użyj default_options/1:
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
@tenant_key {__MODULE__, :tenant_prefix}
def put_tenant(prefix) do
Process.put(@tenant_key, prefix)
end
def get_tenant do
Process.get(@tenant_key)
end
@impl true
def default_options(_operation) do
case get_tenant() do
nil -> []
prefix -> [prefix: prefix]
end
end
endOd tego momentu cały kod biznesowy jest identyczny jak w aplikacji single-tenant:
# Ustaw tenanta raz - na początku requesta
MyApp.Repo.put_tenant("tenant_acme")
# Wszystkie zapytania automatycznie celują w schemat tenant_acme
Repo.all(User) # SELECT * FROM "tenant_acme"."users"
Repo.all(Order) # SELECT * FROM "tenant_acme"."orders"
Repo.insert(%User{}) # INSERT INTO "tenant_acme"."users" ...Plug do ekstrakcji tenanta z subdomeny
defmodule MyAppWeb.Plugs.TenantFromSubdomain do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
case extract_subdomain(conn.host) do
nil ->
conn |> send_resp(404, "Tenant not found") |> halt()
subdomain ->
case MyApp.Tenants.get_by_subdomain(subdomain) do
nil ->
conn |> send_resp(404, "Unknown tenant") |> halt()
tenant ->
MyApp.Repo.put_tenant(tenant.schema_prefix)
conn
|> assign(:current_tenant, tenant)
end
end
end
defp extract_subdomain(host) do
case String.split(host, ".") do
[subdomain, _, _] -> subdomain
_ -> nil
end
end
endLiveView - hook on_mount
defmodule MyAppWeb.TenantHook do
import Phoenix.LiveView
def on_mount(:default, _params, %{"tenant_id" => tenant_id}, socket) do
tenant = MyApp.Tenants.get!(tenant_id)
MyApp.Repo.put_tenant(tenant.schema_prefix)
{:cont, assign(socket, :current_tenant, tenant)}
end
def on_mount(:default, _params, _session, socket) do
{:halt, redirect(socket, to: "/")}
end
endTworzenie nowego tenanta + migracje
defmodule MyApp.TenantManager do
alias Ecto.Adapters.SQL
alias MyApp.Repo
@tenant_migrations_path "priv/repo/tenant_migrations"
def create_tenant(slug) do
prefix = "tenant_#{slug}"
# 1. Utwórz schemat PostgreSQL
SQL.query!(Repo, ~s(CREATE SCHEMA "#{prefix}"), [])
# 2. Uruchom migracje tenant-specific w tym schemacie
migrations_path = Application.app_dir(:my_app, @tenant_migrations_path)
Ecto.Migrator.run(
Repo,
migrations_path,
:up,
all: true,
prefix: prefix
)
# 3. Zarejestruj tenanta w schemacie public
Repo.insert(%MyApp.Tenant{slug: slug, schema_prefix: prefix})
end
def migrate_all_tenants do
migrations_path = Application.app_dir(:my_app, @tenant_migrations_path)
Repo.all(MyApp.Tenant)
|> Enum.each(fn tenant ->
Ecto.Migrator.run(Repo, migrations_path, :up, all: true, prefix: tenant.schema_prefix)
end)
end
endDwa katalogi migracji:
priv/repo/migrations/- migracje globalne (tabelatenants, konfiguracja)priv/repo/tenant_migrations/- migracje per tenant (tabele biznesowe)
Nowy tenant = CREATE SCHEMA + uruchomienie wszystkich migracji z tenant_migrations. Istniejący tenanci = migrate_all_tenants przy każdym deployu.
Kiedy wybrać schema-per-tenant
- 10-500 tenantów (SaaS B2B z płatnymi planami)
- Potrzeba izolacji silniejszej niż RLS, ale bez złożoności osobnych baz
- Niezależne indeksy per tenant (duży tenant może mieć dodatkowe indeksy)
- Niezależny backup per tenant (
pg_dump -n tenant_acme) - Planer PostgreSQL z osobnymi statystykami per schemat (lepsze plany zapytań)
Ograniczenia
Przy 10 000 tenantów z 50 tabelami = 500 000 plików tabel w katalogu danych PostgreSQL. Skutki: wyczerpanie inode'ów, wolny VACUUM, powolny pg_dump, rozdęty pg_catalog. Dlatego limit to ~500 tenantów.
Podejście 3: Database-per-tenant
Najsilniejsza izolacja. Osobna baza, osobna pula połączeń, osobny backup.
Dynamiczny Repo
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
def with_dynamic_repo(credentials, callback) do
default = get_dynamic_repo()
{:ok, repo} = start_link([name: nil, pool_size: 2] ++ credentials)
try do
put_dynamic_repo(repo)
callback.()
after
put_dynamic_repo(default)
Supervisor.stop(repo)
end
end
endCache puli połączeń
Start/stop puli per request jest drogi. Produkcyjny pattern: cache puli w GenServer:
defmodule MyApp.TenantRepoPool do
use GenServer
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def get_repo(tenant_id) do
GenServer.call(__MODULE__, {:get_repo, tenant_id})
end
@impl true
def init(_) do
{:ok, %{repos: %{}}}
end
@impl true
def handle_call({:get_repo, tenant_id}, _from, state) do
case Map.get(state.repos, tenant_id) do
nil ->
credentials = fetch_credentials(tenant_id)
{:ok, pid} = MyApp.Repo.start_link([name: nil, pool_size: 5] ++ credentials)
{:reply, {:ok, pid}, put_in(state, [:repos, tenant_id], pid)}
pid ->
if Process.alive?(pid) do
{:reply, {:ok, pid}, state}
else
credentials = fetch_credentials(tenant_id)
{:ok, new_pid} = MyApp.Repo.start_link([name: nil, pool_size: 5] ++ credentials)
{:reply, {:ok, new_pid}, put_in(state, [:repos, tenant_id], new_pid)}
end
end
end
defp fetch_credentials(tenant_id) do
[
hostname: "db-#{tenant_id}.internal",
database: "tenant_#{tenant_id}",
username: "app",
password: System.get_env("DB_PASSWORD")
]
end
endPlug integrujący dynamiczny Repo
defmodule MyAppWeb.Plugs.DynamicTenantRepo do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
tenant = conn.assigns.current_tenant
case MyApp.TenantRepoPool.get_repo(tenant.id) do
{:ok, repo_pid} ->
MyApp.Repo.put_dynamic_repo(repo_pid)
conn
{:error, reason} ->
conn |> send_resp(503, "Database unavailable") |> halt()
end
end
endKiedy wybrać database-per-tenant
- Enterprise klienci z wymaganiami compliance (dane fizycznie oddzielone)
- Regulowane branże (finanse, medycyna) - audytor chce widzieć osobną bazę
- Klient płaci wystarczająco, żeby uzasadnić osobną infrastrukturę
- Potrzeba niezależnego skalowania (jeden tenant generuje 90% ruchu)
- Niezależne schedule backupów i retencji
Ograniczenia
PostgreSQL domyślnie: max_connections = 100. Przy 50 tenantach × pool_size: 5 = 250 połączeń. Wymaga PgBouncer lub podobnego poolera. Każda pula to pamięć i overhead zarządzania.
Podejście hybrydowe - najczęstsze w praktyce
W rzeczywistości rzadko wybiera się jedno podejście dla wszystkich tenantów:
Tier Free/Trial (1000+ tenantów)
→ RLS na shared tables
→ Jedna migracja, jeden backup, minimalne koszty
Tier Business (50-200 tenantów)
→ Schema-per-tenant
→ Osobne statystyki planera, osobny pg_dump
Tier Enterprise (5-10 tenantów)
→ Database-per-tenant
→ Osobna infrastruktura, dedykowany SLAdefmodule MyApp.TenantRouter do
def setup_tenant_context(tenant) do
case tenant.tier do
:free ->
# RLS - ustaw sesję PostgreSQL
MyApp.Repo.set_rls_tenant(tenant.id)
:business ->
# Schema - ustaw prefix Ecto
MyApp.Repo.put_tenant("tenant_#{tenant.slug}")
:enterprise ->
# Osobna baza - przełącz dynamic repo
{:ok, repo} = MyApp.TenantRepoPool.get_repo(tenant.id)
MyApp.Repo.put_dynamic_repo(repo)
end
end
endTenant zaczyna na darmowym planie z RLS. Przechodzi na Business - migrujemy do osobnego schematu. Podpisuje kontrakt Enterprise - przenosimy do osobnej bazy. Każdy krok to większa izolacja, większe koszty, większe możliwości customizacji. I każdy krok jest odwracalny.
Bezpieczeństwo - defense in depth
Nie polegaj na jednej warstwie. Najlepsze podejście to kombinacja:
Warstwa 1: Plug/Hook
→ Ekstrakcja tenanta z subdomeny/headera
→ Ustawienie kontekstu w conn/session
Warstwa 2: Ecto (prepare_query/default_options)
→ Automatyczne wstrzykiwanie WHERE tenant_id = ?
→ Programista NIE MOŻE zapomnieć
Warstwa 3: PostgreSQL RLS
→ Nawet jeśli warstwy 1 i 2 zawiodą
→ Baza danych nie zwróci cudzych danych
Warstwa 4: Testy
→ Test, który próbuje odczytać dane innego tenanta
→ Musi zwrócić pustą listędefmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
require Ecto.Query
@impl true
def prepare_query(_operation, query, opts) do
cond do
opts[:skip_tenant_id] || opts[:schema_migration] ->
{query, opts}
tenant_id = opts[:tenant_id] || get_tenant_id() ->
{Ecto.Query.where(query, tenant_id: ^tenant_id), opts}
true ->
raise "tenant_id is required - use skip_tenant_id: true for global queries"
end
end
endprepare_query/3 jest wywoływany przed każdym zapytaniem. Jeśli tenant_id nie jest ustawiony i nie ma jawnego skip_tenant_id: true, aplikacja crashuje. Nie zwraca danych wszystkich tenantów - crashuje. Fail-fast zamiast fail-unsafe.
Wpływ na wydajność
| Operacja | RLS | Schema | Database |
|---|---|---|---|
| Proste SELECT | +0.1ms (policy evaluation) | +0ms (natywna izolacja) | +0ms |
| JOIN między tabelami tenanta | Identyczny | Identyczny | Identyczny |
| Cross-tenant raport | Prosty (brak filtra) | N zapytań (per schemat) | N połączeń (per baza) |
| Migracja DDL | 1 operacja | N operacji | N operacji |
| VACUUM | Jedna tabela (duża) | N tabel (małych) | N baz (niezależnych) |
| Planer zapytań | Globalne statystyki* | Per-schemat (dokładne) | Per-baza (dokładne) |
*Globalne statystyki RLS mogą powodować suboptymalne plany, gdy jeden tenant ma 10x więcej danych niż reszta. Rozwiązanie: partycjonowanie po tenant_id.
Migracja między podejściami
Jedną z największych zalet Ecto jest to, że przejście między podejściami wymaga minimalnych zmian w kodzie biznesowym:
- RLS → Schema: Utwórz schematy, skopiuj dane z filtrowane po
tenant_id, zmieńRepo.set_rls_tenantnaRepo.put_tenant - Schema → Database: Utwórz bazy,
pg_dump -n tenant_X | psql target_db, zmień naput_dynamic_repo - Każde → każde: Kod biznesowy (
Repo.all(Order)) nie zmienia się. Zmienia się tylko warstwa konfiguracji tenanta
To jest kluczowa decyzja architektoniczna, którą warto podjąć wcześnie - ale nie jest to decyzja nieodwracalna.
Budujesz system SaaS i nie wiesz, które podejście do multi-tenancy wybrać? Porozmawiajmy - pomożemy dobrać architekturę do skali i wymagań Twoich klientów.