AWS におけるマルチテナント SaaS の実装パターン

~ Amazon Elastic Kubernetes Service (Amazon EKS) 編

2024-01-06
ビジネス x クラウド

Author : 櫻谷 広人

SaaS 開発・運用に携わるみなさん、こんにちは。AWS で SaaS に特化した技術支援を行なっているパートナーソリューションアーキテクトの櫻谷です。

前回の記事 では、Amazon Elastic Container Service (Amazon ECS) をベースにしたマルチテナントアーキテクチャの構成例についてご紹介しましたが、今回は Amazon EKS を基盤とする SaaS on AWS のベストプラクティス実装について見ていきます。AWS でコンテナベースのシステムを構築する際によく検討に上がるこの 2 つのサービスで、どのような違いがあるのかに着目してみるのもおもしろいかもしれません。

「AWS におけるマルチテナント SaaS の実装パターン」の連載記事はこちら

選択
  • 選択
  • Amazon Elastic Container Service (Amazon ECS) 編
  • Amazon Elastic Kubernetes Service (Amazon EKS) 編
  • サーバーレス編

このクラウドレシピ (ハンズオン記事) を無料でお試しいただけます »

毎月提供されるクラウドレシピのアップデート情報とともに、クレジットコードを受け取ることができます。 


Amazon EKS SaaS リファレンスアーキテクチャ

Amazon EKS は、AWS クラウドおよびオンプレミスデータセンターで Kubernetes を実行するためのマネージド Kubernetes サービスです。クラウドでは、Amazon EKS は、コンテナのスケジューリング、アプリケーションの可用性の管理、クラスターデータの保存、および他の重要なタスクを担当する Kubernetes コントロールプレーンノードの可用性とスケーラビリティを自動的に管理します。

Amazon EKS を使用すると、AWS インフラストラクチャのすべてのパフォーマンス、スケール、信頼性、および可用性のメリットを享受できるだけでなく、AWS ネットワーキングおよびセキュリティサービスとの統合の恩恵も受けることができます。オンプレミスの EKS は、統合されたツールと AWS Outposts、仮想マシン、またはベアメタルサーバーへの簡単なデプロイを備えた、一貫性のあるフルサポートの Kubernetes ソリューションを提供します。

Amazon EKS SaaS リファレンスアーキテクチャ は、AWS クラウド上に Kubernetes クラスターを構築し、クラスター内にマルチテナント SaaS アプリケーションをデプロイしています。アーキテクチャは AWS CDK をベースに構築されています。セットアップの手順は、README をご覧ください。カスタムドメインを使用する場合とそうでない場合の 2 種類のセットアップ方法がありますが、適用されるベストプラクティスは基本的には同じです。また、今回は AWS のサービスである Amazon EKS をベースとしていますが、多くの考え方はその他の Kubernetes 環境にも同様に当てはまります。

※記事執筆時点で最新の main ブランチの バージョン を元にしています。最新のコードとは異なる可能性がありますのでご注意ください)。また、README にあるように SaaS Builder Toolkit for AWS (SBT) との統合が完了したため、Developer Guide の情報に一部古い情報が含まれていることにご注意ください。


アーキテクチャの全体像

まずは、全体の構成を確認しましょう。前回説明したコントロールプレーンとアプリケーションプレーンの考え方が取り入れられ、このアーキテクチャも 2 つのパーツから構成されています。コントロールプレーンは前述の通り SBT を組み込み、開発工数を削減しています。アプリケーションプレーンは EKS クラスター内にホストされ、テナントごとにサイロモデルのアプリケーションサービスをデプロイします。

アプリケーションは ECS のリファレンスとほとんど同じです。SaaS プロバイダーが利用する管理者用コンソールと、テナントが利用する E コマースアプリケーションがあり、E コマースアプリケーションは商品サービスと注文サービスの 2 つのマイクロサービスで構成されています。この記事では EKS に焦点を当てるため、詳細については省略します。気になる方は 前回の記事 をご覧ください。

アプリケーションプレーン

