PulumiでマルチテナントSaaS基盤を構築する設計アンチパターンとベストプラクティス:Stack分割の限界と突破口
こんにちは。テックリードの皆さん、日々のインフラ管理にお疲れ様です。
数千社に及ぶテナントを擁するマルチテナントSaaS基盤の設計・運用において、IaC(Infrastructure as Code)の選択と設計はプロダクトの生死を分けます。
特に、汎用的なプログラミング言語(TypeScript, Python, Goなど)をそのままインフラ定義に使えるPulumiは、その表現力の高さゆえに、設計を誤ると「地獄のスタック肥大化」と「APIレートリミットの嵐」を引き起こします。
今回は、数千テナント規模のSaaS基盤をPulumiで構築・運用する現場で、私が血を流しながら導き出した「Stack分割の限界と、それを突破するための実践的アーキテクチャ」を、コードと設計思想の双方向から徹底解説します。
—
1. 現場が陥る「Stack肥大化」という名のアンチパターン
Pulumiの基本単位は Stack です。開発者は最初はこう考えがちです。
「よし、テナントごとにStackを分けよう。`tenant-a`, `tenant-b`, …, `tenant-9999` だ!」
なぜこれは破綻するのか?
1. Stateファイルの爆発とコンフリクト
数千のStackが存在すると、Pulumi Service(またはバックエンドのS3/GCS)のメタデータ管理が重くなり、`pulumi refresh` や `pulumi preview` の全体実行に耐えられなくなります。
2. 依存関係の迷宮(Cross-Stack Referencesの呪い)
共通基盤(VPC, 共通RDS, 認証基盤)とテナント個別リソースをStack間で参照させすぎると、DAG(有向非巡回グラフ)の解決に途方もない時間がかかります。
3. CI/CDパイプラインの麻痺
1つの共通コンポーネントを修正しただけで、数千のStackすべてで `pulumi up` の差分検知を走らせる必要が生じ、デプロイパイプラインが数時間〜数日単位で止まります。
突破口:Stackの「垂直分割」とテナントの「論理的グルーピング」
大規模SaaSにおける正解は、「テナントごとにStackを作るな。リソースのライフサイクルと権限境界でStackを垂直分割し、テナントはコード内のデータ構造(動的プロビジョニング)として扱え」です。
- Tier 1: Global / Core Stack(IAM, ネットワーク共通基盤, 共通DBクラスタ)
- Tier 2: Regional / Service Stack(ECS/EKSクラスター, 共通ALB, テナント管理用ルーター)
- Tier 3: Tenant Pool Stack(テナントの論理グループ単位でのリソース群。例: `tenant-pool-shard-01`)
—
2. 動的プロビジョニングと並行実行数の最適化
数千テナントを少数のStack(またはShard Stack)内で安全に処理するためには、Pulumiのプログラム内で動的にリソースをループ生成する必要があります。しかし、ここで立ちはだかるのがクラウドプロバイダーのAPIレートリミットと、Pulumi Engineの並行処理(Concurrency)の衝突です。
以下のTypeScriptコードは、テナントごとのスキーマやS3バケットを安全に一括プロビジョニングする、プロダクション品質のパターンです。
import as aws from “@pulumi/aws”;
import as pulumi from “@pulumi/pulumi”;
// 設定からテナントリストを読み込む(実際にはDBやSecrets Managerから動的取得を推奨)
interface TenantConfig {
id: string;
tier: “standard” | “enterprise”;
region: string;
}
const config = new pulumi.Config();
const tenants = config.requireObject
// 共通タグの定義(コスト配分の要)
const commonTags = {
ManagedBy: “Pulumi”,
Architecture: “MultiTenantSaaS”,
CostCenter: “SaaS-Infrastructure”,
};
// リソースの爆発とレートリミットを防ぐため、バッチ処理や非同期制御を意識する
const tenantResources = tenants.map((tenant) => {
// テナントごとのS3バケット(データ分離要件を満たす)
const bucket = new aws.s3.Bucket(`tenant-data-${tenant.id}`, {
bucket: `saas-tenant-${tenant.id}-${pulumi.getStack()}`,
forceDestroy: tenant.tier === “standard”, // スタンダードは容易に削除可能に
tags: {
…commonTags,
TenantId: tenant.id,
TenantTier: tenant.tier,
},
});
// テナントごとの暗号化キー(KMS CMK: Enterpriseは専用キー、Standardは共有キーなど)
const key = new aws.kms.Key(`tenant-key-${tenant.id}`, {
description: `KMS key for tenant ${tenant.id}`,
deletionWindowInDays: 7,
tags: {
…commonTags,
TenantId: tenant.id,
},
});
return {
tenantId: tenant.id,
bucketName: bucket.bucket,
keyArn: key.arn,
};
});
// エクスポートして外部システムから参照可能にする
export const managedTenantsCount = tenants.length;
export const primaryBucketOutputs = tenantResources.map(t => t.bucketName);
プロの知見:並行実行制御のチューニング
Pulumiを実行する際は、環境変数 `PULUMI_PARALLELISM` を適切に設定してください。デフォルトのままだと、数千リソースを同時にAWS APIへ投げてしまい、`ThrottlingException` の嵐になります。
同時リクエスト数を制限し、APIスロットリングを回避する(例: 20並行に絞る)
export PULUMI_PARALLELISM=20
pulumi up –non-interactive –yes
—
3. コスト管理を制するための「厳格なタグ付け戦略」
マルチテナントSaaSにおいて、「どのテナントがどれだけのインフラコストを消費しているか」を可視化できないのは、経営上の致命傷です。AWS Cost Explorerと連携させるためのタグ戦略を、Pulumiの `Transformation` 機能を使って強制します。
個別のリソース定義ごとにタグを書き忘れるヒューマンエラーは、Pulumiのグローバル・トランスフォーメーションで完全に根絶します。
import as pulumi from “@pulumi/pulumi”;
// すべてのリソースに自動的にテナントタグや共通タグを付与するパトロール・フック
pulumi.runtime.registerStackTransformation((args) => {
// S3やRDS、Lambdaなど、タグをサポートするリソースのみ対象外判定を避けて付与
if (args.resource.type.startsWith(“aws:”)) {
args.props.tags = {
…(args.props.tags || {}),
Environment: pulumi.getStack(),
ManagedBy: “Pulumi-Engine”,
Framework: “TypeScript”,
};
}
return { props: args.props, opts: args.opts };
});
このコードをエントリポイント(`index.ts`)の最上部に仕込むだけで、開発者がタグを書き忘れても、AWS上にデプロイされる全リソースに自動でガバナンスが効きます。
—
4. 開発スピードを極限まで高める:実務的ツールチェーン
ここからは、日々の開発・運用で数分、数時間のロスを防ぐための「プロの道具箱」を公開します。
1. 開発効率を爆上げするキーボードショートカット & エディタ設定(VS Code)
PulumiをTypeScriptで書く場合、型補完(IntelliSense)の速度が開発スピードに直結します。
- `Ctrl + Space`(Macは `Cmd + Space`):プロパティの候補を瞬時に呼び出す。
- 絶対入れるべきVS Code拡張機能:
- Pulumi: リソースのホバープレビューやドキュメント参照がエディタ内で完結。
- Error Lens: インフラ定義のエラー(型の不一致など)をコード行の末尾にインライン表示させ、デバッグの手間をゼロにする。
2. チーム開発の共通ルール:Policy as Code (Pulumi CrossGuard)
「開発者が勝手に公開S3バケットを作ってしまった」
これを人力のコードレビューで防ぐのは不可能です。Pulumiの CrossGuard(Policy Pack)を導入し、CI/CDパイプラインのゲートキーパーとして機能させます。
// policy/index.ts (CrossGuard Policy Pack)
import { PolicyPack, validateResource } from “@pulumi/policy”;
import as aws from “@pulumi/aws”;
new PolicyPack(“saas-security-guardrails”, {
policies: [
{
name: “s3-no-public-read”,
description: “マルチテナント環境におけるS3バケットのパブリック公開を厳禁とする”,
enforcementLevel: “mandatory”, // 違反時はデプロイを強制ブロック
validateResource: validateResource(aws.s3.Bucket, (bucket, args, reportViolation) => {
// パブリックアクセスブロックが有効かチェック
// (簡易的なサンプルですが、ACLやPublicAccessBlockを厳格に検査します)
if (bucket.acl && bucket.acl === “public-read”) {
reportViolation(“テナントのS3バケットに public-read ACLを設定することは禁止されています。”);
}
}),
},
],
});
これをCIに組み込むことで、レビュー工数をかけずにセキュアなマルチテナント基盤の品質が担保されます。
—
5. 模範となる設定ファイル構成(実務フォーマット)
数千テナントを扱うプロジェクトでの、ディレクトリ構造と設定ファイルのベストプラクティスです。
.
├── Pulumi.yaml # プロジェクト定義
├── Pulumi.dev.yaml # 開発環境用スタック設定(機密情報は Pulumi Secret で暗号化)
├── Pulumi.prod.yaml # 本番環境用スタック設定
├── index.ts # エントリポイント(トランスフォーメーション登録)
├── tsconfig.json
├── package.json
├── policies/ # CrossGuard ポリシー定義
│ └── index.ts
└── src/
├── config.ts # 設定ローダー
├── core/ # Tier 1: 共通基盤(VPC, IAM)
│ └── network.ts
└── tenants/ # Tier 3: テナント動的プロビジョニングロジック
└── provisioner.ts
`Pulumi.prod.yaml` の実例(シークレットの扱い)
encryptionsalt: v1:xxxxx:yyyyy # Pulumiが自動生成する暗号化ソルト
config:
aws:region: ap-northeast-1
# テナントリストは暗号化(pulumi config set –secret で格納)
myapp:tenants:
secure: v1:AAAAg…[暗号化されたテナントメタデータJSON]…
—
結びに代えて:IaCの「その先」へ
Pulumiを用いたマルチテナントSaaS基盤の構築は、単なる「リソースの静的定義」ではありません。それは「変化し続けるテナントのライフサイクルをコードで美しく調停するシステムエンジニアリング」です。
Stackの肥大化に怯えず、垂直分割と動的プロビジョニング、そしてPolicy as Codeによるガバナンスを組み合わせることで、数千社を抱えても揺るがぬ、強靭で拡張性の高いインフラストラクチャをあなたの手で構築してください。
現場の健闘を祈る。