はじめに
SaaS 開発・運用に携わるみなさん、こんにちは。AWS で SaaS に特化した技術支援を行なっているパートナーソリューションアーキテクトの櫻谷です。
前回の記事 では、Amazon Elastic Container Service (Amazon ECS) をベースにしたマルチテナントアーキテクチャの構成例についてご紹介しましたが、今回は Amazon EKS を基盤とする SaaS on AWS のベストプラクティス実装について見ていきます。AWS でコンテナベースのシステムを構築する際によく検討に上がるこの 2 つのサービスで、どのような違いがあるのかに着目してみるのもおもしろいかもしれません。
builders.flash メールメンバー登録
Amazon EKS SaaS リファレンスアーキテクチャ
アーキテクチャの全体像
まずは、全体の構成を確認しましょう。前回説明したコントロールプレーンとアプリケーションプレーンの考え方が取り入れられ、このアーキテクチャも 2 つのパーツから構成されています。コントロールプレーンは前述の通り SBT を組み込み、開発工数を削減しています。アプリケーションプレーンは EKS クラスター内にホストされ、テナントごとにサイロモデルのアプリケーションサービスをデプロイします。
アプリケーションは ECS のリファレンスとほとんど同じです。SaaS プロバイダーが利用する管理者用コンソールと、テナントが利用する E コマースアプリケーションがあり、E コマースアプリケーションは商品サービスと注文サービスの 2 つのマイクロサービスで構成されています。この記事では EKS に焦点を当てるため、詳細については省略します。気になる方は 前回の記事 をご覧ください。
アーキテクチャ図
アプリケーションプレーン
ECS のアーキテクチャでは、サーバーレスコンピューティング基盤である AWS Fargate を一部利用していましたが、今回のアーキテクチャでは マネージドノードグループ を使用して、クラスターノードの管理を自動化しています。ノードは複数のアベイラビリティーゾーンに冗長化され、かつ負荷に応じて動的にスケーリングするように設計されています。
構成図
Kubernetes 特有のポイント
これらのノード上に各テナント専用のマイクロサービスをデプロイしていくわけですが、ここに Kubernetes 特有のポイントが 1 つあります。それは、Namespace (名前空間) です。テナントごとに専用の Namespace を作成して論理的な分離境界を引くアプローチは、Kubernetes で構築するマルチテナント SaaS における代表的な分離モデルの 1 つです。テナント分離については後の章でもう少し詳しく説明します。
テナントごとに作成するリソースを詳しく図解したものが以下です。詳しい実装内容を知りたい方は tenant-onboarding-stack.ts をご覧ください。
テナントごとに作成するリソース
認証とデータストア
認証には Amazon Cognito を使用し、テナントごとにユーザープールを作成します。フロントエンドは共有のアプリケーションを使用しており、アクセスしているテナントの情報に基づいてルーティング先を切り替えます。マイクロサービスとの通信は、NGINX Ingress Controller をプロキシとして使用しています。
データストアについては、サービスによって異なる分離モデルを使い分けています。どちらも Amazon DynamoDB を使用していますが、注文サービスは各テナントごとにテーブルを作成し、商品サービスは単一のテーブルを共有するモデルを採用しています。実際のワークロードでは、データのアクセスパターンや、セキュリティ、パフォーマンス、コンプライアンス要件、コスト効率などを考慮して最適なモデルを選択してください。
Kubernetes リソース
作成された Kubernetes リソースについても見てみましょう。任意のテナントの Namespace を指定して、リソースの一覧を取得します。
kubectl get all -n 2b14e651-cdf3-4a77-a573-9343d0285045
NAME READY STATUS RESTARTS AGE
pod/order-6699f75994-snlc2 1/1 Running 0 14h
pod/product-767ff9555-qvws5 1/1 Running 0 14h
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/order-service NodePort 10.100.115.150 <none> 80:31225/TCP 14h
service/product-service NodePort 10.100.139.85 <none> 80:31751/TCP 14h
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/order 1/1 1 1 14h
deployment.apps/product 1/1 1 1 14h
NAME DESIRED CURRENT READY AGE
replicaset.apps/order-6699f75994 1 1 1 14h
replicaset.apps/product-767ff9555 1 1 1 14h
Ingress リソース
また、Ingress リソースについては以下の通りです。
kubectl get ingress -n 2b14e651-cdf3-4a77-a573-9343d0285045
NAME CLASS HOSTS ADDRESS PORTS AGE
order-service-ingress <none> ac33d4fd3e41245b5a327482f333908a-e4044c4908dfca7c.elb.us-east-1.amazonaws.com ac33d4fd3e41245b5a327482f333908a-e4044c4908dfca7c.elb.us-east-1.amazonaws.com 80 14h
product-service-ingress <none> ac33d4fd3e41245b5a327482f333908a-e4044c4908dfca7c.elb.us-east-1.amazonaws.com ac33d4fd3e41245b5a327482f333908a-e4044c4908dfca7c.elb.us-east-1.amazonaws.com 80 14h
オンボーディング