ECS のアーキテクチャでは、サーバーレスコンピューティング基盤である AWS Fargate を一部利用していましたが、今回のアーキテクチャでは マネージドノードグループ を使用して、クラスターノードの管理を自動化しています。ノードは複数のアベイラビリティーゾーンに冗長化され、かつ負荷に応じて動的にスケーリングするように設計されています。

これらのノード上に各テナント専用のマイクロサービスをデプロイしていくわけですが、ここに Kubernetes 特有のポイントが 1 つあります。それは、Namespace (名前空間) です。テナントごとに専用の Namespace を作成して論理的な分離境界を引くアプローチは、Kubernetes で構築するマルチテナント SaaS における代表的な分離モデルの 1 つです。テナント分離については後の章でもう少し詳しく説明します。

テナントごとに作成するリソースを詳しく図解したものが以下です。詳しい実装内容を知りたい方は tenant-onboarding-stack.ts をご覧ください。

認証には Amazon Cognito を使用し、テナントごとにユーザープールを作成します。フロントエンドは共有のアプリケーションを使用しており、アクセスしているテナントの情報に基づいてルーティング先を切り替えます。マイクロサービスとの通信は、NGINX Ingress Controller をプロキシとして使用しています。

データストアについては、サービスによって異なる分離モデルを使い分けています。どちらも Amazon DynamoDB を使用していますが、注文サービスは各テナントごとにテーブルを作成し、商品サービスは単一のテーブルを共有するモデルを採用しています。実際のワークロードでは、データのアクセスパターンや、セキュリティ、パフォーマンス、コンプライアンス要件、コスト効率などを考慮して最適なモデルを選択してください。

作成された 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 リソースについては以下の通りです。

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

オンボーディング

SBT を導入しているため、オンボーディングのフローは SBT 側でトリガーおよびオーケストレーションされます。管理者用のアプリケーションからテナントを作成すると、コントロールプレーンの API が呼び出され、DynamoDB にテナントのデータが登録されます。ここまでは共通処理ですが、ScriptJob (旧 JobRunner) を使用して追加のカスタムスクリプトも実行しています。Kubernetes のリソースが作成されるのはこのタイミングです。

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

具体的には、AWS CodeBuild を使用してテナントリソースのデプロイを行います。以下はさらに詳細のスニペットですが、services/tenant-onboarding をルートに、cdk deploy を実行しています。これが先ほど画像でも確認したテナントごとのリソースを定義した TenantOnboardingStack になります。

lib/constructs/tenant-onboarding.ts

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`
        ),
        },
    },
    }),
});

この cdk deploy によって、Kubernetes の namespace やサービスアカウント、Cognito ユーザープール、注文サービス用の DynamoDB テーブルなどが作成されます。

また、post_build のフェーズでは、商品マイクロサービス、注文マイクロサービスをそれぞれデプロイするプロジェクトを実行しています。こちらの詳細は以下の通りです。

lib/constructs/application-service.ts

const tenantDeployProject = new codebuild.Project(this, `${id}EKSTenantDeployProject`, {
...省略
  buildSpec: codebuild.BuildSpec.fromObject({
    version: '0.2',
    phases: {
      install: {
        commands: [
          `export API_HOST=$(echo '${
            props.internalApiDomain || ''
          }' | awk '{print tolower($0)}')`,
          'curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"',
          'chmod +x ./kubectl',
        ],
      },
      pre_build: {
        commands: [],
      },
      build: {
        commands: [
          'aws eks --region $AWS_REGION update-kubeconfig --name $CLUSTER_NAME',
          'echo "  newName: $ECR_REPO_URI" >> kubernetes/kustomization.yaml',
          'echo "  newTag: latest" >> kubernetes/kustomization.yaml',
          'echo "  value: $API_HOST" >> kubernetes/host-patch.yaml',
          'cp kubernetes/path-patch-template.yaml kubernetes/path-patch.yaml',
          'echo "  value: /$TENANT_ID/$SERVICE_URL_PREFIX" >> kubernetes/path-patch.yaml',
          'cp kubernetes/svc-acc-patch-template.yaml kubernetes/svc-acc-patch.yaml',
          `echo "  value: $TENANT_ID-service-account" >> kubernetes/svc-acc-patch.yaml`,
          'kubectl apply -k kubernetes/ -n $TENANT_ID',
        ],
      },
      post_build: {
        commands: [],
      },
    },
  }),

