4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

マルチテナントSaaSを設計するとき、最初の検討事項になるのがテナントのデータと実行環境をどう分離するかです。

テナント分離戦略にはいくつかのモデルがあり、その中でも最も分離レベルの高い選択肢が、テナントごとに専用のAWSアカウントを払い出す Silo (サイロ) モデルです。エンタープライズ向け SaaS やコンプライアンス要件の厳しい領域では有力な選択肢ですが、テナント数の増加に対してアカウントの払い出しを自動化する必要があります。

本記事では、SaaS Builders Toolkit for AWS (SBT) を活用し、サイロモデルにおける 新規 AWS アカウント作成パイプラインを構築する方法を解説します。

SaaS Builders Toolkit for AWS(SBT)とは

SaaS Builders Toolkit for AWS(SBT) は、AWS が公開しているOSS の CDK コンストラクトライブラリです。多くのSaaSで共通して必要になる機能を、再利用可能な部品として提供します。

SBT は、SaaS を2つの面 (Plane) に分けて捉えます。

Plane 役割 主なリソース
Control Plane テナントの登録・管理・課金・ユーザー管理など、テナント横断の管理機能 テナント管理 API、テナント管理用 DynamoDB、認証基盤、EventBridge
Application Plane テナントへ提供するアプリと、そのプロビジョニング (払い出し) 処理 プロビジョニングの Step Functions / CodeBuild、テナントごとのアプリスタック

SBT の設計で最も重要なのは、この2つの Plane をEventBridge で疎結合に連携させている点です。

  • Control Planeはテナント登録を受け付けると、onboardingRequest イベントを EventBridge に発行する
  • Application Planeはそのイベントを購読し、プロビジョニング処理 (本記事では AWS アカウント作成) を実行する

本記事で使用する SBT の主なコンストラクトは次のとおりです。

  • ControlPlane: テナント管理 API・認証・イベント基盤一式
  • CognitoAuth: Cognito ベースの認証実装
  • IEventManager: Plane 間をつなぐ EventBridge のイベント管理
  • CoreApplicationPlane: イベントを受けてジョブを実行するアプリプレーンの土台
  • ProvisioningScriptJob: プロビジョニング処理を CodeBuild + Step Functions として実行するジョブ

SBT がテナント管理とPlane間のイベント連携を担うため、開発者はイベントを受け取った後の処理 (=アカウント作成) に集中できます。

参考: SBT README.md /

サイロモデルにおけるテナント分離の粒度

SaaS のテナント分離戦略について、AWS Well-Architected Framework の SaaS Lens では、大きく分けて以下の3つのモデルに分類しています。

モデル 分離レベル 概要
Pool 全テナントが同じリソースを共有し、テナントIDで論理的に分離
Bridge 一部リソースは共有、一部はテナント専用
Silo テナントごとに専用のリソーススタックを持つ

参考: Core isolation concepts / Silo isolation / Pool isolation / Bridge model

サイロ=アカウント分割とは限らない

注意点として、サイロモデルは「テナントごとに AWS アカウントを分ける」という意味に限りません。サイロはテナント専用のスタックを何らかの境界で囲う概念であり、その境界の粒度には次のようなレベルがあります。

サイロの粒度 境界 分離の強さ 運用負荷
リソース単位 テナント専用の DB / コンピュートを IAM・セキュリティグループ等で分離 中〜高 低〜中
VPC / NW 単位 テナントごとに VPC やサブネットを分ける
アカウント単位 AWSアカウントそのものを境界にする 最高

実務では、同一アカウント内でテナント専用のリソース(DB やクラスターなど) を分けるサイロ構成も広く採用されます。コストと運用のバランスを取りやすいためです。アカウント単位の分割はサイロの中でも最も粗い粒度 (coarse-grained) かつ最も強い分離であり、要件が重いケースでの選択肢になります。

本記事では、この中でAWSアカウントそのものをテナント境界にするパターンを扱います。

アカウント単位で分けることのメリット

