マルチテナントWebアプリケーションのためのデータベースアーキテクチャ
James Reed
Infrastructure Engineer · Leapcell

マルチテナントデータベースソリューションによるスケーラブルなWebアプリケーションの構築
今日のクラウドネイティブな世界では、Software-as-a-Service(SaaS)アプリケーションが至る所で利用されています。多くのSaaSオファリングに共通する特徴は、マルチテナンシーの概念です。これは、単一のアプリケーションインスタンスが複数の顧客、すなわち「テナント」にサービスを提供するものです。このアプローチは、コスト効率、合理化されたメンテナンス、および展開の簡素化という点で大きなメリットをもたらします。しかし、そのようなアプリケーションのための堅牢でスケーラブルなデータベースアーキテクチャを設計することは、特有の課題を提示します。この記事では、マルチテナントWebアプリケーションのための様々なデータベースアーキテクチャパターン、その基本原則、および実装のための実践的な考慮事項を探ります。
マルチテナンシーの基盤を理解する
アーキテクチャパターンに飛び込む前に、データベースのコンテキストにおけるマルチテナンシーを取り巻くいくつかのコアコンセプトを明確にしましょう。
- テナント: 同じアプリケーションインスタンスを共有するが、他のグループとはデータが分離されているユーザーまたは組織の個別のグループ。各テナントは、独自の専用ソフトウェア環境を持っているかのように動作します。
- データ分離: マルチテナンシーにおける基本的な要件。テナントは、他のテナントのデータにアクセスしたり、影響を受けたりしてはなりません。これは、セキュリティ、プライバシー、およびコンプライアンスにとって重要です。
- スキーマ: データベースの構造であり、テーブル、列、関係、およびデータ型を定義します。
- パフォーマンス分離: あるテナントのアクションが、他のテナントが経験するパフォーマンスに悪影響を与えないことを保証します。これは、データ分離よりも難しいことがよくあります。
- カスタマイズ: 他のテナントに影響を与えることなく、個々のテナントのためにアプリケーションまたはデータスキーマの側面を調整する能力。
あらゆるマルチテナントデータベースアーキテクチャの目標は、スケーラビリティ、コスト、セキュリティ、および運用上の複雑さといった要因を考慮しながら、これらの懸念事項を効果的にバランスさせることです。
複数のテナントのためのアーキテクチャ
データベースレベルでマルチテナンシーを処理するための3つの主要なアーキテクチャパターンがあり、それぞれにトレードオフがあります。
1. テナントごとのデータベース分離
これは最も直接的で安全なアプローチです。各テナントは独自の専用データベースインスタンスを持っています。
原則: データの完全な物理的分離。各テナントのデータは、独自の分離されたデータベースに存在します。
実装: 新しいテナントがサインアップすると、そのための新しいデータベースがプロビジョニングされます。アプリケーションは、認証されたテナントに基づいて適切なデータベースに接続します。
例(接続プールを使用した簡略化された疑似コード):
# FlaskまたはDjangoのようなWebフレームワークで from flask import g, request import psycopg2 DATABASE_CONFIG = { "tenant_a": {"host": "db_a_host", "database": "tenant_a_db", "user": "user_a", "password": "password_a"}, "tenant_b": {"host": "db_b_host", "database": "tenant_b_db", "user": "user_b", "password": "password_b"}, # ... その他のテナント } def get_tenant_db_connection(): tenant_id = request.headers.get('X-Tenant-ID') # またはセッション、サブドメインなどから if tenant_id not in DATABASE_CONFIG: raise Exception("無効なテナントID") config = DATABASE_CONFIG[tenant_id] if not hasattr(g, 'db_connection'): g.db_connection = psycopg2.connect( host=config['host'], database=config['database'], user=config['user'], password=config['password'] ) return g.db_connection @app.route('/data') def get_data(): conn = get_tenant_db_connection() cursor = conn.cursor() cursor.execute("SELECT * FROM some_table") data = cursor.fetchall() return {"data": data}
長所:
- 最も強力なデータ分離: データベースレベルでのクロステナントデータ漏洩のリスクはありません。
- バックアップと復元の簡素化: 個々のテナントデータを独立してバックアップおよび復元できます。
- パフォーマンス分離: 1つのテナントのデータベースでのパフォーマンス問題は、データベースサーバーが別々であるか、十分なリソース割り当てがされている限り、他のテナントに影響を与える可能性は低いです。
- カスタマイズの容易さ: 1つのテナントのスキーマ変更は、他のテナントに影響しません。
- コンプライアンス: 厳格な規制コンプライアンス要件に対しては、しばしば推奨される選択肢です。
短所:
- 最も高い運用オーバーヘッド: 数百または数千もの個別のデータベースを管理することは、複雑でリソースを消費する可能性があります(監視、パッチ適用、アップグレード)。
- 高いインフラストラクチャコスト: 各データベースインスタンスはリソースを消費するため、特に小規模なテナントでは、共有アプローチと比較してコストが高くなる可能性があります。
- リソースの利用不足: 小規模なテナントは、専用のデータベースリソースを十分に活用していない場合があります。
- 複雑な移行: 多数のデータベースにわたるスキーマ移行の調整は困難な場合があります。
2. テナントごとのスキーマ分離
このアプローチでは、すべてのテナントは同じデータベースサーバーインスタンスを共有しますが、各テナントは、そのデータベース内に独自の専用スキーマを持っています。
原則: 共有物理データベース内でのデータの論理的分離。
実装: 新しいテナントがプロビジョニングされると、新しいスキーマ(例: tenant_a_schema
、tenant_b_schema
)が共有データベース内に作成されます。そのテナントのすべてのテーブル、ビューなどの定義はそのスキーマ内に作成されます。アプリケーションは、現在のテナントのスキーマをプレフィックスとしてテーブル名を調整するようにクエリを変更します。
例(PostgreSQLを使用した簡略化された疑似コード):
# Webフレームワークで from flask import g, request import psycopg2 SHARED_DB_CONFIG = { "host": "shared_db_host", "database": "multi_tenant_db", "user": "shared_user", "password": "shared_password" } def get_shared_db_connection(): if not hasattr(g, 'db_connection'): g.db_connection = psycopg2.connect( host=SHARED_DB_CONFIG['host'], database=SHARED_DB_CONFIG['database'], user=SHARED_DB_CONFIG['user'], password=SHARED_DB_CONFIG['password'] ) return g.db_connection @app.before_request def set_tenant_schema(): tenant_id = request.headers.get('X-Tenant-ID') if not tenant_id: raise Exception("テナントIDが提供されていません") conn = get_shared_db_connection() cursor = conn.cursor() # 現在のセッションの検索パスを設定します cursor.execute(f"SET search_path TO {tenant_id}_schema, public;") conn.commit() # DDL/スキーマ変更、またはセッション設定に重要 @app.route('/data') def get_data(): conn = get_shared_db_connection() cursor = conn.cursor() # search_pathが処理するため、明示的なスキーマプレフィックスなしでクエリを実行します cursor.execute("SELECT * FROM some_table") data = cursor.fetchall() return {"data": data}
長所:
- 良好なデータ分離: 強力な論理的分離により、アプリケーションが正しくスキーマプレフィックスまたは検索パスを使用している限り、誤ってクロステナントデータにアクセスすることを防ぎます。
- 分離データベースよりも低い運用オーバーヘッド: 単一のデータベースインスタンス(監視、バックアップ、アップグレード)の管理が容易です。
- より効率的なリソース活用: リソースはテナント間でプールされます。
- スキーマ管理の容易さ: 一般的なスキーマ変更は一度適用されるとすべてのテナントに影響します(スキーマが同一である場合)。
短所:
- パフォーマンス分離が低い: 単一のデータベースサーバーは共有リソースを意味します。「ノイジーネイバー」テナントが他のテナントに影響を与える可能性があります。
- バックアップ/復元の複雑化: 単一テナントの復元は、多くの場合、データベース全体を復元してからテナントのスキーマを抽出/再インポートすることを意味します。
- スキーマのずれの可能性: テナントのみがカスタムスキーマ変更を必要とする場合、単一データベース内でのユニークなスキーマの管理は複雑になる可能性があります。
- アプリケーションレイヤーへのセキュリティ依存: 適切なスキーマ処理はアプリケーションコードで重要であり、間違いはデータを公開する可能性があります。
3. テナント識別子による共有データベース、共有スキーマ
これは最もリソース効率の良いアプローチであり、すべてのテナントが同じデータベースとスキーマを共有します。データは、テナント固有のデータを含むすべてのテーブルに「テナントID」列を格納することによって分離されます。
原則: 共有データベースと共有スキーマ内でのデータの論理的分離。アプリケーションレベルのフィルタリングによって強制されます。
実装: 関連するすべてのテーブルには tenant_id
列(またはそれに類似するもの)が含まれます。すべてのクエリは、WHERE tenant_id = <current_tenant_id>
句を含める必要があります。
例(簡略化された疑似コード):
# Webフレームワークで from flask import g, request import psycopg2 SHARED_DB_CONFIG = { "host": "shared_db_host", "database": "multi_tenant_db", "user": "shared_user", "password": "shared_password" } def get_db_connection(): if not hasattr(g, 'db_connection'): g.db_connection = psycopg2.connect( host=SHARED_DB_CONFIG['host'], database=SHARED_DB_CONFIG['database'], user=SHARED_DB_CONFIG['user'], password=SHARED_DB_CONFIG['password'] ) return g.db_connection @app.route('/data') def get_data(): conn = get_db_connection() cursor = conn.cursor() tenant_id = request.headers.get('X-Tenant-ID') if not tenant_id: raise Exception("テナントIDが提供されていません") # 重要なのは、すべてのクエリでtenant_idでフィルタリングすることです cursor.execute("SELECT * FROM some_table WHERE tenant_id = %s", (tenant_id,)) data = cursor.fetchall() return {"data": data} # テナントフィルタリングによるORMベースのアプローチの例(例:SQLAlchemy) # from sqlalchemy import create_engine, Column, Integer, String # from sqlalchemy.orm import sessionmaker, declarative_base # # Base = declarative_base() # # class Item(Base): # __tablename__ = 'items' # id = Column(Integer, primary_key=True) # tenant_id = Column(String, nullable=False) # 必須のテナント判別子 # name = Column(String) # # # セッション管理またはORMクエリビルダー内: # # session.query(Item).filter(Item.tenant_id == current_tenant_id).all()
長所:
- 最も低い運用オーバーヘッド: 単一のデータベースサーバーと単一のスキーマを管理することは最も簡単です。
- 最も低いインフラストラクチャコスト(開始時): すべてのデータが統合されているため、優れたリソース活用が可能です。
- 最も簡単なスキーマ移行: 変更は単一のスキーマに一度適用されます。
- 多数のテナントに対する高いスケーラビリティ: 非常に多数の小規模テナントを期待するアプリケーションに最適です。
短所:
- 最も弱いデータ分離(アプリケーションロジックに強く依存): 単一のコーディングエラーまたは忘れられた
WHERE
句は、クロステナントデータを公開する可能性があります。厳格なテストと堅牢なORM機能またはクエリインターセプトが必要です。 - パフォーマンス分離なし: 単一のテナントが重いクエリを実行すると、他のすべてのテナントに影響を与える可能性があります。
- 個々のテナントのバックアップ/復元の複雑化: 単一テナントのデータ抽出にはフィルタリングが必要であり、復元は選択的な再挿入を意味する可能性があります。
- データ成長の問題の可能性: 多数のテナントの何百万もの行を持つ単一のテーブルは、適切にインデックス付けおよび最適化されていない場合、パフォーマンスのボトルネック(例: インデックスの肥大化、大規模なテーブルスキャン)につながる可能性があります。
- カスタマイズの制限: すべてのテナントは全く同じスキーマを共有します。スキーマを(例: JSONB列などの)汎用フィールドで大幅に拡張しない限り、カスタマイズは困難または不可能です。
結論
適切なマルチテナントデータベースアーキテクチャを選択することは、SaaSアプリケーションのスケーラビリティ、セキュリティ、コスト、および保守性に影響を与える重要な決定です。「テナントごとのデータベース分離」は最も高い分離とセキュリティを提供しますが、運用上の複雑さとインフラストラクチャコストがかかります。「テナント識別子による共有データベース、共有スキーマ」は、最も高いリソース効率とスキーマ管理の簡素さを提供しますが、アプリケーションレベルでの厳密なデータ分離が必要です。「テナントごとのスキーマ分離」パターンは、完全に分離されたデータベースと比較して、運用オーバーヘッドを削減し、良好な分離を提供します。最終的に、最良のアプローチは、運用上の複雑さに対する許容度、セキュリティ要件、およびテナントの予想される数とサイズを含む、アプリケーションの特定の要件に依存します。