activerecord-tenanted について
Basecamp の `activerecord-tenanted` gem を使い、Rails のテナントデータを個別の SQLite データベースに分離しつつ、ID やテナント情報をグローバルに保つ方法をまとめたスキルガイドです。シャード選択、リクエストのスコープ、ジョブ、マイグレーション、テストを扱います。
主な機能
- 01グローバルとテナントのレコードを分離
- 02テナントごとの SQLite データベース作成
- 03リクエストパスからテナントを設定
- 04ジョブ内でテナントスコープを再設定
- 05テナントDBの安全なマイグレーションとテスト
活用シーン
- 01パスベースの Rails マルチテナント設計
- 02IDレコードとワークスペースデータの分離
- 03バックグラウンドジョブからのテナントレコード操作
- 04アクティブなテナントが必要なコードのテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill activerecord-tenantedインストール後、Claude Code で/activerecord-tenantedを入力して読み込まれたか確認します。
同梱ファイルreference.md
詳細
activerecord-tenanted(テナントごとに SQLite)
Basecamp の activerecord-tenanted gem を使い、テナントごとに SQLite ファイルを 1 つ用意します。ID、セッション、テナントのレコードはグローバルデータベースに保存します。製品データは storage/tenants/{env}/{tenant_key}/main.sqlite3 に保存します。
これは Herald / Cortex / Innkeeper のモデルです。以下とは異なります。
- Nebula の
acts_as_tenant— データベースは 1 つで、すべての行にworkspace_idを設定します - Fizzy のパスベースのテナント管理 — データベースは 1 つで、
Current.accountとSCRIPT_NAMEの URL プレフィックスを使います
アプリですでに acts_as_tenant や Fizzy 方式のアカウントスラッグを共有スキーマで使用している場合、この gem を導入しないでください。
基本方針
- ベースクラスは 2 つ、データベースも 2 つです。
GlobalRecordはテナントデータを参照しません。ApplicationRecordは ID を参照しません。 - テナントが指定されていない状態でテナントのクエリを実行しないでください。
with_tenant/Current.tenant=の外でのUser.findは、ジョブや rake タスクでもバグです。 - シャードは URL(または Current)で選択します。 gem のデフォルトのサブドメインリゾルバーはパスベースのアプリには適しません。無効にしてください。
- ジョブではテナントコンテキストを再設定します。 Active Job がシャードを自動的に引き継ぐことはありません。グローバル ID(アカウント/テナント/プロジェクト)を渡し、
performをwith_tenantでラップしてください。
データの保存先
グローバル(GlobalRecord) | テナント(ApplicationRecord) |
|---|---|
| Identity、Session、MagicLink | 実際の製品レコード |
| Tenant / Account | User(Cortex/Innkeeper — テナント内のプロフィール) |
| Membership(Identity ↔ tenant) | そのワークスペース内のその他すべて |
| JoinCode、API トークン | Active Storage の blob(tenanted_rails_records が有効な場合) |
Herald では、シャードが選択される前にスラッグ/API キーを利用できるよう、Project をグローバルに保持し、リリース/購読者をアカウントのテナントに保存します。Cortex/Innkeeper では、ほとんどの製品テーブルをテナントのデータベースに配置します。どちらでも問題ありません。どちらかを選び、一貫して運用してください。
ステップ 1:Gem と database.yml
# Gemfile
gem "activerecord-tenanted"
# config/database.yml — every environment
primary:
adapter: sqlite3
database: storage/tenants/<%= Rails.env %>/%{tenant}/main.sqlite3
tenanted: true
max_connection_pools: 50
global:
adapter: sqlite3
database: storage/<%= Rails.env %>.sqlite3
migrations_paths: db/global_migrate
queue:
adapter: sqlite3
database: storage/<%= Rails.env %>_queue.sqlite3
migrations_paths: db/queue_migrate
# cache + cable the same way
%{tenant} は gem のプレースホルダーです。primary の database_tasks: false は任意です(Herald では、Rails が単一の primary スキーマをダンプしようとしないように設定しています)。
マイグレーション:
db/global_migrate/— Identity、Tenant/Account、Membership、Session。一度だけ実行します。db/migrate/— テナントのテーブル。gem がテナントごとに実行します。
bin/rails db:migrate # tenanted
bin/rails db:migrate:global # global — check the gem's task name in the app
ステップ 2:2 つの抽象クラス
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
tenanted
end
# app/models/global_record.rb
class GlobalRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :global, reading: :global }
end
新しいモデルが人物、セッション、またはテナントのレコード自体であれば GlobalRecord を使います。ワークスペース内のデータであれば ApplicationRecord を使います。ここを間違えることが、最も多いバグです。
ステップ 3:サブドメインリゾルバーとデフォルトテナントを無効にする
パスベースのアプリ(すべての自社アプリ)では、gem のサブドメインリゾルバーを使用しないでください。
# config/initializers/tenanted.rb
Rails.application.configure do
config.active_record_tenanted.tenant_resolver = ->(request) { nil }
config.active_record_tenanted.default_tenant =
Rails.env.local? ? "#{Rails.env}-tenant" : nil
config.active_record_tenanted.tenanted_rails_records = true
config.active_record_tenanted.connection_class = "ApplicationRecord"
end
本番環境では default_tenant を nil にしてください。 そうしないと、起動時に ARTENANT not set で失敗するか、さらに悪い場合、すべてのリクエストが残っている 1 つのシャードを共有します。ローカルでは、名前付きのデフォルトテナントを設定すると、コンソールやテストを起動できます。
tenanted_rails_records = true にすると、Active Storage / Action Text がキーにプレフィックスを付けてテナントのデータベースに保存されます。active-storage-multitenant スキルと組み合わせてください。
ステップ 4:Current でシャードを切り替える
class Current < ActiveSupport::CurrentAttributes
attribute :identity, :session
attribute :tenant # or :account — pick one name and use it everywhere
attribute :user
def tenant=(tenant)
super
return if ActiveRecord::Base.shard_swapping_prohibited?
ApplicationRecord.current_tenant =
tenant ? tenant.external_id : ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
end
end
Herald では Current.account= と account.tenant_key("account-#{id}")を使います。Cortex/Innkeeper では Current.tenant= と tenant.external_id を使います。仕組みは同じです。
リクエスト終了時にリセットします(Cortex)。
resets do
next if ActiveRecord::Base.shard_swapping_prohibited?
ApplicationRecord.current_tenant = ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
end
ステップ 5:URL からテナントを解決する
gem のリゾルバーではなく、コントローラーの concern で行います。
module TenantScoping
extend ActiveSupport::Concern
included do
before_action :set_current_tenant
before_action :set_current_user
end
def set_current_tenant
record = Tenant.find_by(external_id: params[:tenant_external_id])
Current.tenant = record if record
end
def set_current_user
return unless Current.identity && Current.tenant
Current.user = User.find_by(identity_id: Current.identity.id)
end
end
ルートはテナントキー(/:tenant_external_id/...、または Account を読み込むプロジェクトスラッグ)の下にネストします。サインインやベンダーからの Webhook など、認証を必要としないページではこの処理をスキップし、テナントを必要とする処理だけをラップします。
Tenant/Account の作成時にテナントデータベースを作成します。
after_create :provision_tenant_database
def ensure_tenant_database!
ApplicationRecord.create_tenant(tenant_key) unless database_exists?
rescue ActiveRecord::Tenanted::TenantExistsError
# parallel tests / retries
end
ステップ 6:ジョブ、rake、コンソール
def perform(tenant_external_id, record_id)
ApplicationRecord.with_tenant(tenant_external_id) do
Current.tenant = Tenant.find_by(external_id: tenant_external_id)
Record.find(record_id).do_work
end
end
テナント対応の Active Record を GID としてシリアライズし、perform で読み込めると期待しないでください。ジョブはテナントなしで実行されます。グローバルなテナントキーとレコード ID を渡してください。
Rake/単発実行:
Tenant.find_each do |tenant|
ApplicationRecord.with_tenant(tenant.external_id) do
puts Project.count
end
end
URL にテナントが含まれない公開インバウンド Webhook の場合は、テナントを順に処理する(Innkeeper)か、テナントを参照するグローバルトークンを検索してから with_tenant を使用してください。ジョブの最初に User を検索しないでください。
テスト
テナント対応モデルにアクセスするすべてのテストで、テナントを設定する必要があります。ヘルパーを使用してください。
def with_tenant(tenant = tenants(:acme), &block)
ApplicationRecord.with_tenant(tenant.external_id, &block)
end
統合テストでは、TenantScoping が実行されるように、テナントプレフィックスを含む URL にアクセスしてください。Current.tenant をスタブしてから HTTP の結果を検証しないでください。シャードがリクエストと一致しなくなります。
アンチパターン
- すでにテナント対応のアプリで
acts_as_tenant :workspaceを使う — テナントシステムが二重になります。Nebula はもう一方のモデルです。混在させないでください。 - Identity または Session を ApplicationRecord に配置する — これらのテーブルはシャードの切り替え後も存続する必要があります。
- ジョブで
with_tenantを使わずにUser.findを実行する — 結果が暗黙に空になるか、TenantNotSetが発生します。 - サブドメインリゾルバーを有効にしたままにする — パスベースのアプリで誤ったテナントが解決されるか、テナントが解決されません。
- 本番環境で
default_tenantを使う — すべてのプロセスで 1 つのデータベースファイルが共有されます。 - テナント DB で整数の PK を前提にする — テナント対応テーブルでは UUID を使用します。
uuid-primary-keysを参照してください。 - 現在のテナントが設定されていない状態で blob を作成する — 「テナントなしでは Blob キーを生成できません。」
with_tenantと Active Storage スキルを使用してください。
関連項目
- Identity と User、メンバーシップ、参加コード:
identity-membership - テナント対応の Active Storage/Lexxy:
active-storage-multitenant - テナント内で実行されるアウトバウンド Webhook:
outgoing-webhooks - パスワードレスサインイン(グローバル Identity):
magic-link-auth
database.yml、Herald と Cortex の命名、シャード切り替え時の注意点については、reference.md を参照してください。