アーキテクチャ
SaaS のマルチテナントをどう実現するか
1 つのアプリケーションで複数の顧客(テナント)を預かるとき、何を共有し、何を分け、その境界をどこで強制するか。Silo / Pool / Bridge の分類とデータ層の 4 パターンを出発点に、識別・強制・越境・運用を順に辿り、最後に Firestore 上のかけっこ教室向け SaaS「be-hero-karte」が採った構成を具体例として読む。
01マルチテナントとは何を共有することか
マルチテナントとは、1 つのソフトウェア実体を複数の顧客が同時に使い、それぞれの顧客には自分のデータしか見えない状態を指す。共有するのはコード・デプロイ・運用の仕組みであり、分けるのはデータと設定である。この「何を共有し何を分けるか」を決めることがマルチテナント設計のすべてで、後の章はその決め方を扱う。
- テナント
- 分離の主体。契約単位の顧客。法人・店舗・チームなど、業態により粒度は違う
- テナント境界
- テナント A のユーザーがテナント B のデータに触れられないという保証。これを破ることを越境(cross-tenant access)と呼ぶ
- コントロールプレーン
- テナントの開設・課金・機能フラグ・運営者の操作を担う、全テナント共通の管理層
- アプリケーションプレーン
- テナントごとの業務データを扱う層。境界はここに引かれる
マルチテナントと「顧客ごとに別インスタンスを配る」ことの違いは、共有のコントロールプレーンがあるかどうかにある。顧客ごとに別のデータベースを持たせていても、開設・課金・監視・デプロイが 1 本の仕組みで回っていればマルチテナント SaaS であり、逆にコードが同じでも顧客ごとに別々の運用をしているならそれは受託のホスティングである。
マルチテナントの難しさは「分けること」ではなく「分けたうえで運用を 1 本にすること」にある。分離を強くするほど運用は顧客数に比例して増え、共有を強くするほど越境事故と隣人の負荷(noisy neighbor)の対策が重くなる。
02Silo・Pool・Bridge の 3 モデル
分離の度合いを語るとき、AWS の SaaS Lens が定義する Silo / Pool / Bridge の 3 分類が共通語彙として使われる。どれか 1 つを選ぶというより、システムの部位ごとにどこに置くかを決めるための物差しである。
- Silo
- テナントごとに専用のリソース(DB、コンピュート、場合によってはスタック全体)を用意する。ただし開設・認証・運用は共有のまま
- Pool
- テナントがリソースを共有する。古典的なマルチテナントで、規模の経済と俊敏さを得る代わりに、境界をソフトウェアで守る
- Bridge
- 部位によって Silo と Pool を混ぜる。規制やノイジーネイバーの性質でサービスごとに寄せ方を変える
Silo であっても「開設・認証・運用は共有」である点は見落とされやすい。SaaS Lens もこの点を強調しており、専用リソースを持っていてもテナントが共通の仕組みで管理・デプロイされていることが SaaS の条件だとしている。逆に言えば、Silo の運用コストを抑えられるかどうかは、コントロールプレーンをどれだけ自動化できるかで決まる。
| 観点 | Silo | Pool |
|---|---|---|
| 越境事故の起きにくさ | 構造的に起きない(接続先が別) | クエリの条件漏れ 1 つで起きる |
| ノイジーネイバー | 影響しない | 大口テナントの負荷が他に波及 |
| テナントあたりコスト | 固定費が乗る(DB 1 台分など) | ほぼゼロに近づく |
| スキーマ変更・デプロイ | テナント数だけ繰り返す | 1 回 |
| テナント別のカスタマイズ | やりやすい(やりすぎる) | 設定駆動に限られる(健全) |
| データの持ち出し・削除 | DB ごと渡す・消す | 絞り込みで抽出・削除する |
03データ層で境界を引く 4 つの方法
Silo と Pool の間には段階がある。データ層に限って言えば、境界の引き方は次の 4 つに整理できる。上から下へ行くほど Pool に近づき、安くなる代わりに「所属を書き忘れる」余地が増える。
リレーショナル DB で Pool を選ぶなら 3 が基本形であり、PostgreSQL の Row Level Security(RLS) がその「条件漏れ」を DB 側で補う。テーブルに ENABLE ROW LEVEL SECURITY を宣言し、CREATE POLICY の USING(読める行)と WITH CHECK(書ける行)にテナント条件を書くと、アプリが WHERE を忘れても DB が行を隠す。ただしテーブルの所有者とスーパーユーザーは既定でポリシーを素通りするので、アプリ用のロールを所有者と分けるか FORCE ROW LEVEL SECURITY を付ける必要がある。
ALTER TABLE students ENABLE ROW LEVEL SECURITY;
ALTER TABLE students FORCE ROW LEVEL SECURITY; -- 所有者にも適用
CREATE POLICY by_tenant ON students
USING (tenant_id = current_setting('app.tenant_id')::text)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::text);
-- 接続ごとに SET app.tenant_id = '...' してからクエリする
ドキュメント DB(Firestore など)では 4 が自然な形になる。コレクションの親ドキュメントがテナントなので、参照を組み立てた時点で所属が決まる。3 と違って「フィルタを足す」のではなく「そこにしか置き場がない」ため、書き忘れの余地が消える。この違いは第 6 章の be-hero-karte が 3 から 4 へ移行した理由そのものである。
Firestore は 1 プロジェクトに複数の名前付きデータベースを作れるため、1 のように「テナントごとに別 DB」も選べる。ただし Firebase SDK は既定で (default) に接続するので初期化時に DB の ID を渡す必要があり、無料枠も既定 DB にしか効かない。小規模テナントを多数抱える段階では 4 の方が扱いやすい。
04「誰のテナントか」を解決する
境界を引いても、リクエストがどのテナントのものかを毎回決められなければ意味がない。この解決(tenant resolution)は入口で 1 回だけ行い、以後の処理はその結果を信じるのが定石である。解決の材料は大きく 3 つあり、信頼できる順に並べると次のようになる。
Firebase Auth の場合、クレームは custom claims として Admin SDK の setCustomUserClaims で付ける。上限は 1000 バイト、予約語は使えず、呼ぶたびに既存のクレームを丸ごと置き換える。反映されるのは次に ID トークンが発行されたときで、通常は 1 時間以内、即時に反映したければクライアントで getIdToken(true) を呼んで強制更新する。Security Rules からは request.auth.token.tenantId のように参照できる。
「サブドメインでテナントを識別する」設計は多いが、それは表示用の入口であって認可の根拠ではない。acme.example.com に来たリクエストでも、トークンのテナントが acme でなければ拒否する。見た目とクレームの両方を持ち、一致を確かめるのが安全な形である。
05境界を強制する層と多層防御
テナントを解決したあと、その境界をどこで強制するか。アプリのコードだけに頼ると、1 か所の条件漏れが即座に越境になる。守る層を複数持ち、上の層が破れても下の層が拾う形にする。
Firestore で特に重要なのが 「Rules はフィルタではない」 という原則である。Rules はクエリの結果を絞り込むのではなく、クエリが返しうる集合全体が許可条件を満たすかを静的に評価し、満たさない可能性があれば要求そのものを失敗させる。したがって「読める行だけ返してほしい」という発想は通用せず、クエリ側が Rules と同じ制約(同じパス、同じ where)を持っていなければならない。裏を返せば、Rules を通るクエリは越境しないことが保証される。
もう 1 つの罠が collectionGroup クエリである。match /{path=**}/records/{rid} の再帰ワイルドカードで階層をまたいで一致させられるが、この形ではパス変数 {tid} を取り出せない。親がどのテナントかを Rules が知る術がないため、ドキュメント自身に tenantId を非正規化して持たせ、書き込み時にパスと一致することを強制し、読み取りはそのフィールドで判定する、という二段構えになる。
06be-hero-karte の実装
ここまでの一般論を、かけっこ教室向けの SaaS「be-hero-karte」がどう具体化したかを見る。構成は Vite + Vue 3 の静的フロント、Firestore、認証は Firebase Auth(管理者は Google、保護者は LINE の ID トークンを Cloud Run で検証してカスタムトークンに交換)、サーバは Go の Cloud Run 1 本である。テナントは法人(教室の運営事業者)で、データ層は第 3 章の 4(パスに埋める)、強制は第 5 章の 3 層すべてを持つ。
当初は各ドキュメントに tenantId フィールドを持たせる 3 の形(共有コレクション + フィールド)で始めたが、collectionGroup のフィードだけ Rules で強制できない既知のギャップが残った。実利用ユーザーがゼロで実テナントが 1 つの時期に tenants/{tid}/… のサブコレクションへ移行し、Rules の判定を「パスの tid とクレームの tenantId の 1 比較」に落とした。
Rules 側は次の 3 つの関数に集約されている。adminTenant() はクレームの tenantId を返し、無ければ既定テナントとして扱う(クレーム導入前からいる管理者の互換)。canActOn(t) が「自テナントか、運営者か、条件付きでお試し環境か」を判定し、tenants/{tid} ブロック内の tenantAdmin() は isAdmin() && canActOn(tid) の 1 行になる。
function adminTenant() {
return request.auth.token.get('tenantId', 'be-with');
}
function canActOn(t) {
return t == adminTenant()
|| isOperator()
|| (t == 'sandbox' && isOwner() && sandboxEnabled(adminTenant()));
}
match /tenants/{tid} {
function tenantAdmin() { return isAdmin() && canActOn(tid); }
match /students/{sid} {
allow read: if tenantAdmin() || isOwnerOf(sid);
match /records/{rid} {
// 書き込み時にパスとフィールドの一致を強制(collectionGroup 判定の担保)
allow create, update: if tenantAdmin() && request.resource.data.tenantId == tid;
}
}
}
// collectionGroup は再帰ワイルドカードで受け、非正規化フィールドで判定
match /{path=**}/records/{rid} {
allow read: if isAdmin() && canActOn(resource.data.tenantId);
}
フロントは activeTenant という 1 つの reactive な値を持ち、ログイン状態が変わるたびに ID トークンのクレームからテナントを引き直す。実効テナントが変わると Firestore の購読(onSnapshot)をすべて張り直す。Firestore の購読はパスに固定されるので、テナントが変わったら購読も作り直さなければ前のテナントのデータが画面に残る。サーバ側の Go も Firestore の参照を tenants/{tid}/… で組み立てるが、現状は設定で既定テナントに固定されており、リクエスト元のクレームから動的に解決する改修は残課題である。
| 一般論 | be-hero-karte での対応物 |
|---|---|
| テナント | tenants/{tid}。法人単位。businessUnitId は法人内の事業の副軸 |
| データ層の分離(第 3 章の 4) | サブコレクション tenants/{tid}/…。services は tenantCollection() 経由のみ |
| テナント解決: クレーム | 管理者の custom claims tenantId / businessUnitId / operator。招待受付でサーバが付与 |
| テナント解決: 対応表 | 保護者は lineUsers/{uid}.tenantId。無ければ既定テナント |
| 強制: ポリシー層 | Firestore Rules。canActOn(tid) の 1 比較 + collectionGroup は非正規化フィールド |
| 強制: 物理配置 | パスそのもの。旧トップレベルの match を削除して default deny |
| コントロールプレーン | tenants/{tid}.features(機能フラグ、運営者のみ書ける)、招待、Cloud Run の accept-invite |
| テストでの担保 | Rules 単体テストで越境を assertFails、E2E で別テナントのデータが混ざらないことを確認 |
07境界を越える必要があるもの
境界を完全に閉じると運営が回らない。SaaS には意図して境界を越える経路が必ず数本あり、それらを「例外」として散らさず、正面から設計に入れるかどうかで安全性が決まる。be-hero-karte で実際に必要になった越境は 4 種類ある。
- 1 対応表
- クレームを持てない身元(LINE の uid)は、グローバルなコレクションを 1 件読んで自分のテナントを知る。この 1 件は「自分のドキュメントだけ読める」Rules で守る
- 2 運営者
- 2 社目以降のセットアップとサポートを 1 つのアカウントで行うための経路。テナントごとに Google アカウントを持たない。
operatorクレームはsetCustomUserClaimsが丸ごと置換する性質上、他のクレームを付け直すたびに消えないよう注意が要る - 3 お試し環境
- 本番データに触れずに新機能を試す固定テナント。特定テナントの写しではなく汎用のダミーで、毎朝作り直す。入れるのは「オーナー」かつ「自テナントのフラグが ON」の人だけで、フラグは運営者しか書けない
- 4 共有の入口
- 保護者向け LIFF アプリと LINE Login チャネルは 1 つ。各教室は自分の LINE 公式アカウントに同じ LIFF のリンクを置くだけで、分岐は 1 の対応表で起きる
「まず既存顧客のテナントで友人に試してもらう」は越境の最悪の形である。同じテナントの管理者は全データを読めるため、別事業者に既存顧客の生徒・保護者情報が見える。試したい人には最初から専用テナントを発行する。テナント作成がスクリプト 2 本で済むなら、それが最も安い越境防止策になる。
08運用指針と判断の目安
最後に、設計を選んだあとに効いてくる運用上の判断をまとめる。マルチテナントは「作って終わり」ではなく、テナントが増えるたびに新しい越境経路と負荷の偏りが生まれる。
Pool から始めて、必要なものだけ Silo に寄せる
小規模テナントを多数抱える SaaS は Pool(第 3 章の 3 か 4)で始めるのが定石である。テナント数が少ないうちに Silo を選ぶと、運用の自動化が追いつかずに顧客ごとの手作業が積み上がる。Silo に寄せる判断は「規制でデータの物理分離を求められた」「1 テナントの負荷が他に波及した」「顧客ごとの DB 移管を契約で約束した」のいずれかが起きてからで遅くない。Bridge はその結果として自然に生まれる。
所属を決める場所を 1 つにする
アプリ層で最も効く対策は、参照を組み立てる関数を 1 つにして、それ以外の経路を禁止することである。be-hero-karte の tenantCollection() がその例で、レビューでは「直接 collection() を呼んでいないか」だけを見ればよい。PostgreSQL なら接続直後にセッション変数へテナントを入れるミドルウェアがこれにあたる。
越境はテストで担保する
「A のユーザーで B のデータを読むと拒否される」は、機能テストではなく境界テストとして独立に書く。Firestore なら Rules の単体テストで assertFails、SQL なら別テナントのセッション変数でクエリして 0 行を確かめる。Rules や RLS を変えたら、デプロイの前にこのテストを通すことをパイプラインで強制する。
移行は「実テナントが少ないうち」に
第 3 章の 3 から 4 への移行のようなデータ配置の変更は、テナントが増えるほど高くつく。be-hero-karte は実テナント 1・実利用ユーザー 0 の時期に移行を済ませ、旧データは Rules の match を消して不可視にしたまま検証期間だけ残した。「公開前が最後の安い移行タイミング」という判断は、多くの SaaS で繰り返される。
| 状況 | 寄せる先 | 理由 |
|---|---|---|
| 小規模テナントが多数・原価をゼロに近づけたい | Pool(3 か 4) | テナントあたりの固定費が乗らない。運用が 1 本 |
| ドキュメント DB・クライアントから直接読む構成 | 4(パスに埋める)+ ポリシー層 | 所属が構造で決まり、Rules の判定が 1 比較になる |
| リレーショナル DB・サーバ経由 | 3(tenant_id 列)+ RLS | WHERE 漏れを DB が拾う。所有者バイパスに注意 |
| 規制でデータの物理分離が要る | Silo(1 か 2) | 監査に「別 DB です」と答えられる |
| 大口 1 社の負荷が他に波及 | Bridge(その 1 社だけ Silo) | 全体を Silo にせず、原因だけ切り出す |
| サポートで他社テナントに入りたい | 運営者クレーム + 画面での明示 | 共有アカウントを配らず、経路を Rules に列挙する |