📝

RailsでPostgreSQLのRow Level Security Policyを使ったマルチテナント

2021/03/07に公開

Clipkit(クリップキット) というSaaSを作っています。マルチスキーマ方式のマルチテナントシステムなのですが、テナント数が1,000近くなってきて辛さも出てきているので他の方式を検証中です。

LD;TR

PostgreSQLのRow Level Security Policyを利用したマルチテナントの実装を試してみました。

問題なく実装できてうまく動きそうでした。が、結局今回は採用を見送りました。RDBのマルチテナントの手法は一長一短で難しい。

個人的には最初はRLSではなくマルチスキーマ方式で始めれば良いのではないかと思いました。

はじめに

SaaS型のWebサービスでは、顧客ごとの独立したアプリケーションを、1つのシステムに同居させる方式があります。これをマルチテナントといいます。

RDBのマルチテナント方式

まず普通に考えると、テーブルに複数のテナントのデータを混在させる設計を思いつきます。しかしそれだとプログラムにバグがあった場合、他のテナントのデータが見えてしまうなど非常に大きなセキュリティ上の問題を起こしてしまう可能性があります。

なので、絶対に混線が起こらないようにテナントごとにデータをしっかり分離させる方法を考える必要があります。

RDBでマルチテナントを実現するには、ざっくり以下の3つの方法があります。

マルチインスタンス(サイロ)

テナントごとに独立したDBインスタンス(仮想マシンなど)を使用する。独立性が高いがコストや保守性のメリットが小さい。

シングルインスタンス・マルチスキーマ(ブリッジ)

単一のDBインスタンス内にテナントごとのスキーマを用意する。テナントごとに独立したテーブルを持つのでテーブル定義の管理が煩雑。

シングルスキーマ(プール)

単一のスキーマ内のテーブルにすべてのテナントのデータを混在させる。最もリソースの効率が良いがプログラムにバグが入ると他のテナントのデータが混線するなどの大きなリスクがある。

簡単に実現できるのはマルチスキーマ方式

RailsだとApartmentというgemがある。これで全テナントへの一斉マイグレーションなども勝手にやってくれます。

マルチスキーマ方式の欠点

テナントごとにスキーマを分けるということで、テーブルの構造を変更する際には、すべてのスキーマに対して同じようにマイグレーションを実行する必要があります。2〜3秒のマイグレーション処理だったとしても数千以上のテナント数になるとそれなりに厳しくなってくるでしょう。すべてのテナントでマイグレーションが確実に完了できるようにする管理コストも大きくなります。

なのでアクセス制御さえ確実にできれば、シングルスキーマ方式が理想のような気がしてきます。

Row Level Security Policyを利用したシングルスキーマ方式

概要

PostgeeSQL 9.5以降には「行セキュリティポリシー」(Row Level Security Policy :RLS)という機能があります。これはユーザーのロールや実行時パラメータに応じてあらかじめ指定された条件の行以外にはアクセスできないようにする機能です。

設定方法

具体的には次のように設定します。

例)usersテーブルのtenant_idカラムが特定の値のレコード以外は見えないようにしたい。

RLSを設定。(これは実行時パラメータに応じて制御する設定)

ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_isolation_policy ON users FOR ALL USING (tenant_id = current_setting('tenant.id')::BIGINT);

あとは実行時パラメータを次のように設定すると、以降、tenant_id=999のレコード以外にはアクセスできなくなります。

SET tenant.id = 999;

Railsでの実装(案)

テナントを管理するtenantsテーブル(Tenantモデル)を作っておきます。(※ 説明用の例なのでテーブルの定義とかは省略します)

Tenant#switchメソッドでテナントを切り替えられるように実装します。さらにTenant.currentで現在のテナントを取得できるようにしておくと便利です。

class Tenant < ApplicationRecord
  def switch
    ActiveRecord::Base.connection.execute("SET tenant.id = #{id}")
  end
  def self.current
    find(ActiveRecord::Base.connection.execute('SHOW tenant.id').getvalue(0, 0))
  end
end

ApplicationControllerbefore_actionで、リクエストのドメインに応じてテナントが切り替わるようにします。

class ApplicationController < ActionController::API
  before_action :switch_tenant

  def switch_tenant
    Tenant.find_by(domain: request.host).switch
  end
end

以上で自分のテナントのデータだけに触れるようになります。

