アーキテクチャ

SaaS のマルチテナントをどう実現するか

1 つのアプリケーションで複数の顧客(テナント)を預かるとき、何を共有し、何を分け、その境界をどこで強制するか。Silo / Pool / Bridge の分類とデータ層の 4 パターンを出発点に、識別・強制・越境・運用を順に辿り、最後に Firestore 上のかけっこ教室向け SaaS「be-hero-karte」が採った構成を具体例として読む。

01マルチテナントとは何を共有することか

マルチテナントとは、1 つのソフトウェア実体を複数の顧客が同時に使い、それぞれの顧客には自分のデータしか見えない状態を指す。共有するのはコード・デプロイ・運用の仕組みであり、分けるのはデータと設定である。この「何を共有し何を分けるか」を決めることがマルチテナント設計のすべてで、後の章はその決め方を扱う。

テナント
分離の主体。契約単位の顧客。法人・店舗・チームなど、業態により粒度は違う
テナント境界
テナント A のユーザーがテナント B のデータに触れられないという保証。これを破ることを越境(cross-tenant access)と呼ぶ
コントロールプレーン
テナントの開設・課金・機能フラグ・運営者の操作を担う、全テナント共通の管理層
アプリケーションプレーン
テナントごとの業務データを扱う層。境界はここに引かれる

マルチテナントと「顧客ごとに別インスタンスを配る」ことの違いは、共有のコントロールプレーンがあるかどうかにある。顧客ごとに別のデータベースを持たせていても、開設・課金・監視・デプロイが 1 本の仕組みで回っていればマルチテナント SaaS であり、逆にコードが同じでも顧客ごとに別々の運用をしているならそれは受託のホスティングである。

利用者 テナント A の管理者 テナント B の管理者 テナント C の管理者 1 つのソフトウェア実体(共有) アプリケーション 同じコード・同じデプロイ コントロールプレーン テナント開設・課金・機能フラグ 運営者の操作・監視・デプロイ 全テナント共通で 1 本 アプリケーションプレーン(分離) テナント A のデータ テナント B のデータ テナント C のデータ 互いに見えない区画 共有するもの = コード・運用の仕組み  分けるもの = データ・設定
FIG 01共有する層と分離する層

マルチテナントの難しさは「分けること」ではなく「分けたうえで運用を 1 本にすること」にある。分離を強くするほど運用は顧客数に比例して増え、共有を強くするほど越境事故と隣人の負荷(noisy neighbor)の対策が重くなる。

02Silo・Pool・Bridge の 3 モデル

分離の度合いを語るとき、AWS の SaaS Lens が定義する Silo / Pool / Bridge の 3 分類が共通語彙として使われる。どれか 1 つを選ぶというより、システムの部位ごとにどこに置くかを決めるための物差しである。

Silo
テナントごとに専用のリソース(DB、コンピュート、場合によってはスタック全体)を用意する。ただし開設・認証・運用は共有のまま
Pool
テナントがリソースを共有する。古典的なマルチテナントで、規模の経済と俊敏さを得る代わりに、境界をソフトウェアで守る
Bridge
部位によって Silo と Pool を混ぜる。規制やノイジーネイバーの性質でサービスごとに寄せ方を変える
Silo テナントごとに専用リソース App A App B App C DB A DB B DB C 共有の開設・認証・運用 隔離が強い・隣人の影響なし コストと運用がテナント数に比例 Pool 全テナントでリソース共有 App(A・B・C 共用) DB(A・B・C の行が同居) 共有の開設・認証・運用 安い・俊敏・運用が 1 本 境界をソフトウェアで守る必要 Bridge 部位ごとに混ぜる App(共用) DB(A・B 同居) DB C 専用 共有の開設・認証・運用 大口・規制対象だけ Silo に寄せる 2 経路の保守が要る
FIG 02Silo・Pool・Bridge。いずれも開設・認証・運用は共有する

Silo であっても「開設・認証・運用は共有」である点は見落とされやすい。SaaS Lens もこの点を強調しており、専用リソースを持っていてもテナントが共通の仕組みで管理・デプロイされていることが SaaS の条件だとしている。逆に言えば、Silo の運用コストを抑えられるかどうかは、コントロールプレーンをどれだけ自動化できるかで決まる。

観点SiloPool
越境事故の起きにくさ構造的に起きない(接続先が別)クエリの条件漏れ 1 つで起きる
ノイジーネイバー影響しない大口テナントの負荷が他に波及
テナントあたりコスト固定費が乗る(DB 1 台分など)ほぼゼロに近づく
スキーマ変更・デプロイテナント数だけ繰り返す1 回
テナント別のカスタマイズやりやすい(やりすぎる)設定駆動に限られる(健全)
データの持ち出し・削除DB ごと渡す・消す絞り込みで抽出・削除する

03データ層で境界を引く 4 つの方法

Silo と Pool の間には段階がある。データ層に限って言えば、境界の引き方は次の 4 つに整理できる。上から下へ行くほど Pool に近づき、安くなる代わりに「所属を書き忘れる」余地が増える。

分離が強い 共有が強い 1 テナントごとに別データベース 接続先が違うので越境は構造的に不可能。 接続文字列の管理・マイグレーションの反復が負担 2 同じ DB 内で別スキーマ search_path の切替で所属を決める。1 接続で済むが テナント数分のテーブルが増え、DDL が重い 3 共有テーブル + tenant_id 列 最も安い。全クエリに WHERE tenant_id = ? が要り、 漏れを RLS などで下から拾う設計が前提 4 パスの階層に tenantId を埋める tenants/{tid}/students/… のように親に置く。ドキュメント DB 向き。 参照先そのものが所属なので 3 の「条件漏れ」が起きない
FIG 03データ層の境界の引き方。上が Silo 寄り、下が Pool 寄り