同一アカウント内でリソースを分けるのではなく、アカウントごと分けることで得られる主なメリットは次のとおりです。

  1. 強固なセキュリティ境界
    AWS アカウントは AWS における最も強力な分離境界です。IAM・ネットワーク・API レートリミット・サービスクォータがアカウント単位で分かれるため、あるテナントの不具合や負荷が他テナントへ波及しにくくなります (Noisy Neighbor 問題の回避)。

  2. コスト可視化と課金の単純化
    コストがアカウント単位で集計されるため、テナントごとの利用コストがそのまま把握できます。タグ設計に依存せず、請求がテナント別に分かれます。

  3. コンプライアンス・データレジデンシー対応
    顧客データを他テナントと分離する要件 (金融・公共・エンタープライズ案件で多い) に対し、アカウント分離という明快な分離単位で応えられます。

  4. 影響範囲 (Blast Radius) の最小化
    設定ミスやインシデントの影響が、そのテナントのアカウント内に閉じます。

トレードオフ

アカウント単位のサイロには、同一アカウント内のリソース分離と比べたデメリットもあります。

  • コスト効率の低下: テナントごとに独立スタックを持つため、アイドルリソースが生じやすく、リソース共有によるコスト圧縮効果が得られにくい
  • アカウント数の上限・ガバナンス: アカウント数の増加に伴い、請求統合・SCP・ベースライン適用などのガバナンスが複雑化し、数百〜数千規模では運用効率が低下する
  • アジリティの低下: テナント環境が分散するため、機能デプロイや監視を単一の管理面に集約する手間が増える
  • オンボーディングの重量化: 1テナントごとにインフラを作るため、アカウントの払い出しを自動化しないと運用が成立しにくい

メリット・デメリットの詳細は Silo model pros and cons を参照してください。テナント数が多くコスト効率を重視する場合や、分離要件がそこまで厳しくない場合は、Poolモデルや同一アカウント内のリソース分離が適します。ティアごとにモデルを使い分ける Tier-based isolation という考え方もあります。

アカウント単位のサイロを選ぶ場合、アカウント払い出しの自動化が前提になります。本記事では、AWS Control Tower の Account Factory をイベント駆動で自動実行する方法を解説します。

全体アーキテクチャと構成スタック

各スタックの役割

本機能は2つのCDKスタックで構成されます。どちらもコントロールプレーン用のアカウント (テナントアカウントとは別のアカウント) にデプロイする想定です。

スタック Plane 役割
TenantManagementStack Control Plane SBTの ControlPlane 一式 (テナント管理API・認証・EventManager) を構築
TenantProvisioningStack Application Plane onboardingRequest イベントを受けてアカウント作成・デプロイを実行

両者を bin/cdk.ts で紐づけます。TenantManagementStack が生成した eventManagerTenantProvisioningStack に渡すことで、Plane 間のイベント連携が成立します。

// bin/cdk.ts(抜粋・簡略化)
const tenantManagementStack = new TenantManagementStack(app, 'TenantManagementStack', {
  // ...
});

new TenantProvisioningStack(app, 'TenantProvisioningStack', {
  eventManager: tenantManagementStack.eventManager, // ← Plane間をつなぐ
  // ...
});

基盤となる TenantManagementStack

TenantManagementStack はシンプルです。SBTの ControlPlane コンストラクトを1つ作るだけで、テナント管理に必要な機能 (API・Cognito 認証・DynamoDB・EventBridge) が立ち上がります。

// lib/control-plane/tenant-management-stack.ts(抜粋)
export class TenantManagementStack extends Stack {
  public readonly eventManager: sbt.IEventManager;

  constructor(scope: Construct, id: string, props?: any) {
    super(scope, id, props);

    const cognitoAuth = new sbt.CognitoAuth(this, 'CognitoAuth', { /* ... */ });

    const controlPlane = new sbt.ControlPlane(this, 'ControlPlane', {
      auth: cognitoAuth,
      systemAdminEmail: process.env.SYSTEM_ADMIN_EMAIL!,
    });

    // 後続の Application Plane へ渡すために EventManager を公開
    this.eventManager = controlPlane.eventManager;

    // SBT が内部生成するテナント管理テーブル名をSSMに保存し、
    // Application Plane 側の Lambda から動的に参照できるようにする
    const sbtTables = controlPlane.node.findAll()
      .filter((c) => c instanceof sbt.TenantManagementTable) as sbt.TenantManagementTable[];

    new ssm.StringParameter(this, 'ManagementTableNameParam', {
      parameterName: '/sbt/control-plane/management-table-name',
      stringValue: sbtTables[0].tenantDetails.tableName,
    });
  }
}

