Sansan Builders Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

マルチテナントSaaSのテナント分離をRow-Level Securityに移行した

こんにちは、クラウド請求書受領サービス「Bill One」の開発に携わっているソフトウェアエンジニアの加藤です。Bill OneはB2BのマルチテナントSaaSであり、データベースとして Cloud SQL 上のPostgreSQLを利用しています。従来はマルチテナントのデータを分離するために、テナントごとにPostgreSQLのスキーマを分けていましたが、2020年12月にRow-Level Securty(行レベルセキュリティ。以降RLSと表記)による分離に移行しました。

本稿では、移行の背景とRLS組み込みにあたって考慮したポイントをご紹介します。

マルチテナントSaaSのテナント分離

マルチテナントSaaSにおけるテナント分離方法はいくつか知られており、大きく次の3つに分けられます。

  1. アプリケーションの実行環境ごと完全に分離する
  2. データベースのみをインスタンスやスキーマで分離する
  3. データベースのインスタンスやスキーマはマルチテナントとして、テーブル内のテナントIDで分離する

上のものほどデータの分離が明確で安全であるものの、リソース効率は悪くなります。3は最もリソース効率が良いものの、開発時にテナント間のデータが混ざらないよう気をつける必要があります。とはいえ人間の注意力には限界があるので、Bill OneではRLSの力を借りて、2 → 3に移行しました。

従来のテナント分離: スキーマ分離

Bill Oneでは初期に設計を行った際に、テナントの分離方針で頭を悩ませ、次のような先人の知見を参考にしました。

HRBrainさんのスライドで紹介されているRLSは気になったものの、データベースのコネクション管理が複雑になるのは避けたかったので、Bill Oneの開発初期にはテナントごとにPostgreSQLのスキーマを分離する方針を採用しました。SmartHRさんのスライドから、スキーマ数が増えるとデータベースのマイグレーションにかかる時間は長くなるものの、1000テナント程度までは大丈夫かなと判断しました。

一旦はそれでうまくいきました。スキーマを分離することで、テナント間のデータが混ざる可能性を考慮せずに開発できるためです。しかしローンチ後に順調に契約テナント数が伸びるにつれて、マイグレーションにかかる時間が問題になってきました。普段のリリースではCloud RunやApp Engineのトラフィックを切り替えるだけなので数秒で終わりますが、マイグレーションがある場合は全テナントのマイグレーションを完了するまでに5〜10分程度かかっていました。

さらにBill Oneでは、オンラインでサインアップ可能な無償のスモールビジネスプランを導入することで、それまでの有償契約のみに比べて、テナント数が大幅に増えることが想定されました。

移行後のテナント分離: Row-Level Security

そんな折、AWSさんのRLSに関する記事を読み、 current_setting() を使うことでコネクション管理を複雑にせずともRLSを採用できることを知りました。この情報を、ちょうど Sansan Seminar Manager を開発していたメンバーに共有したところ、サクッと導入していい感じだと教えてくれたため、Bill OneでもRLSに移行することにしました。

RLS はPostgreSQLではバージョン9.5 以降で利用できる機能で、あらかじめテーブルごとにポリシーを定義しておくことで、SELECT文などに暗黙的なWHERE句が追加されるようなイメージの機能です。

RLSの動作については次の記事を含め、世の中に参考となる情報が多数あるため、本稿では割愛します。ポイントとしては、ポリシーに current_user ではなく current_setting を使うことで、テナントごとにロールを作成しなくてもよいという点です。

aws.amazon.com

RLSを適切に使用することで、スキーマを分割せずとも、テナント間のデータが混ざる可能性を考慮せずに開発できるようになります。スキーマが1つになることで、マイグレーションにかかる時間を短縮でき、テナント作成時に既存のテーブルにINSERTするだけでよくなります。

RLSを利用した実装

RLSを利用した場合の実装は次のようになります。

テーブルを定義するとき

テーブル定義時には次の3点を実施します。

  1. テーブルに tenant_id カラムを作る(Bill Oneでは先頭に作るというルールにしました)
  2. 作成したテーブルに対して、 ENABLE ROW LEVEL SECURITY を適用する
  3. 作成したテーブルに対して、 CREATE POLICY する

マイグレーションのSQLは次のようになります。

CREATE TABLE invoice (
    tenant_id varchar(20) NOT NULL,
    invoice_uuid uuid NOT NULL PRIMARY KEY,
    publisher varchar(50) NOT NULL
);

ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_policy ON invoice
    USING (tenant_id = current_setting('app.tenant_id'));

コネクションを取得するとき

次のSQLでテナントIDを設定します。SET コマンドではなく set_config 関数を使用しているのは、プレースホルダーを使うためです。アプリケーションからコネクションを取得する処理は1つのラッパー関数に集約しているので、そこで実行することで、設定漏れを防ぎます。