scripts/provisioning.sh
スクリプト
# Provision EKS tenant.
aws codebuild start-build --project-name TenantOnboardingProject --environment-variables-override \
name=TENANT_ID,value=$tenantId,type=PLAINTEXT \
name=PLAN,value=$tier,type=PLAINTEXT \
name=COMPANY_NAME,value=$tenantName,type=PLAINTEXT \
name=ADMIN_EMAIL,value=$email,type=PLAINTEXT
lib/constructs/tenant-onboarding.ts
具体的には、AWS CodeBuild を使用してテナントリソースのデプロイを行います。以下はさらに詳細のスニペットですが、services/tenant-onboarding をルートに、cdk deploy を実行しています。これが先ほど画像でも確認したテナントごとのリソースを定義した TenantOnboardingStack になります。
const onboardingProject = new codebuild.Project(this, `TenantOnboardingProject`, {
...省略
buildSpec: codebuild.BuildSpec.fromObject({
version: '0.2',
phases: {
install: {
commands: ['npm i'],
},
pre_build: {
commands: [],
},
build: {
commands: [
'npm run cdk bootstrap',
`npm run cdk deploy TenantStack-$TENANT_ID -- --require-approval=never ${cfnParamString}`,
],
},
post_build: {
commands: props.applicationServiceBuildProjectNames.map(
(x) =>
`aws codebuild start-build --project-name ${x}TenantDeploy --environment-variables-override name=TENANT_ID,value=\"$TENANT_ID\",type=PLAINTEXT`
),
},
},
}),
});
lib/constructs/application-service.ts
この cdk deploy によって、Kubernetes の namespace やサービスアカウント、Cognito ユーザープール、注文サービス用の DynamoDB テーブルなどが作成されます。また、post_build のフェーズでは、商品マイクロサービス、注文マイクロサービスをそれぞれデプロイするプロジェクトを実行しています。こちらの詳細は以下の通りです。
services/application-services/product-service/kubernetes/service.yaml
マニフェストの管理を楽にするために kustomize を使用していますが、サービスに関わる重要な部分については service.yaml に記載されています。商品サービス、注文サービスごとに Deployment、Service、Ingress リソースを定義しています。
apiVersion: apps/v1
kind: Deployment
metadata:
name: product
spec:
replicas: 1
selector:
matchLabels:
app: product
template:
metadata:
labels:
app: product
spec:
serviceAccountName: KUSTOMIZE_SVC_ACCOUNT_NAME
automountServiceAccountToken: true
containers:
- name: product
image: KUSTOMIZE_IMAGE
ports:
- containerPort: 5000
name: "http"
---
apiVersion: v1
kind: Service
metadata:
name: product-service
spec:
selector:
app: product
ports:
- name: http
protocol: TCP
port: 80
targetPort: 5000
type: NodePort
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: product-service-ingress
annotations:
kubernetes.io/ingress.class: "nginx"
nginx.org/mergeable-ingress-type: "minion"
spec:
rules:
- host: KUSTOMIZE_API_HOST
http:
paths:
- path: /KUSTOMIZE_TENANT_NAME/products
backend:
service:
name: product-service
port:
number: 80
pathType: Prefix
テナントごとに契約するプラン選択が可能
このリファレンスではサイロモデルの構成になっていますが、場合によってはあるサービスだけ共有 namespace にプールモデルとしてデプロイすることもあるでしょう。あるいは、ECS リファレンスアーキテクチャにもあった階層化の概念を取り入れて、テナントの階層に応じてデプロイモデルを切り替えることもできるはずです。このリファレンスアーキテクチャでは、テナントごとに契約するプランが選択できるようになっています。必要に応じて、このデータを使用して階層型の体験を提供するようにソリューションを独自にカスタマイズしてみてください。
ルーティング
テナントからのリクエストは、まず Amazon CloudFront ディストリビューションに送られます。フロントエンドは Amazon S3 バケットをオリジンとするシングルページアプリケーションです。カスタムドメインを使用する場合、テナント専用のサブドメインでアクセスできるように、オンボーディングの際に追加の設定が実行されます。以下がその部分のスニペットですが、共通の CloudFront ディストリビューションに対して、テナント ID をキーにしたサブドメインでエイリアスレコードを作成していることがわかるかと思います。
services/tenant-onboarding/lib/tenant-onboarding-stack.ts
API Gateway にはカスタムドメインとして api.{domain} が割り当てられています。リソースは ANY {proxy+} で設定されており、ここではシンプルなプロキシとしての機能のみを担っていますが、ECS リファレンスであったようにティアごとにスロットリングを設定したい場合などは、このレイヤーをカスタマイズすることで対応できるでしょう。 マイクロサービスへのルーティングは、Ingress リソースの設定ですでに見た通り、パスからテナント ID を抽出することで実現しています。
if (usingCustomDomain) {
// add alias to existing distribution
const tenantAppDomain = `${props.tenantid}.${props.customDomain}`;
const hostedZone = route53.PublicHostedZone.fromHostedZoneAttributes(
this,
'PublicHostedZone',
{
hostedZoneId: props.hostedZoneId!,
zoneName: props.customDomain!,
}
);
const distribution = cloudfront.Distribution.fromDistributionAttributes(
this,
'CloudFrontDistribution',
{
distributionId: appDistributionId.valueAsString,
domainName: distributionDomain.valueAsString,
}
);
new route53.ARecord(this, `AliasRecord`, {
zone: hostedZone,
recordName: tenantAppDomain,
target: route53.RecordTarget.fromAlias(new alias.CloudFrontTarget(distribution)),
});
} else {
フロントエンドからマイクロサービスへのリクエスト
証明書も最初のセットアップの段階で作成されています。SaaS アプリケーション用の *.app.{domain} に加えて、管理アプリケーション用の admin.{domain}、API 用の api.{domain} も作成済みです。
フロントエンドからマイクロサービスへのリクエストは、Amazon API Gateway を介して VPC リンクで NLB にフォワードされ、Nginx Ingress リソースによって適切なテナント namespace 内の各サービスにルーティングされます。

マイクロサービスへのルーティング
API Gateway にはカスタムドメインとして api.{domain} が割り当てられています。リソースは ANY {proxy+} で設定されており、ここではシンプルなプロキシとしての機能のみを担っていますが、ECS リファレンスであったようにティアごとにスロットリングを設定したい場合などは、このレイヤーをカスタマイズすることで対応できるでしょう。 マイクロサービスへのルーティングは、Ingress リソースの設定ですでに見た通り、パスからテナント ID を抽出することで実現しています。
spec:
rules:
- host: KUSTOMIZE_API_HOST
http:
paths:
- path: /KUSTOMIZE_TENANT_NAME/products
backend:
service:
name: product-service
port:
number: 80
pathType: Prefix
テナント分離
さて、最後にテナント分離についてですが、冒頭で述べた通り、今回はテナントごとに namespace を作成するモデルを採用しています。しかしこれはあくまで論理的な境界に過ぎず、テナント間のアクセスを防ぐためには追加の仕組みが必要です。
その 1 つが、サービスアカウントです。EKS には IRSA という仕組みがあり、IAM ロールとサービスアカウントを連携させて、Kubernetes クラスター内の Pod が簡単に AWS サービスの API を呼び出せるようにすることができます。サービスアカウントはオンボーディングのタイミングで作成されており、必要なポリシーが割り当てられています。
services/tenant-onboarding/lib/tenant-onboarding-stack.ts
スクリプト
// create service account for tenant
const tenantServiceAccount = cluster.addServiceAccount(`TenantServiceAccount`, {
name: `${props.tenantid}-service-account`,
namespace: props.tenantid,
});
// permission for order and product tables
tenantServiceAccount.addToPrincipalPolicy(
new iam.PolicyStatement({
actions: [
'dynamodb:GetItem',
'dynamodb:BatchGetItem',
'dynamodb:Query',
'dynamodb:PutItem',
'dynamodb:UpdateItem',
'dynamodb:DeleteItem',
'dynamodb:BatchWriteItem',
'dynamodb:Scan',
],
resources: [orderTable.tableArn],
effect: iam.Effect.ALLOW,
})
);
DynamoDB テーブルへアクセス権限を付与するポリシー作成
今回の場合は、データの操作のために DynamoDB にアクセスする必要があるため、dynamodb:xxx の各種アクションを許可しています。 プールモデルの商品テーブルについては、ECS SaaS リファレンスアーキテクチャで紹介したのと同じ、行レベルセキュリティが実現できる IAM の機能を使用してポリシーを絞り込んでいます。
// create service account for tenant
const tenantServiceAccount = cluster.addServiceAccount(`TenantServiceAccount`, {
name: `${props.tenantid}-service-account`,
namespace: props.tenantid,
});
// permission for order and product tables
tenantServiceAccount.addToPrincipalPolicy(
new iam.PolicyStatement({
actions: [
'dynamodb:GetItem',
'dynamodb:BatchGetItem',
'dynamodb:Query',
'dynamodb:PutItem',
'dynamodb:UpdateItem',
'dynamodb:DeleteItem',
'dynamodb:BatchWriteItem',
'dynamodb:Scan',
],
resources: [orderTable.tableArn],
effect: iam.Effect.ALLOW,
})
);
ネットワークレベルの分離
さらにもう 1 つの観点として、ネットワークレベルの分離という問題もあります。namespace そのものはネットワーク境界として機能しないため、セキュリティを強化するためには、例えば Calico のようなソフトウェアを導入し、追加のセキュリティポリシーを適用する必要があります。
services/tenant-onboarding/resources
services/tenant-onboarding/resources には、追加のカスタマイズで利用可能な構成ファイルのプレースホルダーが置かれています。SBT 統合によりこれらの適用は一時的に無効化されていますが、これらを使用した独自実装を施すことでカスタマイズすることは可能です。ネットワークポリシーだけでなく、例えばリソースクォータをティアごとに増減する構成などもできるでしょう。basic.yaml は、ベーシックティアのテナントリソースに適用する設定値を表しています。
まとめ
今回は Amazon EKS に焦点を当てて、マルチテナント SaaS の実装例を確認しました。一般的な考え方は ECS でも紹介したものを踏襲しつつ、namespace やサービスアカウントなど、Kubernetes 環境特有の概念が登場する場面もありました。Kuberenetes はオープンソースであるがゆえに、コミュニティの力を活用することができるという利点があります。Calico のような追加のツールを導入することで、自社の要件に合った細かいカスタマイズが可能になるかもしれません。このリファレンスアーキテクチャをベースに、ぜひ様々なカスタマイズを試してみてください。
実装の詳細については掘り下げたい方は 開発者ガイド やコードをご覧ください。来月はサーバーレスリファレンスについて紹介する予定ですので、次回もお楽しみに !
筆者プロフィール
櫻谷 広人
アマゾン ウェブ サービス ジャパン合同会社
パートナーソリューションアーキテクト
大学 4 年から独学でプログラミングを習得。新卒で SIer に入社して Web アプリケーションの受託開発案件を中心にバックエンドエンジニアとして働いた後、フリーランスとして複数のスタートアップで開発を支援。その後、toC 向けのアプリを提供するスタートアップで執行役員 CTO を務める。現在は SaaS 担当のパートナーソリューションアーキテクトとして、主に ISV のお客様の SaaS 移行を支援。

Did you find what you were looking for today?
Let us know so we can improve the quality of the content on our pages