ポイントは2つです。

  1. eventManagerpublic readonly で公開: Control Plane と Application Plane を繋ぐ接点になります。
  2. テーブル名を SSM Parameter Store に保存: SBT が生成する DynamoDB テーブル名はデプロイ時に決まるため、ハードコードせず SSM 経由で動的参照します。

イベント駆動の全体像

テナント登録から新規AWSアカウント作成までの流れは次のとおりです。

onboardingRequest を受け取った SBT の CoreApplicationPlane が、Step Functions 経由で CodeBuild ジョブを起動します。Control Tower のアカウント作成や CloudFormation StackSets の適用には時間がかかるため、CodeBuild 内でステータスをポーリングして待機します。アカウント作成が完了すると、アカウント ID がイベント経由でテナント管理テーブルに書き戻されます。

新規AWSアカウント作成処理の実装

アカウント作成は2つの要素で実現しています。

  • CDK側 (ProvisioningScriptJob): CodeBuildジョブと IAM 権限を定義
  • 実行側 (Lambda + Bash): Service Catalog APIを呼び出してアカウントを作成

CDK でプロビジョニングジョブを定義する

TenantProvisioningStack では、sbt.ProvisioningScriptJob でCodeBuildベースのジョブを定義します。SBTがこれをStep Functionsステートマシンに組み込み、onboardingRequest イベントに紐づけます。

// lib/control-plane/tenant-provisioning-stack.ts (抜粋・簡略化)
const accountProvisioningJob = new sbt.ProvisioningScriptJob(this, 'AccountProvisioningJob', {
  permissions: new iam.PolicyDocument({
    statements: [
      // アカウント作成 Lambda の呼び出し
      new iam.PolicyStatement({
        actions: ['lambda:InvokeFunction'],
        resources: [accountProvisioningFunction.functionArn],
        effect: iam.Effect.ALLOW,
      }),
      // 管理アカウントのプロビジョニングロールへ AssumeRole
      new iam.PolicyStatement({
        actions: ['sts:AssumeRole'],
        resources: [`arn:aws:iam::${managementAccountId}:role/<YourProvisioningRole>`],
        effect: iam.Effect.ALLOW,
      }),
      // Service Catalog / StackSet のステータス確認
      new iam.PolicyStatement({
        actions: [
          'servicecatalog:DescribeRecord',
          'cloudformation:DescribeStackInstance',
          'cloudformation:ListStackInstances',
        ],
        resources: ['*'],
        effect: iam.Effect.ALLOW,
      }),
      // ... デプロイ・通知用の権限が続く
    ],
  }),
  // CDK synth 時にプレースホルダーを実値へ置換してスクリプトを埋め込む
  script: fs.readFileSync(path.join(__dirname, '..', '..', 'scripts', 'provision-tenant.sh'), 'utf-8')
    .replace('__ENVIRONMENT__', currentEnv)
    .replace('__MANAGEMENT_ACCOUNT_ID__', managementAccountId)
    .replace('__ACCOUNT_PROVISIONING_FUNC__', accountProvisioningFunction.functionName)
    // ...
  ,
  // SBT イベントから取り出す入力変数
  environmentStringVariablesFromIncomingEvent: ['tenantId'],
  // 成功時にイベントへ書き戻す出力変数
  environmentVariablesToOutgoingEvent: {
    tenantRegistrationData: ['accountId', 'provisioningStatus'],
  },
  eventManager: props.eventManager,
  projectProps: {
    environment: { computeType: codebuild.ComputeType.SMALL, buildImage: codebuild.LinuxBuildImage.STANDARD_7_0 },
    timeout: cdk.Duration.minutes(90),
  },
});

this.coreApplicationPlane = new sbt.CoreApplicationPlane(this, 'CoreApplicationPlane', {
  eventManager: props.eventManager,
  scriptJobs: [accountProvisioningJob],
});

