ProCat Solutions
Egy elfelejtett WHERE: így választjuk szét a bérlőket egy SaaS-ban
Bérlőelkülönítési modellek, row-level security, bérlőnkénti konfiguráció adatbázisban, migrációk, zajos szomszéd, számlázási hookok és mentés egy SaaS-ban.
Egyetlen elfelejtett WHERE tenant_id = ..., és az egyik ügyfél a másik adatait látja. Ez a többbérlős (multi-tenant) SaaS alapkockázata: egy kódbázis és egy infrastruktúra szolgál ki sok, egymástól független ügyfelet. Az előző bejegyzés terhelési problémái itt egy újabb réteget kapnak. A rendszernek gyorsnak és biztonságosnak kell lennie, és garantálnia kell, hogy egyik bérlő se lássa, se lassítsa a másikat. Az alábbi döntéseket az elmúlt év SaaS-projektjeiben hoztuk meg.

Három modell
A bérlők adatait háromféleképpen lehet elválasztani, és mindháromnak megvan a helye.
Közös séma, tenant_id oszloppal. Minden tábla tartalmaz egy bérlőazonosítót, minden lekérdezés szűr rá. Ez a legolcsóbb üzemeltetni, a migrációk egyszerűek, a mentés egyetlen adatbázist érint. A kockázat: egy elfelejtett WHERE tenant_id = ..., és kész az adatszivárgás.
Séma bérlőnként. Egy PostgreSQL-adatbázison belül minden bérlő saját sémát kap. Erősebb az elkülönítés, és a search_path beállításával a lekérdezések változatlanok maradnak. Cserébe a migrációt bérlőnként kell futtatni, és néhány száz séma felett a katalógusműveletek észrevehetően lassulnak.
Adatbázis bérlőnként. Teljes elkülönítés, bérlőnként külön mentés és visszaállítás, akár külön szerveren. Ez a legdrágább, és csak szerződéses vagy szabályozási kényszer indokolja.
A legtöbb projektünk a közös sémát választja. Az elfelejtett szűrés kockázatát a fejlesztői fegyelemre nem bízzuk, arra adatbázis-szintű eszköz van.
Az adatbázis szűr
A PostgreSQL row-level security (RLS) mellett a szűrést maga az adatbázis kényszeríti ki, akkor is, ha az alkalmazás hibázik. A mintánk:
- minden bérlőhöz kötött táblán
ENABLE ROW LEVEL SECURITYés egy policy, amely acurrent_setting('app.tenant_id')értékére szűr, - az alkalmazás a kérés elején, a tranzakción belül
SET LOCAL app.tenant_id = ...utasítással állítja be a bérlőt, - az alkalmazás adatbázis-felhasználója nem a tábla tulajdonosa, így a policy rá is vonatkozik.
Ha így hiányzik egy WHERE feltétel, az eredmény egy üres halmaz. Ez sokkal feltűnőbb, mint egy adatszivárgás, és sokkal kevésbé fáj. Az ára a lekérdezéstervek minimális bonyolódása, valamint az, hogy PgBouncer tranzakciós módjában a SET LOCAL az egyetlen biztonságos forma.

A beállítások táblába kerülnek
Ezt a leckét többször is megfizettük. A bérlőspecifikus beállítások (limitek, bekapcsolt funkciók, integrációs kulcsok, arculati elemek) helye az adatbázis, egy tenant_settings jellegű, verziózott tábla. Környezeti változóban és konfigurációs fájlban nem tartjuk őket. Az okok:
- új bérlő felvételéhez nem kell deploy,
- a beállítások módosítása naplózható és visszavonható,
- az adminisztrációs felület ugyanazt az adatot látja, mint az alkalmazás.
A titkos értékeket (API-kulcsok, jelszavak) titkosítva tároljuk, a kulcsot az alkalmazás környezetéből olvassuk. A beállításokat rövid TTL-lel Redisben cache-eljük, mert minden kérésnél kellenek.
Migrációk és a zajos szomszéd
A közös séma nagy előnye, hogy a migráció egyszer fut. Egy nagy tábla módosítása viszont minden bérlőt egyszerre érint. Ezért néhány szabályt tartunk: éles idő alatt csak additív változtatás, oszlop törlése két lépésben (előbb a kód, aztán a séma), és minden indexre CREATE INDEX CONCURRENTLY.
A „zajos szomszéd” ellen, amikor egy bérlő terhelése lassítja a többit, több szinten védekezünk:
- bérlőnkénti rate limit Redisben, a globális mellett,
- a nehéz műveletek (export, tömeges import, riport) háttérsorba kerülnek, bérlőnkénti párhuzamossági korláttal,
- a lekérdezéseken
statement_timeoutvan, hogy egyetlen rossz lekérdezés ne foglaljon le egy kapcsolatot percekig, - a bérlőazonosító minden metrikán címkeként szerepel, így a problémás bérlő azonnal látszik.
Számlázás és mentés
Egy SaaS-ban a számlázás architekturális kérdés, utólag nehéz hozzáépíteni. Két dolgot az első naptól beépítünk:
Használati események. Minden számlázható művelet (API-hívás, elküldött üzenet, tárolt rekord) eseményt ír egy append-only táblába, bérlőazonosítóval és időbélyeggel. A számlázási időszak végén ebből készül az összesítés, vita esetén ebből lehet visszakeresni.
Állapotátmenet-hookok. A bérlő életciklusa (próbaidőszak, aktív, fizetési késedelem, felfüggesztett, törölt) explicit állapotgép. Minden átmenetnél lefutnak a hozzá tartozó hookok: értesítés, funkciók korlátozása, az adatmegőrzési időzítő indítása. Arra a kérdésre, hogy „mi történik, ha nem fizet”, így dokumentált válasz van.
A közös séma gyenge pontja a mentés: egyetlen bérlő visszaállítása nem triviális. Ezért a napi teljes mentés mellett bérlőnkénti logikai exportot is készítünk (pg_dump szűrt adatokkal vagy alkalmazásszintű export), és a visszaállítást rendszeresen, a gyakorlatban is kipróbáljuk.
A multi-tenant architektúra egy tucat kisebb, egymásba kapaszkodó döntésből áll. A közös séma RLS-sel, az adatbázisban tárolt bérlőkonfiguráció és a bérlőnkénti korlátok a legtöbb esetben jó egyensúlyt adnak az üzemeltetési egyszerűség és a biztonság között. Ha egy bérlő szerződéses okból teljes elkülönítést kér, külön adatbázist kap. Ez nálunk kivétel marad.
Ilyen architektúrát építünk és üzemeltetünk ügyfeleinknek is (SaaS fejlesztés és üzemeltetés). A saját termékünkön, az Abigel.ai-on esettanulmányban mutatjuk be, hogyan működik élesben.