App Runner vs ECS Fargate:Pulumiコードから暴く「コストと運用の境界線」
クラウドインフラの進化において、コンテナ実行環境の選択肢は多様化した。AWS App RunnerとAmazon ECS (Fargate)。どちらもサーバーレスの文脈で語られることが多いが、その設計思想、内部アーキテクチャ、そしてコスト構造は根本から異なる。
凡百の記事は「App Runnerは簡単、Fargateは柔軟」という表層的な比較で終わる。しかし、我々SRE・インフラエンジニアが直面するのは、「予測不可能なトラフィック変動におけるミリ秒単位のレイテンシ」「コールドスタートの許容度」「VPC統合に伴う隠れたデータ転送コスト」、そして「IaC(Infrastructure as Code)における状態管理の複雑性」という残酷な現実だ。
本稿では、Pulumi(TypeScript)を用いて両者のコードを極限まで抽象化・構造化し、そのトレードオフの正体をコードの行間から徹底的に剥き出しにする。
—
1. アーキテクチャの根源的違いとPulumiによる抽象化の比較
まずは、両者が背負う「抽象化の代償」をコードの記述量とリソースの粒度から見極める。
AWS App Runnerの思想:プロビジョニングの完全な隠蔽
App Runnerは、ロードバランサー、TLS終端、コンテナランタイム、オートスケーリンググループを1つの「Service」という抽象に閉じ込めている。開発者はインフラの「イ」を意識する必要がない。
Pulumiにおける実装も、`aws.apprunner.Service` 一発で完結する。
ECS Fargateの思想:モジュール性の極みと責任の分散
一方、Fargateは「コンテナが動く基盤(ECS Cluster)」、「タスクの定義(Task Definition)」、「ネットワーク(VPC, Subnet, Security Group)」、「トラフィック制御(ALB, Target Group)」のすべてをエンジニアが明示的に配線する必要がある。
自由度が高い反面、IaCコードのボイラープレート(定型コード)は必然的に肥大化する。
| 比較項目 | AWS App Runner | Amazon ECS (Fargate) |
| :— | :— | :— |
| 抽象度 | 极高(PaaSに近い) | 中〜高(IaaSとPaaSのハイブリッド) |
| VPC接続 | アウトバウンドのみ(VPC Connectorが必要) | ネイティブサポート(ENI直接付与) |
| コールドスタート | 発生し得る(インスタンスゼロスケール時) | ほぼなし(タスク常時起動の場合) |
| Pulumiコード量 | 数十行で完結 | 数百行のモジュール設計が必要 |
—
2. コストとトラフィックの境界線:数式としての判断基準
コンテナ基盤選定の最大の分岐点は「24時間365日の常時稼働コスト」と「リクエスト単価」のトレードオフにある。
App Runnerのコスト構造
- CPU / メモリのプロビジョニングコスト: リクエスト処理中(Active)か、アイドル中(Provisioned)かで単価が変わる。
- アイドル料金: インスタンスが「1つ」常時維持される場合、最小構成(0.25 vCPU / 0.5 GB)でも月額約14ドルが最低ラインとして発生。
- スケーリングの限界: 最大同時リクエスト数を超えると即座にインスタンスが垂直・水平にスケールアップ・アウトするが、スケールダウンは緩慢であり、トラフィックの波が細かい環境では無駄なプロビジョニングコストが発生しやすい。
ECS Fargateのコスト構造
- 完全な従量課金(秒単位): タスクが起動している時間分だけCPU/メモリの料金が発生(最低1分)。
- インフラ固定費の存在: ALB(Application Load Balancer)の料金(約16ドル/月)+NAT Gatewayのデータ処理費・稼働費が無条件で固定費として乗っかってくる。
【結論:どちらを選ぶべきか?の数式】
- App Runnerが勝つ領域: トラフィックが間欠的、または小規模。ALBやVPCの維持費(固定費約$30〜$50/月)を払う価値がないシステム。
- Fargateが勝つ領域:
1. 常に一定以上のベースロードがあり、Fargateのコミットメント(Savings Plans)やスポットインスタンスを活用して単価を圧縮できる場合。
2. ALBの高度なルーティング(パスベース、ホストヘッダーベース、WAF統合)が必須の場合。
—
3. Pulumi実装:App Runner & Fargate の極限構成コード
ここからが本題だ。TypeScriptを用いたPulumiコードで、それぞれの実用的な構成を構築する。ここでは、単なるリソース定義ではなく、本番運用に耐えうる頑健性(冪等性、タグ付けの統一、環境変数のセキュアな注入)を担保したコードを示す。
パターンA:App Runnerの実装(高速デプロイとミニマムコスト)
import as pulumi from “@pulumi/pulumi”;
import as aws from “@pulumi/aws”;
// 1. ECRリポジトリの参照(または作成)
const repo = new aws.ecr.Repository(“my-app-repo”, {
imageScanningConfiguration: { scanOnPush: true },
imageTagMutability: “MUTABLE”,
});
// 2. App Runner用IAMロール(インスタンスロール)
const appRunnerRole = new aws.iam.Role(“app-runner-role”, {
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({
Service: “build.apprunner.amazonaws.com”,
}),
});
new aws.iam.RolePolicyAttachment(“app-runner-ecr-policy”, {
role: appRunnerRole.name,
policyArn: “arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess”,
});
// 3. App Runner サービスの定義
const appRunnerService = new aws.apprunner.Service(“my-app-runner”, {
serviceName: “high-performance-api”,
sourceConfiguration: {
authenticationConfiguration: {
accessRoleArn: appRunnerRole.arn,
},
autoDeploymentsEnabled: true,
imageRepository: {
imageConfiguration: {
port: “8080”,
runtimeEnvironmentVariables: {
“NODE_ENV”: “production”,
“LOG_LEVEL”: “info”,
},
},
imageIdentifier: pulumi.interpolate`${repo.repositoryUrl}:latest`,
imageRepositoryType: “ECR”,
},
},
instanceConfiguration: {
cpu: “1 vCPU”,
memory: “2 GB”,
instanceRoleArn: appRunnerRole.arn, // アプリケーションからAWSリソースへアクセスする場合
},
// 自動スケーリング設定の明示化(デフォルトに依存しないSREの鉄則)
autoScalingConfigurationArn: new aws.apprunner.AutoScalingConfigurationV4(“app-runner-asg”, {
autoScalingConfigurationName: “strict-asg-config”,
maxConcurrency: 100, // 1インスタンスあたりの最大同時リクエスト数
maxSize: 10,
minSize: 1, // コールドスタートを抑制するための最小常時起動数
}).arn,
});
// エンドポイントの公開
export const appRunnerUrl = appRunnerService.serviceUrl;
パターンB:ECS Fargateの実装(完全制御とスケーラビリティ)
次に、Fargateの堅牢な構成だ。VPC、ALB、ECSクラスター、サービス、タスク定義を美しく配線する。
import as pulumi from “@pulumi/pulumi”;
import as aws from “@pulumi/aws”;
import as awsx from “@pulumi/awsx”; // 高度なAWSコンポーネント
// 1. 既存または新規のVPCを活用(awsxでセキュアなVPCを数行で構築)
const vpc = new awsx.ec2.Vpc(“custom-vpc”, {
numberOfAvailabilityZones: 2,
natGateways: { strategy: “Single” }, // コスト最適化のためシングルNAT(本番はPerAZ推奨)
});
// 2. ECSクラスター
const cluster = new aws.ecs.Cluster(“ecs-cluster”, {
name: “production-fargate-cluster”,
});
// 3. ALBの構築
const alb = new aws.lb.LoadBalancer(“app-alb”, {
subnets: vpc.publicSubnetIds,
securityGroups: [/ 適切なSGを指定 /],
loadBalancerType: “application”,
});
const targetGroup = new aws.lb.TargetGroup(“app-tg”, {
port: 8080,
protocol: “HTTP”,
targetType: “ip”,
vpcId: vpc.vpcId,
healthCheck: {
path: “/healthz”,
matcher: “200”,
},
});
const listener = new aws.lb.Listener(“app-listener”, {
loadBalancerArn: alb.arn,
port: 80,
defaultActions: [{
type: “forward”,
targetGroupArn: targetGroup.arn,
}],
});
// 4. ECS タスク実行ロールとタスクロール
const execRole = new aws.iam.Role(“ecs-exec-role”, {
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ Service: “ecs-tasks.amazonaws.com” }),
});
new aws.iam.RolePolicyAttachment(“ecs-exec-policy”, {
role: execRole.name,
policyArn: “arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy”,
});
// 5. タスク定義とFargateサービス
const taskDefinition = new aws.ecs.TaskDefinition(“app-task”, {
family: “fargate-api”,
cpu: “256”, // 0.25 vCPU
memory: “512”, // 0.5 GB
networkMode: “awsvpc”,
requiresCompatibilities: [“FARGATE”],
executionRoleArn: execRole.arn,
containerDefinitions: pulumi.jsonStringify([
{
name: “api”,
image: “123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest”,
portMappings: [{ containerPort: 8080, hostPort: 8080 }],
logConfiguration: {
logDriver: “awslogs”,
options: {
“awslogs-group”: “/ecs/fargate-api”,
“awslogs-region”: “us-east-1”,
“awslogs-stream-prefix”: “ecs”,
},
},
},
]),
});
const service = new aws.ecs.Service(“app-service”, {
cluster: cluster.arn,
taskDefinition: taskDefinition.arn,
desiredCount: 2, // 可用性のための冗長化
launchType: “FARGATE”,
networkConfiguration: {
subnets: vpc.privateSubnetIds,
assignPublicIp: false,
securityGroups: [/ コンテナ用セキュリティグループ /],
},
loadBalancers: [{
targetGroupArn: targetGroup.arn,
containerName: “api”,
containerPort: 8080,
}],
options: { ignoreChanges: [“desiredCount”] }, // オートスケーリング導入時のドリフト防止
});
export const albDnsName = alb.dnsName;
—
4. エキスパートハック:本番運用で見落とされがちな「罠」と最適化
両者を運用する上で、シニアエンジニアが知るべき実践的な知見(ハック)を共有する。
ハック1:App RunnerのVPCコネクタと「データ転送の罠」
App Runnerから内部データベース(RDSなど)にアクセスする場合、App Runner VPC Connectorをアタッチする必要がある。
ここで注意すべきは、VPCコネクタを経由するトラフィックに対して、通常のNAT Gatewayを通るようなデータ処理費やクロスAZ転送費が発生する点だ。高スループットなバッチ処理や大量のDBクエリを叩くアーキテクチャの場合、App Runnerのインフラ代が安く済んでもAWSのネットワーク転送量でコストが爆発する現象が起きる。高スループット系は最初からVPCネイティブなFargateを選ぶべきだ。
ハック2:Fargateのコールドスタート最適化(Init ContainerとCPUバースト)
FargateでJava、Go、Node.jsなどのアプリケーションを起動する際、タスク起動時のCPUが「256 (0.25 vCPU)」だと、起動時の初期化処理(JVMのウォームアップやモジュールのロード)でCPUスロットリングが発生し、ヘルスチェックに失敗してコンテナが無限ループに陥ることがある。
知見: Fargateのタスク定義では、起動時のみCPUを「1 vCPU」以上に割り当て、アプリケーション起動後に軽量なモードに切り替えるか、最初から最小CPUを「512」以上に設定するのが、可用性を担保する上での定石である。
ハック3:Pulumiによるドリフト検知とオートスケーリングの競合
ECS FargateでAWS Application Auto Scalingを組み合わせる場合、Pulumiコード側で `desiredCount` をハードコーディングすると、オートスケーリングがスケールアウトした後にPulumiの `up` を叩いた瞬間、元の `desiredCount` に強制的に引き戻される(スケーリングの巻き戻し事故)。
前述のコード例にも入れた通り、`ignoreChanges: [“desiredCount”]` をPulumiのオプションに付与し、スケール状態の管理をインフラコードから切り離すのが、プロダクション環境におけるデプロイの安全性を守る鉄則である。
—
結言
AWS App RunnerとECS Fargateのどちらを選択すべきか。その答えは極めてシンプルだ。
- 「ビジネスの検証スピード」「インフラ管理からの完全な解放」「予測可能な小中規模トラフィック」を求めるなら、迷わず App Runner をPulumiでシンプルにデプロイせよ。
- 「ミリ単位のネットワーク制御」「複雑なVPCトポロジー」「高スループットに伴うコスト最適化(SpotインスタンスやSavings Plansの適用)」が要件であるならば、ECS Fargate を選び、モジュール化されたIaCで完全なるコントロールを握るべきだ。
ツールに踊らされるな。コストと運用のトレードオフをコードレベルで完全に掌握し、ビジネスの成長速度に最もレバレッジがかかる選択を成し遂げることこそが、真のSREの仕事である。