ForgeVision Engineer Blog

フォージビジョン エンジニア ブログ

実践編①:マルチテナントSaaS開発を加速させるSaaS Builder Toolkit(SBT)の魅力

こんにちは、AWSグループの藤岡(@fuji_kol_ry)です。

前回の記事で、導入編として、AWSが提供するマルチテナントSaaS開発ツールのSaaS Builder Toolkit (SBT)についてご紹介しました。

今回は、実際にSBTを使ってSaaS基盤を構築や動作確認をして、SBTの使い勝手についてお伝えいたします。

なお本シリーズは以下の3部構成になっていますため、前回の記事を読んでいない方は、ぜひご一読いただければと思います。

techblog.forgevision.com

techblog.forgevision.com

SBTやってみた

SBTリポジトリのREADMEに従って、SaaSアプリケーションを作ってみましょう。

※ここで注意点を共有しますと、日本語版READMEではなく英語版READMEをなるべく参照しましょう。(日本語版READMEは、最新のSBTライブラリに記載が追従していない部分が多く見られて、ビルドでエラーが発生する可能性が高いです)

セットアップ

まず最初に、CDK環境をセットアップしましょう。そのため、READMEの案内通りに、以下のチュートリアルに沿ってCDKプロジェクトを作成しましょう。

docs.aws.amazon.com

チュートリアルに記載されるステップ1でCDKをインストール後、ステップ10まで完了することで、CDKデプロイまで一通りが実行できる状態を確認できました。

コントロールプレーン/アプリケーションプレーンの構築

CDKのセットアップが完了したので、SBTに戻ります。以下のインストールから始め、順々にREADMEに記載の手順を進めていきます。

npm install @cdklabs/sbt-aws

なお本ブログでの検証ではSBTのバージョンは0.5.18を使用しています。SBTはメジャーバージョンが0系であることも踏まえ、今後もインターフェースレベルの変更が想定されそうです。

次に、CDKを使ってスタックとアプリを定義します。構築するリソースをざっくりまとめると以下になります。

プレーン 機能 作成されるAWSリソース
コントロールプレーン ユーザー認証・管理 Amazon Cognito
APIエンドポイント Amazon API Gateway
データ管理 Amazon DynamoDB
アプリケーションプレーン イベント管理 Amazon EventBridge
プロビジョニング AWS Step Functions
ストレージ Amazon S3

以下がCDK実装です。(エラーが出ないように、SBTのREADME(英語版)から多少の手直しをしています。)

スタック① コントロールプレーン

ファイル:lib/control-plane.ts

後続手順でメールを受信・確認する必要があるため、Adminのメールアドレスは環境変数として取得するよう変更しています

import * as sbt from '@cdklabs/sbt-aws';
import { Stack } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as dotenv from 'dotenv';
  
dotenv.config();
  
export class ControlPlaneStack extends Stack {
  public readonly regApiGatewayUrl: string;
  public readonly eventManager: sbt.IEventManager;
  
  constructor(scope: Construct, id: string, props?: any) {
    super(scope, id, props);
  
    const cognitoAuth = new sbt.CognitoAuth(this, 'CognitoAuth', {
      // Avoid checking scopes for API endpoints. for testing purposes.
      setAPIGWScopes: false,
    });
  
    const controlPlane = new sbt.ControlPlane(this, 'ControlPlane', {
      auth: cognitoAuth,
      systemAdminEmail: process.env.SYSTEM_ADMIN_EMAIL || 'default@example.com',
    });
  
    this.eventManager = controlPlane.eventManager;
    this.regApiGatewayUrl = controlPlane.controlPlaneAPIGatewayUrl;
  }
}

スタック② アプリケーションプレーン

ファイル:lib/app-plane.ts

import * as sbt from '@cdklabs/sbt-aws';
import * as cdk from 'aws-cdk-lib';
import { EventBus } from 'aws-cdk-lib/aws-events';
import { PolicyDocument, PolicyStatement, Effect } from 'aws-cdk-lib/aws-iam';
  
export interface AppPlaneProps extends cdk.StackProps {
  eventManager: sbt.IEventManager;
}
  
