背景と理由:マルチテナンシーの悪夢
SaaS(Software-as-a-Service)プラットフォームの設計を始めた瞬間、一つの問いがあなたを悩ませるでしょう。「どうすればテナントAがテナントBのデータを見るのを防げるか?」という問いです。500社以上の顧客を抱えている場合、単に巨大なテーブルにすべてを詰め込み、WHERE tenant_id = ? フィルタが失敗しないことを祈るだけでは不十分です。アプリケーションロジックに1つでも句が欠けていれば、CEOの非公開の請求書が競合他社に漏洩する可能性があります。それはキャリアを終わらせるような大事件です。
MySQL、MongoDB、そしてPostgreSQLを様々な高トラフィックプロジェクトで扱ってきた経験から、マルチテナンシーにおいてPostgreSQLは疑いようのないヘビー級チャンピオンであると感じています。スキーマ(Schema)と行レベルセキュリティ(RLS)のネイティブサポートは、他の多くのリレーショナルデータベースが太刀打ちできないレベルの安全性を提供します。通常、選択は「高度な分離」と「運用上の健全性」のトレードオフになります。
私は通常、戦略を次の3つのカテゴリに分類します:
- テナントごとのデータベース: 完全な分離。しかし、コストが高く、顧客が100社を超えると管理が困難になります。
- テナントごとのスキーマ: 堅実な妥協案。各テナントは1つのデータベース内で専用の名前空間を持ちます。
- 共有スキーマ(行レベル): 全員が同じテーブルに同居します。データベースが内部ポリシーを通じて分離を強制します。
モダンなバックエンドにとって最も実用的な2つの戦略を見ていきましょう。
インストール:土台を作る
実際に試すには、Postgresのインスタンスが必要です。Dockerがインストールされていれば、約10秒でローカル環境を構築できます。最新のRLSパフォーマンス最適化を活用するため、Postgres 16以降を使用することをお勧めします。
# Postgres 16のコンテナを起動
docker run --name saas-db -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d postgres:16
# CLIに入る
docker exec -it saas-db psql -U postgres
中に入ったら、新しいデータベースを作成します。デフォルトの postgres データベースからすぐに移行することは、環境管理を改善するための小さくとも重要な習慣です。
CREATE DATABASE itfromzero_saas;
\c itfromzero_saas;
設定:分離戦略の実装
戦略1:スキーマベースのマルチテナンシー
Postgresの「スキーマ」を専用のフォルダーのように扱います。各テナントは独自のフォルダーを持ちます。このアプローチは、他の誰のレコードにも触れずに単一の顧客データをバックアップまたは復元できるため、高いコンプライアンスが求められる業界に最適です。
新しい会社が登録した際、バックエンドはワークスペースをプロビジョニングするためのスクリプトを実行する必要があります。
-- 2つの新しい顧客用にスキーマをプロビジョニング
CREATE SCHEMA tenant_apple;
CREATE SCHEMA tenant_banana;
-- 同一のテーブル構造をデプロイ
CREATE TABLE tenant_apple.users (id SERIAL PRIMARY KEY, name TEXT);
CREATE TABLE tenant_banana.users (id SERIAL PRIMARY KEY, name TEXT);
-- テナント固有のデータを挿入
INSERT INTO tenant_apple.users (name) VALUES ('Steve');
INSERT INTO tenant_banana.users (name) VALUES ('Bob');
ここでの真の利点は search_path です。SQLにスキーマ名をハードコーディングする代わりに、アプリケーションが接続プールから接続を取得した直後にセッションコンテキストを設定します。
-- Appleのコンテキストに切り替え
SET search_path TO tenant_apple;
SELECT * FROM users; -- Steveが表示される
-- Switch to Banana's context
SET search_path TO tenant_banana;
SELECT * FROM users; -- Bobが表示される
戦略2:行レベルセキュリティ(RLS)
スキーマはテナントが1,000社に達するまではクリーンです。その規模になると、カラムを追加するために単純な ALTER TABLE を実行するだけで、データベースが1,000個の個別のテーブルを反復処理するため、数時間かかることがあります。ここでRLSが威力を発揮します。全員が1つのテーブルを共有しますが、データベースが見えない門番として機能します。
まず、必須の tenant_id カラムを持つ共有テーブルを定義します。
CREATE TABLE projects (
id SERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL
);
-- 門番を有効にする
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
次に、アクティブなリクエストの「現在のテナント」が誰であるかをPostgresに伝える方法が必要です。私はセキュリティポリシーと組み合わせたカスタムセッション変数を使用しています。
-- ポリシーの定義:セッションのテナントIDに一致する行のみを表示
CREATE POLICY tenant_isolation_policy ON projects
USING (tenant_id = current_setting('app.current_tenant'));
-- 特権昇格を防ぐため、アプリ用に制限されたユーザーを作成
CREATE ROLE app_user WITH LOGIN PASSWORD 'password';
GRANT ALL ON projects TO app_user;
その後、アプリケーションは単一のトランザクション内で以下のステップを実行します:
BEGIN;
-- この特定のWebリクエストのテナントを特定
SET LOCAL app.current_tenant = 'customer_123';
-- このクエリがcustomer_456のデータを見ることは決してありません
SELECT * FROM projects;
COMMIT;
これはあなたのセーフティネットです。開発者が複雑なJOINで WHERE 句を忘れたとしても、データベースが許可されていない行を単に返さないようにします。本番環境で何度も私の窮地を救ってくれました。
検証と監視:堅牢性を保つ
分離は「一度設定すれば終わり」の機能ではありません。正しく動作していることを証明する必要があります。私は常に、意図的にデータを交差させようとする統合テストを書きます。もしテナントAがテナントBのIDを正常に照会できてしまったら、ビルドは即座に失敗しなければなりません。
はもう一方の重要な要素です。RLSはすべてのクエリに暗黙のフィルタを追加するため、注意を払わないとサイレントキラーになる可能性があります。必ず tenant_id カラムにインデックスを貼ってください。インデックスがないと、テーブルが大きくなるにつれてデータベースがシーケンシャルスキャンを実行し、10msのクエリが500msの遅延フェスに変わってしまいます。
-- RLSパフォーマンスのために必須のインデックス
CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);
最近のプロジェクトでは、RLSに切り替えたことで1つのテーブルで2,500のテナントを管理できるようになりました。スキーマ更新の移行時間は45分から30秒未満に短縮されました。しかし、フィンテックやヘルスケア分野にいる場合、セキュリティ監査に合格するために、テナントごとのスキーマモデルによる物理的な分離が依然として必要な対価となるかもしれません。どちらの道を選んでも、分離ロジックができるだけデータに近い場所に存在するようにしてください。

