ProCat Solutions
Multi-tenant SaaS architektúra a gyakorlatban
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.
A többbérlős (multi-tenant) SaaS az a rendszertípus, ahol egyetlen kódbázis és egyetlen infrastruktúra szolgál ki sok, egymástól független ügyfelet. Az előző bejegyzésben említett terhelési problémák itt egy újabb réteggel bővülnek: nem elég, hogy a rendszer gyors és biztonságos legyen, azt is garantálni kell, hogy egyik bérlő se lássa, se lassítsa a másikat. Az alábbiakban azokat a döntéseket szedjük össze, amelyeket az elmúlt év SaaS-projektjeiben meghoztunk.
Három elkülönítési modell
A bérlők adatainak elválasztására három alapvető modell létezik, és mindegyiknek 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á. A legolcsóbb üzemeltetni, a migrációk egyszerűek, a mentés egyetlen adatbázist érint. A kockázat: egyetlen elfelejtett WHERE tenant_id = ... és adatszivárgás van.
Séma bérlőnként. Egy PostgreSQL-adatbázison belül minden bérlő saját sémát kap. Az elkülönítés erősebb, a search_path beállításával a lekérdezések nem változnak. Cserébe a migrációt bérlőnként kell futtatni, és néhány száz séma felett a katalógus mű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 akkor indokolt, ha szerződéses vagy szabályozási okból van rá szükség.
A legtöbb projektünk a közös sémás modellt választja, és az elfelejtett szűrés kockázatát nem fegyelemmel, hanem adatbázis-szintű eszközzel kezeli.
Row-level security mint biztonsági háló
A PostgreSQL row-level security (RLS) lehetővé teszi, hogy a szűrést ne az alkalmazás, hanem az adatbázis kényszerítse ki. A minta, amit használunk:
- 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 érvényes.
Ezzel a hiányzó WHERE feltétel nem adatszivárgást, hanem üres eredményhalmazt okoz, ami sokkal jobban észrevehető és sokkal kevésbé fájdalmas. Az ára a lekérdezéstervek minimális bonyolódása és az, hogy PgBouncer tranzakciós módban a SET LOCAL az egyetlen biztonságos forma.
Bérlőnkénti konfiguráció az adatbázisban
Egy tanulság, amit többször megfizettünk: a bérlőspecifikus beállításokat (limitek, bekapcsolt funkciók, integrációs kulcsok, arculati elemek) nem környezeti változóban és nem konfigurációs fájlban tartjuk, hanem az adatbázisban, egy tenant_settings jellegű, verziózott táblában. Ennek több oka van:
- új bérlő felvétele nem igényel deployt,
- 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 pedig az alkalmazás környezetéből olvassuk. A beállításokat Redisben cache-eljük rövid TTL-lel, mert minden kérésnél előkerülnek.
Migrációk és a zajos szomszéd
A közös sémás modell nagy előnye, hogy a migráció egyszer fut. Ugyanakkor egy nagy tábla módosítása minden bérlőt egyszerre érint, ezért néhány szabályt betartunk: csak additív változtatások éles idő alatt, oszlop törlése két lépésben (előbb a kód, aztán a séma), és CREATE INDEX CONCURRENTLY minden indexre.
A “zajos szomszéd” jelenség (egy bérlő terhelése lassítja a többit) ellen több szinten védekezünk:
- bérlőnkénti rate limit Redisben, nem csak globális,
- 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ésekre
statement_timeoutvan beállítva, hogy egyetlen rossz lekérdezés ne foglaljon el egy kapcsolatot percekig, - a bérlőazonosító minden metrikán címkeként szerepel, így a problémás bérlő azonnal látható.
Számlázási hookok és mentés
A számlázás egy SaaS-ban nem utólagos kiegészítés, hanem architekturális kérdés. Két dolgot építünk be az első naptól:
Használati események. Minden számlázható művelet (API-hívás, elküldött üzenet, tárolt rekord) egy 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, é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 futnak a hozzá tartozó hookok: értesítés, funkciók korlátozása, adatmegőrzési időzítő indítása. Így a “mi történik, ha nem fizet” kérdésre van dokumentált válasz.
A mentésnél a közös sémás modell hátránya, hogy egyetlen bérlő visszaállítása nem triviális. Ezt úgy hidaljuk át, hogy 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ási folyamatot rendszeresen, nem csak elméletben próbáljuk ki.
Összefoglalva: a multi-tenant architektúra nem egyetlen döntés, hanem egy tucat kisebb, egymással összefüggő döntés. A közös séma RLS-sel, adatbázisban tárolt bérlőkonfigurációval és bérlőnkénti korlátokkal a legtöbb esetben jó egyensúlyt ad 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, azt külön adatbázissal oldjuk meg, de ezt kivételként kezeljük, nem szabályként.