特に重要なのは次の3点です。

  • environmentStringVariablesFromIncomingEvent: ['tenantId']
    SBTが onboardingRequest イベントから tenantId を取り出し、CodeBuildの環境変数として注入します。
  • environmentVariablesToOutgoingEvent
    ジョブが出力した accountId / provisioningStatus を、SBTが provisionSuccess イベントとして発行し、テナント管理テーブルへ書き戻します。アカウントIDの永続化を自前で実装する必要がありません。
  • 実行スクリプト (provision-tenant.sh) の読み込みと置換
     CodeBuild が実際に実行する Bash スクリプトを読み込み、内部の __MANAGEMENT_ACCOUNT_ID__ などのプレースホルダーを、CDK synth 時に環境別の実値へ差し替えてジョブに埋め込んでいます。

Lambda による Service Catalog 経由のアカウント作成

アカウント作成の核となる処理は Lambda (Python) で実行します。AWS Control Tower の Account Factory は Service Catalog の製品として提供されるため、provision_product APIを呼び出してアカウントを払い出します。

参考: AWS Control Tower Account Factory / Service Catalog ProvisionProduct API

処理の流れは次のとおりです。

# lambda/tenant-account-provisioning/index.py (処理の流れ)
def handler(event, context):
    tenant_id = event['tenantId']
    environment = event.get('environment', 'production')

    # 1. テナントID・環境からアカウント名/メール/配置先OUを決定
    account_name  = generate_account_name(tenant_id, environment)   # 例: MyApp-dev-<uuid>
    account_email = generate_account_email(tenant_id, environment)  # 例: myapp+dev-<id>@example.com
    ou_id         = get_ou_id(environment)                          # dev / prod のOUを切り替え

    # 2. 管理アカウントのプロビジョニングロールへ AssumeRole
    sc_client, org_client = assume_provisioning_role()

    # 3. Control Tower 用の "OUName (ou-id)" 形式を解決
    ou_display = get_ou_display(org_client, ou_id)

    # 4. Portfolio から Account Factory の Product ID とアクティブなバージョンを動的取得
    product_info = get_account_factory_product_info(sc_client)

    # 5. ProvisionProduct API でアカウント作成を開始
    result = provision_account(
        sc_client, account_name, account_email, ou_display,
        product_info['ProductId'], product_info['ProvisioningArtifactId'],
    )

    # 6. RecordId / ProvisionedProductId を返す (以降は CodeBuild 側でポーリング)
    return {
        'statusCode': 200,
        'recordId': result['RecordId'],
        'provisionedProductId': result['ProvisionedProductId'],
        'accountName': account_name,
        'accountEmail': account_email,
    }

本番運用を想定した実装では、AWS および Service Catalog の仕様に合わせて以下の考慮を入れています。

  • アカウント名 / メールの命名規則と文字数制限
    アカウント名は最大 50 文字、メールアドレスは最大64文字という制限があります。テナント ID (UUID) をそのまま使うと超過するため、環境プレフィックス (dev/prod)+短縮 ID で収める命名にしています。メールは myapp+dev-<id>@example.com のようにサブアドレッシング (+ エイリアス) を使い、1 ドメインで一意なアドレスを生成しています。
  • Product ID / バージョンを動的に取得
    Account Factory の Product ID や Provisioning Artifact (バージョン) は、OU 構成変更やバージョン更新で変わり得ます。ハードコードせず、毎回 Portfolio から検索し、アクティブなバージョンを選択します。
  • 管理アカウントへのクロスアカウント AssumeRole
    AWS Control Tower の Account Factory は、Organizations の管理アカウントでしか実行できない仕様です。そのため、コントロールプレーン (別アカウント)の Lambda から、管理アカウントに用意したプロビジョニング専用ロールを AssumeRole して実行するアーキテクチャにしています。
  • Account Factory の同時実行制限
    Control Tower Account Factoryは、複数アカウントの同時プロビジョニングができません。テナント登録が同時多発した場合、後発の処理は FAILED になってしまいます。そのため、実際のアーキテクチャでは Step Functionsでのリトライ処理や、キューイングによる逐次実行の仕組みを組み込む必要があります。

