本文へスキップ
Starone

SaaS

本番品質のSaaSインフラの構築

マルチテナントのプロダクトと、tenant_idカラムを持つだけのアプリケーションを分けるもの。それは分離、テナント単位の制限、コントロールプレーン、安全なマイグレーション、そして1社の顧客だけを復元できる能力です。

· 5分で読めます · Starone エンジニアリング

SaaSプラットフォームとは、互いのデータが見えることも、互いを遅くすることも、互いを壊すこともあってはならない多数の顧客のために運用される、1つのシステムです。tenant_idカラムを追加すれば、アプリケーションはデモができる段階には到達します。しかし、対価を支払う顧客のために運用するには、より難しい問いへの答えが必要です。テナントをどう分離するのか、あるテナントの負荷をどう封じ込めるのか、スキーマ変更をダウンタイムなしでどうリリースするのか、そして1社の顧客のデータをどう復元するのか、という問いです。

私たちの基本方針は、データベースとプラットフォームのエッジで分離を強制するプール型アーキテクチャに、特定のテナントを専用インフラに配置できる選択肢を加えた構成です。共有システムならではのコストと運用負荷の水準を保ちながら、要件上、専用のスタックが正当化される顧客のための道筋を残せます。

テナンシーモデルはレイヤーごとに選ぶ

テナンシーは通常、各テナントが専用のリソースを持つサイロと、全テナントがリソースを共有するプールとの二者択一として説明されます。実際には、この選択はコンピュート、データベース、キャッシュ、キュー、ストレージのそれぞれについて個別に行うものであり、結果が混在型になるのはごく普通のことです。

  • プール型:テナントをキーとする共有テーブルです。コストは最も低く、運用も最も単純ですが、分離は正しいエンジニアリングに依存します。
  • テナントごとのスキーマまたはデータベース:分離はより強固で、テナント単位の復元も容易ですが、マイグレーションを数千のスキーマにわたって実行する必要があり、コネクションプールも増殖します。
  • サイロ型:テナントごとにフルスタックを用意します。分離は最も強固でアップグレードも独立して行えますが、コストとフリート管理の労力が顧客の増加とともに膨らみます。

テナントコンテキストはセキュリティ境界である

プール型モデルでは、WHERE句の書き忘れがそのままデータ漏えいになります。すべての開発者がすべてのクエリでフィルターを忘れないことに頼るのは、統制とは言えません。私たちは、テナントをエッジで一度だけ、認証済みのトークンから解決します。クライアントが制御できるリクエストパラメータやヘッダーから解決することは決してありません。そのうえで、アプリケーションコードより下の層でテナントを強制します。

私たちが選ぶ仕組みは、PostgreSQLの行レベルセキュリティ(RLS)です。アプリケーションがトランザクションに対してテナントを設定すると、各テーブルのポリシーが、フィルターを書き忘れたクエリも含めてすべてのステートメントをそのテナントの行に制限します。アプリケーションのデータベースロールは、テーブルの所有者であってはならず、BYPASSRLS属性を持っていてもなりません。そうでなければ、ポリシーは何の警告もなくスキップされます。トランザクションモードのコネクションプーラーを使う場合は、テナントの設定をトランザクションにスコープし、同じコネクション上の次のリクエストへ漏れないようにする必要があります。

同じコンテキストは、データを保持する他のすべての場所にも届いていなければなりません。テナントをプレフィックスに持つキャッシュキー、アクセスがスコープされたオブジェクトストレージのパス、テナントを運ぶキューメッセージ、テナントでフィルターする検索インデックス、テナントを記録するログ行です。

ノイジーネイバーとテナント単位の制限

インフラを共有するということは、キャパシティを共有するということです。大規模なインポートを実行したり、出来の悪い連携処理を動かしたりするテナントが1社あるだけで、データベースコネクションが枯渇したり、キューが埋まったりします。そして他のすべてのテナントが、自分たちには何の原因もない障害を経験することになります。グローバルなレート制限は役に立ちません。全員に等しくペナルティを課すことでプラットフォームを守る仕組みだからです。

  • テナントをキーとするレート制限と同時実行数の制限を、ゲートウェイで強制
  • 1社のテナントのバックログが他のテナントを飢餓状態にしないための、バックグラウンド処理の公平なスケジューリング
  • テナントのプランに紐づく、ストレージ、シート数、API使用量のクォータ
  • データ層でのステートメントタイムアウトと、テナント単位のコネクション上限
  • 影響を受けている1社の顧客が見えるよう、テナント別に分解したレイテンシとエラーの指標