ただしデータを追加するときはtenant_idを自分で入れなくてはいけません。これが面倒なので自動的に入るようにModelの基底クラス(ApplicationRecord)に実装します。

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true
  after_initialize :set_tenant_id

  def set_tenant_id
    if new_record?
      if has_attribute?(:tenant_id)
        self.tenant_id = Tenant.current.id
      end
    end
  end
end

これでほぼテナントを意識せず透過的にデータアクセスできるようになりました。

注意点

RLSは一般ユーザーにしか効かない

CREATE TABLEしたユーザーやSUPERUSERに対してはRLSの制限は無効となります。なので、migrationはSUPERUSERで実行、アプリは一般ユーザーで起動。などとする必要があります。

一般ユーザーには次のように必要な権限を与えておきましょう。

GRANT SELECT, UPDATE, INSERT, DELETE ON ALL TABLES IN SCHEMA public TO PUBLIC;
GRANT SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO PUBLIC;

UNIQUE制約に注意

UNIQUE制約はtenant_idとの複合インデックスにする必要があります。(アプリケーションからは見えないのでバリデーションについては複合条件にする必要はない)

INSERT時の注意

SELECTは透過的に制約されたレコードしか見えませんが、INSERTするときはtenant_idを自分でセットする必要があります。(前述の実装案ではModelの基底クラスApplicationRecordを使って自動的に入るようにした)

マイグレーションの注意

テーブルを追加するときに必要になるCREATE POLICYは、マイグレーションでやりたくなりますが、その場合schema.rbに反映されないので、db:reset/db:setupは使えません。(db:migrate:resetはok)

デメリット

今回は実際にこの実装で運用したわけではないのですが、考えられるデメリットを上げてみます。

テーブルが肥大化する

RDBはレコード数が膨大になると取り扱いが大変になります。インデックスを設定していてもメモリに乗らず急激に重くなったり。

そこでパーティショニング(テーブル分割)機能の利用を検討することになります。カラムの値に応じて分割するリストパーティションという方法があるので、それを使うことになるでしょう。

tenant_idごとにテーブル分割する。という戦略を最初に思いつきますが、一般的にパーティショニングで100を超えるような子テーブルを作るのは想定されていないようで、パフォーマンスに問題がでるという報告も見られます(実際に試してはいませんが)。このアプローチはあまり現実的ではなさそうです。

データが増えたら臨機応変に手動で分割していく、といった戦略になりそう。めんどくさいですね。

テナントの削除が面倒

すべてのテーブルのレコードを消して回らないといけないので大変そう。マルチスキーマ方式の場合はスキーマを削除するだけなので簡単でした。

他の環境からのデータ移行が難しい

SaaS型のサービスだけどオンプレミスでも提供する。といった場合、オンプレミスからSaaSへのデータ移行が必要になったときに大変そう。各テーブルのidが変わってしまうためです。マルチスキーマ方式の場合はダンプ&リストアするだけで済みました。

RLSは見送ることにした

やはりテーブルの肥大化がつらそう。という懸念が払拭できませんでした。

マルチスキーマ方式でマイグレーションがつらい、というのはデプロイのときだけの問題であり、日常的にパフォーマンスを気にするよりずっとマシな気がします。

そのマイグレーションも数百程度のテナント数ならほとんど問題はないので、テナント数の想定にもよりますがスタートアップ段階ではマルチスキーマ方式でも良いのではないか? と思いました。

Apartmentはテナントに応じてDBサーバを変えられる機能などもあり、性能に関してはこちらのほうが安心感が大きいです。

他のソリューション

Citus
https://www.citusdata.com/

マルチテナントをいい感じに実現してくれるPostgreSQLの拡張機能。

OSSなのでEC2にはインストールできますが、RDSでは使えないですね……

2016年〜 にAWS上でマネージドサービスを提供するCitus Cloudというサービスがあったようです。

ところが、2019年にMicrosoftがCitusを買収。Citus Cloudは終了。代わりにAzureで利用できるようなったようです。あ"〜

AWSはマストなので厳しい……

Apartmentの開発が停滞してる

よし、やっぱりこれからもApartmentで行くぞ! と思って新しいプロジェクト(Rails 6)で使おうとしたら動かなくてあれれとなりました。

結構メジャーなGemだと思うのですが、なんと今時点(2020年7月)でまだRails 6に対応していないのでした。

活発にメンテされているFork版があったのでとりあえずこちらを使えば大丈夫そうですが。

Discussion