CodeBuild でのポーリング

Lambda はアカウント作成を開始するだけで、完了は待ちません。Control Tower や CloudFormation Stack Sets によるアカウントベースラインの適用には数分〜十数分かかるため、完了待ちは長時間実行に向く CodeBuild 側で行います。

CDK 側で CodeBuild に渡していた実行スクリプト (provision-tenant.sh) のアカウント作成部分は次のとおりです。

# scripts/provision-tenant.sh(アカウント作成部分の流れ)

# Step 1: アカウント作成 Lambda を呼び出し、RecordId を得る
aws lambda invoke --function-name "$ACCOUNT_PROVISIONING_FUNC" --payload "$LAMBDA_PAYLOAD" ...
RECORD_ID=$(jq -r '.recordId' /tmp/account-provisioning-response.json)

# Step 2: Service Catalog の Record ステータスを 60 秒間隔でポーリング(最大60分)
# 管理アカウントへ AssumeRole した一時認証情報で describe-record を回す
while [ $ELAPSED -lt $MAX_WAIT_SECONDS ]; do
  RECORD_STATUS=$(aws servicecatalog describe-record --id "$RECORD_ID" \
    --query 'RecordDetail.Status' --output text)
  [ "$RECORD_STATUS" == "SUCCEEDED" ] && break
  [ "$RECORD_STATUS" == "FAILED" ]   && exit 1
  sleep 60
done

# Step 3: 作成されたアカウントIDを RecordOutputs から取得
ACCOUNT_ID=$(aws servicecatalog describe-record --id "$RECORD_ID" \
  --query "RecordOutputs[?OutputKey=='AccountId'].OutputValue" --output text)

# Step 3.5: アカウントに TenantId タグを付与(後からテナント特定できるように)
aws organizations tag-resource --resource-id "$ACCOUNT_ID" \
  --tags Key=TenantId,Value="$tenantId" --region us-east-1

# Step 4: Control Tower のベースライン StackSet 適用完了を待機
#         (Adminロール用 StackSet は必須なので失敗したら中断)

# 完了: accountId をエクスポート → SBTが provisionSuccess イベントで DynamoDB に保存
export accountId=$ACCOUNT_ID
export provisioningStatus="success"

設計上のポイントは以下です。

  • 長時間ポーリングは CodeBuild で行う: Lambda の最大実行時間 (15分)では不足する可能性があるため、完了待ちは CodeBuild で行います。「作成の開始 = Lambda」「完了待ち = CodeBuild」という役割分担です。
  • StackSetの適用待ち: Control Tower が適用するベースラインの他、組織固有のガードレールや IAM ロール等を CloudFormation StackSet で自動展開するようにしています。後続のアプリデプロイはこのベースラインに依存するため、StackSetが CURRENT になるまで待ちます。Account Factory Customization の Blueprints は記事執筆時点でコンソール経由でしか利用できないため、StackSets で代替しています
  • accountId をexportするだけ: アカウント作成処理が成功すると、SBTが provisionSuccess イベントとしてテナント管理テーブルへ書き戻します。

まとめ

  • サイロモデル (テナント = AWS アカウント) を選択する場合、アカウント払い出しの自動化が運用の前提になります
  • SBTのイベント駆動アーキテクチャと Control Tower Account Factory を組み合わせることで、テナント登録イベントを起点にアカウント作成を自動化できます
  • TenantManagementStack (Control Plane) と TenantProvisioningStack (Application Plane) を EventManager で疎結合に接続し、onboardingRequest イベントを起点にアカウントを自動作成します
  • アカウント作成は Lambda (開始) + CodeBuild (完了待ち・後続処理) の2段構成とし、Lambda の時間制限を回避しつつ、Control Tower や StackSets によるアカウントベースラインの適用完了まで確実に見届ける構成としています

テナントごとに AWS アカウントを払い出す構成も、SBT と Control Tower を組み合わせることで、イベントを起点とした自動パイプラインとして実装できます。サイロモデルの採用を検討している方や、アカウント管理の自動化に取り組む方の参考になれば幸いです。

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?