Pulumiによる数千社マルチテナントSaaS基盤の極限設計:Stack肥大化の呪縛を断ち切るアーキテクチャ
数千社、あるいは数万社に及ぶテナントを収容するマルチテナントSaaS基盤をクラウド上に構築する際、インフラストラクチャ・アズ・コード(IaC)の限界点に最も早く到達するのは「ステート管理の肥大化と並行性の破綻」である。
世の中の入門書は「ひとつの環境にひとつのStack」というお花畑のようなユースケースしか教えてくれない。しかし、現実のSaaSは違う。テナントごとに異なるセキュリティ要件、データムーン(データ所在地)規制、専用リソースの割り当て、そして容赦なく変動するライフサイクルが存在する。
本稿では、Pulumiを骨の髄まで使い倒し、数千テナント規模のSaaS基盤を破綻なく自動化するための設計アンチパターンと、それを打ち破る最先端のベストプラクティスを、低レイヤの挙動とコードの実装に踏み込んで解説する。
—
1. 誰もが陥る「単一Stack肥大化」のアンチパターン
多くのチームが最初に犯す過ちは、すべてのテナントリソースを単一のPulumi Stack(あるいは少数のStack)に詰め込むことだ。
何が起きるのか?
1. Stateファイルの肥大化とOOM: リソース数が10万を超えたあたりから、Pulumi EngineがStateのJSONパースと差分計算(Diff)に消費するメモリが爆発的に増加し、CI/CDランナーがOOM (Out of Memory) でクラッシュする。
2. Blast Radius(影響範囲)の最大化: テナントAの設定変更を適用したつもりが、ステートのロック競合や依存関係の誤認により、無関係なテナントBのデータベースがダウンする。
3. 並行性の完全な欠如: 1つの巨大なStateに対してロックがかかるため、複数のテナントプロビジョニングを並行して走らせることが不可能になる。CIのパイプラインがボトルネックとなり、新規テナントのオンボーディングに数十分かかるようになる。
[アンチパターン: 巨大な単一Stack]
Pulumi CLI ──> [巨獣のようなState File (数GB)]
├── Tenant A (AWS RDS, S3…)
├── Tenant B (AWS RDS, S3…)
└── Tenant 9999 (AWS RDS, S3…)
※ 1つの変更で全テナントが人質に取られる。
—
2. 突破口:動的プロビジョニングと「テナント分割型Stackアーキテクチャ」
この限界を突破するためには、「テナントライフサイクルとインフラストラクチャのライフサイクルを完全に切り離す」必要がある。
私たちは、数千のテナントを静的なStackとしてハードコードするのではなく、Pulumi Automation APIを組み込んだ独自のコントロールプレーン(APIサービス)を構築し、テナントごとに動的にスタックを生成・破棄するアーキテクチャを採用すべきだ。
テナント分割の粒度設計
テナント規模に応じ、以下のハイブリッド戦略をとる。
- Free/Standardプラン: マルチテナント共有リソース(スキーマ分離など)とし、リソースはグローバルな共通Stackで管理。
- Enterpriseプラン: 完全分離(Dedicated Tenant)。1テナント=1専用Pulumi Stack(`tenant-{tenant_id}`)として動的生成。
—
3. 実装:Pulumi Automation APIによる動的プロビジョニング・エンジン
手動で `pulumi stack init` を叩く人間など、このアーキテクチャには存在しない。テナント登録APIが叩かれた瞬間に、プログラムが自律的にStackを生成し、デプロイを実行するPython(またはTypeScript)によるプロビジョニングエンジンの核心部を示す。
import os
import pulumi
from pulumi import automation as auto
def deploy_tenant_infrastructure(tenant_id: str, tier: str, region: str):
“””
Pulumi Automation APIを用いて、テナントごとの専用インフラストラクチャを
プログラムから動的にプロビジョニング・更新する。
“””
project_name = “saas-tenant-engine”
stack_name = f”tenant-{tenant_id}”
# インフラ定義を行うインラインプログラムの定義
def pulumi_program():
import pulumi_aws as aws
# テナント専用のS3バケット(データ分離要件を満たす)
bucket = aws.s3.Bucket(f”tenant-data-{tenant_id}”,
bucket=f”saas-tenant-{tenant_id}-{region}”,
acl=”private”,
opts=pulumi.ResourceOptions(
# 誤削除を防ぐための保護
protect=True if tier == “Enterprise” else False
)
)
# テナント識別用のコスト配分タグを強制付与
pulumi.export(“bucket_name”, bucket.id)
pulumi.export(“tenant_id”, tenant_id)
try:
# 1. 既存のワークディレクトリまたはインラインでStackを初期化/取得
# バックエンドはセキュアなS3/GCSバケット、またはPulumi Service Backendを指定
stack = auto.create_or_select_stack(
stack_name=stack_name,
project_name=project_name,
program=pulumi_program,
work_dir=os.path.join(“/tmp/pulumi”, tenant_id)
)
# 2. クラウドプロバイダーのコンフィグを動的に注入
stack.set_config(“aws:region”, auto.ConfigValue(value=region))
stack.set_config(“saas:tier”, auto.ConfigValue(value=tier))
print(f”[{tenant_id}] インフラストラクチャのデプロイを開始します…”)
# 3. べき等性を担保した状態でプレビュー&アップデートを実行
# 競合を防ぐため、Automation API内部でロックが制御される
up_res = stack.up(on_output=print)
print(f”[{tenant_id}] デプロイ成功: Outputs -> {up_res.outputs}”)
return up_res.outputs
auto.ConcurrentUpdateError as e:
# 並行実行時の競合をハンドリングし、必要に応じてリトライキューへ積む
print(f”[{tenant_id}] デプロイが他のプロセスと競合しました: {e}”)
raise e
except Exception as e:
print(f”[{tenant_id}] デプロイメント致命的エラー: {e}”)
raise e
実行例(コントロールプレーンからの呼び出し)
if __name__ == “__main__”:
deploy_tenant_infrastructure(
tenant_id=”acme-corp-001″,
tier=”Enterprise”,
region=”ap-northeast-1″
)
—
4. 並行実行数(Concurrency)の最適化とスロットリング
数千のテナントが一斉にアップデート(あるいはプロビジョニング)を行う場合、クラウドプロバイダー(AWS/GCP/Azure)のAPIレートリミット(Throttling)に必ず激突する。また、CI/CDワーカーのCPU/メモリ枯渇も招く。
究極の並行制御ハック
1. 分散タスクキュー(Celery + Redis または AWS SQS)の導入:
テナントのプロビジョニングリクエストは直接実行せず、すべてキューイングする。
2. トークンバケットアルゴリズムによるAPIスロットリング:
Pulumiのプロセスが同時にAWS APIを叩く上限を制御する(例: 同時実行数最大10まで)。
3. Pulumiの環境変数チューニング:
並行処理時のパフォーマンスを最大化するため、以下の環境変数をランナーに強制する。
同時に処理するプロバイダーリクエストの最大数(APIレートリミット対策)
export PULUMI_PARALLELISM=20
ログの冗長性を抑え、メモリ消費を最適化
export PULUMI_LOG_LEVEL=info
—
5. コスト管理の生命線:強制タグ付け戦略(Tagging Strategy)
「どのテナントがいくらのインフラストラクチャコストを発生させているか」を正確に把握できなければ、SaaS事業はunit economics(単体経済性)の悪化によって崩壊する。
手動のタグ付けに頼るなど言語道断である。PulumiのProviderの `default_tags` 機能とResource Policy(政策ガードレール)を組み合わせ、コードベースのレベルで「タグの付いていないリソースの存在を許さない」構造を作る。
import pulumi
import pulumi_aws as aws
グローバルに強制されるデフォルトタグ
すべてのリソースにコスト配分とオーナーシップを義務付ける
mandatory_tags = {
“ManagedBy”: “Pulumi”,
“SaaSPlatform”: “CoreEngine-v2”,
“CostCenter”: “TenantInfrastructure”,
}
AWSプロバイダーにデフォルトタグを強制適用
これにより、個別のリソース定義でタグを書き忘れても、AWS側で自動付与される
aws_provider = aws.Provider(“aws-provider”,
region=”ap-northeast-1″,
default_tags=aws.ProviderDefaultTagsArgs(
tags=mandatory_tags
)
)
テナント固有のリソース定義には、動的テナントIDタグを付与
def create_tenant_bucket(tenant_id: str):
return aws.s3.Bucket(f”tenant-bucket-{tenant_id}”,
bucket=f”saas-secure-storage-{tenant_id}”,
# default_tagsに加えて、テナント固有タグをマージ
tags={
“TenantID”: tenant_id,
“Environment”: “Production”
},
opts=pulumi.ResourceOptions(provider=aws_provider)
)
さらに厳格を期すならば、Pulumi Policy (CrossGuard) を用い、CI/CDパイプライン上で「`TenantID` タグのない `aws.s3.Bucket` が定義されていたら即座にビルドを失敗させる」ポリシーコードを義務付けるべきだ。
—
6. 失敗しないためのアーキテクチャチェックリスト
数千社規模のマルチテナントSaaSをPulumiで構築・運用するにあたり、以下の鉄則を死守せよ。
- [ ] Stateの爆発を防げ: テナントごとにStackを分離し(Automation APIを活用)、1つのStateが数千リソースを超えないようにしているか?
- [ ] API制限を考慮したか: キューイングシステムを挟み、`PULUMI_PARALLELISM` を適切にチューニングしてクラウドプロバイダーのスロットリングを回避しているか?
- [ ] コストの可視化は完璧か: プロバイダーレベルの `default_tags` と CrossGuard によるポリシーチェックで、無タグリソースの発生を物理的に封じているか?
- [ ] DR(ディザスタリカバリ)戦略はあるか: テナントごとのStateバックエンド(S3等)が、リージョン障害や誤削除に対してバージョニングおよびクロスリージョンレプリケーションされているか?
結びにかえて
Pulumiの真価は、単なる「YAMLの代替としてのプログラミング言語によるIaC」にあるのではない。「インフラストラクチャをソフトウェアとして扱い、APIやビジネスロジックと完全に統合する」ことのてこ入れにある。
数千社に及ぶテナントを管理するSaaS基盤において、力技の運用は確実に破綻する。Automation APIを駆使した動的プロビジョニング基盤を構築し、インフラストラクチャの神髄を自らの手で掌握せよ。