- 1 1. この記事について
- 2 2. テナント分離モデル — Silo/Pool/Bridge と IAM ABAC
- 3 3. セルベースアーキテクチャ — blast radius 削減
- 4 4. 従量計測・課金 — Marketplace SaaS metering
- 5 5. オンボーディング自動化 — control plane / application plane
- 6 6. per-tenant 監視・コスト配賦
- 7 7. 実戦統合パターン — 分離 × セル × 計測
- 8 8. 詰まりポイント・アンチパターン・まとめ
- 8.1 8-1. 詰まりポイント7選
- 8.1.1 詰まり①: Pool分離でのテナント越境リスク(RLS設定漏れ)
- 8.1.2 詰まり②: noisy neighbor(pooledで最頻問題・Lambda concurrency枯渇)
- 8.1.3 詰まり③: per-tenantコスト可視化困難(タグ付け漏れ・CUR未設定)
- 8.1.4 詰まり④: オンボーディング手戻り(環境非対称・べき等性なし)
- 8.1.5 詰まり⑤: セル分割の過剰・過少(運用コスト増 vs blast radius未削減)
- 8.1.6 詰まり⑥: Marketplace metering連携の落とし穴(6時間制約未対応・pricing dimension後付け不可)
- 8.1.7 詰まり⑦: IAM ABACのスケール限界(PrincipalTag数上限・ポリシーサイズ制約)
- 8.2 8-2. アンチパターン → 正解パターン変換例 5選
- 8.3 8-3. まとめ
- 8.4 8-4. 関連記事クロスリンク
- 8.1 8-1. 詰まりポイント7選
1. この記事について
AWSでSaaSプロダクトを本番運用していると、「マルチテナント設計はできた。でも運用フェーズで詰まった」という壁にぶつかるケースが多い。テナント間のデータ漏洩リスク、一部テナントの暴走による全体影響(noisy neighbor)、従量課金の計測基盤、新規テナントの自動プロビジョニング——これらは設計段階では後回しにされがちだが、本番では必ず向き合うことになる問題だ。
「マルチテナント」という言葉は、AWS公式ドキュメントや技術ブログで頻繁に登場する。しかし多くの記事は EKS の namespace 分離、RDS のマルチテナント接続といった個別サービスの話にとどまっている。本番 SaaS を構築・運用するエンジニアが本当に必要なのは、テナント分離・従量計測・自動化・コスト配賦を統合した全体設計の知識だ。

本シリーズは AWS SaaS/マルチテナント本番運用 に特化した実戦ガイドだ。AWS SaaS Builder Toolkit (SBT) が提唱する control plane / application plane の分離思想を軸に、テナント分離・セルベースアーキテクチャ・従量計測・オンボーディング自動化・per-tenant コスト配賦を横断的に解説する。
AWS SaaS Builder Toolkit (SBT) とは
AWS SaaS Builder Toolkit (SBT) は、AWS CDK 上に構築されたオープンソースのフレームワークだ。SaaS 特有の横断的関心事——テナント管理・認証・分離・計測——を first-class constructs として提供し、アプリケーション固有のビジネスロジックと切り離して実装できる。
SBT の最重要概念が control plane / application plane の分離だ。
| レイヤー | 役割 | 代表サービス |
|---|---|---|
| control plane | テナント登録・認証・プロビジョニング・分析・運用管理 | Cognito / DynamoDB / Step Functions / EventBridge |
| application plane | テナントが実際に利用するビジネスロジック | Lambda / ECS / RDS / DynamoDB(テナントデータ) |
control plane はすべてのテナントに共通のインフラを提供する。application plane はテナントの分離モデル(Silo/Pool/Bridge)によって構成が変わる。この分離があることで、新テナントのオンボーディングを control plane イベントとして標準化でき、application plane 側はそのイベントを受け取ってテナントリソースを自動プロビジョニングできる。
従来の「アプリケーションコードにテナント管理をねじ込む」設計と比較すると、SBT アプローチは以下の点で優れている。
- 新テナント追加時の IAM 変更不要: ABAC(属性ベースアクセス制御)によりテナント ID を実行時に解決するため、新テナント追加のたびに IAM ポリシーを更新する必要がない
- オンボーディングの標準化: EventBridge を介した疎結合なイベント連携により、application plane の変更が control plane に影響しない
- tier 別プロビジョニング: SaaS の contract tier(basic/standard/premium)に応じたインフラ構成を CloudFormation テンプレートで管理し、tier アップグレードを自動化できる
SaaS Builder 視点 — なぜ本番でマルチテナント設計が詰まるか
設計段階では綺麗に見えたマルチテナント設計が、本番フェーズで一気に崩れるケースには共通のパターンがある。
① テナント越境バグ
Pool モデルで全テナントが共有 DynamoDB テーブルを使う設計の場合、「WHERE tenant_id = ?」のフィルタを1箇所でも忘れると、別テナントのデータがレスポンスに混入する。設計レビューでは見逃されても、本番負荷が上がった状態でのリクエスト急増時に発覚するケースが多い。
② noisy neighbor 問題
大量データをエクスポートするテナント A の処理が Lambda 関数の同時実行枠を占有し、テナント B・C へのリクエストがタイムアウトする。Pool モデルでは全テナントが同一インフラを共有するため、1テナントの異常動作が全体に波及する。per-tenant の concurrency 制限を設計段階で入れていないと、本番で手戻りが大きい。
③ 新テナント追加のたびに IAM 変更が必要
RBAC(ロールベースアクセス制御)でテナントごとに IAM ポリシーを管理すると、テナント 100 社が 1,000 社になった段階でポリシー管理が破綻する。ポリシーサイズ上限(6,144 文字)に達したり、テナント追加のデプロイパイプラインが複雑化したりする。ABAC への移行が必要になるが、後付けでの移行コストは非常に高い。
④ オンボーディングの属人化
「新規テナントの追加は担当エンジニアが手動で実施」という運用が続くと、スケール時に人的ボトルネックになる。DNS 設定・証明書発行・DB スキーマ作成・IAM ロール作成・Cognito ユーザープール設定——各ステップが手動のまま放置されると、テナント数が 10 を超えた時点でオンボーディングが SLA の律速になる。
⑤ per-tenant コスト可視化の欠如
「マルチテナントで全体コストは把握できているが、テナント単位のコストがわからない」状態が続くと、低利益テナントを特定できず価格改定の判断材料がなくなる。AWS Cost and Usage Report でのコスト配賦設計は後付けが難しく、タグ戦略は初期から組み込む必要がある。
これらの問題に共通するのは、「マルチテナントの横断的関心事をアプリケーションロジックに混在させている」設計だ。SBT の control plane / application plane 分離は、この構造的問題を解決するアーキテクチャパターンを提供する。
SBT アーキテクチャ — EventBridge で疎結合な二層構造
SBT を使った SaaS では、control plane と application plane が EventBridge を介して非同期に連携する。これにより、application plane の実装を変えても control plane は影響を受けない。
【control plane】 【application plane】
┌─────────────────────┐ ┌─────────────────────┐
│ テナント管理 API│ │ テナント A リソース│
│ (Cognito + DynamoDB) │ │ (Lambda / DynamoDB) │
││ ││
│ Provisioning │──EventBridge→│ TenantCreated │
│ Service │◄─EventBridge─│ ProvisioningComplete │
│ (Step Functions) │ ││
││ │ テナント B リソース│
│ Metering│ │ (Lambda / DynamoDB) │
│ (Marketplace API)│ └─────────────────────┘
└─────────────────────┘
EventBridge ルールで「TenantCreated」イベントを application plane の Lambda に配信することで、application plane は新テナントを検知して自動的にリソースをプロビジョニングする。control plane 側はプロビジョニングの詳細を知らなくてよい——application plane の実装が CloudFormation から Terraform に変わっても control plane への影響はゼロだ。
この記事の読者像と学習目標
対象読者:
– AWS 上で SaaS プロダクトを構築・運用しているバックエンドエンジニア・SRE エンジニア
– マルチテナント設計は理解しているが、本番運用フェーズの詰まりポイントを体系的に把握したいエンジニア
– AWS Marketplace 経由の SaaS 提供を検討しているアーキテクト
– Pool 型で始めた SaaS がスケールし、Silo / セル分割への移行を検討している開発者
前提知識:
– AWS IAM の基本(ロール・ポリシー・条件キー)
– DynamoDB のデータモデル(パーティションキー・ソートキー)
– Lambda / API Gateway の基本的な利用経験
– Python / AWS SDK(boto3)の読み書きができること
この記事で得られること:
– Silo/Pool/Bridge 各モデルの選定基準と、IAM ABAC によるテナントコンテキスト境界の実装方法
– PostgreSQL RLS / DynamoDB LeadingKeys / S3 prefix 設計によるデータ分離の実装パターン
– セルベースアーキテクチャで blast radius を最小化するルーティング設計
– AWS Marketplace SaaS metering API による従量課金基盤の構築方法
– control plane / application plane 分離に基づくオンボーディング自動化パターン
– per-tenant コスト配賦と noisy neighbor 対策の実装
旧来のアプローチと SBT アプローチの比較
AWS SaaS Builder Toolkit が解決する課題をより具体的に理解するため、旧来の設計アプローチと比較してみよう。
| 観点 | 旧来アプローチ | SBT アプローチ |
|---|---|---|
| テナント管理 | アプリコードに混在(ミドルウェアや共通ライブラリで管理) | control plane として独立分離 |
| オンボーディング | 手動または独自スクリプト | Step Functions ワークフロー(標準化・自動化) |
| テナント分離 | アプリ層のフィルタ条件(バグリスク大) | IAM ABAC + DB 層分離(多層防御) |
| 新テナント追加 | IAM ポリシー変更 + デプロイが必要 | ABAC により IAM 変更不要(メタデータのみ) |
| 計測・課金 | 独自ログ集計 + 手動報告 | Marketplace metering API(自動集計・直接課金) |
| コスト可視化 | 全体コストのみ把握 | Cost Allocation Tags でテナント単位に配賦 |
| tier 別リソース | コードで分岐(if tier == ‘premium’…) | CloudFormation テンプレート切替(インフラ定義) |
| 障害影響範囲 | 全テナント共通(Pool) か全体(Silo 運用ミス) | セルベースで blast radius を最小化 |
旧来アプローチの最大の問題は、「テナント管理ロジックがアプリケーションと密結合している」点だ。テナント数の増加とともにアプリコードの複雑度が指数的に上昇し、バグの温床になりやすい。SBT アプローチはこの複雑性を control plane / application plane の分離によって構造的に解決する。
SBT は GitHub で公開されており、CDK construct として npm や PyPI からインストールできる。SBT を直接使わない場合でも、その設計思想(control/application plane の分離・EventBridge 連携・ABAC)は AWS Well-Architected SaaS Lens で推奨されているベストプラクティスとして参考にできる。
このシリーズの構成
本シリーズ(AWS SaaS/マルチテナント本番運用)は、Vol1 の分離・計測・オンボーディング基盤から始まり、Vol2 以降でより高度なトピック(マルチリージョン展開・テナントレベル SLO 管理・カオスエンジニアリング等)を扱う予定だ。各 Vol は単独で読んでも実用的な内容を目指している。
本 Vol1 は SaaS 本番運用の「基礎を固める」一冊として、§2〜§8 で実装パターンを網羅的にカバーする。まずテナント分離(§2)で「データが混ざらない状態を保証する」基盤を固め、セルベース(§3)で「障害が広がらない構造」を作り、計測(§4)・オンボーディング(§5)で「ビジネスの自動化」を実現し、コスト配賦(§6)で「テナント単位の収益性把握」を可能にする。§7 の統合パターンと §8 の詰まりポイントで実践的な判断軸を補足する。
Vol1 では §2〜§8 の全章を通じてマルチテナント本番運用の核心を解説します。
2. テナント分離モデル — Silo/Pool/Bridge と IAM ABAC
テナント分離はマルチテナント SaaS 設計の核心だ。AWS Well-Architected SaaS Lens が定義する 3 つのモデル——Silo・Pool・Bridge——を正しく理解し、ユースケース・コスト・規制要件に応じて選択することが本番運用の出発点となる。