リレーショナル DB で Pool を選ぶなら 3 が基本形であり、PostgreSQL の Row Level Security(RLS) がその「条件漏れ」を DB 側で補う。テーブルに ENABLE ROW LEVEL SECURITY を宣言し、CREATE POLICYUSING(読める行)と 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 つあり、信頼できる順に並べると次のようになる。

解決の材料(信頼できる順) 認証トークンのクレーム IdP が署名した JWT に tenantId を入れる。 改ざん不可・追加の読み取りなし 身元 → テナントの対応表 認証済みの uid で 1 件引く。クレームを 発行できない外部 ID(LINE 等)向け リクエストの見た目 サブドメイン・パス・ヘッダ・クエリ。 誰でも書けるので単独では信じない 入口で 1 回解決 ログイン直後 / ミドルウェア tenant context { tenantId: "…" } 見た目の値はクレームや 対応表と突き合わせて 一致しなければ拒否する ここより奥では再解決しない 以後の処理はコンテキストを信じる DB 参照の組み立て(パス・WHERE) 購読(リアルタイム)の張り直し 機能フラグ・設定の読み込み 画面上の「どのテナントか」表示
FIG 04テナント解決は入口で 1 回。材料は信頼できる順にクレーム・対応表・見た目

Firebase Auth の場合、クレームは custom claims として Admin SDK の setCustomUserClaims で付ける。上限は 1000 バイト、予約語は使えず、呼ぶたびに既存のクレームを丸ごと置き換える。反映されるのは次に ID トークンが発行されたときで、通常は 1 時間以内、即時に反映したければクライアントで getIdToken(true) を呼んで強制更新する。Security Rules からは request.auth.token.tenantId のように参照できる。

「サブドメインでテナントを識別する」設計は多いが、それは表示用の入口であって認可の根拠ではない。acme.example.com に来たリクエストでも、トークンのテナントが acme でなければ拒否する。見た目とクレームの両方を持ち、一致を確かめるのが安全な形である。

05境界を強制する層と多層防御

テナントを解決したあと、その境界をどこで強制するか。アプリのコードだけに頼ると、1 か所の条件漏れが即座に越境になる。守る層を複数持ち、上の層が破れても下の層が拾う形にする。

上の層が破れても下の層が拾う アプリケーション層 参照の組み立てを 1 つのヘルパに集約し、直接 collection() や生 SQL を書かせない。テナント切替時は購読も張り直す 漏れやすい データストアのポリシー層 Firestore Security Rules / PostgreSQL RLS。「パスの tid == クレームの tenantId」「行の tenant_id == セッション変数」を DB 側で照合 アプリの漏れを拾う 物理配置の層 tenants/{tid}/… のパス、別スキーマ、別 DB。所属が構造で決まり、フィルタという概念自体が要らない 構造で決まる Firestore の原則: Rules はフィルタではない。条件のないクエリは「拒否される可能性がある」時点で丸ごと失敗する
FIG 05強制する 3 つの層。be-hero-karte は 3 層とも持つ

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 比較」に落とした。

tenants/{tid}/… に所属を埋める tenants/{tid} 本体: name / schools / lineAccounts / features / levels(公開 read) students/{sid} 生徒。status / classKeys / bestTime … records/{rid} 計測記録。tenantId を非正規化 collectionGroup + where(tenantId) scheduleEvents/{eid} 開催日。date / classId / cancelled attendance/{sid} 出欠。tenantId を非正規化 collectionGroup + where(tenantId) classes awards parentLinks waitlist withdrawalRequests runOrders services は utils/tenant.js の tenantCollection(db, name) / tenantDoc(db, name, id) だけを通す。 collection(db, 'students') のような直接参照は書かない(所属を決める場所を 1 つにする) トップレベルに残すもの(理由つき) admins/{uid} 認可の入口。テナントはクレームで判定するため ここ自体はテナント配下に置かない lineUsers/{lineUserId} 身元 → テナントの対応表。LINE の uid は クレームを持てないので、認証後にここを読んで 初めて自分のテナントを知る(第 4 章の 2 番目) adminInvites/{email} 招待。受付時にサーバが admins を作り クレーム(tenantId / role)を付ける tenantSecrets/{tid} LINE のチャネルトークン等。Rules は全拒否・サーバ専用
FIG 06be-hero-karte の Firestore 配置。所属はパスで決まり、越境が必要なものだけトップレベルに残す

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 種類ある。

tenants/A tenants/B tenants/sandbox 汎用ダミーデータ 1 身元 → テナントの対応表 lineUsers を 1 回読んで自分の tid を知る 2 運営者(operator クレーム) 任意のテナントに入る。切替はタブの間だけ 3 お試し環境 オーナー + 自テナントの features.sandbox が ON 4 共有の入口 LIFF と LINE Login チャネルは全テナント共通 → 1 で分岐 自テナント = 1 比較 運営者だけ 全オーナー共用
FIG 07意図した越境の 4 経路。すべて Rules の canActOn に列挙されている
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 列)+ RLSWHERE 漏れを DB が拾う。所有者バイパスに注意
規制でデータの物理分離が要るSilo(1 か 2)監査に「別 DB です」と答えられる
大口 1 社の負荷が他に波及Bridge(その 1 社だけ Silo)全体を Silo にせず、原因だけ切り出す
サポートで他社テナントに入りたい運営者クレーム + 画面での明示共有アカウントを配らず、経路を Rules に列挙する