2018-08-17 追記
数百テナント程度なら大きな問題なく利用できる一方、数千テナント規模になってくると migration コストが重くなってきました。
一つの解として SmartHR 社では citus という DB As a Service への移行を決めました。
詳しくはこちらのブログ記事をご参照ください。
SmartHR が定期メンテナンスを始めた理由とやめる理由 - SmartHR Tech Blog
概要
Rails でマルチテナントサービスを実現する apartment という gem を紹介します。
バージョン
- rails: 4.2.0
- apartment: 1.0.0
マルチテナントサービス?
1 つのサービスの中に複数のテナントが同居するサービスです。
例としては Qiita:Team や Trello などなど、企業やチームで利用する SaaS 型のサービスが想像しやすいかと思います。
このようなサービスでは、あるユーザが他のユーザの情報にアクセスしないよう「データの分離」が大きなカギとなります。
では、どのような設計が考えられるでしょうか?
- ①案: 1 つの DB を利用し、ユーザは所属するテナントの情報のみアクセスするよう、アプリ層で頑張る
- ②案: テナントごとに DB を分ける
①案はシンプルですが、いかにも危険な臭いがします
(例: テナントとのヒモ付を忘れたら?/default_scope 地獄になりそう!/scope 管理大変そう!)
②案は安心ですが、スキーマの管理や、データパッチを当てるのが煩雑になりそうです。
apartment は②案を(比較的)簡単に実現するための gem です。
apartment の動作
apartment はテナントごとに DB を作成し、必要に応じて適切な DB に切り替えてくれます。
導入
gem 'apartment'
bundle install
# config/initializers/apartment.rb が作成される
bundle exec rails g apartment:install
設定例
詳しくはコード内のコメントを読んでください。
Apartment.configure do |config|
...
# 全てのテナントが共有するテーブル
config.excluded_models = %w{Tenant User}
# db:migrate の対象となるテナント
config.tenant_names = lambda { Tenant.all.map(&:virtual_subdomain) }
# テナント作成後に db:seed を実行する
config.seed_after_create = true unless Rails.env.test?
end
テナントの操作
新規作成
Apartment::Tenant.create('tenant_name')
CREATE DATABASE development_tenant_name
の実行後、 マイグレーションが実行されます。
※DB の切り替えはまだ行われません。
切り替え
Apartment::Tenant.switch!('tenant_name')
use development_tenant_name
が実行され、DB の切り替えが行われます。
以降の処理は tenant_name
に対して行われます。
また、引数にはブロックを渡すことも可能です。
Apartment::Tenant.switch('tenant_name') do
# you can do anything you want in this tenant
end
引数にブロックを渡した場合、処理を抜けたあとには元のテナントに戻ります。
テナントの削除
Apartment::Tenant.drop('tenant_name')
DROP DATABASE development_tenant_name
が実行されます。
リセット
Apartment::Tenant.reset
public なテナントに戻ります。
現在のテナントを取得する
Apartment::Tenant.current
migration
db:migrate
すると全テナントに対して migration が実行されます。
実装例
-
Tenant
has_manyUser
なモデルを用意する - ユーザがサインアップ時に
User
オブジェクトと共にTenant
オブジェクトを作成する -
Tenant
オブジェクトのafter_commit
内でApartment::Tenant.create(subdomain)
を呼びだし、 DB を作成する - サインアップ完了後に
Apartment::Tenant.switch!(current_user.tenant.subdomain)
を呼び出す
Tips
テナントはワザワザ都度アプリ内で切り替えるの?
リクエスト単位で自動的に切り替える仕組み(= Elevators)がいくつか用意されています。
- サブドメイン単位で切り替える
- ドメイン単位で切り替える
- 自作
...
# サブドメイン単位で切り替える
config.middleware.use 'Apartment::Elevators::Subdomain'
他のテナントにアクセスできないようにしたい
ApplicationController 内で対応するのが良いかと思います。
例
class ApplicationController < ActionController::Base
...
before_action :check_subdomain
def check_subdomain
# システム管理者はどのテナントでもアクセスできる
return if current_user.sys_admin?
# 自身の所属するサブドメイン以外のアクセスは許可しない
routing_error if (request.subdomain != current_user.tenant.subdomain)
end
end
テナントが見つからない場合のエラーを補足したい
テナントが見つからない場合のエラーは middleware 層で拠出されるので、ApplicationController などではキャッチできません。
以下のような middleware を作成し、対応しました。
# http://stackoverflow.com/questions/27188411/apartment-ruby-gem-want-to-catch-an-exception/28233828#28233828
module RescuedApartmentMiddleware
def call(env)
super
rescue Apartment::TenantNotFound
Rails.logger.error "ERROR: Apartment Tenant not found: #{Apartment::Tenant.current.inspect}"
# request = Rack::Request.new(env)
return [404, { 'Content-Type' => 'text/html' }, ["#{File.read(Rails.root.to_s + '/public/404.html')}"]]
end
end
...
require 'rescued_apartment_middleware'
MyCustomElevator.prepend RescuedApartmentMiddleware
参考: Apartment ruby gem : Want to Catch an exception - Stack Overflow
特定のサブドメインアクセスされたときにテナントを切り替えないようにする
全テナントがアクセスできる汎用的なページを用意したい場合など。
Apartment::Elevators::Subdomain.excluded_subdomains = ['www']
注意点
あくまでリクエスト単位での自動切り替え(=Elevator)の機能を制限するものです。
コード内での直接テナントの作成や切り替えは普通に実行されます。
Apartment::Tenant.create('www')
とすると作成されるし、Apartment::Tenant.switch!('www')
とすると切り替えも行われます。
データパッチを当てたい!
テナントをグルグル回して操作します。
Tenant.all.each do |tenant|
Apartment::Tenant.switch(tenant.subdomain) do
# ここに処理をかく
end
end
rails runner script/onetime/20150401xxxxxx_update_hoge.rb
テナント名にハイフンを許容する場合の注意
ドメイン名と DB 名とで許容する文字列が異なるため、注意が必要です。
特にサブドメインをテナント名として利用している場合。
アンダースコア | ハイフン | |
---|---|---|
ドメイン名 | ☓ | ○ |
MySQL の DB 名 | ○ | ☓1 |
対応例として、サブドメインのハイフンを裏側ではアンダースコアとして扱う方法を紹介します。
実装例
カスタム Elevator を作成します。
class MyCustomElevator < Apartment::Elevators::Generic
def self.excluded_subdomains
@excluded_subdomains ||= []
end
def self.excluded_subdomains=(arg)
@excluded_subdomains = arg
end
def parse_tenant_name(request)
request_subdomain = subdomain(request.host)
# If the domain acquired is set to be excluded, set the tenant to whatever is currently
# next in line in the schema search path.
tenant = if self.class.excluded_subdomains.include?(request_subdomain)
nil
else
# DB 名にハイフンを使いたくないのでアンダースコアで置換した
# DB 名を利用する
request_subdomain.split('-').join('_')
end
tenant.presence
end
protected
# *Almost* a direct ripoff of ActionDispatch::Request subdomain methods
# Only care about the first subdomain for the database name
def subdomain(host)
subdomains(host).first
end
def subdomains(host)
return [] unless named_host?(host)
host.split('.')[0..-(Apartment.tld_length + 2)]
end
def named_host?(host)
!(host.nil? || /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.match(host))
end
end
...
# DB名にハイフンを使いたくないので、アンダースコアで置き換えたものを利用する
Rails.application.config.middleware.use 'MyCustomElevator'
class Tenant < ActiveRecord::Base
after_commit :create_database
has_many :users, inverse_of: :tenant
validates :subdomain,
presence: true,
uniqueness: true,
format: { with: /\A[0-9a-z\-]+\z/ },
exclusion: { in: NG_WORDS },
length: { minimum: 3, maximum: 25 }
# DB 名にハイフン使いたくないので置換する
# MyCustomElevator 参照
def virtual_subdomain
subdomain.split('-').join('_')
end
private
def create_database
Apartment::Tenant.create(virtual_subdomain)
end
end
パブリックモデル
いくつかのモデルはテナントをまたいでアクセスしたいことがあります。
config.excluded_models = ["User", "Company"]
ここで指定したモデルはテナントをまたいで利用されます。
ただしテーブル自体はそれぞれのテナント(スキーマ)に作成されます。
参考
- Multitenancy in Rails
- Multi-Tenant Applications: Separating SQL Databases - Quick Left
- Web アプリケーションをマルチテナント型 SaaS ソリューションに変換する
脚注
-
使えないことはないけどハマる ↩