2.1 Silo / Pool / Bridge 選定フレームワーク
Silo モデル — 完全分離
Silo モデルは各テナントに専用のインフラスタックを提供する。RDS インスタンス・DynamoDB テーブル・Lambda 関数・API Gateway ステージがすべてテナント専用となる。
テナント A: [VPC-A] → [RDS-A] → [Lambda-A] → [API-A]
テナント B: [VPC-B] → [RDS-B] → [Lambda-B] → [API-B]
テナント C: [VPC-C] → [RDS-C] → [Lambda-C] → [API-C]
Silo を選ぶべきケース:
– 規制産業(金融・医療・官公庁)でデータの物理分離が契約要件になっている
– エンタープライズ顧客が「他社テナントとリソースを共有しない」ことを要求する
– テナント単位での SLA(可用性・性能)保証が必要
– テナントごとのカスタムポリシー・設定変更が頻繁に発生する
Silo のトレードオフ:
| 利点 | 課題 |
|---|---|
| テナント間の物理分離でデータ漏洩リスク最小 | テナント数 × インフラコストが線形増加 |
| テナント固有の設定変更が容易 | オンボーディングに時間がかかる(スタック作成) |
| 障害の blast radius がテナント単位に限定 | 運用負荷が高い(スタック数 × 監視・パッチ) |
| テナント単位の性能最適化が自由 | 小規模テナントでもコスト固定(アイドルリソース) |
Pool モデル — 共有インフラ
Pool モデルは共有インフラ上で全テナントを処理する。DynamoDB テーブルを全テナントで共有し、パーティションキーにテナント ID を含める設計が典型例だ。
全テナント → [共有 ALB] → [共有 Lambda Fleet] → [共有 DynamoDB]
├─ PK: TENANT#tenant-a
├─ PK: TENANT#tenant-b
└─ PK: TENANT#tenant-c
Pool を選ぶべきケース:
– SaaS スタートアップ段階でコスト最小化が最優先
– テナント間のデータ分離要件が法的・契約的に緩い(一般向け B2C SaaS 等)
– 大量の小規模テナント(数千〜数万単位)を低コストで捌く必要がある
– スケールアウトによる性能向上を共有インフラで実現したい
Pool のトレードオフ:
| 利点 | 課題 |
|---|---|
| インフラコストをテナント数で割り算できる | noisy neighbor 問題が発生しやすい |
| 新テナント追加が瞬時(メタデータ追加のみ) | テナント越境バグのリスクが高い |
| スケールアウトが全テナントに均等に適用 | エンタープライズ顧客の要求を満たせないことがある |
| 管理・監視対象リソース数が最小 | テナント単位の性能保証が困難 |
Bridge モデル — 混在設計
Bridge モデルは Silo と Pool を組み合わせる。高価値テナントは Silo、スモールビジネステナントは Pool という tier 別の分離を実現する。
Enterprise テナント: [専用 VPC] → [専用 RDS] → [専用 Lambda] ← Silo
SMB テナント群:[共有 VPC] → [共有 RDS] → [共有 Lambda] ← Pool
Bridge を選ぶべきケース:
– SaaS ビジネスが成長フェーズに入り、エンタープライズ顧客の獲得を目指している
– Premium tier は Silo 分離を保証しつつ、Basic tier のコストを Pool で抑えたい
– 当初 Pool で始めて、重要テナントを段階的に Silo に移行するロードマップを持つ
モデル選定早見表:
| 観点 | Silo | Pool | Bridge |
|---|---|---|---|
| 初期コスト | 高(テナント数×固定費) | 低(共有インフラ) | 中 |
| 規制対応 | ◎(物理分離) | △(論理分離のみ) | ◯(tier 選択) |
| テナント数スケール | × | ◎ | ◯ |
| 運用複雑度 | 高 | 低 | 中〜高 |
| エンタープライズ顧客獲得 | ◎ | × | ◎ |
| blast radius 限定 | ◎(テナント単位) | △(全テナント共有) | ◯ |
2.2 データ分離実装パターン
テナント分離モデルを選んだ後、実際のデータ層でどう分離を実装するかが実装の核心となる。
行レベル分離 — PostgreSQL RLS
PostgreSQL の Row Level Security (RLS) は、同一テーブル内でテナントごとにアクセス可能な行を自動フィルタリングするデータベース機能だ。Pool モデルで PostgreSQL を採用する場合の標準的な分離手法となる。
-- テーブル作成: tenant_id カラムを必ず持つ
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_idTEXT NOT NULL,
order_data JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- インデックス: tenant_id を先頭にした複合インデックスが必須
CREATE INDEX idx_orders_tenant ON orders (tenant_id, created_at DESC);
-- RLS を有効化
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- ポリシー定義: 現在のアプリユーザーが持つ tenant_id と一致する行のみアクセス可
CREATE POLICY tenant_isolation_policy ON orders
USING (tenant_id = current_setting('app.current_tenant_id'));
-- アプリ側: リクエスト処理開始時にセッション変数を設定
-- (Python 例)
-- conn.execute("SET app.current_tenant_id = %s", [tenant_id])
RLS ポリシーを設定すると、SET app.current_tenant_id = 'tenant-abc' を実行したセッションは、tenant_id = 'tenant-abc' の行しか見えなくなる。アプリケーションコードにフィルタ条件を書き忘れても RLS がデータベース層でブロックする。
RLS 実装の詰まりポイント:
-- 誤り: スーパーユーザー(postgres)は RLS をバイパスする
-- 本番では専用のアプリロールを作成し、スーパーユーザー権限を与えない
CREATE ROLE saas_app_user LOGIN PASSWORD 'secure_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO saas_app_user;
-- スーパーユーザーには BYPASSRLS 権限があるため、本番 DB 接続にはアプリ専用ロールを使う
-- 確認: RLS が有効か確認
SELECT schemaname, tablename, rowsecurity FROM pg_tables
WHERE tablename = 'orders';
-- rowsecurity = true が正常
-- 確認: 現在のポリシー一覧
SELECT * FROM pg_policies WHERE tablename = 'orders';
行レベル分離 — DynamoDB LeadingKeys
DynamoDB では パーティションキーの先頭にテナント ID を含める設計(LeadingKeys パターン)と、IAM の dynamodb:LeadingKeys 条件キーの組み合わせでテナント分離を実現する。
import boto3
from boto3.dynamodb.conditions import Key
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('saas-shared-orders')
# 書き込み: PK に必ずテナント ID を含める
def put_order(tenant_id: str, order_id: str, data: dict):
table.put_item(Item={
'PK': f'TENANT#{tenant_id}',# パーティションキー
'SK': f'ORDER#{order_id}',# ソートキー
'tenantId': tenant_id,
'orderData': data
})
# 読み取り: テナント ID でクエリ(スキャン禁止)
def get_orders(tenant_id: str) -> list:
response = table.query(
KeyConditionExpression=Key('PK').eq(f'TENANT#{tenant_id}')
)
return response['Items']
TENANT#tenant-abc というプレフィックスにより、テナントの全データが同一パーティションに集まる。クエリ時は必ず PK = TENANT#{tenant_id} を条件にするため、他テナントのデータに誤ってアクセスすることを防げる。
Scan 操作の禁止を IAM で強制する:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query"
],
"Resource": "arn:aws:dynamodb:ap-northeast-1:ACCOUNT:table/saas-shared-orders",
"Condition": {
"ForAllValues:StringLike": {
"dynamodb:LeadingKeys": ["TENANT#${aws:PrincipalTag/TenantId}"]
}
}
}
]
}
dynamodb:LeadingKeys 条件キーは、クエリのパーティションキー値がポリシーの条件と一致する場合のみ許可する。${aws:PrincipalTag/TenantId} で実行時にテナント ID を解決するため、新テナント追加時にポリシー変更は不要だ。
スキーマ分離 — PostgreSQL schemas per tenant
各テナントに PostgreSQL スキーマ(名前空間)を割り当てる方式だ。テーブルは同一 DB サーバー内に存在するが、スキーマが異なるため論理的に分離される。
-- テナントごとにスキーマ作成
CREATE SCHEMA tenant_abc;
CREATE SCHEMA tenant_xyz;
-- 各スキーマにテーブルを作成
CREATE TABLE tenant_abc.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_data JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE tenant_xyz.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_data JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- テナント専用ロール作成
CREATE ROLE tenant_abc_role LOGIN;
GRANT USAGE ON SCHEMA tenant_abc TO tenant_abc_role;
GRANT ALL ON ALL TABLES IN SCHEMA tenant_abc TO tenant_abc_role;
-- アプリ側: 接続時に search_path を設定
-- SET search_path = tenant_abc;
スキーマ分離は RLS よりも強い分離境界を提供する。誤ったクエリが他スキーマにアクセスするにはスキーマ名を明示する必要があり、アプリのバグによる越境リスクが低い。
スキーマ分離のトレードオフ:
| 利点 | 課題 |
|---|---|
| 論理分離が強固(スキーマ名前空間) | テナント数増加でスキーマ数も増加 |
| テナント固有インデックス・制約が自由 | スキーマ一覧管理が複雑になる |
| マイグレーション管理が独立 | 全テナントへのマイグレーション適用が煩雑 |
DB per tenant — RDS インスタンス分離(Silo モデル)
最も強い分離。各テナントが専用の RDS インスタンスを持つ。コスト最大だが、規制要件を満たす唯一の方法となるケースがある。
import boto3
rds = boto3.client('rds', region_name='ap-northeast-1')
def provision_tenant_db(tenant_id: str, db_class: str = 'db.t3.medium') -> str:
response = rds.create_db_instance(
DBInstanceIdentifier=f'saas-{tenant_id}-db',
DBInstanceClass=db_class,
Engine='postgres',
EngineVersion='15.4',
MasterUsername='saas_admin',
MasterUserPassword='<secret_from_secrets_manager>',
DBName=tenant_id,
VpcSecurityGroupIds=['sg-xxxxxxxxxx'],
DBSubnetGroupName='saas-db-subnet-group',
BackupRetentionPeriod=7,
StorageType='gp3',
StorageEncrypted=True,
Tags=[
{'Key': 'TenantId', 'Value': tenant_id},
{'Key': 'ManagedBy', 'Value': 'saas-provisioner'}
]
)
return response['DBInstance']['DBInstanceArn']
DB per tenant の場合、暗号化キーもテナントごとに分けることでデータの強い分離を実現できる(per-tenant 暗号化は KMS コスト増に注意)。
S3 prefix 設計
S3 でのテナントデータ分離は bucket prefix でテナント ID を先頭に置く設計が基本だ。
s3://saas-data-bucket/
├── tenant-abc/
│├── uploads/2025-01/
││├── file-001.pdf
││└── file-002.csv
│└── exports/2025-01/
│ └── report-2025-01.xlsx
├── tenant-xyz/
│├── uploads/2025-01/
│└── exports/2025-01/
IAM ポリシーで prefix を強制する:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::saas-data-bucket/${aws:PrincipalTag/TenantId}/*",
"arn:aws:s3:::saas-data-bucket"
],
"Condition": {
"StringLike": {
"s3:prefix": ["${aws:PrincipalTag/TenantId}/*"]
}
}
}
]
}
${aws:PrincipalTag/TenantId} により、Lambda 実行ロールのタグに設定されたテナント ID のプレフィックス配下のみアクセスが許可される。テナント A の Lambda が誤ってテナント B の prefix にアクセスしようとすると、IAM が拒否する。
2.3 IAM ABAC によるテナントコンテキスト境界
IAM でテナント分離を実装する場合、従来の RBAC(ロールベースアクセス制御)には大きな制約がある。「テナントを追加するたびに IAM ポリシーを更新しなければならない」問題だ。
ABAC(属性ベースアクセス制御)はこの問題を解決する。ポリシーにテナント ID をハードコードする代わりに、IAM プリンシパルのタグ(属性)を使って実行時にアクセス可否を判定する。
aws:PrincipalTag によるテナント ID 実行時解決
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedDynamoDBAccess",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query"
],
"Resource": "arn:aws:dynamodb:ap-northeast-1:ACCOUNT:table/saas-data",
"Condition": {
"ForAllValues:StringLike": {
"dynamodb:LeadingKeys": ["${aws:PrincipalTag/TenantId}*"]
}
}
},
{
"Sid": "TenantScopedS3Access",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::saas-data-bucket/${aws:PrincipalTag/TenantId}/*"
}
]
}
このポリシーを Lambda 実行ロールに設定し、ロールに TenantId: tenant-abc タグを付与すると、そのロールは TENANT#tenant-abc プレフィックスの DynamoDB アイテムと tenant-abc/ S3 prefix にのみアクセスできる。新テナントを追加しても IAM ポリシーの変更は不要だ。
ロールへのタグ付与(プロビジョニング時):
import boto3
iam = boto3.client('iam')
def create_tenant_execution_role(tenant_id: str) -> str:
role = iam.create_role(
RoleName=f'saas-tenant-{tenant_id}-role',
AssumeRolePolicyDocument=json.dumps({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}),
Tags=[
{'Key': 'TenantId', 'Value': tenant_id},
{'Key': 'ManagedBy', 'Value': 'saas-provisioner'}
]
)
# 共通 ABAC ポリシーをアタッチ(全テナント共通の1ポリシーで全テナントに対応)
iam.attach_role_policy(
RoleName=f'saas-tenant-{tenant_id}-role',
PolicyArn='arn:aws:iam::ACCOUNT:policy/saas-tenant-abac-policy'
)
return role['Role']['Arn']
Silo モデルではテナントごとに専用 Lambda 関数・専用ロールを持つ。Pool モデルでは共有 Lambda が実行時に STS でテナントスコープのロールに AssumeRole することでテナント境界を実現する。
STS AssumeRoleWithWebIdentity + JWT 方式
フロントエンド(モバイルアプリ・SPA)から直接 AWS リソースにアクセスするアーキテクチャでは、Cognito User Pool の JWT を STS に提示し、テナントスコープの一時クレデンシャルを取得する方式が有効だ。
import boto3
import json
sts = boto3.client('sts', region_name='ap-northeast-1')
def get_tenant_credentials(cognito_token: str, tenant_id: str) -> dict:
"""
Cognito JWT を STS に提示してテナントスコープのクレデンシャルを取得。
JWT の custom:tenantId クレームと tenant_id が一致することを Cognito 側で保証。
"""
response = sts.assume_role_with_web_identity(
RoleArn='arn:aws:iam::ACCOUNT:role/saas-tenant-app-role',
RoleSessionName=f'tenant-{tenant_id}-session',
WebIdentityToken=cognito_token,
DurationSeconds=3600,
# セッションタグでテナント ID を付与 → ABAC ポリシーが解決
Tags=[
{'Key': 'TenantId', 'Value': tenant_id}
]
)
return response['Credentials']
STS が返す一時クレデンシャルには TenantId セッションタグが付与される。このクレデンシャルを使って DynamoDB / S3 にアクセスすると、先述の ABAC ポリシーがテナント ID を aws:PrincipalTag/TenantId として解決し、自テナントのデータのみアクセスを許可する。
JWT の aws.amazon.com/tags クレームを使った方式(Cognito Identity Pool):
Cognito Identity Pool を使う場合、User Pool の JWT カスタム属性を自動的に IAM セッションタグにマッピングできる。
// Cognito User Pool カスタム属性
{
"custom:tenantId": "tenant-abc",
"custom:tenantTier": "premium"
}
Identity Pool の「認証済みロール」に sts:TagSession を許可し、PrincipalTags マッピングを設定すると、custom:tenantId が自動的に IAM セッションの TenantId タグになる。アプリケーション側でタグを手動設定する必要がなく、Cognito が発行した JWT の内容がそのままテナントコンテキストになる。
2.4 詰まりポイント — Pool 分離でのテナント越境リスク
Pool モデルの最大のリスクはテナント越境バグだ。アプリケーションコードが WHERE 句にテナント ID を含め忘れると、別テナントのデータが見えてしまう。
よくあるバグパターン:
# 危険: テナント ID フィルタなし(全テナントのデータが返る)
def get_all_orders():
response = table.scan() # Scan は絶対禁止
return response['Items']
# 危険: テナント ID を動的に取得できていない
def get_orders(tenant_id: str = None):
if not tenant_id:
tenant_id = "default" # デフォルト値で誤動作
...
防御策: テナント ID を必須パラメータとする設計:
from dataclasses import dataclass
@dataclass
class TenantContext:
tenant_id: str
def __post_init__(self):
if not self.tenant_id or not self.tenant_id.startswith('tenant-'):
raise ValueError(f"Invalid tenant_id: {self.tenant_id}")
def get_orders(ctx: TenantContext) -> list:
# TenantContext が必ず渡されるため、テナント ID 忘れはコンパイルエラーに近い
response = table.query(
KeyConditionExpression=Key('PK').eq(f'TENANT#{ctx.tenant_id}')
)
return response['Items']
IAM ABAC のスケール限界:
ABAC ポリシーのサイズ制限(IAM ポリシーの最大サイズは 6,144 文字)やセッションタグの最大数(50 タグ)には注意が必要だ。テナントに複数の属性(tier・region・compliance-level 等)を付与する場合、タグ数が上限に近づく可能性がある。また、条件キーが増えるほど IAM の評価処理が複雑になりレイテンシに影響する場合もある。
大規模な SaaS(テナント数 10 万以上)では ABAC だけでなく、テナント ID を API ゲートウェイで検証し Lambda に渡すアプリケーション層のテナント検証と組み合わせた多層防御が実践的だ。
Pool + ABAC 実装の鉄則
- DynamoDB Scan を IAM で禁止する:
dynamodb:Scanを IAM ポリシーで許可しない。Scan はテナント ID フィルタが効かないため Pool 分離の抜け穴になる - RLS のスーパーユーザーバイパスに注意: PostgreSQL RLS はスーパーユーザーをバイパスする。本番 DB 接続には必ずアプリ専用ロール(非スーパーユーザー)を使う
- テナントコンテキストをスレッドローカルに置かない: 非同期処理(asyncio / threading)でテナントコンテキストがリクエスト間で混在するバグが多発。コンテキストは必ず関数パラメータとして渡す
- 移行前の分離テスト: Pool から Silo への移行前に、テナント A のクレデンシャルでテナント B のデータにアクセスできないことを自動テストで検証する。移行後にリグレッションが起きやすい箇所だ
3. セルベースアーキテクチャ — blast radius 削減

3.1 セルベースアーキテクチャの設計原則
セルベースアーキテクチャは、SaaSシステムを「セル」と呼ぶ独立したレプリカ群に分割する設計思想です。各セルはコンピュート・データベース・キャッシュ・ネットワーク層を独自に持ち、自己完結した単位として機能します。設計の本質は 単一セルの障害が隣接セルへ波及しない 障害隔離(fault isolation)にあります。
従来のモノリシックなマルチリージョン展開では、デプロイバグやデータ破損が全テナントへ即時伝播するリスクがあります。セルアーキテクチャは障害の物理的な封じ込め(containment)を設計レベルで保証します。あるセルでメモリリークが発生しても、他のセルは正常に稼働し続けます。
AWS Well-Architected SaaS Lensでは、セルベースアーキテクチャを「高負荷・高可用性要件のSaaSに適した分割パターン」として明示しています。エンタープライズ向けSaaS(SLA 99.99%以上を要求される)や、特定テナントへの過剰負荷が他テナントに影響するnoisy neighbor問題が深刻な場合に特に有効です。
AWS上での実装では、各セルはVPC、ECS/EKSクラスター、RDSインスタンス(またはAurora)、ElastiCacheクラスターをそれぞれ独立して持ちます。セル間のリソース共有は原則として行いません。
3.2 cell分割設計の判断基準
セルをどのサイズ・数で設計するかは、サービスの成長段階と要件によって変化します。テナント数・負荷・地理要件の三軸で判断します。
テナント数軸
1セルあたりのテナント数上限を定めることがセル設計の出発点です。実績上の目安として、SMB向けSaaSでは1セルあたり100〜300テナント、エンタープライズ向けでは1セル1テナント(Silo構成)が採用されます。
しかし「テナント数」だけでセル容量を決めるのは危険です。テナントごとの活性度(1日のアクティブユーザー数、API呼び出し頻度、データ処理量)を加味した 加重テナント数 を指標にします。軽量テナント10社と重量テナント1社を同等に扱うと、セルが期待より早く飽和します。
大口テナント(エンタープライズ層)には専用セルを割り当て、SMB/スタートアップ層は複数テナントを1セルに集約するという段階的設計が一般的です。SaaS Lensの「Silo/Pool/Bridge」モデルと対応させると、Bridge構成(重要テナントSilo化+残りPool)とセルアーキテクチャは自然に組み合わさります。
負荷軸
セルの適切なサイズはコンピュート(CPU/メモリ)とデータストア(IOPS/スループット/ストレージ)の両面で決まります。各セルのリソース使用率をCloudWatchカスタムメトリクスで継続的に計測し、設定閾値(例:CPU平均60%)を超えたテナント群を別セルへ再割当てする自動化機構を組み込みます。
新規テナント登録時の判断フローは次のとおりです。
新規テナント登録
→ 全セルの加重使用率を取得 (DynamoDB scan または CloudWatch メトリクス)
→ 最も使用率が低いセルを選択
→ 使用率が閾値以下 → 既存セルへ追加 → ルーティングテーブルに登録
→ 使用率が全セルで閾値超え → 新規セルを自動起動 → テナント割当
地理要件軸
データレジデンシー規制(EUのGDPRにおけるEU域内保存義務など)がある場合、規制対象テナントをリージョン限定セルへ割り当てる必要があります。セルとリージョンの対応をルーティングテーブルに持つことで、ルーティング層でコンプライアンスを自動担保できます。
マルチリージョン展開では各リージョンに独立したルーティング層を設けるか、Route 53 geolocation routingとセルルーティングを組み合わせる設計が実践的です。地理要件を持つテナントは登録時にリージョンを指定させ、対応セルに自動割当します。
3.3 blast radius削減の数学的保証
N個のセルが均等にテナントを分担している場合、単一セルの完全停止が与える最大影響は次の式で表されます。
影響テナント数の最大割合 = 1/N
例:
5セル構成 → 最大20%のテナントが影響
10セル構成 → 最大10%のテナントが影響
20セル構成 → 最大5%のテナントが影響
10セル構成で1セルが完全停止した場合でも、残り9セルで90%のリクエストを処理し続けます。この上限は設計時に数学的に保証できるため、テナントへのSLA提示において強力な根拠となります。「インフラ障害時でも最大10%のテナントのみが影響を受ける」という数値は、エンタープライズ向けの販売・契約において大きな差別化ポイントになります。
blast radiusを広げるアンチパターン
最も避けるべきは、セル間で共有コンポーネントを持つことです。よくある失敗例を挙げます。
- 共有セッションストア: 全テナント共用のElastiCache Redisが停止すると、全セルのセッションが無効化される
- 共有認証エンドポイント: 中央の認証サービスが障害を起こすと全テナントがログイン不能になる
- 共有ログ集約先: 共有のKinesisストリームが詰まると全セルのログが欠落する
コントロールプレーン(テナント管理・請求・管理者コンソール)は中央管理層として共有を許容しますが、データプレーン(テナントのAPIリクエスト処理)は各セルで完全独立させます。
セルの均等化(balancing)
テナントの加入・脱退・成長により、セル間の偏りが生じます。定期的なbalancingジョブ(例:週次のLambda実行)でセル間のテナント数・負荷を確認し、閾値超えのセルからテナントを移行するバックグラウンド処理を組み込みます。balancingをオンデマンドのみとすると、気づかないうちにホットセルが形成されてblast radiusの保証が崩れます。
3.4 セルルーティング実装
DynamoDBルーティングテーブル設計
セルルーティングの中核はDynamoDBに保持するルーティングテーブルです。以下のデータモデルが実績上のベースラインです。
{
"PK": "TENANT#acme-corp",
"SK": "ROUTING",
"cell_id": "cell-us-east-1-03",
"alb_dns": "cell-03-alb-xxxx.us-east-1.elb.amazonaws.com",
"status": "active",
"tier": "enterprise",
"region": "us-east-1",
"updated_at": "2026-06-01T00:00:00Z"
}
パーティションキーをテナントIDにすることで、O(1)の単一レコードルックアップが実現します。DynamoDB DAX(インメモリキャッシュ)を前段に配置することで、P99レイテンシをマイクロ秒オーダーに抑えられます。ルーティングテーブルはread-heavy(書き込みはテナント登録・移行時のみ)なので、DAXのキャッシュヒット率は高く保てます。
cell routerの処理フロー
1. クライアントからAPIリクエスト受信 (Authorization: Bearer <JWT>)
2. JWTシグネチャ検証 → tenantId クレーム抽出
3. DAX → DynamoDB: GetItem("TENANT#<tenantId>", "ROUTING")
4. cell_id / alb_dns / status 取得
5. status == "active" を確認
6. 対象セルのALBへリクエストフォワード (X-Tenant-ID: <tenantId> ヘッダー付与)
7. ALBレスポンスをクライアントへ返却
セルがdrainingまたはofflineの場合は503を返すのではなく、メンテナンス専用のスタブサービスへ誘導し「一時的にご利用いただけません」を返す設計が推奨です。突然の接続断よりグレースフルな応答の方がテナント体験として優れます。
cell routerの実装選択
| 実装パターン | 特徴 | 適するケース |
|---|---|---|
| Amazon API Gateway + Lambda オーソライザー | マネージドで運用負荷低 | レイテンシ許容度が高い(追加数十ms) |
| ALB + Lambda ターゲット | カスタムロジックを組みやすい | 柔軟なルーティング条件が必要 |
| Envoy Proxy (ECS/EKS上) | 超低レイテンシ・高スループット | レイテンシ感応度が高いAPI |
| Lambda@Edge | エッジでのルーティング | CDN経由のグローバルアクセス |
セル状態管理
DynamoDBルーティングテーブルにセルのステータスを持たせ、cell routerはステータスを確認してからフォワードします。
| ステータス | 意味 | cell routerの動作 |
|---|---|---|
| active | 通常稼働 | 通常フォワード |
| maintenance | 計画メンテナンス中 | メンテナンス応答を返却 |
| draining | 移行中(既存接続維持) | 新規リクエストは移行先セルへ |
| offline | 停止中 | フォールバックセルへ誘導 |
ヘルスチェック失敗時の自動ステータス切り替えをEventBridgeとLambdaで実装し、人手介入なしのblast radius制御を実現します。Lambda関数はCloudWatchアラームをトリガーとして、DynamoDBのstatusフィールドを原子的に更新します。
ALBへのフォワード実装例
import boto3
def route_request(tenant_id: str, request_path: str, headers: dict):
item = dax_client.get_item(
TableName='tenant-routing',
Key={
'PK': {'S': f'TENANT#{tenant_id}'},
'SK': {'S': 'ROUTING'}
}
)['Item']
status = item['status']['S']
if status != 'active':
raise ServiceUnavailableError(f"Cell {item['cell_id']['S']} is {status}")
alb_dns = item['alb_dns']['S']
target_url = f"https://{alb_dns}{request_path}"
forward_headers = {**headers, 'X-Tenant-ID': tenant_id}
return forward_to_cell(target_url, forward_headers)
3.5 Route 53 weighted routingとの組合せ
セルレベルのトラフィック制御にRoute 53 weighted routingを組み合わせることで、ブルーグリーンデプロイや段階的なトラフィック移行が可能になります。
新セル追加時の段階的トラフィック導入
初期状態:
cell-01-alb → weight: 50
cell-02-alb → weight: 50
cell-03-alb → weight: 0(新セル、本番流入なし)
段階的増加:
Step 1: cell-03 weight = 10
Step 2: 監視15分、問題なければ weight = 25
Step 3: 監視15分、問題なければ weight = 50 (均等化完了)
問題発生時は即座にweightを0へ戻します。影響範囲がカナリアセル(weight=10)に限定されているため、最大10%のテナントのみが影響を受けます。
Route 53とDynamoDBの使い分け
| 制御方法 | 反映速度 | 用途 |
|---|---|---|
| DynamoDBステータス更新 | 即時(次リクエストから) | 緊急時のセル切り離し・テナント単位の移行 |
| Route 53 weighted routing | DNS TTL依存(最大数十秒〜数分) | セル間の大まかな負荷配分・新セル段階投入 |
「大まかなセル間負荷分散はRoute 53、緊急・テナント単位の制御はDynamoDB」という使い分けが実践的です。Route 53はDNS TTLの影響を受けるため即時制御には不向きです。
各セルのALBエンドポイントにRoute 53ヘルスチェックを設定し、失敗時に自動フェイルオーバーします。ヘルスチェックインターバルはFast(10秒)か標準(30秒)を要件に応じて設定します。
3.6 段階的デプロイ戦略
セルアーキテクチャの最大のメリットの一つが、デプロイリスクの制御です。全テナントへ一斉デプロイするのではなく、1セルずつ順次適用することで、障害があってもそのセルの範囲に留めます。
デプロイフェーズ設計
Phase 1 — カナリアセル (cell-01)
対象: 全テナントの 1/N(10セルなら約10%)
監視時間: 15〜30分
監視項目: HTTP 5xx エラー率 / P99レイテンシ / ビジネスKPI
Phase 2 — 小規模展開 (cell-02〜cell-03)
対象: 全テナントの累計 20〜30%
監視時間: 30分
Phase 3 — 全セル展開 (cell-04〜cell-N)
対象: 残りの全テナント
監視時間: 60分
カナリアセルには実トラフィックが流れている本番セルを選択します。ステージング環境での成功は本番での成功を保証しないため、本番のカナリアセルで確認する原則を崩しません。
自動rollbackトリガー
CodePipelineのステージ間にCloudWatchアラームを組み込み、以下の条件でパイプラインを自動停止・ロールバックします。
アラーム条件(いずれか一つでもTRUEになると停止):
- HTTP 5xx エラー率 > 1%(5分間平均)
- P99 レイテンシ > 基準値の 150%
- カスタムメトリクス: テナントエラーイベント数 > 閾値
ロールバックは影響セルのみに限定されるため、他セルは新バージョンのまま稼働を継続します。全セル同時デプロイと比較して、ロールバックコスト(再デプロイ時間)が1/Nになります。
「デプロイ完了」は「全セルへの展開 + 最終監視期間(1時間)の経過 + アラートなし」とします。「展開完了」と「安定確認」の2段階で定義することで、問題の後発検知への対処が可能になります。
3.7 セル間移行(テナント再割当)パターン
テナントのティアアップグレード、セルの負荷超過、地理要件の変化が生じると、テナントを別セルへ移行する必要があります。これは実装上の難所の一つです。
ゼロダウンタイム移行の基本フロー
Step 1: 移行先セルへの全データコピー開始(バックグラウンドジョブ)
Step 2: DynamoDB Streamsで差分をリアルタイムレプリケーション
Step 3: 旧セルをdrainingモードへ変更(新規リクエストを移行先へ)
Step 4: 最終的な差分レプリケーションが完了するまで待機(通常数秒〜数分)
Step 5: ルーティングテーブルを原子的に更新(移行先セルのalb_dnsへ切り替え)
Step 6: 旧セルからテナントデータを削除(非同期・慌てない)
Step 5がカットオーバーポイントです。DynamoDBのConditionExpression付きUpdateItemを使うことで、「現在のcell_idが旧セルである場合にのみ更新する」という原子的操作が可能です。
table.update_item(
Key={
'PK': f'TENANT#{tenant_id}',
'SK': 'ROUTING'
},
UpdateExpression='SET cell_id = :new_cell, alb_dns = :new_alb, #st = :active',
ConditionExpression='cell_id = :old_cell',
ExpressionAttributeNames={'#st': 'status'},
ExpressionAttributeValues={
':new_cell': new_cell_id,
':new_alb': new_alb_dns,
':active': 'active',
':old_cell': old_cell_id
}
)
移行中のwrite整合性
移行中に旧セルへ書き込みが発生すると、移行先へのレプリケーション順序が乱れる可能性があります。書き込み系APIのリクエストをSQSへ一時退避して移行完了後に再実行するか、書き込み系エンドポイントを移行先セルへ先行リダイレクトする方式で整合性を確保します。
テナント移行の自動化
移行を手動で実施するとヒューマンエラーのリスクが高まります。Step Functions を使ったステートマシンで移行フローを自動化し、各ステップの完了確認・エラーハンドリング・補正処理(Compensating transaction)を組み込みます。移行失敗時は旧セルをactiveに戻す補正ステップで自動ロールバックします。
3.8 §3 詰まりポイント集
セル数が少なすぎるとblast radiusが大きくなり、多すぎると運用オーバーヘッド(監視・デプロイ・コスト管理の全てがN倍)が増大します。初期設計で「1セルあたりの加重テナント数上限」と「スケール閾値(何テナントで新セルを追加するか)」を文書化します。後から変更する際のデータ移行コストは非常に高く、設計変更に追い込まれた事例が多数報告されています。
「コスト削減のため」という理由でセル間に共有ElastiCacheや共有メッセージキューを持つと、そのコンポーネント障害時のblast radiusが全テナントへ拡大します。コントロールプレーンは共有を許容しますが、データプレーンは各セルで完全独立を維持してください。共有コンポーネントを発見したら「blast radiusの保証を破っている」という認識を持ち、原則として除去します。
「ルーティングテーブルを書き換えるだけ」という認識が最もよくある設計ミスです。実際にはデータレプリケーション・カットオーバー整合性・移行中の書き込み処理・ロールバック手順のすべてを実装する必要があります。移行Playbookを事前に作成し、ステージング環境で少なくとも1回リハーサルを実施してから本番移行に臨んでください。
時間節約のためにカナリアフェーズを省略して全セルへ一斉デプロイすると、問題発生時のblast radiusが最大(全テナント)になります。セルアーキテクチャの段階デプロイを「オプション」と捉えずに「必須の安全装置」として組織のデプロイポリシーへ明文化します。
4. 従量計測・課金 — Marketplace SaaS metering

4.1 AWS Marketplace SaaS meteringの概要
AWS Marketplaceを通じてSaaSを販売する場合、使用量の計測と報告はAWS Marketplace Metering Serviceを介して行います。SaaSアプリケーションは使用量を定期的にAPIで申告し、AWSがテナントへの請求を代行します。これによりSaaSベンダーは請求システムを自社で構築する必要がなく、AWS Marketplaceのエコシステム(数十万の購入者)へのアクセスを得られます。
Metering Serviceのエンドポイントはmetering.marketplace.us-east-1.amazonaws.com(グローバルエンドポイント)です。APIはリージョン関係なくus-east-1の単一エンドポイントへ送ります。
計測に必要な要素は次のとおりです。
- ProductCode: Marketplace登録時に付与される製品識別子
- CustomerIdentifier: テナントの購入者識別子(ResolveCustomer APIで取得)
- Dimension: 計測する使用量の単位(GB、API calls、ホスト数など)
- Quantity: 計測期間内の使用量
4.2 SaaS subscriptions vs SaaS contracts の違い
AWS MarketplaceでのSaaS販売形態は2種類あり、metering APIの使い方が異なります。
SaaS subscriptions(サブスクリプション型)
テナントが使った分だけ計測し、AWSがテナントへ従量請求します。使用量に応じて請求額が変動します。すべての使用量を漏れなく申告する責任がSaaSベンダー側にあります。申告漏れは収益損失に直結します。
請求フロー:
テナント使用 → SaaSアプリがBatchMeterUsage API呼び出し
→ AWS Marketplace が使用量を集計 → 月次でテナントへ請求
SaaS contracts(コントラクト型)
テナントが事前に使用量(entitlement)を契約購入します。契約量の範囲内は追加請求なし、超過分のみBatchMeterUsageで申告します。entitlement(購入済み使用量)はGetEntitlements APIで確認します。
請求フロー:
テナントが事前契約 (例: 100ホスト/月 を購入)
→ 100ホスト以内の使用は計測不要
→ 100ホスト超過分のみ BatchMeterUsage で申告
→ 超過分についてAWSが追加請求
選択の指針
- SaaS subscriptions: 使用量予測が難しいユーザー向け、PLG(Product-Led Growth)で裾野を広げる場合
- SaaS contracts: エンタープライズ向け、予算確定型の調達プロセスに合わせる場合
多くの成熟したSaaSは両形態を並行提供し、テナントが選択できるようにしています。コントラクト型ではentitlement確認とover-usage計測の両方を実装します。
4.3 ResolveCustomer API — テナント登録時の接続
テナントがAWS Marketplaceを通じてSaaSを購入すると、Marketplaceはテナントをx-amzn-marketplace-token付きでSaaSのランディングページへリダイレクトします。SaaSアプリはこのトークンをResolveCustomer APIで解決し、テナント情報を取得します。
APIシグネチャ
import boto3
client = boto3.client('marketplace-metering', region_name='us-east-1')
response = client.resolve_customer(
RegistrationToken='x-amzn-marketplace-token の値'
)
# レスポンス例
# {
#'CustomerIdentifier': 'CUSTOMER_ID_xxxx',
#'CustomerAWSAccountId': '123456789012',
#'ProductCode': 'PRODUCT_CODE_xxxx'
# }
取得したCustomerIdentifierはテナントレコードに紐付けて保存します。以後のBatchMeterUsage呼び出しでこのIDを使います。
IAM権限
ResolveCustomer APIを呼び出すIAMポリシーに次の権限を付与します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "aws-marketplace:ResolveCustomer",
"Resource": "*"
}
]
}
エラーハンドリング
ResolveCustomerが失敗するケースは主に以下のとおりです。
ExpiredTokenException: トークン有効期限切れ(15分程度)。テナントにMarketplaceからのリンクをクリックし直すよう案内しますInvalidTokenException: 不正なトークン。直接URLアクセスや改ざんの場合に発生しますDisabledApiException: 本番未公開の製品でのAPI呼び出し。開発時は注意が必要です
4.4 BatchMeterUsage API — 使用量申告
BatchMeterUsage APIはSaaSアプリケーションが使用量をMarketplaceへ申告するための主要なAPIです。
APIの制約
| 制約項目 | 値 |
|---|---|
| 1回の呼び出しで送れるUsageRecord数 | 最大25件 |
| Usage recordの有効期間 | イベント発生から6時間以内 |
| 同一(CustomerIdentifier, Dimension, Timestamp)の重複 | DuplicateRequestExceptionが返る |
| APIエンドポイント | us-east-1固定 |
6時間制約への対応
使用量イベントをリアルタイムに収集しても、6時間以内にBatchMeterUsageで申告しなければ受理されません。バッチ処理の実行頻度を1時間ごとに設定するのが安全な設計です。
import boto3
from datetime import datetime, timezone
client = boto3.client('marketplace-metering', region_name='us-east-1')
response = client.batch_meter_usage(
UsageRecords=[
{
'Timestamp': datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc),
'CustomerIdentifier': 'CUSTOMER_ID_xxxx',
'Dimension': 'api_calls',
'Quantity': 1500
},
# 最大25件まで
],
ProductCode='PRODUCT_CODE_xxxx'
)
# レスポンスのUnprocessedRecordsを確認
for record in response.get('UnprocessedRecords', []):
print(f"未処理: {record['CustomerIdentifier']}")
レスポンスのUnprocessedRecordsの処理
BatchMeterUsageは部分成功を返します。UnprocessedRecordsに残ったレコードは再試行が必要ですが、6時間制約内で再試行することが前提です。UnprocessedRecordsを無視すると収益損失につながります。
重複申告の防止
同一(CustomerIdentifier, Dimension, Timestamp)の組み合わせは重複として扱われます。一度成功した申告を再送するとDuplicateRequestExceptionが返りますが、これはエラーではなく「既に受理済み」のシグナルです。申告済みレコードをDynamoDBに保存し、再申告前に確認するパターンが推奨です。
4.5 使用量集計パイプライン
SaaSアプリケーション内で発生する使用量イベントをリアルタイムに収集し、6時間以内にBatchMeterUsage APIへ申告するまでのパイプラインを構築します。
パイプライン全体構成
[アプリケーション]
↓ PutRecord (使用量イベント)
[Kinesis Data Streams]
↓ イベントソースマッピング
[Lambda: Aggregator]
↓ 集計結果を書き込み
[DynamoDB: usage_aggregation テーブル]
↑ 定期読み取り
[EventBridge Scheduler: 毎時0分]
↓ 起動
[Lambda: Metering Reporter]
↓ BatchMeterUsage API呼び出し
[AWS Marketplace Metering Service]
Kinesis Data Streamsでのイベント収集
アプリケーションはAPIリクエスト・ファイル転送・その他の計測対象イベントが発生するたびに、Kinesis Data StreamsへPutRecordします。
kinesis_client = boto3.client('kinesis')
kinesis_client.put_record(
StreamName='saas-usage-events',
Data=json.dumps({
'tenant_id': 'acme-corp',
'customer_identifier': 'CUSTOMER_ID_xxxx',
'event_type': 'api_call',
'dimension': 'api_calls',
'quantity': 1,
'timestamp': '2026-06-01T10:30:00Z'
}),
PartitionKey='acme-corp'
)
PartitionKeyをテナントIDにすることで、同一テナントのイベントが同一シャードに集まり、集計の順序保証が得やすくなります。
Lambdaによる集計処理
KinesisトリガーのLambdaは、テナント×ディメンション×時間ウィンドウ(1時間単位)で使用量を集計し、DynamoDBへ累積書き込みします。
from decimal import Decimal
import json
def aggregate_usage(records):
for record in records:
event = json.loads(record['kinesis']['data'])
tenant_id = event['tenant_id']
dimension = event['dimension']
quantity = event['quantity']
# DynamoDB: ADD で原子的加算
table.update_item(
Key={
'PK': f"TENANT#{tenant_id}",
'SK': f"USAGE#{dimension}#{get_hour_key()}"
},
UpdateExpression='ADD quantity :q',
ExpressionAttributeValues={':q': Decimal(quantity)}
)
DynamoDBのADD演算子を使うことで、複数のLambdaインスタンスが並列実行しても集計値が正確に累算されます(原子的インクリメント)。
EventBridge Schedulerによる定期バッチ
毎時0分にEventBridge Schedulerが「Metering Reporter」Lambdaを起動します。このLambdaはDynamoDBから前の1時間の集計値を取得し、BatchMeterUsage APIへ申告します。
def report_metering(event, context):
hour_key = get_previous_hour_key()
pending_records = get_pending_usage(hour_key)
# 25件ずつに分割してAPIを呼び出す
for batch in chunks(pending_records, 25):
response = marketplace_client.batch_meter_usage(
UsageRecords=batch,
ProductCode=PRODUCT_CODE
)
# 申告済みをマーク、UnprocessedRecordsを再キューイング
mark_reported(batch, response)
申告済みレコードの管理
BatchMeterUsageの呼び出し結果をDynamoDBに保存し、同一レコードの二重申告を防ぎます。申告ステータス(pending/reported/failed)をDynamoDBのステータスフィールドで管理し、DynamoDB Streamsで申告完了イベントを下流サービスへ通知するパターンが拡張性の面で優れます。
4.6 ティア設計とpricing dimension
AWS Marketplaceでの製品公開時に定義するpricing dimensionは、製品公開後に変更できません。設計初期にディメンション設計を確定することが最重要です。
pricing dimensionの定義原則
良いpricing dimensionの条件は以下のとおりです。
- テナントがコントロールできる量: テナントが使う量を意識的に増減できる単位(API calls数、データ転送GB、ホスト数など)
- 計測が容易: アプリケーション側で正確に計測できる単位
- テナントが価値を感じる単位: ティアアップグレードの動機になる制限
典型的なdimensionの例を示します。
api_calls: APIコール数 (1,000コール単位)
data_transfer : データ転送量 (GB単位)
active_hosts: アクティブホスト数
active_users: アクティブユーザー数
storage_gb : ストレージ使用量 (GB単位)
1製品あたり最大24のdimensionを登録できますが、3〜5個に絞るのが実践的です。多すぎるdimensionはテナントの混乱と申告ロジックの複雑化を招きます。
ティア別entitlement管理
free/basic/premium等のティアをDynamoDBのテナントレコードで管理し、各ティアの上限(quota)を定義します。
{
"PK": "TENANT#acme-corp",
"SK": "ENTITLEMENT",
"tier": "premium",
"quotas": {
"api_calls_per_month": 1000000,
"data_transfer_gb": 500,
"active_hosts": 50
},
"overage_billable": true,
"contract_type": "subscription"
}
アプリケーションはAPIリクエスト処理前にentitlementを確認し、quota超過時に429 Too Many Requestsを返す設計にします。
ティア別の制限実装
def check_quota(tenant_id: str, dimension: str, quantity: int) -> bool:
entitlement = get_entitlement(tenant_id)
current_usage = get_current_month_usage(tenant_id, dimension)
if current_usage + quantity > entitlement['quotas'][dimension]:
if entitlement['overage_billable']:
# 超過分を計測対象としてBatchMeterUsage用にキューイング
queue_overage(tenant_id, dimension, quantity)
return True # 処理は続行
else:
return False # 上限到達で拒否
return True
4.7 upgrade/downgradeフロー
テナントがティアを変更する際のフローを整備します。特にdowngradeはデータ損失リスクがあるため慎重に設計します。
upgradeフロー
テナントがupgradeを要求
→ 管理コンソールでMarketplace決済処理
→ DynamoDBのentitlementレコードを更新 (tier + quotas)
→ テナントへ即時反映(新しいquotaで次のAPIから有効)
→ Marketplaceへの契約更新通知(SaaS contractsの場合)
upgradeは即時反映が理想です。テナントはupgradeしたその瞬間から新しい制限内でサービスを利用できることを期待します。
downgradeフロー
テナントがdowngradeを要求
→ 現在の使用量と新ティアのquotaを比較
→ 使用量が新quotaを超えている場合: 警告表示(強制downgrade不可)
→ 使用量が新quota以内の場合: 月末まで現ティアを維持 → 月初にdowngrade適用
→ entitlementレコードを downgrade_at フィールドで管理
downgradeは月末締めで適用するのが一般的です。即時downgradeは「契約期間中のサービス品質低下」としてテナントクレームの元になります。DynamoDBにdowngrade_atフィールドを持たせ、月初にバッチジョブが処理します。
4.8 §4 詰まりポイント集
BatchMeterUsage APIはイベント発生から6時間以内のusage recordのみを受理します。6時間を超えたrecordはAPIから拒否され、その使用量は永久に申告できなくなります。収益損失を防ぐため、集計バッチは毎時実行が必須です。24時間バッチや「日次集計」の設計は絶対に避けてください。
AWS Marketplaceに製品を公開(Published状態)にした後は、pricing dimensionの追加・削除・変更が一切できません。dimensionの定義はα版・β版テスト中に確定させ、公開前に十分なレビューを行ってください。設計ミスに気づいた場合は、製品を新規作成して移行するしかありません(テナントの再登録を含む大規模作業になります)。
BatchMeterUsageは部分成功を返します。成功したrecordと失敗したrecordが混在する場合、レスポンスのUnprocessedRecordsに失敗分が含まれます。これを無視すると、その使用量は申告されず収益損失になります。UnprocessedRecordsを再キューイングし、6時間以内に再試行する仕組みを必ず実装してください。
SaaS subscriptionsとSaaS contractsは計測ロジックが異なります(subscriptionsは全使用量申告・contractsは超過分のみ)。どちらの形態で公開するかを先に確定し、申告ロジックをその形態に合わせて実装します。両形態をサポートする場合は、entitlementレコードのcontract_typeフィールドで分岐し、それぞれ正しいロジックで申告します。
5. オンボーディング自動化 — control plane / application plane

SaaS アーキテクチャでは、テナント管理を担う control plane と、テナント固有のビジネスロジックを実行する application plane を明確に分離することが設計の要です。この分離があって初めて、新規テナントのオンボーディングを完全自動化できます。
5.1 control plane / application plane アーキテクチャ
control plane は SaaS 全体のテナント管理インフラを担当します。
- テナント登録・プロビジョニング・削除
- 認証・認可(テナント ID 発行・JWT 発行)
- テナントの監視・運用・分析
- 課金・メタデータ管理
application plane はテナントが実際に使うビジネスロジック層です。
- テナント固有のデータ処理
- ビジネスルール実装
- テナントリソース(Lambda / DynamoDB / API Gateway 等)の実行
両者は SaaS Builder Toolkit(SBT) のイベントバスを介して連携します。SBT は control plane と application plane を疎結合に接続するリファレンスアーキテクチャで、EventBridge を中心とした双方向イベント連携を実現します。control plane が「テナント作成」イベントを発行すると、application plane が受け取りテナント固有リソースを自動構築します。
{
"source": "sbt.control-plane",
"detail-type": "TenantOnboarding",
"detail": {
"tenantId": "tenant-abc123",
"tenantTier": "premium",
"tenantName": "Example Corp"
}
}
application plane 側は EventBridge ルールでこのイベントを受け取り、テナント固有の Lambda / DynamoDB テーブル / API Gateway ステージを作成します。逆に application plane が「プロビジョニング完了」イベントを発行すると、control plane のテナントステータスが active に更新されます。
設計原則: control plane は application plane の内部実装を知らない
イベントバス(EventBridge)を介した非同期連携とすることで、application plane のリソース構成を変更しても control plane への影響ゼロになります。新機能追加・新サービス組み込み時も疎結合を維持したまま拡張できます。
5.2 テナントプロビジョニング IaC — Step Functions × CloudFormation
オンボーディングの核心は Tenant Provisioning Service です。新規テナント登録リクエストを受けた control plane は、このサービスを通じてテナント固有インフラを自動生成します。
全体フロー
テナント登録 API(POST /tenants)
│
▼
Tenant Provisioning Service(Lambda)
│ ①テナントメタデータ検証・DynamoDB 登録(status=provisioning)
│
▼
Step Functions ステートマシン起動
│
├─ Step1: ValidateTenant(tier・メールドメイン確認・重複チェック)
├─ Step2: CreateTenantStack(CloudFormation / tier 別テンプレート)
├─ Step3: ConfigureDNS(Route53 / テナント専用サブドメイン割当)
├─ Step4: ProvisionCertificate(ACM TLS 証明書発行・DNS 検証)
├─ Step5: RegisterTenantUsers(Cognito User Pool / テナント管理者アカウント作成)
├─ Step6: SendWelcomeEmail(SES / ウェルカムメール送信)
└─ Step7: MarkActive(DynamoDB status=active 更新 + EventBridge 完了イベント発行)
Step Functions を使うことで各ステップを独立した Lambda として実装し、失敗時のリトライ・補償トランザクションを宣言的に記述できます。Step2 の CloudFormation スタック作成では .sync:2 統合パターンを使い、スタック完成まで Step Functions が自動的に待機します。
{
"Comment": "Tenant Provisioning Workflow",
"StartAt": "ValidateTenant",
"States": {
"ValidateTenant": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-1:ACCOUNT:function:validate-tenant",
"Next": "CreateTenantStack",
"Retry": [{
"ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException"],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2.0
}],
"Catch": [{
"ErrorEquals": ["ValidationError"],
"Next": "NotifyProvisioningFailure"
}]
},
"CreateTenantStack": {
"Type": "Task",
"Resource": "arn:aws:states:::cloudformation:createStack.sync:2",
"Parameters": {
"StackName.$": "States.Format('tenant-{}', $.tenantId)",
"TemplateURL.$": "States.Format('s3://saas-cfn-templates/tier-{}.yaml', $.tenantTier)",
"Parameters": [
{"ParameterKey": "TenantId","ParameterValue.$": "$.tenantId"},
{"ParameterKey": "TenantTier", "ParameterValue.$": "$.tenantTier"}
],
"Tags": [
{"Key": "TenantId","Value.$": "$.tenantId"},
{"Key": "TenantTier", "Value.$": "$.tenantTier"},
{"Key": "ManagedBy", "Value": "saas-provisioner"}
],
"OnFailure": "ROLLBACK"
},
"Next": "ConfigureDNS",
"Catch": [{
"ErrorEquals": ["States.ALL"],
"Next": "DeleteIncompleteResources",
"ResultPath": "$.error"
}]
},
"ConfigureDNS": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-1:ACCOUNT:function:configure-dns",
"Next": "ProvisionCertificate"
},
"NotifyProvisioningFailure": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-1:ACCOUNT:function:notify-failure",
"End": true
},
"DeleteIncompleteResources": {
"Type": "Task",
"Resource": "arn:aws:states:::cloudformation:deleteStack.sync:2",
"Parameters": {
"StackName.$": "States.Format('tenant-{}', $.tenantId)"
},
"Next": "NotifyProvisioningFailure"
}
}
}
tier 別 CloudFormation テンプレート切替
テナントの契約 tier(basic / standard / premium)に応じて、CloudFormation テンプレートを切り替えます。States.Format で動的に S3 パスを生成するため、新 tier 追加もテンプレートファイルを追加するだけで対応できます。
| tier | DynamoDB | Lambda | API Gateway |
|---|---|---|---|
| basic | 共有テーブル(テナント ID パーティション) | 共有 Function(環境変数でテナント切替) | 共有 API(API キーで識別) |
| standard | 専用テーブル | 専用 Function | 専用 API ステージ |
| premium | 専用テーブル + PITR バックアップ | 専用 Function + 予約済み同時実行 | 専用 API + カスタムドメイン |
# tier-premium.yaml(抜粋)
Parameters:
TenantId:
Type: String
TenantTier:
Type: String
Resources:
TenantDynamoDBTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: !Sub "${TenantId}-data"
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
Tags:
- Key: TenantId
Value: !Ref TenantId
- Key: TenantTier
Value: !Ref TenantTier
TenantLambdaFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub "${TenantId}-processor"
ReservedConcurrentExecutions: 200
Environment:
Variables:
TENANT_ID: !Ref TenantId
TENANT_TIER: !Ref TenantTier
TABLE_NAME: !Ref TenantDynamoDBTable
TenantCustomDomain:
Type: AWS::ApiGateway::DomainName
Properties:
DomainName: !Sub "${TenantId}.yoursaas.example.com"
CertificateArn: !Ref TenantCertificate
コスト設計の鍵: basic tier は共有リソースで原価を下げる
basic tier テナントを共有テーブル・共有 Lambda で処理することで、初期ユーザー獲得コストを圧縮できます。成長して standard / premium に移行するテナントが収益の柱となる SaaS ビジネスモデルと相性が良い設計です。tier アップグレード時の移行手順(5.4 参照)を事前に設計しておくことが重要です。
5.3 2025 年最新: 数秒でテナント有効化
2025 年時点の SaaS オンボーディングは「申し込み後数秒で利用開始」が競争優位の指標です。実現のポイントは証明書の自動化・DNS の自動化・テナントメタデータのキャッシュ設計の 3 つです。
① 証明書のプロビジョニング自動化
ACM(AWS Certificate Manager)の DNS 検証を Route53 と連携させることで、TLS 証明書の発行を完全自動化します。
import boto3
import time
acm = boto3.client('acm', region_name='us-east-1')
route53 = boto3.client('route53')
def provision_certificate(tenant_id: str, domain: str, hosted_zone_id: str) -> str:
# 証明書リクエスト
cert = acm.request_certificate(
DomainName=f"{tenant_id}.{domain}",
ValidationMethod='DNS',
Tags=[{'Key': 'TenantId', 'Value': tenant_id}]
)
cert_arn = cert['CertificateArn']
# 検証レコードが生成されるまで待機(通常数秒〜30 秒)
for _ in range(10):
detail = acm.describe_certificate(CertificateArn=cert_arn)
options = detail['Certificate']['DomainValidationOptions']
if options and 'ResourceRecord' in options[0]:
break
time.sleep(5)
# DNS 検証レコードを Route53 に自動追加
changes = []
for option in options:
if 'ResourceRecord' in option:
record = option['ResourceRecord']
changes.append({
'Action': 'UPSERT',
'ResourceRecordSet': {
'Name': record['Name'],
'Type': record['Type'],
'TTL': 60,
'ResourceRecords': [{'Value': record['Value']}]
}
})
route53.change_resource_record_sets(
HostedZoneId=hosted_zone_id,
ChangeBatch={'Changes': changes}
)
return cert_arn
Route53 への CNAME 追加後、ACM は自動的に DNS 検証を行い数分以内に証明書を発行します。テナントがブラウザでアクセスした時点ですでに HTTPS が有効になっています。
② DNS 設定の自動化
テナント専用サブドメイン(tenant-abc.yoursaas.com)を Route53 で自動作成します。
def create_tenant_dns(tenant_id: str, alb_dns_name: str, hosted_zone_id: str):
route53.change_resource_record_sets(
HostedZoneId=hosted_zone_id,
ChangeBatch={
'Changes': [{
'Action': 'CREATE',
'ResourceRecordSet': {
'Name': f"{tenant_id}.yoursaas.com",
'Type': 'A',
'AliasTarget': {
'HostedZoneId': 'ALB_HOSTED_ZONE_ID',
'DNSName': alb_dns_name,
'EvaluateTargetHealth': True
}
}
}]
}
)
Alias レコードで ALB / CloudFront に紐付けることで、追加コストゼロで DNS を構成できます。Route53 の DNS 伝播は通常 60 秒以内で完了します。
③ テナントメタデータのキャッシュ設計
テナント ID → tier・設定・エンドポイントのマッピングを DynamoDB + ElastiCache(Redis)でキャッシュします。初回リクエスト時に DynamoDB から取得しキャッシュに書き込み、以降はキャッシュから高速返却します。
import redis
import json
redis_client = redis.Redis(host='saas-cache.xxx.cache.amazonaws.com', port=6379)
dynamodb = boto3.resource('dynamodb')
tenants_table = dynamodb.Table('saas-tenants')
def get_tenant_config(tenant_id: str) -> dict:
cache_key = f"tenant:{tenant_id}"
# キャッシュ確認(TTL: 300 秒)
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# キャッシュミス → DynamoDB から取得
item = tenants_table.get_item(Key={'tenantId': tenant_id})
config = item.get('Item', {})
# キャッシュに書き込み
redis_client.setex(cache_key, 300, json.dumps(config))
return config
数万テナントが同時接続する高負荷時も、キャッシュヒット率 99% 以上を維持することで DynamoDB コストを大幅に抑えつつ低レイテンシを実現できます。
5.4 ライフサイクル管理 — suspend / delete / migrate
テナントのライフサイクルはオンボーディングだけではありません。suspend(一時停止)・delete(削除)・migrate(移行) を自動化することで、運用コストを大幅に削減できます。
Suspend(一時停止)
支払い遅延・アカウント違反等でテナントを一時停止する場合:
- Cognito User Pool でテナントユーザーを無効化 → ログイン不可
- API Gateway の usage plan を quota=0 に変更 → API 呼び出し遮断
- Lambda 関数の予約済み同時実行を 0 に設定 → 処理停止
- DynamoDB テーブルへの書き込み権限を IAM ポリシーで剥奪
- DynamoDB データはそのまま保持(復元可能)
- テナントメタデータの status を
suspendedに更新
import boto3
import json
import time
def suspend_tenant(tenant_id: str):
sf = boto3.client('stepfunctions')
sf.start_execution(
stateMachineArn='arn:aws:states:ap-northeast-1:ACCOUNT:stateMachine:tenant-suspend',
name=f"suspend-{tenant_id}-{int(time.time())}",
input=json.dumps({'tenantId': tenant_id, 'action': 'suspend'})
)
suspend から復旧(unsuspend)する際は、Step Functions の unsuspend ワークフローで逆順に権限を復旧します。テナントデータが保持されているため、数分以内に完全復旧できます。
Delete(完全削除)
解約・退会時のテナント削除では、テナント固有リソースを完全に消去します。
- データエクスポート(テナントのデータを S3 に保存 → 法的保存期間まで保持)
- DynamoDB テーブルの削除(専用テーブルの場合)/ パーティションデータの削除(共有テーブルの場合)
- Lambda 関数・API Gateway・CloudFormation スタック削除
- Route53 DNS レコード削除・ACM 証明書削除
- Cognito User Pool 内テナントユーザー削除
- テナントメタデータを
deletedに更新(監査ログとして保持)
冪等性設計: 削除処理は CloudFormation スタック削除を最後に実行することで、途中失敗時の再実行を安全に行えます。スタック削除は CloudFormation が依存関係順に処理するため、手動リソース削除より確実です。
// delete ワークフロー(Step Functions)
{
"States": {
"ExportTenantData": {
"Type": "Task",
"Resource": "arn:aws:lambda:::function:export-tenant-data",
"Next": "DeleteTenantStack"
},
"DeleteTenantStack": {
"Type": "Task",
"Resource": "arn:aws:states:::cloudformation:deleteStack.sync:2",
"Parameters": {
"StackName.$": "States.Format('tenant-{}', $.tenantId)"
},
"Next": "CleanupSharedResources"
},
"CleanupSharedResources": {
"Type": "Task",
"Resource": "arn:aws:lambda:::function:cleanup-shared-resources",
"Next": "MarkDeleted"
},
"MarkDeleted": {
"Type": "Task",
"Resource": "arn:aws:lambda:::function:mark-tenant-deleted",
"End": true
}
}
}
Migrate(移行)
テナントの成長に伴う tier アップグレード、または Pool から Silo への移行を管理します。
Pool → Silo アップグレード(例: basic → premium)
- 移行元(共有テーブル)のテナントデータを DynamoDB Export → S3
- 新しい専用 CloudFormation スタックを作成(premium テンプレート)
- S3 からデータを新専用テーブルに DynamoDB Import
- DNS / API Gateway エンドポイントを新スタックに切替(Blue/Green 方式)
- 旧テナントデータを共有テーブルから削除
- テナントメタデータの tier を
premiumに更新・キャッシュ破棄
セル間移行(水平スケール時)
セル A が過負荷になった場合、テナントをセル B に移行します。
- テナントのデータをセル B の DynamoDB にレプリケーション(DynamoDB Streams 活用)
- セル B でのリソース確認・ウォームアップ
- セルルーターのルーティングテーブル(DynamoDB)を更新(テナント → セル B に書き換え)
- キャッシュ(ElastiCache)のテナントエントリを無効化
- セル A のリソースをクリーンアップ
移行中のダウンタイムを最小化するため、Step3 の DNS / ルーティング切替は原子的に実行します。DynamoDB の conditional write を使い、切替の競合を防止します。
詰まりポイント: オンボーディング手戻りの罠
- 補償トランザクション未実装: Step4(DNS 設定)で失敗した場合、Step1〜3 で作成済みのリソースが残留します。各ステップの Catch に補償処理(リソース削除)を必ず実装してください。CloudFormation の
OnFailure: ROLLBACKを必ず指定することも重要です。 - tier 別テンプレートの検証不足: basic / premium でテンプレートを分けた場合、premium テンプレートの変更が basic に反映されないまま本番が割れるケースがあります。CI/CD で全 tier テンプレートを同時デプロイ・テストする仕組みを組み込んでください。
- ライフサイクル管理の漏れ: delete 処理で共有テーブルのテナントパーティションデータを消し忘れるケースが多発します。削除後に件数確認クエリを Step Functions の Verify ステップに組み込み、残存データを必ず検証してください。
- ACM 証明書の上限: ACM 証明書はリージョン毎にデフォルト 2,500 件の上限があります。テナント数増加時は事前に Service Quotas コンソールで上限緩和申請が必要です。マルチテナント SaaS では早期に申請してください。
- suspend 中の CloudWatch ログ課金: Lambda 予約済み同時実行を 0 にしても、CloudWatch Logs の保存コストは継続します。suspend 期間が長期化する場合はログ保存期間(Retention)を短縮する処理を追加してください。
6. per-tenant 監視・コスト配賦

マルチテナント SaaS で「このテナントは利益が出ているか?」「あのテナントが他テナントに迷惑をかけていないか?」を把握するには、テナント別のコストと負荷を AWS レベルで可視化する必要があります。
6.1 Cost Allocation Tags によるテナント別コスト可視化
タグ設計
すべてのテナントリソースに以下のタグを付与します。オンボーディング時に Step Functions + CloudFormation で自動付与するため、人為的な漏れを防げます。
# CloudFormation テンプレートの Tags セクション(全リソース共通)
Tags:
- Key: TenantId
Value: !Ref TenantId # 例: tenant-abc123
- Key: TenantTier
Value: !Ref TenantTier # 例: premium
- Key: SaaSEnv
Value: !Ref Environment # 例: production
- Key: CostCenter
Value: !Sub "saas-${TenantId}"
手動で作成したリソース(コンソール操作)にはタグが付かないケースが多発します。Tag Policy(AWS Organizations)で必須タグを強制することで、タグ付け漏れを自動検出できます。AWS Config で required-tags マネージドルールを有効化すると、タグ未付与リソースを自動検出してアラートを上げられます。本番リリース前に Config ルールを有効化しておくことで、タグ設計の抜け漏れをゼロに近づけられます。
Billing Console でのアクティベート
Cost Allocation Tags は作成しただけでは機能しません。AWS Billing Console → コスト配分タグ でアクティベートが必要です。
- Billing Console → [コスト配分タグ] を開く
- 対象タグ(
TenantId/TenantTier)を選択してチェックを入れる - [アクティブ化] をクリック
アクティベートから Cost and Usage Report(CUR)への反映は最大 24 時間かかります。本番リリース前に早期アクティベートしてください。タグのアクティベートは過去データには遡及しないため、早期対応が重要です。
Cost and Usage Report(CUR)での分析
CUR を S3 に出力し、Athena でテナント別コストをクエリします。
-- Athena: テナント別月次コスト集計
SELECT
resource_tags_user_tenantidAS tenant_id,
resource_tags_user_tenanttier AS tier,
SUM(line_item_unblended_cost) AS monthly_cost_usd
FROM
your_cur_database.your_cur_table
WHERE
bill_billing_period_start_date = '2025-01-01'
AND resource_tags_user_tenantid IS NOT NULL
AND resource_tags_user_tenantid != ''
GROUP BY
resource_tags_user_tenantid,
resource_tags_user_tenanttier
ORDER BY
monthly_cost_usd DESC;
このクエリで「テナント A の月額インフラコストは $234、月額課金は $499 → 利益率 53%」という単位経済性(Unit Economics)を算出できます。tier 別の原価率を把握することで、pricing 改定・cost 最適化の意思決定が精度よく行えます。
Cost Intelligence Dashboard の活用
AWS Cost Intelligence Dashboard(CID)を Athena + QuickSight で構築すると、テナント別コストのトレンド・異常検知・tier 別利益率をビジュアライズできます。CUR → Athena → QuickSight のパイプラインを初期に整備しておくことを推奨します。AWS が公開している CID デプロイ用 CloudFormation テンプレートを使えば短時間に構築できます。
6.2 noisy neighbor 検知・対策
Pool モデル(共有リソース)では noisy neighbor 問題 が最頻の本番インシデントです。1 つのテナントが大量のデータエクスポートや夜間バッチを実行すると、同じ Lambda コンカレンシーや DynamoDB 読み取りキャパシティを共有する他テナントの応答が劣化します。
問題の構造
テナント A(正常)
→ Lambda 呼出し 100 req/s → コンカレンシー消費 10
テナント B(異常: 大規模エクスポート開始)
→ Lambda 呼出し 2,000 req/s → コンカレンシー消費 400
→ アカウント上限(例: 1,000)に接近
テナント C / D / E(被害)
→ Lambda スロットリング発生 → 503 エラー増加
→ SLA 違反リスク
対策 1: per-tenant Lambda 予約済み同時実行
各テナントの Lambda 関数に予約済み同時実行(Reserved Concurrency)を設定します。これにより、テナント B が暴走しても tier 別の上限でキャップされ、テナント C / D / E への影響がゼロになります。
import boto3
lambda_client = boto3.client('lambda')
def set_tenant_concurrency(tenant_id: str, tier: str):
concurrency_map = {
'basic': 10,
'standard': 50,
'premium': 200
}
limit = concurrency_map.get(tier, 10)
lambda_client.put_function_concurrency(
FunctionName=f"{tenant_id}-processor",
ReservedConcurrentExecutions=limit
)
print(f"Set concurrency {limit} for tenant {tenant_id} ({tier})")
noisy neighbor 検知時には、上記関数を呼び出して動的に制限を下げることもできます。Step Functions + EventBridge の自動制御フローで、人手介入なしにスロットリングを適用します。
対策 2: API Gateway usage plan によるスロットリング
テナントごとに API キーを発行し、usage plan でリクエストレートを tier 別に制限します。
import boto3
apigw = boto3.client('apigateway')
def create_tenant_usage_plan(tenant_id: str, tier: str, api_id: str, stage: str):
tier_config = {
'basic': {'rate': 10,'burst': 20,'quota': 10_000},
'standard': {'rate': 100, 'burst': 200, 'quota': 100_000},
'premium': {'rate': 1000, 'burst': 2000, 'quota': 1_000_000}
}
cfg = tier_config.get(tier, tier_config['basic'])
plan = apigw.create_usage_plan(
name=f"tenant-{tenant_id}-{tier}",
apiStages=[{'apiId': api_id, 'stage': stage}],
throttle={
'rateLimit': cfg['rate'],
'burstLimit': cfg['burst']
},
quota={
'limit': cfg['quota'],
'period': 'MONTH'
}
)
key = apigw.create_api_key(
name=f"tenant-{tenant_id}",
enabled=True,
tags={'TenantId': tenant_id, 'TenantTier': tier}
)
apigw.create_usage_plan_key(
usagePlanId=plan['id'],
keyId=key['id'],
keyType='API_KEY'
)
return plan['id'], key['id']
| Tier | rate(req/s) | burst | quota(/月) |
|---|---|---|---|
| basic | 10 | 20 | 10,000 |
| standard | 100 | 200 | 100,000 |
| premium | 1,000 | 2,000 | 1,000,000 |
usage plan を超えたリクエストは 429 Too Many Requests を返します。テナント側でリトライ with exponential backoff を実装することで、SaaS 全体の安定性を保てます。
対策 3: DynamoDB on-demand + per-tenant CloudWatch アラーム
DynamoDB は PAY_PER_REQUEST(on-demand) モードを使うことで、単一テナントのバーストが他テナントに影響しない設計になります。ただし共有テーブルの場合でも、per-tenant メトリクス で個別のアクセスパターンを監視することが重要です。
import boto3
cw = boto3.client('cloudwatch')
def create_noisy_neighbor_alarm(tenant_id: str, table_name: str, threshold_rcu: int = 1000):
cw.put_metric_alarm(
AlarmName=f"noisy-neighbor-dynamo-{tenant_id}",
ComparisonOperator='GreaterThanThreshold',
EvaluationPeriods=2,
MetricName='ConsumedReadCapacityUnits',
Namespace='AWS/DynamoDB',
Period=60,
Statistic='Sum',
Threshold=threshold_rcu,
Dimensions=[
{'Name': 'TableName', 'Value': table_name}
],
AlarmActions=[
'arn:aws:sns:ap-northeast-1:ACCOUNT:noisy-neighbor-alert'
],
AlarmDescription=f"Tenant {tenant_id} DynamoDB read exceeds {threshold_rcu} RCU/min"
)
共有テーブルの場合は Lambda ログの EMF メトリクス(後述)でテナント別の読み書き件数を把握します。専用テーブル(premium tier)では DynamoDB のコンソールモニタリングで直接確認できます。
6.3 テナント別監視アーキテクチャ
テナントアクティビティメトリクスの収集
各 Lambda 関数のログに tenant_id を埋め込み、Embedded Metrics Format(EMF) でカスタムメトリクスを CloudWatch に送信します。追加コストなし(CloudWatch Logs 料金のみ)でカスタムメトリクスを生成できます。
import json
import time
def lambda_handler(event, context):
tenant_id= event.get('tenantId', 'unknown')
tenant_tier = event.get('tenantTier', 'unknown')
start_ts = time.time()
result = process_request(event)
elapsed_ms = int((time.time() - start_ts) * 1000)
print(json.dumps({
"_aws": {
"Timestamp": int(time.time() * 1000),
"CloudWatchMetrics": [{
"Namespace": "SaaS/TenantActivity",
"Dimensions": [["TenantId", "TenantTier"]],
"Metrics": [
{"Name": "RequestCount","Unit": "Count"},
{"Name": "ProcessingTime", "Unit": "Milliseconds"},
{"Name": "ErrorCount", "Unit": "Count"}
]
}]
},
"TenantId": tenant_id,
"TenantTier": tenant_tier,
"RequestCount":1,
"ProcessingTime": elapsed_ms,
"ErrorCount": 0
}))
return result
これで CloudWatch の SaaS/TenantActivity 名前空間に RequestCount / ProcessingTime / ErrorCount がテナント次元で蓄積されます。CloudWatch Metrics Insights でテナント別の 95 パーセンタイルレイテンシを集計し、SLA 遵守状況を可視化できます。
noisy neighbor 自動検知・制限フロー
Lambda / DynamoDB / API Gateway
│ メトリクス出力(EMF / CloudWatch Metrics)
▼
CloudWatch アラーム(per-tenant しきい値監視)
│ 閾値超過(2 分間連続)
▼
SNS トピック → Lambda(throttle-enforcement)
│
├─ API Gateway usage plan の rateLimit を一時的に引き下げ
├─ Lambda 予約済み同時実行を半減(例: 200 → 100)
└─ SES → テナント管理者に通知メール
(「リクエストが上限に近づいています。tier アップグレードをご検討ください。」)
アラーム発火から throttle-enforcement Lambda 実行まで通常 30 秒以内です。この間に他テナントが受ける影響は最小限に抑えられます。
監視の落とし穴: メトリクス集計ラグに注意
CloudWatch カスタムメトリクスの集計は最短 1 分ですが、アラーム評価は EvaluationPeriods × Period 分待ちます。上記例では 2 × 60 秒 = 2 分後にアラーム発火します。その間 noisy neighbor が他テナントに影響を与え続けます。リアルタイム性が必要な場合は Kinesis Data Streams + Lambda のストリーム処理でメトリクスをリアルタイム評価し、即時スロットリングを適用してください。
6.4 SLA per tenant 設計
tier 別に SLA(Service Level Agreement)を定義し、テナントごとの応答時間保証・障害通知を実装します。
| Tier | 可用性 SLA | p99 応答時間 | 障害通知 |
|---|---|---|---|
| basic | 99.5% | 2,000ms | 翌営業日メール |
| standard | 99.9% | 500ms | 4 時間以内メール |
| premium | 99.99% | 100ms | 即時アラート / SMS |
CloudWatch Synthetics によるテナント別エンドポイント監視
CloudWatch Synthetics を使い、テナントごとのエンドポイントに定期的な合成監視(Canary)を実行します。tier に応じて監視間隔を変えることで、コストと SLA 保証をバランスします。
import boto3
synthetics = boto3.client('synthetics')
def create_tenant_canary(tenant_id: str, tier: str, endpoint_url: str):
interval_map = {
'basic': 300, # 5 分間隔
'standard': 60, # 1 分間隔
'premium':30# 30 秒間隔
}
interval = interval_map.get(tier, 300)
synthetics.create_canary(
Name=f"tenant-{tenant_id}-health",
Code={
'Handler': 'canary.handler',
'S3Bucket': 'saas-canary-scripts',
'S3Key': f"canary/{tier}_health_check.zip"
},
ArtifactS3Location=f"s3://saas-canary-artifacts/{tenant_id}/",
Schedule={
'Expression': f"rate({interval} seconds)",
'DurationInSeconds': 0
},
RunConfig={
'TimeoutInSeconds': 30,
'EnvironmentVariables': {
'TENANT_ENDPOINT': endpoint_url,
'TENANT_ID': tenant_id
}
},
Tags={'TenantId': tenant_id, 'TenantTier': tier}
)
premium テナントは 30 秒間隔で監視し、可用性を継続的に記録します。basic テナントは 5 分間隔に抑えることで Synthetics の実行コストを最小化します。
SLA 違反時の自動対応フロー
SLA 違反が検知された場合、Step Functions で自動復旧フローを起動します。
CloudWatch Canary 失敗検知
│
▼
EventBridge → Step Functions(SLA-Response-Workflow)
│
├─ Step1: DiagnoseIssue(Lambda ヘルスチェック・DynamoDB ステータス確認)
├─ Step2: AttemptAutoRecovery(Lambda 関数リトライ / DynamoDB キャパシティ確認)
│→ 2 分以内に回復しない場合 Step3 へ
├─ Step3: EscalateToEngineer(SNS / PagerDuty / SMS 発報)
└─ Step4: RecordIncident(DynamoDB へインシデントログ保存・SLA 証跡)
SLA 証跡(いつ・何分ダウンしたか)を DynamoDB に自動保存することで、テナントへの月次 SLA レポート生成を自動化できます。
tier 別 usage plan の自動調整
SLA 閾値に近づいたテナントを自動検知し、usage plan の quota を動的に調整します。
import boto3
apigw = boto3.client('apigateway')
def auto_adjust_usage_plan(tenant_id: str, current_usage_pct: float, plan_id: str):
tenant = get_tenant_config(tenant_id)
if current_usage_pct >= 0.95 and tenant.get('tier') == 'premium':
current_plan = apigw.get_usage_plan(usagePlanId=plan_id)
current_quota = current_plan['quota']['limit']
new_quota = int(current_quota * 1.2)
apigw.update_usage_plan(
usagePlanId=plan_id,
patchOperations=[{
'op': 'replace',
'path': '/quota/limit',
'value': str(new_quota)
}]
)
notify_tenant(tenant_id, f"クォータを一時的に {new_quota:,} に拡張しました。")
elif current_usage_pct >= 0.80:
notify_tenant(tenant_id, "月次クォータの 80% を使用しました。ご確認ください。")
詰まりポイント: per-tenant コスト可視化の落とし穴
- タグ付け漏れ: 手動で作成したリソース(コンソール操作)にはタグが付かないケースが多発します。AWS Config の
required-tagsルールで「TenantId タグ未付与リソース」を自動検出してください。プロビジョニングを CloudFormation 経由に統一することがタグ漏れゼロの近道です。 - 共有リソースのコスト配賦困難: API Gateway や NAT Gateway など、テナント間で共有するリソースのコストはタグで分割できません。「共通インフラ費用」として別枠管理し、テナント数で等分配賦するアプローチを取ります。この金額が月次コストの 20% を超え始めたら Silo 化のトリガーと判断します。
- CUR の反映ラグ: Cost and Usage Report は最大 24 時間の遅延があります。リアルタイムのコスト監視には Cost Anomaly Detection を併用してください。異常なコストスパイクを当日中に検知できます。
- usage plan の quota リセット設計: API Gateway の quota は月単位でリセットされます。月末に quota を使い切ったテナントは翌月 1 日まで API が 429 エラーになります。テナント向けの通知(50% / 80% / 95%)と quota 増加の申請フローを事前に用意してください。
- noisy neighbor の検知遅延: CloudWatch アラームの評価遅延(2 分)の間に他テナントが影響を受けます。SLA が厳しい premium テナントが多い場合は、Lambda 関数内でリアルタイムにレートを計測し、閾値超過で即時スロットリングするクライアントサイド実装を検討してください。
7. 実戦統合パターン — 分離 × セル × 計測
SaaSアーキテクチャは「テナント分離」「セルベース設計」「従量計測」という3軸の組合せで成立する。それぞれを個別に設計しても、組合せ方を誤ると運用コスト爆発・移行困難・課金精度劣化という問題が重なる。本章では成長段階別に最適な組合せパターンを示し、各段階で直面する判断分岐を解説する。
7-1. 成長段階別の選択フレームワーク
SaaSビジネスの成長ステージに応じて、分離モデル・セル設計・計測精度の最適解が変わる。以下の4軸で現在のステージを判断する。
| 判断軸 | スタートアップ期 | 成長期 | エンタープライズ期 |
|---|---|---|---|
| テナント数 | 〜100テナント | 100〜5,000テナント | 5,000テナント以上 |
| 最大テナント負荷 | 均質・予測可能 | 特定テナントがスパイク | 大口顧客が全体の30%超 |
| 規制・コンプライアンス | 基本的なデータ保護のみ | 一部テナントがSOC2要求 | HIPAA/PCI-DSS等の強い規制 |
| コスト感応度 | 高(インフラコスト最小化) | 中(大口顧客のARPU最大化) | 低(コンプライアンス優先) |
スタートアップ期: Pool分離 + 単一セル + 簡易metering
最小コストで立ち上げ、学習しながら設計を洗練させる時期。インフラを共有することで固定費を抑え、テナント数の増加に応じてコストが線形スケールする構成を選ぶ。
テナント分離: Pool分離を採用する。全テナントが共有のDynamoDBテーブル・Lambda関数・API Gatewayエンドポイントを使う。RLSはテナントIDによるLeadingKeysで実装し、アプリケーション層でテナントコンテキストを強制する。IAM ABACでPrincipalTagにtenantIdを付与し、ポリシーの動的解決でテナント追加のたびにIAM構成変更が不要な状態を作る。
セル設計: セルは1つ。インフラ全体が1セル=blast radiusが最大だが、テナント数が少ない段階では実害が限定的。cell routerを先行実装しておき、将来の分割に備えてルーティングテーブル(DynamoDB)経由でテナントとリソースを紐付ける設計にしておく。後からセル数を増やすときにルーティングロジックだけ変更すればよい状態にする。
従量計測: 簡易metering。タグベースのコスト配賦(Cost Allocation Tags)を全リソースに付与し、Cost and Usage Reportからテナント別概算を出す。AWS Marketplace連携が不要な段階ではBatchMeterUsage APIは使わず、内部向けのUsage DBをDynamoDBで管理する。詳細なmetering pipelineへの移行コストを後回しにする代わりに、タグ設計だけは初日から厳密にする。
# スタートアップ期 タグ設計例(IaC必須・後付け不可)
aws dynamodb create-table \
--table-name tenant-data \
--tags Key=tenant-id,Value=placeholder \
Key=tier,Value=free \
Key=product,Value=my-saas
コストイメージ: Lambda + DynamoDB on-demandをPool運用すると、100テナントでも月額数万円台に抑えられる。Silo構成にした場合、テナントごとにRDS/Auroraを立てると最低でも月額$15/テナントのインフラ費が発生する。
成長期: Pool→Bridge移行 + 2〜3セル + Marketplace metering統合
特定テナントからの要求(コンプライアンス・SLA・データ主権)が増え、全員Pool運用では対応できないテナントが出てくる時期。Bridgeパターンで一部テナントだけSilo化しつつ、大多数はPoolに留める。
Pool→Bridge移行のトリガー:
– 規制要件: 顧客がSOC2 Type IIやISO 27001の審査を受ける — 専用インフラの証拠が必要
– 大口顧客獲得: ARPUが既存テナント平均の10倍超 — SLA 99.99%保証が商談条件
– データ主権: 特定国からの要求でデータ残留地域の指定が必要
– インシデント対応: Pool内の他テナントへの影響を懸念する顧客が複数現れた場合
移行は段階的に行う。まず1社だけSilo化し、移行手順・コスト・運用負荷を計測する。測定結果をもとに移行ガイドを整備し、2社目以降は自動化されたprovisioning pipelineで処理する。
セル設計: 2〜3セルに分割する。大口テナント(売上上位10%)を専用セルに集約し、残りのPool/Bridgeテナントを汎用セルで処理する。cell routerがテナントIDに基づきDynamoDBのルーティングテーブルを参照し、適切なセルのALBエンドポイントにトラフィックを向ける。デプロイ時は小規模セル(大口テナントなし)から先行リリースし、問題なければ大口テナントセルに展開する。
# cell routerのルーティングテーブル設計(DynamoDB)
{
"tenantId": "enterprise-acme-corp",
"cellId": "cell-enterprise-01",
"albEndpoint": "cell-ent-01.internal.example.com",
"tier": "enterprise",
"siloMode": true
}
Marketplace metering統合: AWS Marketplace SaaS製品として公開するタイミングでBatchMeterUsage APIを統合する。usage集計はKinesis Data Streamsでリアルタイムに収集し、Lambda処理でDynamoDBのusageテーブルに書き込む。6時間制約を守るため、バッファを持たせずイベント発生の都度BatchMeterUsageを呼ぶ。pricing dimensionは製品作成時に確定させ、後からの追加は不可能なため慎重に設計する。
# BatchMeterUsage呼び出し例(6時間制約対応)
import boto3
import time
marketplace = boto3.client('meteringmarketplace', region_name='us-east-1')
def meter_usage(tenant_id, dimension, quantity):
timestamp = int(time.time())
response = marketplace.batch_meter_usage(
UsageRecords=[
{
'Timestamp': timestamp,
'CustomerIdentifier': tenant_id,
'Dimension': dimension,
'Quantity': quantity,
'UsageAllocations': [
{
'AllocatedUsageQuantity': quantity,
'Tags': [{'Key': 'tenant-id', 'Value': tenant_id}]
}
]
}
],
ProductCode='YOUR_PRODUCT_CODE'
)
return response
エンタープライズ期: Silo分離 + マルチセル + フルコスト配賦
大口顧客が複数存在し、規制・SLA・コスト配賦の精度が事業継続に直結する時期。インフラコストよりもコンプライアンス・信頼性・説明責任が優先される。
Silo分離の全面展開: 大口テナントは全員Silo化する。CloudFormationスタックをテナントごとに独立管理し、パラメータストアでテナントIDを注入する。IAM ABAC+LeadingKeysによるアクセス制御に加え、VPC単位での分離(テナントごとに独立VPC)を実装する顧客も出てくる。
マルチセル設計: テナント規模・地域・SLAティアでセルを分ける。例えば「US大口テナント専用セル」「EU Silo専用セル(データ主権対応)」「Asia-Pacific汎用Poolセル」という3セル構成。cell routerは地理+ティアの2軸でルーティング先を決定する。各セルは独立したCloudFormationスタックでデプロイ管理し、1セルの更新が他セルに波及しない設計にする。
フルコスト配賦: Cost Allocation Tagsに加え、テナントごとのCloudWatchメトリクスでAPI呼び出し数・Lambda実行時間・DynamoDB読み書き容量をリアルタイム計測する。カスタムUsage DBと照合し、月次でテナント別コストレポートを自動生成する。大口顧客向けにはコストダッシュボードへのアクセスを提供し、SLAブリーチ時の説明責任を果たせる体制を作る。
7-2. 統合設計の詰まりポイントと対策
早期Silo選択によるコスト爆発
スタートアップ期に「将来の規制対応に備えてSiloにしておく」という判断をすると、テナント数が増えるにつれてインフラコストが線形以上に増加する。テナントごとにRDS Auroraクラスター(最低$0.10/時 x 24時間 = $72/月)を持つと、100テナントで月額$7,200のインフラ固定費になる。Pool運用なら同規模で$200〜$500程度に収まる。
❌ スタートアップ初期からSiloを全採用
✅ Pool始動→移行トリガー定義→段階的Silo化。移行トリガーを事前にドキュメント化しておく
Pool→Silo移行の複雑性
一度Poolで動いているアプリケーションをSiloに移行するとき、最大の問題はデータ移行とテナントコンテキスト実装の不整合にある。移行中の二重管理期間にテナントコンテキストが混在するリスクが高い。
移行手順を標準化し、Step Functionsのステートマシンでデータ複製→検証→DNSカットオーバー→旧データ削除の順序を自動化する。各ステップで整合性チェックを入れ、問題があれば自動ロールバックできる仕組みを持つ。
# Step Functions移行ステートマシンのステート例
States:
CopyTenantData:
Type: Task
Resource: arn:aws:lambda:::function:copy-tenant-data
Next: ValidateData
ValidateData:
Type: Task
Resource: arn:aws:lambda:::function:validate-tenant-data
Next: SwitchDNS
Catch:
- ErrorEquals: [DataMismatchError]
Next: RollbackCopy
SwitchDNS:
Type: Task
Resource: arn:aws:lambda:::function:switch-tenant-dns
Next: CleanupOldData
7-3. アンチパターン → 正解パターン(§7から3件)
❌ アンチパターン1: 全テナントに同一Lambda concurrency(reserved concurrency未設定)
✅ 正解パターン: ティア別concurrency制限。EnterpriseテナントはReserved Concurrency=500、Freeテナントは50に設定し、1テナントのスパイクが他テナントのLambda実行を阻害しない構成にする。
# Lambda Reserved Concurrencyのティア別設定
import boto3
lambda_client = boto3.client('lambda')
def set_tenant_concurrency(tenant_id, tier):
concurrency_map = {
'enterprise': 500,
'standard': 100,
'free': 20
}
limit = concurrency_map.get(tier, 20)
lambda_client.put_function_concurrency(
FunctionName=f'api-handler-{tenant_id}',
ReservedConcurrentExecutions=limit
)
❌ アンチパターン2: Pool分離のまま大口顧客に対応しSLAを約束する
✅ 正解パターン: Pool→Bridge移行トリガーをSLA交渉前にドキュメント化。「ARPUが月$X超かつSOC2要求がある場合は専用Bridgeに自動移行」という判断基準を契約前に顧客に開示し、移行コストを料金プランに含める。
❌ アンチパターン3: 初期セル設計で細かすぎるセル分割(20セル以上)
✅ 正解パターン: 最初は2〜3セルで始め、実際のblast radius影響を計測しながら分割数を決める。セル数が増えるほどcell routerとCloudFormationスタック管理の複雑度が増すため、分割の経済的メリットとコスト(管理工数増加)をバランスさせる。
8. 詰まりポイント・アンチパターン・まとめ
§2〜§7で解説した分離・セル・計測・オンボーディング・監視・統合パターンを実装するとき、実際の本番環境では教科書通りに動かない箇所が必ず出る。本章ではプロジェクト現場で頻出する7つの詰まりポイントとアンチパターン5選を集約し、再現性の高い解決策を示す。
8-1. 詰まりポイント7選
詰まり①: Pool分離でのテナント越境リスク(RLS設定漏れ)
DynamoDBのLeadingKeysによるRLSを実装したとき、全てのアクセスパターンにLeadingKeysが適用されているかを検証し忘れるケースが多い。特に「管理用途のスキャン処理」「バッチ処理のDynamoDB操作」「Lambda@Edgeからの直接呼び出し」など、通常のAPIフロー以外の経路でテナントコンテキストが欠落する。
症状: テナントAの管理者が、権限外のテナントBのデータを取得できてしまう。アプリケーションレベルでは正しくtenantIdをフィルタしているが、DynamoDBポリシーのLeadingKeys条件が管理者ロールの一部に適用されていない。
解決策:
1. IAMポリシーシミュレーターでテナントID付きのIAMリクエストを全ロールで網羅テスト
2. アクセス経路ごとにIAMロールを分ける(API用ロール・バッチ用ロール・管理用ロール)
3. 管理用ロールにも必ずLeadingKeysを適用し、管理者ロールへの昇格はCloudTrailで監査
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:*:*:table/tenant-data",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["${aws:PrincipalTag/tenantId}"]
}
}
}
- 定期的にAWS Configルールで「LeadingKeys未適用のDynamoDBポリシー」を自動検出するルールを設定
詰まり②: noisy neighbor(pooledで最頻問題・Lambda concurrency枯渇)
Pool構成で最も頻繁に発生する本番問題。1テナントが大規模なデータエクスポートやバッチ処理を実行すると、Lambda concurrencyを食い潰し、他テナントのリクエストが「TooManyRequestsException」で失敗する。特にAPI Gateway経由のLambda呼び出しで、デフォルトのconcurrency制限(1,000/リージョン)に達すると全テナントに影響する。
症状: 特定テナントのAPI応答時間が突発的に増加し、同じタイミングで他テナントでも断続的なエラーが発生する。CloudWatchメトリクスで「ConcurrentExecutions」が急上昇している。
解決策:
1. Reserved Concurrencyをティア別に設定し、1テナントのconcurrency使用量を上限管理
2. API Gateway Usage Planでテナント別APIキーにrate/burst/quotaを設定
3. DynamoDB on-demandを採用し、読み書き容量のnoisy neighbor影響を排除
4. CloudWatchアラームでテナント別concurrency使用率を監視し、90%到達でアラート
import boto3
cloudwatch = boto3.client('cloudwatch')
def create_tenant_concurrency_alarm(tenant_id, threshold):
cloudwatch.put_metric_alarm(
AlarmName=f'concurrency-{tenant_id}',
MetricName='ConcurrentExecutions',
Namespace='AWS/Lambda',
Dimensions=[
{'Name': 'FunctionName', 'Value': f'api-handler-{tenant_id}'}
],
Period=60,
EvaluationPeriods=3,
Threshold=threshold,
ComparisonOperator='GreaterThanThreshold',
AlarmActions=['arn:aws:sns:ap-northeast-1:123456789012:ops-alert']
)
詰まり③: per-tenantコスト可視化困難(タグ付け漏れ・CUR未設定)
Cost Allocation Tagsを設計しても、実際の請求書でテナント別コストが正しく分離できていないケースが後を絶たない。原因の8割はタグの後付け設定と、Cost and Usage Report(CUR)のアクティベーション忘れ。
症状: Billing Consoleのコスト配賦タグでフィルタリングしても「未タグリソース」が全体コストの40〜60%を占める。テナント別コストの信頼性が低く、大口顧客への料金根拠を説明できない。
解決策:
1. タグ設計はプロビジョニングIaCに組み込む(後付け絶対禁止)
2. CURをS3バケットに自動エクスポートし、Athenaでテナント別クエリを自動実行
3. AWS Configルール「required-tags」でタグなしリソースの自動検出と強制タグ付け
4. タグポリシー(Organizations)でtenantId/tierの形式を強制
# AWS Organizations タグポリシー(簡略)
{
"tags": {
"tenant-id": {
"enforced_for": {
"@@assign": [
"dynamodb:table",
"lambda:function"
]
}
}
}
}
詰まり④: オンボーディング手戻り(環境非対称・べき等性なし)
CloudFormation + Step Functionsで自動化したオンボーディングパイプラインが、特定テナントで途中失敗した後に再実行すると二重リソース作成・設定の上書き・DynamoDBへの重複エントリが発生する。べき等性が担保されていないプロビジョニング処理の典型的な失敗パターン。
症状: オンボーディングを再実行すると「ResourceAlreadyExistsException」が発生し、スタックがROLLBACK_COMPLETEに落ちる。手動クリーンアップが必要になり、オンボーディング自動化の意味が半減する。
解決策:
1. CloudFormationスタック名にテナントIDを含め、同一テナントのスタックが1つしか存在できない設計にする
2. DynamoDBへの書き込みはConditionExpression(attribute_not_exists)で重複を防ぐ
3. Step Functionsの各ステートをべき等に設計し、どのステートから再実行しても同じ結果になるよう保証する
# DynamoDB べき等書き込み例
def provision_tenant(tenant_id, config):
try:
dynamodb.put_item(
TableName='tenants',
Item={
'tenantId': {'S': tenant_id},
'status': {'S': 'provisioning'},
'config': {'M': config}
},
ConditionExpression='attribute_not_exists(tenantId)'
)
except dynamodb.exceptions.ConditionalCheckFailedException:
existing = dynamodb.get_item(
TableName='tenants',
Key={'tenantId': {'S': tenant_id}}
)
if existing['Item']['status']['S'] == 'active':
return
詰まり⑤: セル分割の過剰・過少(運用コスト増 vs blast radius未削減)
セル数が多すぎるとCloudFormationスタック・cell router設定・監視ダッシュボードの管理コストが爆発する。逆に少なすぎるとblast radiusの削減効果が薄い。
症状(過剰分割): 20セル構成でデプロイするたびに20スタックの更新管理が必要になり、デプロイ時間が4〜6時間になる。
症状(過少): 2セルで大口テナントを1セルに集約したが、そのセルに売上の80%が集中しており、1セル障害で実質的にサービス停止状態になる。
解決策: セル分割数はblast radius計算に基づいて決める。N セルなら最悪 1/N のトラフィックが影響を受ける。大口テナントが売上の30%を占めるなら、その顧客を専用セルに入れて他テナントとの混在を避ける。
セル数計算の目安:
- SLA 99.9%: 2〜3セルで十分
- SLA 99.99% + 大口顧客あり: 大口専用セル + 中規模用セル + 汎用セル の3セル
- グローバル展開: リージョンごとに2〜3セル(地理的分離で可用性担保)
詰まり⑥: Marketplace metering連携の落とし穴(6時間制約未対応・pricing dimension後付け不可)
BatchMeterUsage APIの6時間制約を知らずに「日次バッチで使用量を集計してまとめて送信」という設計にすると、使用記録が全て拒否される。また、AWS Marketplace製品作成時に設定したpricing dimensionは後から変更・追加できないため、初期設計を間違えると製品の再作成が必要になる。
症状(6時間制約): 深夜バッチで前日分のUsageRecordを一括送信すると全てInvalidUsageRecordExceptionで失敗する。使用量が記録されないため課金が発生せず、収益に直結する問題になる。
症状(pricing dimension): 当初「ユーザー数課金」のdimensionだけで製品を公開したが、後から「API呼び出し数課金」を追加したくても既存製品のdimensionは変更不可。
解決策:
1. usage recordはイベント発生直後にBatchMeterUsageを呼ぶ
2. 送信前にDynamoDBで「送信済みフラグ」を管理し、6時間以内であれば再試行
3. pricing dimensionは製品公開前に将来の課金モデルを含めて全て設計する
# 6時間制約対応のusage記録設計
import time
import json
import boto3
marketplace = boto3.client('meteringmarketplace', region_name='us-east-1')
dynamodb = boto3.client('dynamodb')
sqs = boto3.client('sqs')
def record_and_meter_usage(tenant_id, dimension, quantity, retry_queue_url):
timestamp = int(time.time())
record_id = f"{tenant_id}#{dimension}#{timestamp}"
dynamodb.put_item(
TableName='usage-records',
Item={
'recordId': {'S': record_id},
'tenantId': {'S': tenant_id},
'dimension': {'S': dimension},
'quantity': {'N': str(quantity)},
'timestamp': {'N': str(timestamp)},
'metered': {'BOOL': False}
}
)
try:
marketplace.batch_meter_usage(
UsageRecords=[{
'Timestamp': timestamp,
'CustomerIdentifier': tenant_id,
'Dimension': dimension,
'Quantity': quantity
}],
ProductCode='YOUR_PRODUCT_CODE'
)
dynamodb.update_item(
TableName='usage-records',
Key={'recordId': {'S': record_id}},
UpdateExpression='SET metered = :t',
ExpressionAttributeValues={':t': {'BOOL': True}}
)
except Exception:
sqs.send_message(
QueueUrl=retry_queue_url,
MessageBody=json.dumps({'record_id': record_id}),
DelaySeconds=300
)
詰まり⑦: IAM ABACのスケール限界(PrincipalTag数上限・ポリシーサイズ制約)
IAM ABACでtenantIdをPrincipalTagに設定する設計は、テナント数が増えてもIAM構成変更が不要な利点がある。しかし、テナントの属性情報(tier・region・compliance-level等)を全てPrincipalTagで管理しようとすると、IAMロールのタグ上限(50タグ)とポリシードキュメントのサイズ制限(6,144文字/インラインポリシー)に衝突する。
症状: 大規模マルチテナント環境でIAMロールの更新が「LimitExceededException」で失敗し始める。ポリシーにConditionが増えすぎてポリシー評価が遅くなる。
解決策:
1. PrincipalTagはtenantId・tier・regionの3つに限定し、他の属性はDynamoDBのテナントプロファイルで管理
2. 複雑な判断はLambda AuthorizerでPrincipalTagを参照し、アプリケーション層で詳細なアクセス制御を行う
3. 管理対象が増えたらPermission Boundary + Service Control Policy(SCP)の組み合わせでIAM設計を再構成
IAM ABAC スケール設計のガイドライン:
- PrincipalTag: 3〜5個以内(tenantId, tier, region が基本セット)
- インラインポリシー: 1ロールあたり最大2〜3ポリシー・各4,000文字以内で設計
- マネージドポリシー: テナント共通の権限はカスタムマネージドポリシーで管理
- 定期レビュー: 四半期ごとにIAMロール・ポリシー数の増加トレンドを確認
8-2. アンチパターン → 正解パターン変換例 5選
§7のアンチパターン3件と合わせて、§2〜§6から頻出の失敗パターンを追加する。
アンチパターン④: cost allocation tags後付け設定
❌ 失敗パターン: 「最初はMVP優先でタグなしで起動し、後でタグを付ける」という方針を取る。リソースが増えた後でタグを付け直すと、過去のコスト配賦が不明なまま残り、テナント別コスト計算の精度が永久に低下する。AWS Configの「required-tags」ルール違反リソースが数百件以上積み上がった段階で修正しようとすると、手動作業が膨大になる。
✅ 正解パターン: プロビジョニングIaCのテンプレート作成時点からタグブロックを必須フィールドとして定義する。CloudFormationテンプレートのTagsセクションを「REQUIRED: tenant-id, tier, product, environment」の4タグを最低限として設計し、タグなしでのスタック作成をCI/CDで拒否する。
# CloudFormation テンプレートのタグ必須設計
Resources:
TenantTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: !Sub tenant-data-${TenantId}
Tags:
- Key: tenant-id
Value: !Ref TenantId
- Key: tier
Value: !Ref TenantTier
- Key: product
Value: my-saas
- Key: environment
Value: !Ref Environment
アンチパターン⑤: BatchMeterUsageを日次バッチのみで送信
❌ 失敗パターン: 「使用量データをDynamoDBに溜め込み、毎日0時に一括でBatchMeterUsageを送信」という日次バッチ設計。6時間制約を知らないまま本番運用に入ると、全ての使用記録がInvalidUsageRecordExceptionで失敗し、課金が発生しない。気づくのが翌月になる場合もあり、収益への影響が大きい。
✅ 正解パターン: 使用量イベント発生時にリアルタイムでBatchMeterUsageを呼ぶ設計にする。失敗時はSQSに入れ、最大6時間以内に自動リトライする。DynamoDB StatusフィールドでMetered/Pendingを管理し、Pending状態が6時間を超えたレコードはアラートで通知する。
アンチパターン⑥: テナントIDをDB主キーに非使用
❌ 失敗パターン: DynamoDBのPartition KeyをuuidやタイムスタンプベースのIDにし、tenantIdはSort KeyやGSIで管理する設計。アプリケーションコードでtenantIdフィルタを追加すれば分離できると考えるが、実際にはコードのバグや条件漏れでテナント間のデータ参照が発生する。
✅ 正解パターン: Partition Keyを{tenantId}#{entityId}の複合形式にし、LeadingKeysでIAM制御を効かせる。DynamoDBのアクセスパターン全てがPartition Key = tenantIdで始まるため、LeadingKeys条件が全クエリに自動適用される。
# DynamoDB キー設計の正解パターン
Partition Key: "TENANT#{tenantId}"
Sort Key: "ENTITY#{entityId}#{timestamp}"
# LeadingKeys条件でのPK prefix制御
"dynamodb:LeadingKeys": ["TENANT/${aws:PrincipalTag/tenantId}"]
8-3. まとめ
| 章 | 要点 |
|---|---|
| §2 テナント分離 | Pool/Bridge/Siloは成長段階で使い分け。IAM ABACのLeadingKeysでコード変更なしにテナント追加を実現 |
| §3 セルベース | blast radius = 1/Nセル。cell routerのDynamoDBルーティングで段階的セル増設が可能 |
| §4 metering | 6時間制約を守りイベント駆動で送信。pricing dimensionは製品公開前に確定 |
| §5 オンボーディング | べき等性を持つStep Functions + CloudFormationで再実行可能なプロビジョニングを構築 |
| §6 監視・コスト | タグはプロビジョニング時点から必須。noisy neighborはper-tenant concurrency制限で抑制 |
| §7 統合パターン | 成長段階(スタートアップ→成長→エンタープライズ)でPool→Bridge→Siloの移行トリガーを事前定義 |
読者の次のアクション:
– AWS SaaS Builder Toolkit(SBT)の公式ドキュメントでcontrol planeのリファレンス実装を確認する
– AWS Well-Architected Framework SaaS Lensのチェックリストで現在の設計を評価する
– Pool構成で運用中の場合、IAMポリシーシミュレーターでLeadingKeys適用漏れがないか全ロールを検証する
– AWS Marketplaceに出品予定の場合、Sandbox環境でBatchMeterUsageの動作をpricing dimension全件で検証する
8-4. 関連記事クロスリンク
テナント別APIキーとUsage Planを使ったrate/burst/quota設定、noisy neighbor対策、SLAティア別のスロットリング設計を解説。本記事§6のper-tenant throttling実装の詳細はこちら。
API Gateway Usage Plan 実践ガイドを読む
EKS上でのNamespace分離・NetworkPolicy・Pod Security Standardを使ったコンテナワークロードのマルチテナント設計。本記事のテナント分離モデルをKubernetes環境に適用する際の実装詳細を解説。
EKS マルチテナンシー実践ガイドを読む
Cost Allocation Tags・Cost and Usage Report・Savings Plansを組み合わせたAWSコスト最適化の実践ガイド。本記事§6のper-tenantコスト配賦設計と組み合わせることでSaaSのユニットエコノミクス分析が可能になる。
AWSコスト最適化 実践ガイドを読む