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

2018-08-17 追記

数百テナント程度なら大きな問題なく利用できる一方、数千テナント規模になってくると migration コストが重くなってきました。
一つの解として SmartHR 社では citus という DB As a Service への移行を決めました。

詳しくはこちらのブログ記事をご参照ください。
[SmartHR が定期メンテナンスを始めた理由とやめる理由 \- SmartHR Tech Blog](http://tech.smarthr.jp/entry/2018/04/06/100000)

------

# 概要

Rails でマルチテナントサービスを実現する [apartment](https://github.com/influitive/apartment) という gem を紹介します。

**バージョン**

- rails: 4.2.0
- apartment: 1.0.0


# マルチテナントサービス？

1 つのサービスの中に複数のテナントが同居するサービスです。
例としては Qiita:Team や Trello などなど、企業やチームで利用する SaaS 型のサービスが想像しやすいかと思います。

このようなサービスでは、あるユーザが他のユーザの情報にアクセスしないよう「データの分離」が大きなカギとなります。

では、どのような設計が考えられるでしょうか？

- ①案: 1 つの DB を利用し、ユーザは所属するテナントの情報のみアクセスするよう、アプリ層で頑張る
- ②案: テナントごとに DB を分ける

①案はシンプルですが、いかにも危険な臭いがします
（例: テナントとのヒモ付を忘れたら？/default_scope 地獄になりそう！/scope 管理大変そう！）

②案は安心ですが、スキーマの管理や、データパッチを当てるのが煩雑になりそうです。


[apartment](https://github.com/influitive/apartment) は②案を（比較的）簡単に実現するための gem です。


# apartment の動作

apartment はテナントごとに DB を作成し、必要に応じて適切な DB に切り替えてくれます。

# 導入

```ruby:Gemfile
gem 'apartment'
```

```bash
bundle install

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


**設定例**

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

```ruby: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
```
# テナントの操作

## 新規作成

```ruby
Apartment::Tenant.create('tenant_name')
```

`CREATE DATABASE development_tenant_name` の実行後、 マイグレーションが実行されます。
※DB の切り替えはまだ行われません。


## 切り替え

```ruby
Apartment::Tenant.switch!('tenant_name')
```

`use development_tenant_name` が実行され、DB の切り替えが行われます。
以降の処理は `tenant_name` に対して行われます。

また、引数にはブロックを渡すことも可能です。

```ruby
Apartment::Tenant.switch('tenant_name') do
    # you can do anything you want in this tenant
end
```

引数にブロックを渡した場合、処理を抜けたあとには元のテナントに戻ります。

## テナントの削除

```ruby
Apartment::Tenant.drop('tenant_name')
```

`DROP DATABASE development_tenant_name` が実行されます。

## リセット

```ruby
Apartment::Tenant.reset
```

public なテナントに戻ります。

## 現在のテナントを取得する

```ruby
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）がいくつか用意されています。

- サブドメイン単位で切り替える
- ドメイン単位で切り替える
- 自作

```ruby: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
```

```ruby:config/initializers/apartment.rb
...
require 'rescued_apartment_middleware'
MyCustomElevator.prepend RescuedApartmentMiddleware
```

参考: [Apartment ruby gem : Want to Catch an exception - Stack Overflow](http://stackoverflow.com/questions/27188411/apartment-ruby-gem-want-to-catch-an-exception/28233828#28233828)



## 特定のサブドメインアクセスされたときにテナントを切り替えないようにする

全テナントがアクセスできる汎用的なページを用意したい場合など。

```ruby
Apartment::Elevators::Subdomain.excluded_subdomains = ['www']
```

**注意点**

あくまでリクエスト単位での自動切り替え（=Elevator）の機能を制限するものです。
コード内での直接テナントの作成や切り替えは普通に実行されます。
`Apartment::Tenant.create('www')` とすると作成されるし、`Apartment::Tenant.switch!('www')` とすると切り替えも行われます。

## データパッチを当てたい！

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

```ruby:script/onetime/20150401xxxxxx_update_hoge.rb
Tenant.all.each do |tenant|
  Apartment::Tenant.switch(tenant.subdomain) do
    # ここに処理をかく
  end
end
```

```bash
rails runner script/onetime/20150401xxxxxx_update_hoge.rb
```

## テナント名にハイフンを許容する場合の注意

ドメイン名と DB 名とで許容する文字列が異なるため、注意が必要です。
特にサブドメインをテナント名として利用している場合。

|   | アンダースコア | ハイフン |
| --- | -------- | ------- |
| ドメイン名 | ☓ | ○ |
| MySQL の DB 名 | ○ | ☓[^1] |

[^1]: 使えないことはないけどハマる

対応例として、サブドメインのハイフンを裏側ではアンダースコアとして扱う方法を紹介します。

**実装例**

カスタム Elevator を作成します。

```ruby: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
```

```ruby:config/initializers/apartment.rb
...
# DB名にハイフンを使いたくないので、アンダースコアで置き換えたものを利用する
Rails.application.config.middleware.use 'MyCustomElevator'
```

```ruby: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
```

## パブリックモデル

いくつかのモデルはテナントをまたいでアクセスしたいことがあります。

```ruby
config.excluded_models = ["User", "Company"]
```

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



# 参考

- [Multitenancy in Rails](http://davidlesches.com/blog/multitenancy-in-rails)
- [Multi-Tenant Applications: Separating SQL Databases - Quick Left](https://quickleft.com/blog/multi-tenant-applications-separating-sql-databases/)
- [Web アプリケーションをマルチテナント型 SaaS ソリューションに変換する](http://www.ibm.com/developerworks/jp/cloud/library/cl-multitenantsaas/)


# 脚注