マニフェストの管理を楽にするために kustomize を使用していますが、サービスに関わる重要な部分については service.yaml に記載されています。商品サービス、注文サービスごとに Deployment、Service、Ingress リソースを定義しています。

services/application-services/product-service/kubernetes/service.yaml

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

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:xxx の各種アクションを許可しています。

プールモデルの商品テーブルについては、ECS SaaS リファレンスアーキテクチャで紹介したのと同じ、行レベルセキュリティが実現できる IAM の機能を使用してポリシーを絞り込んでいます。

tenantServiceAccount.addToPrincipalPolicy(
    new iam.PolicyStatement({
    actions: [
        'dynamodb:GetItem',
        'dynamodb:BatchGetItem',
        'dynamodb:Query',
        'dynamodb:PutItem',
        'dynamodb:UpdateItem',
        'dynamodb:DeleteItem',
        'dynamodb:BatchWriteItem',
        'dynamodb:Scan',
    ],
    resources: [
        Arn.format({ service: 'dynamodb', resource: 'table', resourceName: 'Product' }, this),
    ],
    conditions: {
        'ForAllValues:StringEquals': {
        'dynamodb:LeadingKeys': [props.tenantid],
        },
    },
    effect: iam.Effect.ALLOW,
    })
);

さらにもう 1 つの観点として、ネットワークレベルの分離という問題もあります。namespace そのものはネットワーク境界として機能しないため、セキュリティを強化するためには、例えば Calico のようなソフトウェアを導入し、追加のセキュリティポリシーを適用する必要があります。

services/tenant-onboarding/resources には、追加のカスタマイズで利用可能な構成ファイルのプレースホルダーが置かれています。SBT 統合によりこれらの適用は一時的に無効化されていますが、これらを使用した独自実装を施すことでカスタマイズすることは可能です。ネットワークポリシーだけでなく、例えばリソースクォータをティアごとに増減する構成などもできるでしょう。basic.yaml は、ベーシックティアのテナントリソースに適用する設定値を表しています。


まとめ

今回は Amazon EKS に焦点を当てて、マルチテナント SaaS の実装例を確認しました。一般的な考え方は ECS でも紹介したものを踏襲しつつ、namespace やサービスアカウントなど、Kubernetes 環境特有の概念が登場する場面もありました。Kuberenetes はオープンソースであるがゆえに、コミュニティの力を活用することができるという利点があります。Calico のような追加のツールを導入することで、自社の要件に合った細かいカスタマイズが可能になるかもしれません。このリファレンスアーキテクチャをベースに、ぜひ様々なカスタマイズを試してみてください。

実装の詳細については掘り下げたい方は 開発者ガイド やコードをご覧ください。来月はサーバーレスリファレンスについて紹介する予定ですので、次回もお楽しみに !

「AWS におけるマルチテナント SaaS の実装パターン」の連載記事はこちら

選択
  • 選択
  • Amazon Elastic Container Service (Amazon ECS) 編
  • Amazon Elastic Kubernetes Service (Amazon EKS) 編
  • サーバーレス編

builders.flash メールメンバーへ登録することで
AWS のベストプラクティスを毎月無料でお試しいただけます

筆者プロフィール

櫻谷 広人
アマゾン ウェブ サービス ジャパン合同会社
パートナーソリューションアーキテクト

大学 4 年から独学でプログラミングを習得。新卒で SIer に入社して Web アプリケーションの受託開発案件を中心にバックエンドエンジニアとして働いた後、フリーランスとして複数のスタートアップで開発を支援。その後、toC 向けのアプリを提供するスタートアップで執行役員 CTO を務める。現在は SaaS 担当のパートナーソリューションアーキテクトとして、主に ISV のお客様の SaaS 移行を支援。

AWS を無料でお試しいただけます

AWS 無料利用枠の詳細はこちら ≫
5 ステップでアカウント作成できます
無料サインアップ ≫
ご不明な点がおありですか?
日本担当チームへ相談する