$30 off During Our Annual Pro Sale. View Details »

マルチテナントのアプリケーション実装 〜実践編〜

マルチテナントのアプリケーション実装 〜実践編〜

マルチテナントのアプリケーション実装 〜実践編〜
2022.04.20
SaaS.tech #2

Yoshiki Nakagawa

April 20, 2022
Tweet

More Decks by Yoshiki Nakagawa

Other Decks in Technology

Transcript

  1. © 2022 LayerX Inc. 1 マルチテナントのアプリケーション実装 〜実践編〜 SaaSにおけるマルチテナント設計の悩みと勘所 SaaS.tech #2

    2022.04.20
  2. © 2022 LayerX Inc. 2 自己紹介 株式会社LayerX 中川佳希 @yyoshiki41 バクラク請求書のテックリード

    バックエンドからフロントエンドまで. SaaS が扱う業務ドメインへの好奇心と サービスの成長に日々ワクワクを感じています. Gopher.
  3. © 2022 LayerX Inc. 3 1. 導入 a. マルチテナントSaaS 2.

    データベース設計 a. 設計パターン 3. アプリケーション側での実装 a. テーブルスキーマ b. 型(Type)の実装 c. コンテキスト(Request-scoped Data) d. ORM e. バリデーション f. ロギング / モニタリング g. テスト 章立て
  4. © 2022 LayerX Inc. 4 マルチテナント SaaS マルチテナント SaaS は・・・

    同質のソフトウェアを全テナントのユーザーへ提供. リソースの一部またはすべてをテナント間で共有. SaaS 提供側のメリット(シングルテナントアプリケーションと比較) • 機能提供アウトカムの最大化 • サービス運用の効率化 • インフラコストの削減
  5. © 2022 LayerX Inc. 5 開発者を悩ますポイント 1. テナント毎に安全にデータを分離した状態で、アプリケーションを実装できるか? 2. テナント境界線をどのレイヤで実装するか?

    3. テナント間のシステムリソース共有をどこまで行うか? マルチテナント SaaS が絶対に防ぐべきこと => データが他のテナントにも共有されてしまうこと(漏洩) マルチテナント SaaS
  6. © 2022 LayerX Inc. 6 マルチテナント SaaS での安全なデータ分離の実装 安全にテナント毎のデータを分離するには? •

    データベース • ミドルウェア ◦ アプリ => ミドルウェア => データベース のような Proxy を想定 • アプリケーション • テスト 以降では低レイヤの部分から順にみていきます.
  7. © 2022 LayerX Inc. 7 SaaS 開発における最初の分岐点 1. データベースをテナント毎に作成 =>

    データ所有者(テナント)を データベース名 で表現 2. データベースは共有、スキーマをテナント毎に作成 => データ所有者(テナント)を スキーマ で表現 3. データベースは共有、テーブルをテナント毎に作成 => データ所有者(テナント)を テーブル名 で表現 4. データベースは共有、テーブルも共有、 各テーブルがテナント識別カラムを持ち、レコード値で識別・分離 => データ所有者(テナント)を テーブルのカラム値 で表現 データベース設計 Isolated Shared
  8. © 2022 LayerX Inc. 8 1. データベースをテナント毎に作成 メリット 1. データの持ち方としては、もっとも堅牢

    デメリット 1. マイグレーション(テーブルスキーマ変更)コスト 2. アプリからのデータベース接続コスト a. データベースユーザーが異なる場合, セッションも異なる 3. テナント作成時に、毎回データベースの初期化処理が必要 a. テーブル作成、データベースユーザーの認証設定 4. テナントとデータベースのリレーションが別途必要 Tenant B Tenant A
  9. © 2022 LayerX Inc. 9 スキーマとは... PostgreSQL などで名前空間を作成できる仕組み. データベースオブジェクト(テーブル、関数など)を 同じオブジェクト名でもスキーマが異なれば作成可能になる.

    MySQL ではオブジェクトを論理的に分ける (グルーピングする)仕組みに相当するものは見当たらず. 2. データベースは共有、スキーマをテナント毎に作成 Global Tables Tenant A Tables Tenant B
  10. © 2022 LayerX Inc. 10 2. データベースは共有、スキーマをテナント毎に作成 Global Tables Tenant

    A Tables Tenant B メリット 1. スキーマ単位で所有者を決めれる デメリット 1. マイグレーション(テーブルスキーマ変更)コスト 2. アプリからのデータベース接続コスト a. スキーマ所有者が異なる場合, セッションも異なる 3. テナント作成時に、毎回スキーマの初期化処理が必要 a. テーブル作成、スキーマ所有者の認証設定 4. テナントとスキーマのリレーションが別途必要
  11. © 2022 LayerX Inc. 11 メリット 1. 1つのデータベース内でテナントのデータを保持できる デメリット 1.

    マイグレーション(テーブルスキーマ変更)コスト 2. テナント作成時に、毎回テーブルの初期化処理が必要 a. テーブル作成 3. テナントとテーブル名のリレーションが別途必要 4. テナントのテーブル間で外部キーが煩雑になる 3. データベースは共有、テーブルをテナント毎に作成 Global Tenant A tables Tenant B tables
  12. © 2022 LayerX Inc. 12 ID TenantID Name 1 A

    Foo 2 B Bar メリット 1. マイグレーション(テーブルスキーマ変更)コストが低い 2. データベース側での設定コストが低い デメリット 1. 他テナントのレコードへアクセスが容易 a. 同一テーブルの為, 最もカジュアルにアクセス可能 2. ロジックを実装する必要がある a. アプリ側で制御する場合 i. WHERE 句 b. データベース側で制御する場合 i. Row-Level Security での制御 ii. ストアドプロシージャを実装しての制御 4. テーブルにテナント識別カラムを持ち, 行単位で制御 Global tables
  13. © 2022 LayerX Inc. 13 テーブルに識別カラムを持ち, テーブルへのポリシー × データベースユーザーで制御するをデータベース側で実装す る例

    データベースユーザーのセッション管理などが一定ネックになる. • PostgreSQL Row Level Security ◦ PostgreSQL ネイティブの機能 • Implementing row level security in MySQL / SQL Maestro ◦ Trigger や View テーブルを使って, MySQL で RLS を実現する例 4. テーブルにテナント識別カラムを持ち, 行単位で制御
  14. © 2022 LayerX Inc. 14 アプリ => ミドルウェア => データベース のようなプロキシを想定.

    • ProxySQL ◦ mysql_query_rules: WHERE 句に TenantID がないクエリをはじく • MariaDB MaxScale ◦ Deny ルールを作る: WHERE 句に TenantID がないクエリをはじく 上記のような SQL を解釈できるプロキシミドルウェアで, ポリシー(とデータベースユーザーの掛け合わせ)での制御. ポリシーに沿わないレコードへのアクセスを拒否する. 実検証までは行っておらず 🙏 ミドルウェア(+α)
  15. © 2022 LayerX Inc. 15 4. テーブルにテナント識別カラムを持ち, 行単位で制御 主な理由 •

    データベース、マイグレーション運用コストが最も低い ◦ デプロイ(マイグレーション)難易度が高いサービスは致命的 ◦ テナント追加時, データベース側の初期化処理のサブシステム等が不要 • データベース側にロジックを寄せることでのロックインを避けたい ◦ ストアドプロシージャなどの実装への依存も持ちたくない • テナント数のスケールに最も適している ◦ テナント毎のデータベースユーザーと管理, アプリからの接続セッションの管 理なども不要 • ロジックのテストの行いやすさ バクラクでのデータベース設計
  16. © 2022 LayerX Inc. 16 4. テーブルにテナント識別カラムを持ち, 行単位で制御 テナント毎にデータを(論理的に)識別・分離して扱うことは, 至上命題.

    実際の取り組み(ここからが本題) 1. テーブルスキーマ 2. 型(Type)の実装 3. コンテキスト(Request-scoped Data) 4. ORM 5. バリデーション 6. ロギング / モニタリング 7. テスト アプリケーション実装〜実践編〜 ID TenantID Name 1 A Foo 2 B Bar
  17. © 2022 LayerX Inc. 17 全テーブルへ冗長に TenantID (テナント識別カラム)を付ける (中間テーブル、子テーブルでも同様) 親テーブルにはマストで必要.

    子テーブルでは従来, 親テーブルへの外部キー制約だけで充分. 冗長にカラムを付ける理由は, 1. ORM などで, WHERE句に TenantID を機械的に付けれる a. カラム有無を考えなくて良い 2. JOIN 時にも TenantID を機械的に付けれる 3. 子テーブルから自テナントデータのみを取得可能 4. 親, 子テーブルで異なる TenantID のデータを排除 5. シャーディングが必要になった際, キーに使える テーブルスキーマ ID TenantID Name 1 A Foo 2 B Bar parents ID ParentID TenantID Name 11 1 A Foo 22 2 B Bar children
  18. © 2022 LayerX Inc. 18 JOIN 時にも TenantID を機械的に付けれる 通常,

    起きてはいけない不整合データを取得時に排除可能. ※ INSERT時に検知すべきかつ, 従来のリレーショナルモデル では考慮しなくてもよい問題ではある... 例.リレーションのあるテーブル間で異なる TenantID のレコード テーブルスキーマ ID TenantID Name 1 A Foo 2 B Bar parents ID ParentID TenantID Name 11 1 A Foo 22 2 XXX Bar children SELECT * FROM parents INNER JOIN children ON parents.ID = children.ParentID AND parents.TenantID = children.TenantID WHERE parents.TenantID = "B";
  19. © 2022 LayerX Inc. 19 子テーブルから自テナントレコードのみを取得可能 アプリの処理単位もテナント単位なので, 利用しやすいデータモデル. API 等から

    ID 指定でリソース取得するケースでも, 機械的に TenantID を付けてバリデーションできる. テーブルスキーマ ID TenantID Name 1 A Foo 2 B Bar parents ID ParentID TenantID Name 11 1 A Foo 22 2 B Bar children SELECT * FROM children WHERE TenantID = "B"; SELECT * FROM children WHERE TenantID = "B" AND ID IN ("ID1", "ID2", ...);
  20. © 2022 LayerX Inc. 20 シャーディングが必要になった際には, キーとして使用できる. Google’s F1 paper

    で, 分散データベースの階層型のデータモデル(The Cluster Hierarchical Model)が触れられている. リレーショナルモデルでは, 親テーブルへの外部キーを持つカラムだけでリレーションを表 現可能. しかし, 分散データベース環境化では従来のリレーショナルモデルの外部キーだ けではトランザクション, ジョインなどのコストが高価になる. 先祖(親)の ID をプライマリキーに含めることで, 物理的にも同じマシンでの処理が行え る. • Google F1 • Designing your SaaS Database for Scale with Postgres / Citus Data • Sharding a multi-tenant app with Postgres / Citus Data テーブルスキーマ
  21. © 2022 LayerX Inc. 21 従来のリレーショナルモデルとの比較 テーブルスキーマ

  22. © 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 Name 1 A Foo 2 B Bar
  23. © 2022 LayerX Inc. 23 複合キーは列挙したカラム順に連結して, 内部値としてインデックスされる. WHERE tenant_id =

    “B” (インデックスのプレフィックス) だけでも既知の範囲に絞るイ ンデックスとして機能する. アプリが使用するテナントのレコードへの効率的なクエリになる. PrimaryKey を用いないクエリでは, 必須のインデックスになる. ※ PrimaryKey を用いる場合も, 後述する Context が持つ TenantID と一致している か検証するため, WHERE 条件として使用している tips:複合キーのカラムを ForeignKey として使用する際の話 MySQL 外部キー制約とインデックスに必要な知識 - LayerX エンジニアブログ テーブルスキーマ
  24. © 2022 LayerX Inc. 24 A defined type in Go

    TenantID という型を新たに定義して使用 underlying type は, プリミティブな string 型 A defined type の主なメリット • 関数引数, 返り値の間違いや代入ミスをコンパイル時にエラーにできる • 独自メソッドを実装できる 型(Type)の実装 type TenantID string
  25. © 2022 LayerX Inc. 25 Generate go model from a

    database table schema テーブル定義から, Go の Struct 生成を行う. xo というツールで自動生成. TenantID カラム生成時には, 独自定義した型を使用している. 型(Type)の実装 type TableA struct { ID string TenantID TenantID Name string }
  26. © 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) }
  27. © 2022 LayerX Inc. 27 バッチ処理などの実装 テナント毎に処理を実行するように関数を実装する ※ テナントをまたいで処理を行う関数を極力実装しない コンテキスト(Request-scoped

    Data) func ProcessPerTenant(ctx context.Context, input string) context.Context { // do stuff… }
  28. © 2022 LayerX Inc. 28 ORM gorm の Callback plugin

    を実装. WHERE 句に自動で TenantID 条件がつくようにしている. func NewGlobalDB(conf *mysql.Config) { … // Register callback functions db.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) }
  29. © 2022 LayerX Inc. 29 ORM 登録する Callback 関数の実装は, Struct

    内の TenantID フィールドを検出して, WHERE 句をセットする. (SELECT / CREATE / UPDATE / DELETE) アプリから呼び出す際には, TenantID を引数に渡してコールバック関数が登録された DBインスタンスを仕様する. err := app.NewDB(db, tenantID).First(&model).Error
  30. © 2022 LayerX Inc. 30 一部のメソッド(Raw, Exec)では, callback 関数では対応出来ない.. ※

    Raw, Exec は, 記述したSQLをそのまま実行できる機能 • 基本的に使用させない.(CIでの検知) • 管理画面など, 例外的には使用可能 ORM var result Result err := db.Raw("SELECT id, name, age FROM users WHERE name = ?", 3).Scan(&result)
  31. © 2022 LayerX Inc. 31 callback 関数が発火せずに実行されたクエリに対しては, ログを出力するようにもしている. 意図せずに, TenantID

    を指定しないクエリを発見出来るようにする. ORM
  32. © 2022 LayerX Inc. 32 バリデーション APIサーバーの終端でレスポンスデータのバリデーションを実行 RDS含め Redis, ElasticSearch,

    DynamoDB などデータソース全体で, 他のテナントデータが含まれていないことを検証する最後の砦. 関数内で再帰的にレスポンスオブジェクトの TenantID フィールドが一致していることを 検証. Go で書くには骨が折れる reflect での処理. func ValidateTenantID(tenantID TenantID, obj interface{}) { // do validation… }
  33. © 2022 LayerX Inc. 33 ログには常に TenantID を含めて出力 logger 側で

    Context 内の TenantID 自動で出力するように実装 アプリからの呼び出し: モニタリングツール(Datadog)上でも, Log Facets としてフィルタも可能. トラブルシュート時にデータソース特定や カスタマー連絡に役立ちます. ロギング / モニタリング app.LogError(ctx, err).Send()
  34. © 2022 LayerX Inc. 34 TenantID がついていないSQLクエリログをモニタリングツールで監視 正規表現でのチェックが必要なため, まだまだ調整中 (理想はSQLパーサーでアラート条件を作れること)

    ロギング / モニタリング
  35. © 2022 LayerX Inc. 35 単体テスト毎にテナントを作成して, 並列に実行する • 他テナントデータに影響を与える場合, テストで検知出来る

    ◦ 他のテナントへ相互に影響を与える機能が存在しない • パッケージ内の全テスト完了まで, テストデータをドロップ(削除)しない • テスト/サブテスト並列度を上げる動機づけになる • テナントセットアップは, ヘルパー関数を用意 テスト func TestA(t *testing.T) { helper.SetupTenant(t) t.Run(“Case1”, func(t *testing.T) { t.Parallel(t) // run tests… } }
  36. © 2022 LayerX Inc. 36 • アプリケーション実装でのテナントデータの論理的な分離を行う方針を取っていま す. ◦ データベースやミドルウェアでの制御でも,

    ポリシー × ユーザーでのロジック 実装部分は避けられないと思います. • 開発/テスト/デプロイのコストの低さやロックインを避けれるなどメリットはあります. ◦ その反面, アプリ側でのガードレール実装の重要度は高くなります. • 安全かつ開発スピードも落ちない仕組みの改善は進めていきます. まとめ