コントロールプレーンをアプリケーションプレーンから分離する

アプリケーションプレーンは、テナントが利用するプロダクトそのものです。コントロールプレーンは、テナントを管理するシステムです。オンボーディングとプロビジョニング、アイデンティティ設定、プランとエンタイトルメント、課金のためのメータリング、テナントをプール型と専用のどちらのインフラに配置するかの決定、そしてサポート担当者が使う運用ツールがここに含まれます。

両者を分けておくことには2つの利点があります。1つ目は、オンボーディングがチェックリストではなく、自動化された再現可能なワークフローになることです。これは、サイロ型のテナントのためにインフラを作成する必要が生じた時点で、すぐに重要になります。2つ目は、必要なテナント設定とエンタイトルメントをキャッシュしておくことで、コントロールプレーンが利用できないときでもサービスを提供し続けるように、アプリケーションプレーンを構築できることです。課金やプロビジョニングの障害で止まるべきなのは新規のサインアップであり、既存の顧客ではありません。テナントが数社しかないプロダクトであれば、この大部分は先送りできます。ただし、テナントのメタデータが複数の設定ファイルに散らばらず、すでに1か所にまとまっていることが条件です。

ゼロダウンタイムのマイグレーション

共有データベースをスキーマ変更のためにオフラインにすることはできません。また、ローリングデプロイの最中は、アプリケーションの旧バージョンと新バージョンが同じスキーマに対して同時に動作します。したがって、すべてのマイグレーションは1つ前のバージョンと互換性を保たなければなりません。そのためのパターンがエクスパンド・アンド・コントラクトです。新しい構造を追加し、両方に書き込み、バックフィルを行い、読み取りを切り替え、古い構造は後続のリリースで削除します。

大きなテーブルでは細部が重要になります。PostgreSQLでは、インデックスはCREATE INDEX CONCURRENTLYで作成し、制約はNOT VALIDとして追加してから別途検証します。さらにロックタイムアウトを設定し、長時間のトランザクションの後ろで待たされたマイグレーションが、その背後に並ぶすべてのクエリをブロックするのではなく、すぐに失敗するようにします。バックフィルは、レプリケーション遅延を監視しながら、間隔を空けて小さなバッチで実行します。カラムのその場でのリネームが、1ステップで済むことは決してありません。

この進め方は、直接変更する場合よりも多くのリリースを必要とします。顧客が受け入れているメンテナンスウィンドウを持つ初期段階のプロダクトであれば、短時間の計画停止はスピードとの正当な引き換えです。顧客が複数のタイムゾーンにまたがるようになると、その選択肢はなくなります。

テナント単位のバックアップと復元

プール型データベースでのポイントインタイムリカバリは、すべてのテナントを同じ時点に戻します。これはプラットフォーム全体の災害には適した手段ですが、よくある依頼には適していません。その依頼とは、ある顧客が重要なものを削除してしまい、元に戻したいというものです。他の全員を巻き戻すという選択肢はありません。

この能力は、設計して作り込む必要があります。典型的なアプローチでは、バックアップを別のインスタンスに復元し、対象テナントの行を抽出して、データモデルとその外部キーを理解するツールで本番環境にマージします。保持期間付きの論理削除(ソフトデリート)があれば、復元が必要になる前に多くのケースを処理できます。この同じテナント単位のエクスポートが、オフボーディングと検証可能な削除を可能にします。復元は定期的にリハーサルすべきです。テストされていないバックアップは、仮定にすぎないからです。

テナントへのリリースはリング単位で

1つのデプロイメントに多数の顧客が載っていると、不具合のあるリリースは全員に同時に届きます。私たちはプログレッシブデリバリーを選びます。テナント単位で評価されるフィーチャーフラグ、社内テナントと低リスクのテナントから成る最初のリング、そしてエラーバジェットのバーンレートが上昇した際の自動ロールバックです。その代償はフラグの衛生管理です。古くなったフラグは蓄積して未検証の組み合わせを生むため、フラグにはそれぞれオーナーと削除予定日が必要です。

同様の課題に取り組んでいる方へ

ぜひ意見を交換させてください。この記事を書いたエンジニアが直接お話しします。