ActiverecordMultiTenant でマルチテナンシー
Geppoでバックエンドエンジニアをしている@yks0406です。日本酒で膨らんだお腹を凹ませるために、片道8キロの道のりを毎朝毎晩自転車で通勤しています。これまではRailsを中心にバック/フロントどちらも対応するスタイルで開発してきましたが、1年前Geppoにジョインしてからはバックエンドをメインに開発しています。
今回はGeppoに activerecord-multi-tenant を導入した際の話です。
導入の検討を開始したのが2018年1月あたりで、実際に導入が完了したのは2019年10月です。方針決定までに1年4ヶ月、開発に6ヶ月かかりました。途中空白の期間が発生してはいるものの、なかなかの作業ボリュームでした。
※この記事はHR Tech Advent Calendar 2019の最終日の記事です
マルチテナンシーって何?
1つのサービスで複数クライアント(つまりテナント)のデータを管理する仕組みのことをマルチテナンシーと呼びます。
各組織のデータ・セキュリティーを危険にさらすことなく、複数の組織 (SaaS の用語では、テナント) を同じアプリケーションで共存させることができれば、そのアプリケーションはマルチテナント型アプリケーションということになります。
マルチテナンシーには、いくつかのレベルがあります (下記の図を参照)。
1. クラウド内での単純な仮想化により、ハードウェアのみを共有
2. 1 つのアプリケーションで、テナントごとに異なるデータベースを使用
3. 1 つのアプリケーションでデータベースを共有 (最も効率的な真のマルチテナンシー)
引用元: https://www.ibm.com/developerworks/jp/cloud/library/cl-multitenantsaas/index.html
Geppoはデータベースを共有するタイプのマルチテナンシー、上記IBMの引用図でいうところの③のタイプのマルチテナンシーを採用しているB2B SaaSです。マルチテナンシーはSaaSサービスには必須であるものの、セキュリティ考慮漏れが発生するとデータの混濁及び流出というクリティカルな問題が発生するリスクを抱えます。このリスクへの対応がマルチテナンシーの肝です。
なぜActiveRecord Multitenant?
Geppoはデータの混濁/流出に対する対策としてpunditを導入し policyの中で企業データを設定する方式をとっていました。この方式でも正しくコードを書くことでマルチテナンシーが持つリスクは排除できます。一方で、実装/レビューの正確さが最終ラインとなるため、ミスや見落としといった人的リスクを排除することができていませんでした。この問題を解消するために、実装方法に依存しないセキュアなマルチテナンシーを担保する仕組みを検討することになりました。
GeppoはRuby on Railsで作られており、apartment gemとCitusData のどちらを導入するかで1年近く検討が続けられました。検討中、SmartHRさんのSmartHR が定期メンテナンスを始めた理由とやめる理由は大変参考になりました。SmartHRさんありがとうございます。
最終的にはCitusDataを利用せずにCitusDataさんが公開しているactiverecord-multi-tenant gemを利用するという結論に至りました。大きな理由としてはこの3つです。
1. apartment gem を採用した場合、PostgreSQLのスキーマを超えたアクセスがARからは実行できないためAdmin/バッチ処理が煩雑になる
2. apartment gem を採用した場合、事業の成長に伴うデプロイコストを許容できない
3. CitusDataが採用しいているシャーディング技術はテナント毎にデータを分離するわけではないので、マルチテナントへの安全性に寄与するものではない
特に「3」のシャーディングとマルチテナンシー を分離して考えたことはよかったと思っています。SmartHRさんの事例公開のおかげでapartment gemは選択肢から外すことができたものの、CitusDataを利用するとなるとデータ移行が発生する上にDBをロックインされてしまうというマイナス面が懸念として残っていました。しかし、データシャーディングはマルチテナンシーに直接的な作用があるものではないため、セキュリティの担保はgemの導入だけでできると判断しgemとDBを分離することで懸念なくgemを導入できました。
activerecord-multi-tenantを入れたらどうなるの?
ActiveRecordでクエリを発行する際、MultiTenant.with で囲むことによりクエリにテナント条件を強制的に埋め込むことができます。それだけではなく、Controllerのbefore_actionフィルターで set_current_tenant('対象テナント') をフックすれば、アクションを呼び出したリクエスト内の処理全てにテナント条件を強制的に埋め込むことができます。つまり、メソッド単位/リクエスト単位でActiveRecordレベルでテナントを分離することができます。これは画期的!
検証
具体例として、このようなデータで検証してみます。
# 企業データ
> Company.all.each {|c| puts "#{c.id} : #{c.name}"}
Company Load (0.3ms) SELECT "companies".* FROM "companies"
1 : AAA企業
2 : BBB企業
3 : CCC企業
# 従業員データ
> Employee.all.each {|e| puts "#{e.id} #{e.company_id}:#{e.name}"}
Employee Load (0.3ms) SELECT "employees".* FROM "employees"
1 1:AAA太郎
2 1:AAA次郎
3 1:AAA三郎
4 2:BBB太郎
6 2:BBB三郎
5 2:BBB次郎
7 3:CCC太郎
8 3:CCC次郎
9 3:CCC三郎
未適用
> Employee.all.each {|e| puts "#{e.id} #{e.company_id}:#{e.name}"}
Employee Load (0.5ms) SELECT "employees".* FROM "employees"
1 1:AAA太郎
2 1:AAA次郎
3 1:AAA三郎
4 2:BBB太郎
6 2:BBB三郎
5 2:BBB次郎
7 3:CCC太郎
8 3:CCC次郎
9 3:CCC三郎
Employee.all で全従業員を抽出した場合です。当然全てのEmployeeデータが抽出されています。
適用時
class Employee < ApplicationRecord
belongs_to :company
# マルチテナンシーを適用
multi_tenant :company
end
# まずそれぞれの会社データを取得して、、、
company1, company2, company3 = Company.all
# company1を引数指定
> MultiTenant.with(company1) do
> Employee.all.each {|e| puts "#{e.id} #{e.company_id}:#{e.name}"}
> end
Employee Load (0.3ms) SELECT "employees".* FROM "employees" WHERE "employees"."company_id" = 1
1 1:AAA太郎
2 1:AAA次郎
3 1:AAA三郎
# company2を引数指定
> MultiTenant.with(company2) do
> Employee.all.each {|e| puts "#{e.id} #{e.company_id}:#{e.name}"}
> end
Employee Load (0.3ms) SELECT "employees".* FROM "employees" WHERE "employees"."company_id" = 2
4 2:BBB太郎
6 2:BBB三郎
5 2:BBB次郎
# company3を引数指定
> MultiTenant.with(company3) do
> Employee.all.each {|e| puts "#{e.id} #{e.company_id}:#{e.name}"}
> end
Employee Load (0.4ms) SELECT "employees".* FROM "employees" WHERE "employees"."company_id" = 3
7 3:CCC太郎
8 3:CCC次郎
9 3:CCC三郎
ちょっとわかりにくいですが、Employeeモデルに対しては全てallしか呼び出しを行なっていません。変更点はEmployee.allをMultiTenant.withで囲んだだけですが、これで対象テナントを限定できます。発行したSQLを見てもちゃんとWHERE句にcompany_idが指定されていますね。コントローラーレイヤで実行できる set_current_tenant もこのMultiTenant.withと同じ挙動をします。
ちなみにデータ登録時もフォローしてくれます。
> MultiTenant.with(company1) do
> Employee.all
> end
Employee Load (0.2ms) SELECT "employees".* FROM "employees" LIMIT ? [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Employee id: 2, company_id: 1, name: "emp1", created_at: "2019-12-01 22:12:12", updated_at: "2019-12-01 22:12:12">]>
導入手順
Geppoでは1.マルチテナンシーの対象とするテーブルの決定、2.マイグレーションファイルの作成、3.モデルの修正、4.コントローラーの修正、5. RSpec修正の流れで進めることとしました。
0. avtiverecorde-multitenant gemのインストール
gem 'activerecord-multi-tenant'
何はともあれgemをインストールします。Railsのgem管理は楽ですね。Gemfileに上記の1行を追加してbundle installで一発です。
1. マルチテナンシー対象テーブルの決定
マルチテナンシーの対象になる/ならないは、基本的にはテナントテーブルへのリファレンス、つまりGeppoの場合は企業IDカラムを持たせるかどうかで判断しました。ログ的な目的で使用しているテーブルの中には企業IDカラムを既に保有しているものもありましたが、NOT NULL制約をつけられないものに関しては対象から外しました。
ここで判断をミスると後続の作業でトライ&エラーを繰り返すことになります。B2Bサービスはテーブル数が多いと思いますが、丁寧にやり切るしかないです。
2. マイグレーションファイルの作成
1で決定したテーブルにテナントテーブルへの参照用カラムを追加します。
マルチテナンシーの特性上、テナントへの参照用カラムは当然NOT NULL制約を付与します。しかし、カラムを追加した段階では値を特定できないので、①カラムを追加、②カラムに値を設定(update)、③NOT NULL制約を追加、④INDEXを追加 の順で処理することにしました。
厄介だったのは②のカラムに値を設定する部分です。
GeppoのDBはきっちり正規化しています。テーブルにテナントテーブルへの参照用カラムを追加するということは一部非正規化するということであり、遠く離れた参照情報を直接参照できるよう各テーブルに追加します。この参照情報の取得ロジックはテーブル毎個別に作る必要があります。
また、①のカラム追加、③/④の定義変更はマイグレーションファイル内で実施すべきではあるもの、②の値設定はデータ遡及として別実施としたいところなのですが、NOT NULL対象のカラムに別トランザクションで値を入れるとなるとDefault値を設定する必要が出てきます。しかしそれだとアプリケーションレイヤのバグにより値の設定漏れが発生した際に検知できなくなってしまうので、値設定も含めて1つのマイグレーションファイルにまとめました。
3. モデルの修正
モデルに対しては2種類の修正をする必要があります。
① アソシエーションの修正
② multi_tenant 設定追加
①アソシエーションの修正では、テナントクラス(Company等)にはhas_manyやhas_oneを、テナントカラムを追加した各情報テーブルにはbelongs_toを追加します。これは通常のテーブル変更と同じ手順ですね。
②のmutlti_tenant設定はactiverecorde-multitenat独特の設定で、MultiTenant::ModelExtensionsClassMethods によって追加されるクラスメソッドです。このクラスメソッドを呼び出すことでいくつかのクラス/インスタンスメソッドの追加をはじめ、マルチテナントモデルとして動作するための設定を動的に追加します。実は①でbelongs_toの設定を忘れたとしてもここで自動的に追加してくれます。個人的には可読性が落ちるのでモデルで明示的に設定すべきだと思います。詳しくはactiverecord-multi-tenant/model_extensions.rbを読んでみてください。
4. コントローラーの修正
gemの紹介で少し触れましたが、activerecord-multitenantはコントローラーのbefore_actionでset_current_tenantをフックすることですべてのリクエストに対してマルチテナンシーを適用できます。Deviseを導入している場合は、current_userを利用してテナントを指定できます。
とはいえ、すべてのコントローラに対して無条件に適用することができるとも限りません。その辺を加味してGeppoではこのようなイメージの実装にしました。
class ApplicationController < ActionController::Base
class_attribute :no_tenant # マルチテナンシーの適用外であることを表すクラス変数
set_current_tenant_through_filter
before_action :set_my_tenant
class << self
# 自身がマルチテナンシーを適用しないコントローラーであることを宣言するためのメソッド
def non_multi_tenancy!
self.no_tenant = true
end
end
def set_my_tenant
# non_multi_tenancy! を宣言しているコントローラーはテナント設定処理をスキップする
return if no_tenant
set_current_tenant(current_user.tenant)
end
end
5. RSpecの修正
最後にRSpecの修正です。私の場合、マルチテナンシー対応の8割以上の時間をRSpec対応に費やしました。モデルの変更まで完了した時点で全テストケースを流したところ7,000件を超えるテスト失敗が、、、それでもモデルを直していけば良いので加速度的に消化数が上がるだろうと見込んでいたのですが考えが甘かった。
最も時間を要したのはテストデータの改修でした。GeppoはFactoryBotを利用しています。FactoryBotはAssosiationで簡単に紐付けも含めたテストデータを作成できます。ただし、このAssosiationは任意のデータを生成して紐つけるため特定のデータとの紐付けをテストしたいマルチテナンシーには相性が悪く、既存のテストデータが邪魔をしてしまいました。
また、SaaSサービスのほとんどがマルチテナントモデルになると思いますが、UTではマルチテナントを意識したテストケースを書くことは少ないと思います。しかし、actverecord-multitanamt gemを導入すると、少なくともリクエストレイヤではマルチテナントを意識しないと正しいテスト結果が得られません。考慮不足のテストが失敗すること自体はいいことだと思います。ただ、テストコストは増えます。
上記のこともありテストが引っ掛かってはテストデータ/ロジックをチェックして修正してテストを流すの繰り返しでした。しっかりテストケースを作り込んでいればいるほど修正量は増えます。でも、テストケースが作り込まれているからこそ大改修をやり切れました。一括置換はできないのでマルチテナンシーを導入する際はテスト工数を多めに見積もっておくことをお勧めします。
これでactiverecord-multi-tenantの導入は完了です。
マルチテナンシーの導入を考えている方へ
社内から、我々の検討過程もマルチテナンシーの導入を考えている方々に役に立ててもらえるんじゃないかという声があったので当時の検討議事を公開します。我々の考え方・結論が必ずしも正しいわけではないと思いますが、多少なりとも参考になれば幸いです。
検討観点
1. データ分離はどの程度実現できているか?
2. 管理者が横断的にデータへアクセスすることができるか?
3. 今後の機能開発の影響はあるか?
4. 移行には何が必要か?
5. マイグレーションの時間はどの程度増加するか?
6. 新規クライアント追加時の影響はあるか?
独自実装の場合のメリデメ検討
Gemを利用した場合の比較検討
■ 前提: 当時(ActiveRecordMultiTenant導入前)の構成
概要
1 DB 1 スキーマ
1. データ分離はどの程度実現できているか?
クエリによって分離しているため、コードを間違えれば他企業へのデータへアクセスが可能。
ログインが必要なリクエストで、current_user を起点にデータを取得しているものについては問題ないが、それをコード的に強制できていない+そもそもcurrent_userから引くのが適切でないリソースもある(idをもとに検索するなど)。
2. 管理者が横断的にデータへアクセスすることができるか?
可能
3. 今後の機能開発の影響はあるか?
機能が多くなるにつれ考慮点やリスクは高くなる
4. 移行には何が必要か?
なし
5. マイグレーションの時間はどの程度増加するか?
該当テーブル数のみ
6. 新規クライアント追加時の影響はあるか?
特に考慮必要なし
7. DB以外のミドルウェアでの名前空間分離は可能か?
特に実施せず
■ 手法1. apartment gem を利用する
概要
PG schemaを利用して、名前空間を分離する(MySQLはdatabaseレベル)
https://www.postgresql.jp/document/pg940doc/html/ddl-schemas.html
シェアするテーブルと分離するテーブルを分けることができる
サブドメインを見て自動的に切り替えることができる
利用するテナントを明示することも可能
1. データ分離はどの程度実現できているか?
PG schemaを利用しているため、利用するテナントの宣言さえ間違わなければ横断してしまうことはなさそう
# 切り替え例
Apartment::Tenant.switch!('tenant_name')
2. 管理者が横断的にデータへアクセスすることができるか?
決定的な情報はなかったが、おそらく不可
https://stackoverflow.com/q/1463849 のようにSQLとして記述は可能だが、AAでサポートしていない
ARやapaartment gemでのサポートも見つけられなかった
3. 今後の機能開発の影響はあるか?
企業を意識しなくてよくなるので、開発効率は上がる
ただしマイグレーションやデプロイなど、インフラタスクは少し考慮点が増える
4. 移行には何が必要か?
テーブル分離の設計
マイグレーション設計(Apartmentを利用するにあたり、独自でマイグレーションを書く必要がありそう)
サブドメインでの自動テナント切り替えの導入
company_idでのスコープの削除
5. マイグレーションの時間はどの程度増加するか?
単純に テナント数 x マイグレーション数 かかってしまうので、テナント数 倍の時間がかかるようになるはず
6. 新規クライアント追加時の影響はあるか?
新規で追加する場合、schemaの追加が必要になる
■ 手法2. Citus + activerecord-multi-tenant gem を利用する
概要
PGのextenstionであるCitusを利用して、テナントごとに保存するnodeを自動的に分ける(citusが提供するcitus cloudを利用するか、自前で運用)
テナントごとのIDを すべてのテーブルに付与してこれを実現している模様
We do this in Citus by making sure every table in our schema has a column to clearly mark which tenant owns which rows. In the ad analytics application the tenants are companies, so we must ensure all tables have a company_id column.
https://docs.citusdata.com/en/v7.2/use_case_guide/multi_tenant.html#mt-use-case
1. データ分離はどの程度実現できているか?
activerecord-multi-tenant gem が提供されており、利用するテナントの宣言さえ間違わなければ横断してしまうことはなさそう
# 切り替え例
set_current_tenant(customer)
2. 管理者が横断的にデータへアクセスすることができるか?
可能。
schemaなどで分けていないのでsqlでglobalにレコードを取得できる
activerecord-multi-tenant gemを利用する上で何かしら宣言が必要かもしれない
3. 今後の機能開発の影響はあるか?
企業を意識しなくてよくなるので、開発効率は上がる
ただし「マイグレーションやデプロイなど、インフラタスクは少し考慮点が増える」は手法1と同じ。加えて、テナントごとにデータが分散されるため、データ数が増えてもパフォーマンスは見込めそう
4. 移行には何が必要か?
citus cloud 利用設計/ 自前での運用設計
テーブル分離の設計
マイグレーション設計(各テーブルにcompany_idを付与する必要がありそう)
サブドメインでの自動テナント切り替えの導入(gemで対応していないので自前で作成する必要がある)
company_idでのスコープの削除
5. マイグレーションの時間はどの程度増加するか?
node数ごとにマイグレーションがかかるのかどうかわからなかった(が、schemaなどではないので、かなり少なくなるはず)
6. 新規クライアント追加時の影響はあるか?
citusが自動でnodeに振り分けるようなので特に影響なし
最後に
実はactivrecord-multi-tenantを導入した直後はgem自体にクリティカルなバグがいくつかありました。バージョンは0.94辺りです。なので最初の頃は自作パッチをあてていました。しかし1.0で素晴らしく進化しました。もはやパッチは不要です。
マルチテナンシー対応は私一人で担当しました。本当にRSpecには苦しめられてストレスを溜めることもありました。一方でしっかりテストケースを作ってくれているからこそ、RSpecを通すということをゴールとして走り切ることができました。今は膨大なテストを残してくれている現在と過去のメンバーに対して感謝と尊敬の念しかありません。ありがとうございます!
Geppoの開発チームにジョインしたのが去年の1月。なので、もうすぐ1年になろうとしてます。月イチリリースから随時リリースへ変更したり、フロントとバックの疎結合を進めたり。プロダクトはもちろん開発チームとしても大きく進化した1年でした。そして、部門を超えての銀座ランチ会や全社でのクリスマスパーティ(楽しすぎて後半の記憶がありません)等々、会社としても良い方に雰囲気が大きく変わっていくのを感じた1年でした。
最高の仲間と共に来年もGeppoをより良くするために頑張ります!!
それでは皆さん
Merry Christmas & Happy New Year !! 🎉
この記事が気に入ったらサポートをしてみませんか?