- Railsでテナントごとに個別のデータベースを使う構成を構築する方法と、その過程で直面した課題を説明
- ActiveRecordは基本的に単一DB接続を前提に設計されているため、テナントごとの接続切り替えは複雑で扱いにくい
- Rails 6以降のconnected_to機能を活用し、実行時に接続を動的に切り替える方法を提案
- SQLite3は小規模で多数の独立したDBを扱うのに適しており、バックアップ、デバッグ、削除などの管理がしやすい
- 大規模システムの最適化を中心に進化してきたRailsインフラとは異なり、小さく独立したデータベースを中心としたアーキテクチャも可能であることを強調
テナントごとに個別のデータベースを使う理由
- データモデルの中で独立して動作するテナント(
Site)単位で分離すると、データの分離と管理が容易になる
- テナントごとにデータを別DBへ保存すると、大規模サイトへの拡張やセキュリティ上の課題にも有利
- SQLiteを使えばサーバー設定なしでファイル1つだけでデータベースを運用でき、簡便で柔軟
Railsで難しい点
- SQLiteの基本的な
open/close操作は非常に簡単だが、ActiveRecordは内部的に複雑なコネクション管理構造を持つ
- ActiveRecordは接続をモデルに固定して使う構造で設計されているため、実行時のテナント切り替えが難しい
- コネクションプール、クエリキャッシュ、スキーマキャッシュなどがすべて接続に依存しており、接続変更のたびに負担が大きい
Railsのマルチデータベース管理の歴史
- Rails 1:
ActiveRecord::Base単位でDBを指定可能
- Rails 3: コネクションプール導入
- Rails 4:
connection_handling追加
- Rails 6:
connected_to導入
- Rails 7:
connected_to機能拡張とシャーディング対応
- それでもなお「実行時にテナントを動的に追加・削除する」といったシナリオは標準ではサポートされていない
テナントごとのデータベースの利点
- テナントごとのファイルだけをバックアップ・復元できるため、運用とデバッグが簡単になる
- テナント削除は単にファイルを削除する(
unlink)だけでよい
- 大規模データベースサーバーは数十TB級のDB最適化を目指す一方、SQLiteは数千個の小規模DBに最適化されている
- 実際にiCloudも数百万個の小さなSQLite DBをCassandra上に保存する構造を採用している
問題解決の過程
- 従来の方法(手動の
establish_connection)は多重接続環境でConnectionNotEstablishedエラーを引き起こした
- Rails 6以降のやり方に合わせ、コネクションプールを手動管理する代わりにRailsに任せる構造へ変更
- 各テナントごとに動的にconnection poolを作成し、
connected_toブロックで処理を包む
- ミドルウェアを使ってリクエスト時点で必要なDB接続を動的に準備し、解放する方式へ改善
中核となるコードパターン
MUX.synchronize do
if ActiveRecord::Base.connection_handler.connection_pool_list(role_name).none?
ActiveRecord::Base.connection_handler.establish_connection(database_config_hash, role: role_name)
end
end
- 接続後、
connected_toブロック内で安全にクエリを実行
ActiveRecord::Base.connected_to(role: role_name) do
pages = Page.order(created_at: :desc).limit(10)
end
Rackストリーミング処理
- Rackレスポンスがストリーミングの場合、接続管理のために
Rack::BodyProxyとFiberを使って安全にコネクションを閉じる
connected_to_context_fiber = Fiber.new do
ActiveRecord::Base.connected_to(role: role_name) do
Fiber.yield
end
end
connected_to_context_fiber.resume
status, headers, body = @app.call(env)
body_with_close = Rack::BodyProxy.new(body) { connected_to_context_fiber.resume }
[status, headers, body_with_close]
最終的なミドルウェア構成
- リクエストごとに適切なDB接続を見つけて
connected_toへ切り替え、レスポンス終了後に後始末するミドルウェアShardine::Middlewareを作成
- Railsプロジェクトの
config.ruファイルには次のように適用できる
use Shardine::Middleware do |env|
site_name = env["SERVER_NAME"]
{adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}
end
残された課題
- ActiveRecord 6ではまだ
shard機能を活用していないが、以後のバージョンでは読み書き分離も可能
- テナント削除時のコネクションプール整理機能は、現時点では必要がないため未実装
- 今後は「小さなデータベースを多数扱う」アーキテクチャがさらに注目される可能性が高い
1件のコメント
Hacker Newsの意見
約100万人のユーザーとともに「database-per-tenant」方式を運用中
SQLiteは好きだが、既存のOLTPデータベースはインデックスの一部をメモリからアンロードする必要があるのではないかと思う
ほとんどの人にテナントごとのデータベースは不要であり、一般的なやり方ではない
中間的なアプローチとして次を検討できる
偶然にも、Elixir向けのFeebDBに取り組んでいる
Forward Emailは、各メールボックス/ユーザーごとに暗号化されたsqlite dbを使って似たようなことをしている
名前がとても素晴らしい。Sean Conneryを連想させる
「database per tenant」ワークフローは今に始まったことではない
以前に似たようなものを使ったことがあり、とても満足していた
rm username.sqlで簡単に処理できるデータが互いに隔離され、単一テナント内でスケーリングの問題がないなら、ひどい設計をしてしまうのは難しい