マルチテナントのアプリケーション実装 〜実践編〜 2022.04.20 SaaS.tech #2
© 2022 LayerX Inc.1マルチテナントのアプリケーション実装〜実践編〜SaaSにおけるマルチテナント設計の悩みと勘所SaaS.tech #22022.04.20
View Slide
© 2022 LayerX Inc.2自己紹介株式会社LayerX中川佳希@yyoshiki41バクラク請求書のテックリードバックエンドからフロントエンドまで.SaaS が扱う業務ドメインへの好奇心とサービスの成長に日々ワクワクを感じています.Gopher.
© 2022 LayerX Inc.31. 導入a. マルチテナントSaaS2. データベース設計a. 設計パターン3. アプリケーション側での実装a. テーブルスキーマb. 型(Type)の実装c. コンテキスト(Request-scoped Data)d. ORMe. バリデーションf. ロギング / モニタリングg. テスト章立て
© 2022 LayerX Inc.4マルチテナント SaaSマルチテナント SaaS は・・・同質のソフトウェアを全テナントのユーザーへ提供.リソースの一部またはすべてをテナント間で共有.SaaS 提供側のメリット(シングルテナントアプリケーションと比較)● 機能提供アウトカムの最大化● サービス運用の効率化● インフラコストの削減
© 2022 LayerX Inc.5開発者を悩ますポイント1. テナント毎に安全にデータを分離した状態で、アプリケーションを実装できるか?2. テナント境界線をどのレイヤで実装するか?3. テナント間のシステムリソース共有をどこまで行うか?マルチテナント SaaS が絶対に防ぐべきこと=> データが他のテナントにも共有されてしまうこと(漏洩)マルチテナント SaaS
© 2022 LayerX Inc.6マルチテナント SaaS での安全なデータ分離の実装安全にテナント毎のデータを分離するには?● データベース● ミドルウェア○ アプリ => ミドルウェア => データベース のような Proxy を想定● アプリケーション● テスト以降では低レイヤの部分から順にみていきます.
© 2022 LayerX Inc.7SaaS 開発における最初の分岐点1. データベースをテナント毎に作成=> データ所有者(テナント)を データベース名 で表現2. データベースは共有、スキーマをテナント毎に作成=> データ所有者(テナント)を スキーマ で表現3. データベースは共有、テーブルをテナント毎に作成=> データ所有者(テナント)を テーブル名 で表現4. データベースは共有、テーブルも共有、各テーブルがテナント識別カラムを持ち、レコード値で識別・分離=> データ所有者(テナント)を テーブルのカラム値 で表現データベース設計IsolatedShared
© 2022 LayerX Inc.81. データベースをテナント毎に作成メリット1. データの持ち方としては、もっとも堅牢デメリット1. マイグレーション(テーブルスキーマ変更)コスト2. アプリからのデータベース接続コストa. データベースユーザーが異なる場合, セッションも異なる3. テナント作成時に、毎回データベースの初期化処理が必要a. テーブル作成、データベースユーザーの認証設定4. テナントとデータベースのリレーションが別途必要Tenant BTenant A
© 2022 LayerX Inc.9スキーマとは...PostgreSQL などで名前空間を作成できる仕組み.データベースオブジェクト(テーブル、関数など)を同じオブジェクト名でもスキーマが異なれば作成可能になる.MySQL ではオブジェクトを論理的に分ける(グルーピングする)仕組みに相当するものは見当たらず.2. データベースは共有、スキーマをテナント毎に作成GlobalTablesTenant ATablesTenant B
© 2022 LayerX Inc.102. データベースは共有、スキーマをテナント毎に作成GlobalTablesTenant ATablesTenant Bメリット1. スキーマ単位で所有者を決めれるデメリット1. マイグレーション(テーブルスキーマ変更)コスト2. アプリからのデータベース接続コストa. スキーマ所有者が異なる場合, セッションも異なる3. テナント作成時に、毎回スキーマの初期化処理が必要a. テーブル作成、スキーマ所有者の認証設定4. テナントとスキーマのリレーションが別途必要
© 2022 LayerX Inc.11メリット1. 1つのデータベース内でテナントのデータを保持できるデメリット1. マイグレーション(テーブルスキーマ変更)コスト2. テナント作成時に、毎回テーブルの初期化処理が必要a. テーブル作成3. テナントとテーブル名のリレーションが別途必要4. テナントのテーブル間で外部キーが煩雑になる3. データベースは共有、テーブルをテナント毎に作成GlobalTenant AtablesTenant Btables
© 2022 LayerX Inc.12ID TenantID Name1 A Foo2 B Barメリット1. マイグレーション(テーブルスキーマ変更)コストが低い2. データベース側での設定コストが低いデメリット1. 他テナントのレコードへアクセスが容易a. 同一テーブルの為, 最もカジュアルにアクセス可能2. ロジックを実装する必要があるa. アプリ側で制御する場合i. WHERE 句b. データベース側で制御する場合i. Row-Level Security での制御ii. ストアドプロシージャを実装しての制御4. テーブルにテナント識別カラムを持ち, 行単位で制御Globaltables
© 2022 LayerX Inc.13テーブルに識別カラムを持ち,テーブルへのポリシー × データベースユーザーで制御するをデータベース側で実装する例データベースユーザーのセッション管理などが一定ネックになる.● PostgreSQL Row Level Security○ PostgreSQL ネイティブの機能● Implementing row level security in MySQL / SQL Maestro○ Trigger や View テーブルを使って, MySQL で RLS を実現する例4. テーブルにテナント識別カラムを持ち, 行単位で制御
© 2022 LayerX Inc.14アプリ => ミドルウェア => データベース のようなプロキシを想定.● ProxySQL○ mysql_query_rules: WHERE 句に TenantID がないクエリをはじく● MariaDB MaxScale○ Deny ルールを作る: WHERE 句に TenantID がないクエリをはじく上記のような SQL を解釈できるプロキシミドルウェアで,ポリシー(とデータベースユーザーの掛け合わせ)での制御.ポリシーに沿わないレコードへのアクセスを拒否する.実検証までは行っておらず 🙏ミドルウェア(+α)
© 2022 LayerX Inc.154. テーブルにテナント識別カラムを持ち, 行単位で制御主な理由● データベース、マイグレーション運用コストが最も低い○ デプロイ(マイグレーション)難易度が高いサービスは致命的○ テナント追加時, データベース側の初期化処理のサブシステム等が不要● データベース側にロジックを寄せることでのロックインを避けたい○ ストアドプロシージャなどの実装への依存も持ちたくない● テナント数のスケールに最も適している○ テナント毎のデータベースユーザーと管理, アプリからの接続セッションの管理なども不要● ロジックのテストの行いやすさバクラクでのデータベース設計
© 2022 LayerX Inc.164. テーブルにテナント識別カラムを持ち, 行単位で制御テナント毎にデータを(論理的に)識別・分離して扱うことは, 至上命題.実際の取り組み(ここからが本題)1. テーブルスキーマ2. 型(Type)の実装3. コンテキスト(Request-scoped Data)4. ORM5. バリデーション6. ロギング / モニタリング7. テストアプリケーション実装〜実践編〜ID TenantID Name1 A Foo2 B Bar
© 2022 LayerX Inc.17全テーブルへ冗長に TenantID (テナント識別カラム)を付ける(中間テーブル、子テーブルでも同様)親テーブルにはマストで必要.子テーブルでは従来, 親テーブルへの外部キー制約だけで充分.冗長にカラムを付ける理由は,1. ORM などで, WHERE句に TenantID を機械的に付けれるa. カラム有無を考えなくて良い2. JOIN 時にも TenantID を機械的に付けれる3. 子テーブルから自テナントデータのみを取得可能4. 親, 子テーブルで異なる TenantID のデータを排除5. シャーディングが必要になった際, キーに使えるテーブルスキーマID TenantID Name1 A Foo2 B BarparentsID ParentID TenantID Name11 1 A Foo22 2 B Barchildren
© 2022 LayerX Inc.18JOIN 時にも TenantID を機械的に付けれる通常, 起きてはいけない不整合データを取得時に排除可能.※ INSERT時に検知すべきかつ, 従来のリレーショナルモデルでは考慮しなくてもよい問題ではある...例.リレーションのあるテーブル間で異なる TenantID のレコードテーブルスキーマID TenantID Name1 A Foo2 B BarparentsID ParentID TenantID Name11 1 A Foo22 2 XXX BarchildrenSELECT *FROM parentsINNER JOIN children ON parents.ID = children.ParentIDAND parents.TenantID = children.TenantIDWHERE parents.TenantID = "B";
© 2022 LayerX Inc.19子テーブルから自テナントレコードのみを取得可能アプリの処理単位もテナント単位なので,利用しやすいデータモデル.API 等から ID 指定でリソース取得するケースでも,機械的に TenantID を付けてバリデーションできる.テーブルスキーマID TenantID Name1 A Foo2 B BarparentsID ParentID TenantID Name11 1 A Foo22 2 B BarchildrenSELECT * FROM childrenWHERE TenantID = "B";SELECT * FROM childrenWHERE TenantID = "B" AND ID IN ("ID1", "ID2", ...);
© 2022 LayerX Inc.20シャーディングが必要になった際には, キーとして使用できる.Google’s F1 paper で, 分散データベースの階層型のデータモデル(The ClusterHierarchical Model)が触れられている.リレーショナルモデルでは, 親テーブルへの外部キーを持つカラムだけでリレーションを表現可能. しかし, 分散データベース環境化では従来のリレーショナルモデルの外部キーだけではトランザクション, ジョインなどのコストが高価になる.先祖(親)の ID をプライマリキーに含めることで, 物理的にも同じマシンでの処理が行える.● Google F1● Designing your SaaS Database for Scale with Postgres / Citus Data● Sharding a multi-tenant app with Postgres / Citus Dataテーブルスキーマ
© 2022 LayerX Inc.21従来のリレーショナルモデルとの比較テーブルスキーマ
© 2022 LayerX Inc.22ユニークキーやインデックスも TenantID を軸に設計する例えば name カラムにユニークキー制約をつけたい場合,TenantID を先頭(プレフィックス)に付けた複合キー にする.テーブルスキーマCREATE TABLE `table_a` (`id` int(11) NOT NULL,`tenant_id` varchar(36) NOT NULL,`name` varchar(36) NOT NULL,PRIMARY KEY `id`,UNIQUE KEY (`tenant_id`,`name`),CONSTRAINT `fk_tenant_id` FOREIGN KEY(`tenant_id`) REFERENCES `tenants` (`id`)) ENGINE=InnoDB;ID TenantID Name1 A Foo2 B Bar
© 2022 LayerX Inc.23複合キーは列挙したカラム順に連結して, 内部値としてインデックスされる.WHERE tenant_id = “B” (インデックスのプレフィックス) だけでも既知の範囲に絞るインデックスとして機能する.アプリが使用するテナントのレコードへの効率的なクエリになる.PrimaryKey を用いないクエリでは, 必須のインデックスになる.※ PrimaryKey を用いる場合も, 後述する Context が持つ TenantID と一致しているか検証するため, WHERE 条件として使用しているtips:複合キーのカラムを ForeignKey として使用する際の話MySQL 外部キー制約とインデックスに必要な知識 - LayerX エンジニアブログテーブルスキーマ
© 2022 LayerX Inc.24A defined type in GoTenantID という型を新たに定義して使用underlying type は, プリミティブな string 型A defined type の主なメリット● 関数引数, 返り値の間違いや代入ミスをコンパイル時にエラーにできる● 独自メソッドを実装できる型(Type)の実装type TenantID string
© 2022 LayerX Inc.25Generate go model from a database table schemaテーブル定義から, Go の Struct 生成を行う.xo というツールで自動生成.TenantID カラム生成時には, 独自定義した型を使用している.型(Type)の実装type TableA struct {ID stringTenantID TenantIDName string}
© 2022 LayerX Inc.26リクエストを受け取ったAPIサーバーは, コンテキストに TenantID を入れるユーザーの認証後に特定されたテナント情報をアプリのMiddleware層でセット.以降は, この TenantID をもとにレスポンスまで処理を行っていく.コンテキスト(Request-scoped Data)func ContextWithTenantID(ctx context.Context, tenantID TenantID) context.Context {return context.WithValue(ctx, ctxKeyTenantID, tenantID)}
© 2022 LayerX Inc.27バッチ処理などの実装テナント毎に処理を実行するように関数を実装する※ テナントをまたいで処理を行う関数を極力実装しないコンテキスト(Request-scoped Data)func ProcessPerTenant(ctx context.Context, input string) context.Context {// do stuff…}
© 2022 LayerX Inc.28ORMgorm の Callback plugin を実装.WHERE 句に自動で TenantID 条件がつくようにしている.func NewGlobalDB(conf *mysql.Config) {…// Register callback functionsdb.Callback().Query().Before("gorm:query").Register("my_plugin:before_query", callbackTenantID)db.Callback().Update().Before("gorm:update").Register("my_plugin:before_update", callbackTenantID)db.Callback().Delete().Before("gorm:delete").Register("my_plugin:before_delete", callbackTenantID)db.Callback().RowQuery().Before("gorm:row_query").Register("my_plugin:before_row_query", callbackTenantID)}
© 2022 LayerX Inc.29ORM登録する Callback 関数の実装は,Struct 内の TenantID フィールドを検出して, WHERE 句をセットする.(SELECT / CREATE / UPDATE / DELETE)アプリから呼び出す際には, TenantID を引数に渡してコールバック関数が登録されたDBインスタンスを仕様する.err := app.NewDB(db, tenantID).First(&model).Error
© 2022 LayerX Inc.30一部のメソッド(Raw, Exec)では, callback 関数では対応出来ない..※ Raw, Exec は, 記述したSQLをそのまま実行できる機能● 基本的に使用させない.(CIでの検知)● 管理画面など, 例外的には使用可能ORMvar result Resulterr := db.Raw("SELECT id, name, age FROM users WHERE name = ?", 3).Scan(&result)
© 2022 LayerX Inc.31callback 関数が発火せずに実行されたクエリに対しては,ログを出力するようにもしている.意図せずに, TenantID を指定しないクエリを発見出来るようにする.ORM
© 2022 LayerX Inc.32バリデーションAPIサーバーの終端でレスポンスデータのバリデーションを実行RDS含め Redis, ElasticSearch, DynamoDB などデータソース全体で,他のテナントデータが含まれていないことを検証する最後の砦.関数内で再帰的にレスポンスオブジェクトの TenantID フィールドが一致していることを検証.Go で書くには骨が折れる reflect での処理.func ValidateTenantID(tenantID TenantID, obj interface{}) {// do validation…}
© 2022 LayerX Inc.33ログには常に TenantID を含めて出力logger 側で Context 内の TenantID 自動で出力するように実装アプリからの呼び出し:モニタリングツール(Datadog)上でも,Log Facets としてフィルタも可能.トラブルシュート時にデータソース特定やカスタマー連絡に役立ちます.ロギング / モニタリングapp.LogError(ctx, err).Send()
© 2022 LayerX Inc.34TenantID がついていないSQLクエリログをモニタリングツールで監視正規表現でのチェックが必要なため, まだまだ調整中(理想はSQLパーサーでアラート条件を作れること)ロギング / モニタリング
© 2022 LayerX Inc.35単体テスト毎にテナントを作成して, 並列に実行する● 他テナントデータに影響を与える場合, テストで検知出来る○ 他のテナントへ相互に影響を与える機能が存在しない● パッケージ内の全テスト完了まで, テストデータをドロップ(削除)しない● テスト/サブテスト並列度を上げる動機づけになる● テナントセットアップは, ヘルパー関数を用意テストfunc TestA(t *testing.T) {helper.SetupTenant(t)t.Run(“Case1”, func(t *testing.T) {t.Parallel(t)// run tests…}}
© 2022 LayerX Inc.36● アプリケーション実装でのテナントデータの論理的な分離を行う方針を取っています.○ データベースやミドルウェアでの制御でも, ポリシー × ユーザーでのロジック実装部分は避けられないと思います.● 開発/テスト/デプロイのコストの低さやロックインを避けれるなどメリットはあります.○ その反面, アプリ側でのガードレール実装の重要度は高くなります.● 安全かつ開発スピードも落ちない仕組みの改善は進めていきます.まとめ