AWS におけるマルチテナント SaaS の実装パターン
~ Amazon Elastic Container Service (Amazon ECS) 編
Author : 櫻谷 広人
SaaS 開発・運用に携わるみなさん、こんにちは。AWS で SaaS に特化した技術支援を行なっているパートナーソリューションアーキテクトの櫻谷です。
本連載では、これからマルチテナント SaaS を AWS 上に構築しようと考えている方向けに、いますぐ参照できるサンプル実装を紹介していきます。SaaS とはビジネモデルであり、特定のテクノロジースタックやアーキテクチャに縛られるものではありません。ソリューションの性質やドメイン、目指すべき顧客体験によって、最適な構成は異なります。そのため、AWS は、主要なテクノロジースタック別に、SaaS 開発のベストプラクティスに沿った以下のサンプル実装を公開しています。
これらには、SaaS を構成する上で重要なマルチテナントに関する概念が含まれ、独自の SaaS を開発する上で参考になる具体的な実装を見つけることができます。みなさんが得意とする技術や、組織のニーズに合ったものを探して、ぜひ一度検証してみてください。
この記事では第一回として、最も最近公開された Amazon Elastic Container Service (Amazon ECS) をベースにしたリファレンスアーキテクチャを解説します (※記事執筆時点で最新の main ブランチの バージョン を元にしています。最新のコードとは異なる可能性がありますのでご注意ください)
このクラウドレシピ (ハンズオン記事) を無料でお試しいただけます »
毎月提供されるクラウドレシピのアップデート情報とともに、クレジットコードを受け取ることができます。
SaaS で重要なコントロールプレーンとは ?
初回なので、みなさんと認識を合わせるために、「SaaS とは何か」、「SaaS の構築にはどんなものが必要か」という前提について少しおさらいしておきます。冒頭にも書いたように、SaaS とはビジネスモデルです。運用の効率性を高め、俊敏性を獲得し、ビジネスのスケールを図るための手段として、SaaS というデリバリーモデルがここ数年で急速にその地位を確立してきました。しかし、正しく SaaS を事業として展開していくためには、基盤となるアーキテクチャを変えるだけでは不十分です。当然、構築した後の継続的な運用についても考慮しなければなりませんし、営業やマーケティング、カスタマーサポートといったビジネス部門の役割も SaaS に合った形に変革していく必要があります。これを踏まえると、SaaS が単なる技術的な話はでないことは容易に理解できるでしょう。
それでも、技術的な観点から SaaS を特徴づける要素は確かに存在します。その最たるものが、いわゆる「コントロールプレーン」と呼ばれる存在です。SaaS では、新しく「テナント」という概念が登場します。複数のテナントが同じシステムを利用するため、それらの管理・運用を効率的に行うメカニズムが必要になります。これらを実装しない、または手動で行なっている SaaS プロバイダーもいますが、多くの場合、SaaS によって実現できるスケールや効率性といったメリットは犠牲になっているはずです。
このコントロールプレーンに含まれる機能は、オンボーディング、テナント管理、アイデンティティ管理、メータリング、請求、階層化(※1) などさまざまありますが、本連載でその一つ一つを詳細に解説することはしません。これらの機能について興味のある方は、過去の連載 や ホワイトペーパー を参照してください。重要なポイントは、どんな SaaS でもこのような機能は少なからず求められ、それはビジネスのコアな価値を実現するアプリケーションとは別に構築する必要があるということです。また、コントロールプレーンの機能はどの SaaS においてもあまり差異はなく、共通する部分が多いはずです。ゆえに、SaaS Builder Toolkit for AWS のような再利用性の高いものを活用することで、SaaS の構築にかかる工数を大幅に削減し、市場投入までの速度を早めることができるというわけです。
※ 階層化 (tiering) とは、契約するプランなどに応じてテナントを複数の階層 (tier) に分け、異なる価値や体験を提供する SaaS の構築モデルのことです。各テナントが紐付けられる階層のことを、本記事ではティア (tier) と表現しています (例: ベーシックティア、プレミアムティア)。
Amazon ECS SaaS リファレンスアーキテクチャ
さて、前提が共有できたところで、ここから Amazon ECS SaaS リファレンスアーキテクチャ の解説に入っていきます。Amazon ECS は、AWS が提供するフルマネージドなコンテナオーケストレーションサービスで、コンテナ化されたアプリケーションのデプロイ、管理、スケーリングを簡単に行うことができます。コンピューティングの基盤には、Amazon EC2 またはサーバーレスなコンピューティングエンジンである AWS Fargate の 2 つの選択肢があります。AWS Fargate を利用することで、サーバーを管理することなくアプリケーションの構築に集中することができ、また使った分だけの従量課金によるコスト削減の効果が見込めます。このリファレンスアーキテクチャでは、ニーズに合わせて両方を組み合わせて使用しています。
アーキテクチャは AWS CDK をベースに構築され、以下のステップで簡単にセットアップすることができます。実際に動作している環境を詳しく確認したい場合は、お手持ちの AWS アカウントにデプロイしてみてください (※AWS サービスの費用が発生しますのでご注意ください)。
git clone https://github.com/aws-samples/saas-reference-architecture-ecs.git
cd saas-reference-architecture-ecs/scripts
./build-application.sh
./install.sh {管理者用のメールアドレスを入力}
アーキテクチャの全体像
まずは、アーキテクチャの全体像を確認しましょう。大きく分けて以下の 3 つの構成要素があります。
- Web アプリケーション
- コントロールプレーン
- アプリケーションプレーン
図1: ECS SaaS リファレンスのアーキテクチャ全体像
アクターとしては、管理者として管理タスクを実行する SaaS プロバイダーと、SaaS を利用するテナントがいます。また、図には表現されていませんが、テナントはさらに 2 種類に分けられます。SaaS を利用する一般のテナントユーザーと、それらテナントユーザーの作成などの管理を行うテナント管理者です。
また、この SaaS は一般的な E コマースのアプリケーションを想定しています。簡略化のために、商品サービス (Product) と注文サービス (Order) の 2 つのマイクロサービスによって構成され、基本的な CRUD 操作が実行できるようになっています。テナントは、契約するティア (tier) に応じてそれぞれ異なるデプロイモデルの環境に割り当てられます。このサンプルでは、ベーシックティア、アドバンストティア、プレミアムティアの 3 つから選択でき、それぞれ異なるサービス体験が提供されます。この階層化の概念はとても重要なポイントなので意識しておいてください。
Web アプリケーション
図の上部にあるのは、アクターが利用する Web アプリケーションです。SaaS プロバイダーが利用する管理者用のアプリケーションと、テナントが利用する SaaS アプリケーションの 2 つがあります。どちらも AngularJS で構築されており、静的コンテンツは Amazon CloudFront と Amazon S3 を使用してホストされます。ユーザーの操作に応じてアプリケーションプレーン内のマイクロサービスを呼び出し、ビジネスロジックを実行します。
管理者用のアプリケーションでは、テナントの管理を行うことができます。新しくテナントを追加したい場合は、こちらからテナントの名前、テナント管理者のメールアドレス、契約するティアを入力すると、コントロールプレーン内のオンボーディングサービスと連携して自動で必要なリソースのプロビジョニングなどを行うことができます。ログインに必要な情報は、デプロイ時に設定したメールアドレス宛にメールで送られます。
図2: 管理者用アプリケーション
(画像をクリックすると拡大します)
テナントが利用する SaaS アプリケーションは、商品と注文に関するシンプルな CRUD 機能を備えています。適切なテナント分離とデータパーティショニングが実装され、他のテナントのリソースやデータにはアクセスできないようになっています。詳細についてはアプリケーションプレーンの章で解説します。また、テナント管理者は一般のテナントユーザーを追加したりできる Users タブの機能を利用することもできます。
図3: テナント用 SaaS アプリケーション
(画像をクリックすると拡大します)
コントロールプレーン
コントロールプレーンは、SaaS Builder Toolkit for AWS (SBT) を使用して構築されています。これは、AWS が開発中のオープンソースのリファレンス実装です。基本的なコントロールプレーンの機能がサーバーレスアーキテクチャで実装され、再利用可能な AWS CDK のコンストラクトとしてソリューションに組み込むことができます。ECS SaaS リファレンスアーキテクチャでも SBT が提供するオンボーディングやテナント管理の機能を活用して、実装の効率化を図っています。
図4: SBT のアーキテクチャ概要
SBT は疎結合なマイクロサービスアーキテクチャを採用しており、各コントロールプレーンの機能は API を通じて呼び出すことができます (※オプションで管理コンソールを実装して使用することもできます)。また、アプリケーションプレーンとの連携は Amazon EventBridge を使用してイベントを介して行います。これにより、オンボーディングなどのイベントをトリガーとして SaaS アプリケーション側で任意のジョブを実行することができます。今回、このアプリケーションプレーンは Amazon ECS で構築されています。
SBT の詳細については、GitHub のドキュメントやコードをご覧ください。また、手軽に試せる ワークショップ もあるので、こちらもぜひお試しください。
アプリケーションプレーン
本体の SaaS アプリケーションが 2 つのマイクロサービスによって構成されることはすでに述べたとおりですが、そのデプロイモデルはテナントが選択したティアによって異なります。
図5: アプリケーションプレーンのデプロイモデル
- ベーシックティアの場合、すべてのテナントは専用の ECS クラスターにデプロイされている単一の ECS サービスを共有します。ECS の起動タイプとしては Fargate を採用しています。これは、多くのテナントによって利用される需要予測が難しいベーシックティアの性質に合わせて、コストとスケーリングの最適化を図るためです。
- アドバンストティアでは、ECS クラスターは単一のものを他のテナントと共有しますが、ECS サービスについては各テナントに専用のサービスがデプロイされます。こちらでは Fargate ではなく EC2 の起動タイプを採用しています。ベーシックティアとのもう一つの大きな違いは、フロントにリバースプロキシとして Nginx サービスが配置されることです。これは、ルーティングに関する課題の緩和策として導入されているもので、後の章で詳しく解説します。
- プレミアムティアは、ECS クラスターのレベルから専用のインフラストラクチャリソースが割り当てられます。これにより、最も高いレベルで他のテナントとの分離を実現しています。起動タイプはアドバンストティアと同じく EC2 です。
これらのデプロイモデルの違いは、それぞれのティアが目指すサービス体験とマッピングされています。つまり、テナントが求めるビジネスおよび技術上の要件を満たすために採用されているアーキテクチャであるということです。これらは、セキュリティ、コンプライアンス、パフォーマンス、可用性などさまざまな要素に起因しています。
フロントエンドからこれらのサービスを呼び出す際、リクエストはまず Amazon API Gateway に送信されます。ここでは、テナントのティアに応じたスロットリングやクォータのバリデーション、Lambda Authorizer を使用したテナントの認証・認可を行います。バックエンドとの統合は、VPC リンクを使用した API Gateway での REST API のプライベート統合 を利用しています。マイクロサービスはプライベートサブネットで実行されるため、NLB → ALB を経由してリクエストを適切なテナントのサービスにルーティングします。また、各テナントまたはティアごとに AWS Cloud Map に名前空間が作成され、ECS Service Connect によるサービスディスカバリが利用できる構成となっています。
オンボーディング、階層化
図6: 初回のセットアップ時に作成されるリソース
注目すべきはアプリケーションプレーンで、環境のセットアップ時点ではベーシックティア向けの共有プール環境のみ ECS のリソースが作成されています。ベーシックティアのリソースはすべて共有のため、今後テナントが増えても追加でコンピューティンやデータベースのリソースを作成する必要はありません。アドバンストティアとプレミアムティアに関しては、テナントが追加されるたびに TenantTemplateStack をデプロイしてテナント専用のリソースを作成します。
TenantTemplateStack には、ティアに応じたリソースの作成手順が記載されています。ECS クラスター、ECS サービスの他に、認証に使用する Amazon Cognito ユーザープールなども含まれます。ちなみに、ECS の起動タイプやリバースプロキシとして Nginx を利用するかどうかはパラメーターが定義されているので、ニーズに応じて変更することが可能です。デフォルトでは以下のように、アドバンストティアとプレミアムティアで、起動タイプに EC2、リバースプロキシを使用する設定になっています。
const ec2Tier = ['advanced', 'premium'];
const isEc2Tier: boolean = ec2Tier.includes(props.tier.toLowerCase());
const rProxy = ['advanced', 'premium'];
const isRProxy: boolean = rProxy.includes(props.tier.toLowerCase());
デプロイされるスタックとテナントのマッピングは、Amazon DynamoDB テーブルに保存され、今後の更新および削除のために管理されます。
new AwsCustomResource(this, 'CreateTenantMapping', {
installLatestAwsSdk: true,
onCreate: {
service: 'DynamoDB',
action: 'putItem',
physicalResourceId: PhysicalResourceId.of('CreateTenantMapping'),
parameters: {
TableName: props.tenantMappingTable.tableName,
Item: {
tenantId: { S: props.tenantId },
stackName: { S: cdk.Stack.of(this).stackName },
codeCommitId: { S: props.commitId },
waveNumber: { S: waveNumber }
}
}
},
さて、この TenantTemplateStack はいつどのようにデプロイされるのでしょうか?これを理解するためには、SBT の JobRunner について説明する必要があります (※v0.4.0 から JobRunner は ScriptJob に rename されました。記事執筆時点でこのリファレンスアーキテクチャは v0.3.6 をベースにしているため、JobRunner で説明していきます)。
図7: JobRunner を使用したオンボーディングフローのカスタマイズ
テナントのオンボーディングは、SBT のコントロールプレーンの機能を利用して開始されます。コントロールプレーン側の処理が完了すると、EventBridge のイベントバスに Provision event が送信されます。アプリケーション側ではこのイベントバスに任意のルールを作成してイベントをサブスクライブすることで、オンボーディングの完了イベントをフックすることができます。JobRunner はこれを拡張したアプリケーションプレーンが提供するユーティリティの一つです。JobRunner を設定して任意のシェルスクリプトを定義することで、アプリケーションプレーン作成時に AWS Step Functions ステートマシンが作成されます。これにより、Provision event をトリガーにして定義されたスクリプトを実行するワークフローが設定されます。
server/lib/bootstrap-template/core-appplane-stack.ts
const provisioningJobRunnerProps = {
permissions: PolicyDocument.fromJson(
JSON.parse(`
{
"Version":"2012-10-17",
"Statement":[
{
"Action":[
"*"
],
"Resource":"*",
"Effect":"Allow"
}
]
}
`)
),
script: fs.readFileSync('../scripts/provision-tenant.sh', 'utf8'),
scripts/provision-tenant.sh
# Deploy the tenant template for premium && advanced tier(silo)
if [[ $TIER == "PREMIUM" || $TIER == "ADVANCED" ]]; then
STACK_NAME="tenant-template-stack-$CDK_PARAM_TENANT_ID"
if [[ $TIER == "ADVANCED" ]]; then
export CDK_ADV_CLUSTER=$(aws ecs describe-clusters --cluster prod-advanced-${ACCOUNT_ID} | jq -r '.clusters[0].status')
fi
export CDK_PARAM_CONTROL_PLANE_SOURCE='sbt-control-plane-api'
export CDK_PARAM_ONBOARDING_DETAIL_TYPE='Onboarding'
export CDK_PARAM_PROVISIONING_DETAIL_TYPE=$CDK_PARAM_ONBOARDING_DETAIL_TYPE
export CDK_PARAM_OFFBOARDING_DETAIL_TYPE='Offboarding'
export CDK_PARAM_DEPROVISIONING_DETAIL_TYPE=$CDK_PARAM_OFFBOARDING_DETAIL_TYPE
export CDK_PARAM_PROVISIONING_EVENT_SOURCE="sbt-application-plane-api"
export CDK_PARAM_APPLICATION_NAME_PLANE_SOURCE="sbt-application-plane-api"
export CDK_PARAM_TIER=$TIER
cdk deploy $STACK_NAME --exclusively --require-approval never
fi
同様に、deprovisioning の JobRunner を設定することで、テナントが解約した際のリソース削除の自動化などを行うことも可能です。今回実施しているのはリソースのセットアップのみですが、Slack への通知や、API を介したサードパーティ (ex. 請求プロバイダー) のサービス側の設定変更なども考えられます。
API、認証・認可、スロットリング
バックエンドの ECS サービスには API Gateway を介してアクセスしますが、これはすべてのテナントで共有されるプール環境として定義されています。/orders, /products, /users の 3 つのリソースが作成され、それぞれ VPC リンクを使用して NLB → ALB → ECS とフォワードされます。これらのリソースは SharedInfraStack としてデプロイ時に設定されます。
server/lib/shared-infra/shared-infra-stack.ts
this.apiGateway = new ApiGateway(this, 'ApiGateway', {
tenantId: 'ecs-sbt',
isPooledDeploy: props.isPooledDeploy,
lambdaEcsSaaSLayers: lambdaEcsSaaSLayers,
nlb: nlb,
apiKeyBasicTier: {
apiKeyId: basicKey.apiKey.keyId,
value: basicKey.apiKeyValue
},
apiKeyAdvancedTier: {
apiKeyId: advanceKey.apiKey.keyId,
value: advanceKey.apiKeyValue
},
apiKeyPremiumTier: {
apiKeyId: premiumKey.apiKey.keyId,
value: premiumKey.apiKeyValue
},
apiKeyPlatinumTier: {
apiKeyId: platinumKey.apiKey.keyId,
value: platinumKey.apiKeyValue
},
stageName: props.stageName
});
API には 使用量プラン を使用したスロットリングをかけるため、API キーがティアごとに定義されています。これは、マルチテナント環境でよくあるノイジーネイバーの課題に対する緩和策となります。単一の API ゲートウェイを共有している都合上、上位のティアのテナント体験が、フリープランのテナントや下位のティアのテナントによって損なわれるのを防ぐ必要があります。使用量プランの各閾値は以下でカスタマイズ可能です。
server/lib/shared-infra/usage-plans.ts
this.usagePlanBasicTier = props.apiGateway.addUsagePlan('UsagePlanBasicTier', {
quota: {
limit: 1000,
period: Period.DAY
},
throttle: {
burstLimit: 50,
rateLimit: 50
}
});
API キーはフロントエンドから渡されるのではなく、AWS Lambda オーソライザー によってテナントのティアに対応したものが挿入される形を取っています。これは、クライアント側で API キーを管理する必要がなくなる効率的な方法です。また、もちろんティアごとではなくテナントごとに別々のキーや使用量プランを適用することも可能です。その場合、オンボーディングのプロセスでテナント固有のリソースを作成するカスタマイズが必要になります。
Lambda オーソライザーのもう一つの大きな役割は、認証・認可です。このリファレンスアーキテクチャでは、IdP として Cognito ユーザープールを使用し、JWT を活用した SaaS アイデンティティ を構築しています。これは、テナントに関する情報 (ex. テナント ID、ティア、テナント内のロール) をカスタムクレームとして JWT に追加し、ユーザーからテナントの解決を簡単に行えるようにする仕組みのことです。JWT をデータソースとすることで、複数のサービスにまたがる受け渡しも容易になります。
server/lib/shared-infra/Resources/tenant_authorizer.py
user_name = response["cognito:username"]
tenant_id = response["custom:tenantId"]
user_role = response["custom:userRole"]
tenant_tier = response["custom:tenantTier"]
if (tenant_tier.upper() == utils.TenantTier.PLATINUM.value.upper()):
api_key = platinum_tier_api_key
elif (tenant_tier.upper() == utils.TenantTier.PREMIUM.value.upper()):
api_key = premium_tier_api_key
elif (tenant_tier.upper() == utils.TenantTier.ADVANCED.value.upper()):
api_key = advanced_tier_api_key
elif (tenant_tier.upper() == utils.TenantTier.BASIC.value.upper()):
api_key = basic_tier_api_key
上のコードは、JWT をパースしてカスタムクレームからテナントに関する情報を抽出し、ティアに応じた API キーを設定している様子を示しています。また、ロールに応じて、テナント管理者のみがテナントユーザーの操作を行えるように、IAM ポリシーを構築しています。
if (auth_manager.isTenantAdmin(user_role)):
policy.allowMethod(HttpVerb.ALL, "users")
policy.allowMethod(HttpVerb.ALL, "orders")
policy.allowMethod(HttpVerb.ALL, "products")
policy.allowMethod(HttpVerb.ALL, "users/*")
policy.allowMethod(HttpVerb.ALL, "orders/*")
policy.allowMethod(HttpVerb.ALL, "products/*")
else:
#if not tenant admin then only allow access to order and product services
policy.allowMethod(HttpVerb.ALL, "orders")
policy.allowMethod(HttpVerb.ALL, "products")
policy.allowMethod(HttpVerb.ALL, "orders/*")
policy.allowMethod(HttpVerb.ALL, "products/*")
さらに、Lambda オーソライザーはルーティングにおいても重要な役割を果たしています。カスタムクレームから抽出したテナント ID を tenantPath として $context 変数 に追加しています。このデータは 統合リクエストのデータマッピング で使用され、HTTP ヘッダーとして NLB に渡されます。その後、ALB のリスナールールで使用します。
tenantPath = tenant_id
if (tenant_tier.upper() == utils.TenantTier.BASIC.value.upper()):
tenantPath = tenant_tier.lower()
ルーティング、サービスディスカバリ
ルーティングは、SaaS において最も重要なトピックの一つです。特に今回のような、ティアによってサイロ環境とプール環境が異なり混在するようなハイブリッド環境では、できるだけシンプルでスケールする仕組みを実装する必要があります。このリファレンスアーキテクチャでは、ALB のリスナールールを使用してヘッダーの値を元に各テナント環境へのマッピングを行なっています。
先ほど見たように API Gateway の統合リクエストで tenantPath ヘッダーが付与されるので、この値を一つの条件とします。さらに、マイクロサービスごとへのルーティングができるようにリクエストパスも条件に追加します。ベーシックティアのイメージは以下のようになります。
図8: ベーシックティアのルーティングフロー
ECS の設定時にサービスごとにターゲットグループの作成が行われます。今回は EC2 と Fargate の 2 種類の起動タイプが使用されていますが、どちらもネットワークモードは awsvpc で、EC2 の場合は ENI トランキング を有効化しています。ルーティングの設定に関して起動タイプによる違いは特にありません。
server/lib/tenant-template/ecs-cluster.ts
const targetGroupHttp = new elbv2.ApplicationTargetGroup(
this,
`target-group-${info.name}-${tenantId}`,
{
port: info.containerPort,
vpc: this.vpc,
protocol: elbv2.ApplicationProtocol.HTTP,
targetType: elbv2.TargetType.IP,
healthCheck: {
path: `/${info.name}/health`,
protocol: elbv2.Protocol.HTTP
}
}
);
また、図には ECS Service Connect と AWS Cloud Map があります。ECS Service Connect とは、サービスディスカバリとサービスメッシュを構築して、サービス間通信を管理する ECS の機能です。このリファレンスアーキテクチャでは、すべての環境に対して名前空間が作成され、サービスディスカバリとマイクロサービス間の相互通信を可能にしています。これらは、アドバンストティアやプレミアムティアにおいて大きな効果を発揮します。
プレミアムティアのルーティングはどうなっているか見てみましょう (※アドバンストティアでも基本的な構造は同じです)。先ほどのベーシックティアと違って、リスナールールの条件にリクエストパスが入っていないことがわかります。また、テナントパスにはテナント ID が指定されており、サイロ環境のテナント専用のルールになっています。
図9: プレミアムティアのルーティングフロー
もう一つの重要な違いは、ターゲットです。条件にリクエストパスがなくなったことにより、ALB レベルで特定のマイクロサービスへ通信を振り分けることができなくなりました。代わりにその役目を担っているのが Nginx です。これはリバースプロキシとして機能し、単独の ECS サービスとしてデプロイされています。ECS Service Connect によって orders-api.* のような各マイクロサービスの DNS 名が設定されているため、これを利用します。
server/application/reverseproxy/nginx.conf
# orders api
location ~ ^/orders {
# Reject requests with unsupported HTTP method
if ($request_method !~ ^(GET|POST|HEAD|OPTIONS|PUT|DELETE)$) {
return 405;
}
# Only requests matching the expectations will
# get sent to the application server
proxy_pass http://orders-api.prod.sc:3010;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
}
なぜこのような構成になっているのでしょうか?一つはマイクロサービス間の通信を簡素化するためですが、それよりも大きな理由となっているのは、スケーリングに関する課題です。今回のように単一の ALB をすべてのテナントで共有する構成になっている場合、テナントの増加に比例してリスナーのルールも増えていき、最終的にはクォータを超過するリスクがあります。2024 年 8 月時点の クォータ では、ALB あたりのルール (デフォルトルールを除く) は最大 100 までとなっています (※上限緩和可能)。クラウドで SaaS を構築する場合、このようなリソースの上限は必ず意識して設計する必要があります。
今回はマイクロサービスが 2 つしかありませんが、これが 10 個になるとどうなるでしょうか?もしリバースプロキシを使わなかった場合、テナントが 1 つ増えるたびに、リスナーのルールを 10 個追加する必要が出てきます。これは運用において明らかにスケーリング上の問題を引き起こします。しかしリバースプロキシを使うことで、このオーバーヘッドを最小限に抑えることができます。これが今回の設計の背景です。
しかし、それでも問題がすべて解決するわけではありません。ゆっくりではあるものの、ビジネスが長く運用されていければいつかはクォータに達する可能性は残ります。そこで、このリファレンスでは 2 つの改善案を示しています。1 つ目は、テナントごとに専用の ALB を持つパターンです。これは、多くのマイクロサービスが存在するソリューションにおいて特に有効な選択肢になり得ます。統合の関係でフロントの API Gateway および NLB もサイロ型にする必要があるため、追加のコストや運用負荷が発生するデメリットはありますが、トレードオフが合理的であれば十分採用することができるアーキテクチャでしょう。
図10: テナントごとに専用の API Gateway を割り当てるパターン
2 つ目のパターンは、テナントの増加に応じてリソースを動的に並列で増やしていくものです。1 つのスタック (API Gateway, NLB, ALB) あたり 100 テナントを収容できる構成として、テナント 101 以降は新規でスタックを作っていく形になります。1 つ目のパターンよりコストは抑えられますが、テナントと API Gateway のマッピングや、リソースの監視と動的なプロビジョニングなど、より複雑で難しいメカニズムの構築を必要とします。
図11: テナントの増加に応じてリソースを動的に追加するパターン
どちらのパターンも一長一短のトレードオフがあるので、ソリューションの性質や規模、階層化モデル、ビジネス要件などを踏まえて最適なパターンを検討するようにしてください。
テナント分離、データパーティショニング
最後に、基本的なテナント分離とデータパーティショニングのトピックにも触れておきたいと思います。まずコンピューティングのレイヤーですが、ECS サービスはセキュリティグループを使用してクロステナントアクセスを防いでいます。アドバンストティアおよびプレミアムティアでは、テナントごとに固有のセキュリティグループが作成されます。
// ECS SG for ALB to ECS communication
this.ecsSG = new ec2.SecurityGroup(this, 'ecsSG', {
vpc: this.vpc,
allowAllOutbound: true
});
this.ecsSG.connections.allowFrom(this.albSG, ec2.Port.tcp(80), 'Application Load Balancer');
this.ecsSG.connections.allowFrom(this.ecsSG, ec2.Port.tcp(3010), 'Backend Micrioservice');
インバウンドアクセスは、ALB および同一のセキュリティグループからのみを許可しています。つまり、当該テナント専用のマイクロサービス間通信は可能にしています。これで、テナント 1 の環境から テナント 2 のサービスを呼び出すようなことはできなくなります。
データベースについては、今回は DynamoDB を使用しています。サイロ型環境の場合、テナント専用のテーブルがプロビジョニングされるので、テーブル単位でスコープを絞ったポリシーを ECS のタスクロールにアタッチして分離を実現しています。以下はポリシーのテンプレートで、オンボーディング時に <TABLE_ARN> が実際の ARN で置換されて設定されます。
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:BatchGetItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchWriteItem", "dynamodb:UpdateItem", "dynamodb:GetRecords"
],
"Resource": "<TABLE_ARN>",
"Effect": "Allow"
}
]
}
ベーシックティアの共有環境については、単一のテーブルに複数テナントのデータが混在しているため上記の手法は使えません。代わりに、パーティションキーに設定されているテナント ID を使用した動的なクレデンシャル生成の仕組みを構築します。ポリシーの Condition 句で dynamodb:LeadingKeys というキーを使用することで、パーティションキーによるフィルタリングが可能になります。詳細については こちらのブログ をご覧ください。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["dynamodb:*"],
"Resource": ["arn:aws:dynamodb:*:*:table/{{table}}"],
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["{{tenant}}"]
}
}
}
]
}
まとめ
ハイレベルな設計思想から詳細な実装コードまで一通り見てきました。ECS 特有の機能を使ったものもあれば、AWS IAM を活用したテナント分離など、もしかしたら他のアーキテクチャで見たことがあるパターンも登場したかもしれません。最後に注意していただきたいのは、これは ECS を使ってマルチテナント SaaS を構築する際の普遍的な設計図や絶対的な正解ではないということです。途中で何回か言及されているように、アーキテクチャ設計のすべてはトレードオフです。ソリューションのニーズに応じて最適な選択肢は変わります。また、同じソリューションでもビジネス環境が変化していくにつれて、あるパターンから別のパターンへと移行が必要な場合も出てくるでしょう。このソリューションアーキテクチャで示された設計思想や原則を元に最適なアーキテクチャを設計するのはみなさんの仕事です。ぜひこれを参考に、独自のカスタマイズした環境の構築を試行錯誤してみてください。
興味のある方は 開発者ガイド やコードを参照してさらなる詳細をご確認ください。来月は EKS のリファレンスについて紹介する予定ですので、次回もお楽しみに!
筆者プロフィール
櫻谷 広人
アマゾン ウェブ サービス ジャパン合同会社
パートナーソリューションアーキテクト
大学 4 年から独学でプログラミングを習得。新卒で SIer に入社して Web アプリケーションの受託開発案件を中心にバックエンドエンジニアとして働いた後、フリーランスとして複数のスタートアップで開発を支援。その後、toC 向けのアプリを提供するスタートアップで執行役員 CTO を務める。現在は SaaS 担当のパートナーソリューションアーキテクトとして、主に ISV のお客様の SaaS 移行を支援。
AWS を無料でお試しいただけます