export class AppPlaneStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props: AppPlaneProps) {
    super(scope, id, props);
  
    const provisioningScriptJobProps: sbt.TenantLifecycleScriptJobProps = {
      permissions: new PolicyDocument({
        statements: [
          new PolicyStatement({
            actions: [
              'cloudformation:CreateStack',
              'cloudformation:DescribeStacks',
              's3:CreateBucket',
            ],
            resources: ['*'],
            effect: Effect.ALLOW,
          }),
        ],
      }),
      script: `
echo "starting..."

# note that this template.yaml is being created here, but
# it could just as easily be pulled in from an S3 bucket.
cat > template.json << EndOfMessage
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Resources": { "MyBucket":{ "Type": "AWS::S3::Bucket" }},
  "Outputs": { "S3Bucket": { "Value": { "Ref": "MyBucket" }}}
}
EndOfMessage

echo "tenantId: $tenantId"
echo "tier: $tier"

aws cloudformation create-stack --stack-name "tenantTemplateStack-\${tenantId}"  --template-body "file://template.json"
aws cloudformation wait stack-create-complete --stack-name "tenantTemplateStack-\${tenantId}"
export tenantS3Bucket=$(aws cloudformation describe-stacks --stack-name "tenantTemplateStack-\${tenantId}" | jq -r '.Stacks[0].Outputs[0].OutputValue')
export someOtherVariable="this is a test"
echo $tenantS3Bucket

export tenantConfig=$(jq --arg SAAS_APP_USERPOOL_ID "MY_SAAS_APP_USERPOOL_ID" \
--arg SAAS_APP_CLIENT_ID "MY_SAAS_APP_CLIENT_ID" \
--arg API_GATEWAY_URL "MY_API_GATEWAY_URL" \
-n '{"userPoolId":$SAAS_APP_USERPOOL_ID,"appClientId":$SAAS_APP_CLIENT_ID,"apiGatewayUrl":$API_GATEWAY_URL}')

echo $tenantConfig
export tenantStatus="created"

echo "done!"
`,
      environmentStringVariablesFromIncomingEvent: ['tenantId', 'tier'],
      // sbtのバージョンに合わせて修正
      // environmentVariablesToOutgoingEvent: [
      //   'tenantS3Bucket',
      //   'someOtherVariable',
      //   'tenantConfig',
      //   'tenantStatus',
      // ],
      environmentVariablesToOutgoingEvent: {
        'tenantRegistrationData': [],
        'tenantData': [],
      },
      scriptEnvironmentVariables: {
        TEST: 'test',
        PROD: 'prod',
      },
      eventManager: props.eventManager,
    };
  
    const provisioningJobScript: sbt.ProvisioningScriptJob = new sbt.ProvisioningScriptJob(
      this,
      'provisioningJobScript',
      provisioningScriptJobProps
    );
  
    new sbt.CoreApplicationPlane(this, 'CoreApplicationPlane', {
      eventManager: props.eventManager,
      scriptJobs: [provisioningJobScript],
    });
  }
}

アプリケーション

ファイル:bin/{プロジェクト名}.ts

import * as cdk from 'aws-cdk-lib';
import { ControlPlaneStack } from '../lib/control-plane';
import { AppPlaneStack } from '../lib/app-plane';
import 'source-map-support/register';
  
const app = new cdk.App();
  
const controlPlaneStack = new ControlPlaneStack(app, 'ControlPlaneStack', {});
const appPlaneStack = new AppPlaneStack(app, 'AppPlaneStack', {
  eventManager: controlPlaneStack.eventManager,
});

ここまで実装を終えての余談ですが、CDKはやはりTypeScriptに限ると改めて思いました。

上記を用意するにあたって、バージョンに起因するインターフェースの修正が必要でしたが、TypeScriptだからこその型の宣言・分かりやすさのおかげで、比較的スムーズに進めることができました。

デプロイ結果の確認

以下のコマンドで、作成したスタックをデプロイしましょう。

npx cdk deploy --all

デプロイが成功すると、コントロールプレーンのCloudFormationスタックとアプリケーションプレーンのCloudFormationスタックが作成されています。

それぞれのスタックにアクセスして、Infrastructure Composerを使って全体像を掴みましょう。

