LoginSignup
124
103

More than 5 years have passed since last update.

Apartment でマルチテナントサービスを作成する

Last updated at Posted at 2015-04-01

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 に切り替えてくれます。

導入

Gemfile
gem 'apartment'
bundle install

# config/initializers/apartment.rb が作成される
bundle exec rails g apartment:install

設定例

詳しくはコード内のコメントを読んでください。

config/initializers/apartment.rb
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 が実行されます。

実装例

  1. Tenant has_many User なモデルを用意する
  2. ユーザがサインアップ時に User オブジェクトと共に Tenant オブジェクトを作成する
  3. Tenant オブジェクトの after_commit 内で Apartment::Tenant.create(subdomain) を呼びだし、 DB を作成する
  4. サインアップ完了後に Apartment::Tenant.switch!(current_user.tenant.subdomain) を呼び出す

Tips

テナントはワザワザ都度アプリ内で切り替えるの?

リクエスト単位で自動的に切り替える仕組み(= Elevators)がいくつか用意されています。

  • サブドメイン単位で切り替える
  • ドメイン単位で切り替える
  • 自作
config/initializers/apartment.rb
...
# サブドメイン単位で切り替える
config.middleware.use 'Apartment::Elevators::Subdomain'

他のテナントにアクセスできないようにしたい

ApplicationController 内で対応するのが良いかと思います。

app/controllers/application_controller.rb
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 を作成し、対応しました。

lib/rescued_apartment_middleware.rb
# 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
config/initializers/apartment.rb
...
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') とすると切り替えも行われます。

データパッチを当てたい!

テナントをグルグル回して操作します。

script/onetime/20150401xxxxxx_update_hoge.rb
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 を作成します。

app/middleware/my_custom_elevator.rb
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
config/initializers/apartment.rb
...
# DB名にハイフンを使いたくないので、アンダースコアで置き換えたものを利用する
Rails.application.config.middleware.use 'MyCustomElevator'
app/models/tenant.rb
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"]

ここで指定したモデルはテナントをまたいで利用されます。
ただしテーブル自体はそれぞれのテナント(スキーマ)に作成されます。

参考

脚注


  1. 使えないことはないけどハマる 

124
103
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
124
103