目次
- 1. 分離モデルを先に決める
- 2. データ分離(Pool モデルの場合)
- 3. テナントコンテキストの扱い
- 4. 認証・認可
- 5. Noisy Neighbor 対策
- 6. 運用・デプロイ
- 7. 可観測性とコスト
- 8. セキュリティとコンプライアンス
- 9. テスト
- よくあるアンチパターン
1. 分離モデルを先に決める
最初の大きな設計判断は、テナント同士をどこまで物理的に分けるかです。
| モデル | 構成 | 分離の強さ | コスト・運用 | 向いているケース |
|---|---|---|---|---|
| Silo | テナントごとに DB(またはアカウントやスタック)を分ける | 強い | 高い。マイグレーションも N 回必要 | 規制業種、大口顧客 |
| Bridge | DB は共有し、スキーマをテナントごとに分ける | 中程度 | 中程度。テナント数が数千を超えると厳しい | 中規模の B2B |
| Pool | 全テーブル共有で tenant_id 列で区別する | 弱いので、アプリ側と DB 側で担保が必要 | 低い。スケールしやすい | 大多数の SaaS |
- 基本は Pool で始めます。そのうえで、大口テナントだけ Silo に切り出せる「ハイブリッド(ティア別)」構成を取れるようにしておくのが現実的です。
- どのモデルでも、テナントの所在(どの DB・シャードにいるか)を引けるテナントカタログを持っておきます。これがあれば、後からテナントを移動できます。
2. データ分離(Pool モデルの場合)
- 全テーブルに
tenant_idを持たせる:子テーブルにも冗長に持たせます。JOIN しなくても絞り込めるようにするためです。 - インデックスと一意制約は
tenant_idを先頭にする:例としてUNIQUE (tenant_id, email)のようにします。グローバルな一意制約にすると、他テナントのデータが存在することを推測される漏洩経路になります。 - 複合外部キーを使う:
(tenant_id, parent_id)で参照させると、テナントをまたいだ参照を DB レベルで防げます。 - ID は UUID か ULID にする:連番 ID だと、ほかのテナントの規模を推測されたり、ID を推測した攻撃を受けたりする余地が残ります。
- PostgreSQL の RLS(Row Level Security)を多層防御として使う:アプリ側でスコープを付け忘れても、DB 側で止められます。
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);RLS を使うときは、テナントの指定を必ず SET LOCAL(トランザクション単位)で行います。PgBouncer をトランザクションモードで使っている場合、セッション単位の SET は別のリクエストにそのまま残って漏れます。また、アプリが接続する DB ロールはテーブルの owner にも BYPASSRLS にもしないでください。どちらでも RLS が効かなくなります。
3. テナントコンテキストの扱い
- リクエストの入口で一度だけ解決する:サブドメイン、カスタムドメイン、JWT のクレームなどからテナントを特定します。リクエストボディやクエリパラメータの
tenant_idは信用しないでください。 - 解決したコンテキストをすべての経路に伝播させる
- バックグラウンドジョブ:エンキュー時に
tenant_idを引数に含め、ワーカー側で再設定します。 - キャッシュ:キーに
tenant:{id}:のプレフィックスを付けます。 - S3:
s3://bucket/{tenant_id}/...の形にし、IAM のポリシー条件でもプレフィックスを制限します。 - 検索インデックスやベクトル DB:フィルタかネームスペースで分けます。RAG で漏れる事故は多いです。
- WebSocket や Pub/Sub:チャネル名にテナントを含めます。
- バックグラウンドジョブ:エンキュー時に
- Rails の場合は
acts_as_tenantやCurrentAttributesで暗黙的にスコープをかけられます。ただしunscopedや生 SQL、find_by_sqlを使うとスコープが外れるので、RLS と組み合わせておくと安心です。
4. 認証・認可
- ユーザーとテナントは多対多(Membership)で設計します。1 人のユーザーが複数テナントに所属するのは前提として考えます。
- ロールや権限はテナント単位で付与します。
- サポート担当などがテナントをまたいでアクセスする機能は、明示的な「なりすまし」機能として作ります。期限付きにし、テナント側の承認を取り、監査ログを残します。
- エンタープライズ向けには、SSO(SAML/OIDC)や SCIM の設定をテナントごとに持てるようにします。
5. Noisy Neighbor 対策
特定のテナントが大量のリソースを使って、ほかのテナントに影響を出すことへの対策です。
- テナントごとにレート制限とクォータ(API 呼び出し数、ストレージ、同時実行数)を設けます。
- ジョブキューはテナント間で公平に処理されるようにします。テナントごとの同時実行上限を設けたり、ラウンドロビンにしたりします。1 テナントの一括インポートでキュー全体が詰まる事故はよく起きます。
- 重いテナントは、専用のシャードやワーカーに移せる設計にしておきます。
6. 運用・デプロイ
- マイグレーション:Silo や Bridge では N 回実行することになるので、冪等で再開可能にし、段階的にロールアウトします。後方互換を保つ expand/contract パターンが必須です。
- テナント単位のフィーチャーフラグと設定:機能の段階リリースや、プランごとの機能差分を出すのに使います。
- オンボーディングとオフボーディングの自動化:テナント作成、初期データ投入、削除、データエクスポートをコードで実行できるようにしておきます。
- バックアップと復元:Pool モデルでは「1 テナント分だけ過去の状態に戻す」のが難しいです。要件にあるなら、早い段階で設計しておきます。
7. 可観測性とコスト
- ログ、メトリクス、トレースのすべてに
tenant_idを付けます。障害時の影響範囲の特定や、サポート対応で効いてきます。 - テナント別のコストを集計できるようにします(AWS のコスト配分タグ、使用量の計測)。価格設計や赤字テナントの発見に必要です。
- SLO は全体とあわせて、上位テナント単位でも見ます。
8. セキュリティとコンプライアンス
- 必要に応じて、テナントごとに暗号鍵を分けます(KMS、BYOK)。鍵を破棄することで、そのテナントのデータを確実に消去する手段にもなります。
- データの保存地域の要件がある場合は、リージョン別にデプロイし、テナントカタログでルーティングします。
- 監査ログは改ざんできない形で保存し、テナント管理者が閲覧できるようにします。
9. テスト
- テストデータは常に 2 テナント以上で作ります。「テナント A の認証情報でテナント B のリソースにアクセスすると 404 になる」ことを、全エンドポイントに対して網羅的にテストします。
- 静的解析や Lint で、スコープなしのクエリを検出します。
よくあるアンチパターン
- 入力された
tenant_idを、そのまま WHERE 句に使う - 管理画面やバッチだけ、テナントのスコープを外して実装する
- キャッシュキーや S3 のパスにテナントが含まれていない
- テナント固有の要件に、コードの if 分岐で対応する(設定やフラグで対応すべき)
- テナントの移動や分割を想定しない構成で始めてしまう