まずはアプリケーションプレーンから見てみましょう。

ちょっとこのままだと分かりにくいですね...赤枠で囲って、リソースを分類してみました。

アプリケーションプレーンとして作成されたリソースは、オンボーディングを検知するイベントルール、プロビジョニング機能、ロール(実行・処理)に分類できることが分かりました。

ちなみプロビジョニング機能は、app-plane.tsのprovisioningScriptJobへ定義している、テナント用のS3バケット作成です。

つまり、provisioningScriptJobをカスタマイズすることで、SaaSごとに必要なオンボーディングを自由に作成できそうですね。これは便利!

続いてコントロールプレーンを見てみましょう。こちらも同様にリソースを分類してみました。

大量のリソースが作成されていますが、要点としては、テナント/ユーザーのCRUD(作成・読込・更新・削除)が、API Gateway+Lambda、Cognito、DynamoDBといった、AWSのテンプレート的な構成によって用意されているということです。

こういった必須ではあるが競合優位性が低い機能を、SBTを使って簡単に構築できることを確認できました。これもスゴイ!

動作検証

エンドポイントにアクセスして、テナント作成やユーザー登録を試してみましょう。

検証にはSBTが用意するスクリプトを使用しました。(コピーして作業プロジェクトに配置しましょう)

まずは認証情報を設定しましょう。以下を実行してください。

sh sbt-aws.sh configure ControlPlaneStack test@example.com

実行するとパスワード入力が求められます。コントロールプレーン作成時に設定した管理者メールアドレスに、件名「Your temporary password for control plane UI」のパスワード通知が届いてますので、そちらを貼り付けましょう。

成功すると、ホームディレクトリに以下のような.sbt-aws-config ファイルが作成されます。

CONTROL_PLANE_STACK_NAME=ControlPlaneStack
CONTROL_PLANE_API_ENDPOINT=https://{endpoint-id}.execute-api.{region}.amazonaws.com/
ADMIN_USER_PASSWORD={メールで届いたパスワード}
EMAIL_USERNAME=test
EMAIL_DOMAIN=example.com
ACCESS_TOKEN=eyJ・・・・・

次に、以下のコマンドを実行して、テナントを作成しましょう。

sh sbt-aws.sh create-tenant-registration

実行すると、先ほど確認したアプリケーションプレーンのプロビジョニングのStepFunctionsステートマシンが実行されています。

なお、プロビジョニングの状態取得を行いましたが、Statusが"In progress"のままでした。この辺りはSBTが未だ開発中である所以かもしれません。(とはいえ、コントロールプレーンで成功/失敗のイベントパターンを検知できるので、最低限足るとは思います。)

テナントテーブルにも登録を確認できました。

次に、ユーザー登録を行いましょう。以下のコマンドを実行してください。

sh sbt-aws.sh create-user

成功すると、Cognitoユーザープールにユーザーが登録されていることが確認できました。

ここまでの動作検証で確認できたように、テナント/ユーザーのCRUDやプロビジョニングを簡単に用意することができました。

SBTへの理解を深めることや、ドキュメントとの乖離を多少修正する必要があるなど、少し敷居が高い部分もありますが、それでもSBTを試してみるメリットがあると思いました。

まとめ

マルチテナントSaaSの開発を効率化させるSaaS Builder Toolkit (SBT)を取り上げて、実際にコントロールプレーン・アプリケーションプレーンを構築して、動作確認した結果をご紹介しました。

SBTで用意されたCDKスタックを定義することによって、短時間でテナント管理からオンボーディングまで構築することができました。

SBTの仕組みを把握するにはコストが掛かりますが、それに見合った開発効率性や強力な機能が得られると個人的には思いました。

また、SBTライブラリ自体はCDK主体であるのでカスタマイズ性が高く、ライブラリの実装を適切に理解しながら利用すれば、ツール依存を恐れずにSaaSアーキテクチャを構築できると感じました。

次回は実践編パート2として、構築したコントロールプレーンを活用しながら、簡単なアプリケーション構築を行います。

また最終回ですので、SBTを使ってみての総括もお伝えいたします。お楽しみに!