SELECT set_config('app.tenant_id', :tenantId, false);

INSERT文を書くとき

テーブルには tenant_id カラムが含まれているので、INSERT文では tenant_id を意識する必要があります。現在のコネクションの tenant_id は current_setting('app.tenant_id') で取得できるため、bindする必要はありません。

INSERT INTO invoice (
    tenant_id,
    invoice_uuid,
    publisher
) VALUES (
    current_setting('app.tenant_id'),
    :invoiceUUID,
    :publisher
);

SELECT, UPDATE, DELETE文を書くとき

WHERE句に tenant_id = current_setting('app.tenant_id') が暗黙的に挿入されるので、明示的に書く必要はありません。ただし、DELETE文でWHEREが何もないとさすがに気になるなどの理由で、明示的に書いても良いものとしています。

RLSが確実に適用されていることの確認

RLSは便利であるものの、適用されるための条件がいくつかあります。

  • テーブルに ENABLE ROW LEVEL SECURITY されていること
  • テーブルにポリシーが適用されていること
  • RLSの制限を受けるユーザーで接続していること(スーパーユーザーやテーブルの所有者、 BYPASSRLS 付きで作成されたユーザーでないこと)

逆に言うと、次のようなミスをすることでRLSが適用されなくなり、テナント間のデータが混ざってしまう可能性があります。

  • テーブルに ENABLE ROW LEVEL SECURITY をつけ忘れる
  • テーブルにポリシーを設定し忘れる
  • 環境移行時などに、RLSの制限を受けないDBユーザーを設定して、アプリケーションをデプロイしてしまう

このようなミスはなんとしても避ける必要があるので、チェック機構を導入しました。アプリケーションからのDBコネクション取得時に set_config 関数でテナントIDを設定しますが、その直後に次の2つのチェックを実行するというものです。

  • 2件の異なるテナントのデータのみが挿入されたテーブルを用意しておき、このテーブルをWHERE条件無しでSELECTして、(2件ではなく)1件または0件のデータを取得できること
  • pg_catalog.pg_tables テーブルからSELECTして、 rowsecurity がfalseになっているテーブルが、想定されたもの(後述の tenant テーブルなど)以外に存在しないこと

クエリの実行効率はやや悪くなりますが、安全側に寄せて、DBコネクションを取得したタイミングで毎回実行しています。後者のチェックがあることで、運用で一時テーブルを作ったりするとアプリケーションがほぼ使えなくなる可能性はありますが、このミスはRLSヘの移行時に実施した計画メンテナンス時以外には発生していません。

なお、Bill OneではCIでデータベースを利用するAPIテストを実行しているので、テーブル定義レベルで問題があった場合には、そのテストが失敗することで気づけます。

その他の考慮点

テナントを跨いで参照したいデータ

一部のデータはテナントを跨いで参照する必要があります。例えば、Bill Oneでは全テナントを対象に実行するバッチ処理があるため、テナント一覧のデータが必要です。RLSはテーブル単位に設定できるため、 tenant テーブルだけはRLSを適用しないことにしました。これによって、バッチ処理の最初でテナントの一覧を取得し、テナントごとのミニバッチに分割して実行することで、RLSを適用したまま全テナントの処理を実行できます。

インデックス

RLSを使うと暗黙的に tenant_id による検索が追加されることになるので、検索を高速化するためのインデックを張るときは tenant_id との複合インデックスにする方が効率よくなるケースが多いです。

ただBill Oneではテーブルのプライマリキーとして基本的にUUIDを使用しており、万が一にもUUIDが重複した場合にはエラーになって欲しいので、UUIDのプライマリキーに関しては複合プライマリキーではなく、1カラムのみとしています。

まとめ

Bill Oneでは、テナントごとに分かれていたスキーマを1つにして、Row-Level Securityを使ったテナント分離に移行したことで、マイグレーションの所要時間が短くなりました。さらに、細かい点で次のようなメリットもありました。

作業 Before After
テナントの作成 スキーマの作成が必要 テーブルに1行追加するだけで作成できる
テナントを跨いで一意のレコードの検索 最初にテナントのスキーマの特定が必要 UUIDのみで検索できる ※
テナントを跨いだ集計作業 全スキーマに同じSQLを実行する仕組みが必要 単純なSQLで集計できる ※

※将来的にデータベースをシャーディングすると、このメリットは消えてしまう or 薄まってしまいますが。

もちろんメリットばかりではなく、次のようなデメリットもありましたが、メリットの方が上回ると判断しました。

  • 解約時のデータ削除の手間が増える
  • PostgreSQLの機能にロックインされる

RLS導入によって、サービスを素早く改善し、より多くのお客様に使ってもらえる準備が整ったと考えています。本稿がマルチテナントSaaSの設計の参考になれば幸いです。

参考


buildersbox.corp-sansan.com buildersbox.corp-sansan.com

© Sansan, Inc.