プラハという会社でサーバ・インフラをメインに開発している@revenue-hackです。
現在弊社ではマルチテナントを使った新規サービスを開発していて、どの様にしてマルチテナントを解決したかをご紹介したいと思います。
マルチテナントの種類
マルチテナントでは一般的に3つの対応の仕方があります。
- データベース分離方式(インスタンスごと分ける場合と分けない場合)
- 単一データベース個別スキーマ方式
- 単一データベース共通スキーマ方式
これらはそれぞれメリット・デメリットがあるのですが、弊社では最後の3つ目の方式を採用しています。
理由としてはすくないリソースでサービス提供できる点、DBマイグレーションコストを避ける点です。
特にまだMVPを目指している段階で他の方法を採用するのはコスト面では折り合いがつかないため、これを採用しました。
これらのマルチテナントの種類に関してはいくつかネットに文献が載っているので詳しくはそちらで。
PostgresでRow Level Security(RLS)を導入する
選択技術について
RLSを導入する前に、そもそも最初はマルチテナントをする予定がなかったのと、MVPを作成までを目指していたため、技術選択を自分らの得意なものを選択していました。
以下がバックエンドで我々が選択したものです。
- MySQL
- TypeScript(TS)
- TypeORM
- Express
- Docker
弊社ではJSに明るい人が多いため必然的にTSとなり、TSのRDBのORMというとTypeORM一択になるかなと思います(他に良いのがあったら教えて下さい)。
また弊社ではOSSのRDBで普段MySQLを使う人が多いため、こちらもほぼ自然な流れでMySQLを選択していました。
全マスタテーブルにテナントIDを付与する
まず同じスキーマで全テナントを管理するために、DB設計から考えました。
そこで全てのマスタの全テーブルにテナントのIDとなるものを付与して、必ずこのテナントIDをWHERE句にいれて、SELECTをかけることでマルチテナントを1スキーマで担保するようにしました。
必ずテナントIDをWHERE句には?
そうすると次はどのようにして必ずテナントIDをWHERE句に入れるかを検討。
何も考えずに実装すると、必ずどこかでWHERE句の入れ忘れのヒューマンエラーが発生し、それをコードレビューで担保するのは、
プロダクトが小さいうちは可能かもしれないですが大きくなってくにつれて難しくなり、またずっと最初の開発者がこのプロダクトを開発するという思考は、
現代のコード設計に考慮するのは現実的ではありません。
そこでまずはアプリケーションレイヤーでそれを実現しようとしました。
まずTypeORMでSQL発行時にWHERE句を必ず入れられる仕組みを考えました。
しかし。。。
なかなかTypeORMは発展途上なのかスター数こそ多いものの、issueが結構放置されていたり、かゆいところに手が届かないことが多いORM(クラスからDDLを生成してクエリーを流すと、再度クエリーを流す際に差分が出るとか->もはやこれはバグだけど)で、
今回のSQL発行時にWHERE句を入れるというのも実現するには簡単にはいかなそうでした。
そこで色々調べているとPostgreSQLにRLSがあり、それを利用したHR BrainさんのRLSの記事を見ました(https://times.hrbrain.co.jp/entry/postgresql-row-level-security)
この方法だと、必ずテナントIDを一意に設定でき、システム的にセキュリティを担保できる且つ、アプリケーションレイヤーよりも低レイヤーで担保出来るのも良い点です。
RLS導入
長らくおまたせしましたが、ここからがRLSの実際の導入に関する方法です。
全マスタにテナントIDを入れる
まずPostgreSQLで全マスタにテナントIDを入れます。
Column | Type | Collation | Nullable | Default
------------------------+-----------------------------+-----------+----------+---------
id | character varying(64) | | not null |
tenant_id | character varying(64) | | not null |
content | character varying(1000) | | not null |
used_for_documentation | boolean | | not null | false
created_at | timestamp without time zone | | not null | now()
updated_at | timestamp without time zone | | not null | now()
Indexes:
"PK_e7dc17249a1148a1970748eda99" PRIMARY KEY, btree (id)
Foreign-key constraints:
"FK_3053b7bf7430da047f1ee007cd7" FOREIGN KEY (tenant_id) REFERENCES tenants(id)
Referenced by:
TABLE "review_comments_tags" CONSTRAINT "FK_038a39ecfa7ab37f68d4100c9f8" FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
TABLE "tags_questions" CONSTRAINT "FK_6ebef181e74c78da7e76b38423b" FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
TABLE "questions_tags" CONSTRAINT "FK_8cbfe2feee1a1eb04d08f3a7def" FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
TABLE "tags_review_comments" CONSTRAINT "FK_af93adf1be752992a99be494245" FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
弊社の例だとこんな感じで、tenant_id
というのがテナントIDになります。
この様に全てのマスタにテナントIDを入れます。
RLSを全マスタにポリシーを入れる
RLSを導入するにはポリシーを作成します。
上記の参考テーブルだとこんな感じのポリシーを入れます。
CREATE POLICY tags_tenant_id_policy ON tags FOR ALL USING (tenant_id = current_setting('tenant.id'));
簡単に説明すると
CREATE POLICY ポリシー名 ON このポリシーを付与するテーブル名 FOR (ALL|SELECT|UPDATE|INSERT|DELETE) USING (テーブルのテナントID = current_setting(setする変数名))
詳しくはこちらを見るとわかりやすいです。
FOR ALL
にした理由はこちらの記事を見ると良いかと思います。
このやり方はHR Brainさんとかとはちょっと違うのですが、current_setting
というのを使って行います。
これによって独自に定義したtenant.id
というのをset
しないとDMLは全て実行できなくなります。
またこのポリシーがついていると、ALTER
でカラムを追加するときもtenant.id
がないと実行できなくなります。
最後にこのテーブルにポリシーを有効にするようにします。
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE tags FORCE ROW LEVEL SECURITY;
※FORCE
のALTER
文は場合によっては必要です。これについては後述します。
ハマりポイントをいくつかご紹介
Superuser
やBypass RLS
が付与されているユーザだと、全て無視
今回追加したポリシーは、Superuser
やBypass RLS
が付与されているユーザはポリシーを完全に無視します。
これで設定の仕方が間違っているのかと思い、大分悩みました。
テーブルOwnerはRLSを無視する
上記に似た現状として、テーブルを作成したOwnerユーザでDMLなどを実行すると、RLSを無視します。
とはいえlocalでdockerなどでPostgreSQLを立てている人も多いかと思います(つまりテーブルのOwnerでDMLを実行する必要がある)が、その場合は
少し前に書きましたが、
ALTER TABLE tags FORCE ROW LEVEL SECURITY;
これをテーブルに実行することで、OwnerユーザでもRLSが効くようになります。
これもハマったので話しておくと、ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
とALTER TABLE tags FORCE ROW LEVEL SECURITY;
を両方実行しないと、
効かないので気をつけてください(FORCE
使うならFORCE
だけでENABLE
もされるかと思ってハマった)。
Dockerでlocal環境を立てる場合
Dockerでlocalを立てる場合はSuperuser
だとRLSは無視されるので、独自にユーザを作成しないといけないです。
とはいえ、手動でdocker exec
して作るのはナンセンスなのでシームレスにやる方法を紹介します。
version: '3.7'
services:
postgresql:
image: postgres:12
container_name: postgresql
ports:
- "5433:5432"
volumes:
- sample-code-store:/var/lib/postgresql/data
- ./local/create_user_for_postgres.sql:/docker-entrypoint-initdb.d/1_create_user.sql
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: rootsample
POSTGRES_DB: sample_code
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
TZ: "Asia/Tokyo"
container_name: sample-code-db
restart: always
volumes:
sample-code-store:
driver: local
のようにします。
./local/create_user_for_postgres.sql:/docker-entrypoint-initdb.d/1_create_user.sql
この部分が重要で、dockerのvolumeを作る前に最初のcreate時だけ発火されます。
このタイミングでSuperuserを持たないユーザを作成して、そのユーザでアプリケーションはログインするようにします。
create_user_for_postgres.sqlは
CREATE USER sample_code_admin PASSWORD 'samplepassword';
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO sample_code_admin;
こんな感じでマウントしてやるとできます。
これでlocal開発でも何も気にすることなく開発ができます。
デメリット
マイグレーション時にSETが必要
今回の開発におけるデメリットとして
あとから追加したマイグレーションでset tenant.id
をしないといけないというデメリットがあります。
上記で書いたようにtenant.id
が付与されていないとALTER
文も使えないという話をしたかと思います。
なので、弊社ではマイグレーション時に
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`SET tenant.id = ''`, undefined);
await queryRunner.query(`ALTER TABLE "sample" DROP COLUMN "sample_id"`, undefined);
....
というように意味はないですが、ALTER
文を通すためにこの様にしています。
TypeORMで同じSessionを使い回すようにする工夫を入れる
弊社ではAPIのRequestが来たタイミングで、SET tenant.id
をすることで、シームレスに開発またSET tenant.id
忘れというのをなくす様に実装しています。
ただSET tenant.id
というのは同じsession内のみで有効ですので、APIのリクエスト単位ごとに、同じセッションのコネクションを使い回す必要があります。
import { getConnectionManager, getConnection } from 'typeorm'
import { Connection } from 'typeorm'
export default async (tenantId: string): Promise<Connection> => {
const conn = getConnectionManager().get()
await conn.query(`set tenant.id = "${escape(tenantId)}"`)
return conn
}
TypeORMの場合は上記のようにgetConnectionManager
というのを使って、コネクションを生成すると、1つのリクエストに対して、同じセッションを使い回すことができます。
まとめ
一番コストが掛からないであろうマルチテナントの実装において、RLSを使うと、DBレベルでマルチテナントを実現できるので、アプリケーションで実装を担保するよりもヒューマンエラーが圧倒的に起きづらくなる方法について紹介しました。
多くのRLSの使い方が、PosgreのRoleを使っている方法がご紹介されているのですが、そちらでも良いと思います。