月に数回しか走らないバッチがあります。たとえば、ヘッドレスブラウザーを起動してサイトを巡回し、各ページを外部 API に送信して結果を保存するような重い処理です。1 回の実行に CPU とメモリを多く使い、外向き通信も一定量発生します。
この処理を常駐サービスとして動かすと、いくつか運用上の問題が起きます。
# 常駐構成のイメージ: サービスとして 1 タスクを常に起動しておく
resource "aws_ecs_service" "batch" {
name = "batch"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.batch.arn
desired_count = 1 # 待機中も 1 タスク分が動き続ける
launch_type = "FARGATE"
}
desired_count = 1 のサービスは、バッチが走っていない時間も 1 タスク分の Fargate 料金が発生します。月に数回しか使わないなら、稼働時間のほとんどが待機です。さらに、スケールの単位がタスク数になるため、入力が増えたときの増やし方も粗くなります。失敗時のリトライやタイムアウトも、サービス側に自前で書くことになります。
欲しいのは「必要なときだけ起動して、終わったら停止する」構成です。起動・完了待ち・リトライ・失敗判定を 1 か所で宣言でき、バッチが動いていない時間に Fargate タスクを常駐させるコストを払わない形です。この記事では、それを AWS Step Functions と ECS Fargate で構成します。
本番運用では、さらに別の論点があります。最小の起動・完了待ちだけなら、状態機械は Task State 1 つで表現できます。ただし、本番では Retry / Catch / Timeout / 入出力の設計は状態機械側の責務として残ります。そのうえで、状態機械だけでは足りず、状態機械から ECS を起動する権限をどこまで制限するか、予算閾値で遮断フラグをどう立てるか、外向き通信のためのネットワークをどう作るかも設計対象になります。この記事は後半をそこに割きます。
対象バージョンは Terraform (AWS provider 5 系)、Step Functions の標準ワークフロー (ASL)、ECS Fargate (Linux / ARM64) です。コードは実装を一般化した最小例で、project / environment などの変数名と batch / worker のような汎用名を使います。
Step Functions が Fargate タスクを起動して完了まで待つ
まず最小の動く形から始めます。状態機械を 1 つの Task State だけで作り、その State で ECS タスクを起動します。重要なのは、起動して終わりにせず、タスクの完了まで状態機械が待つことです。
.sync の有無で、状態機械がどこまで責任を持つかが変わります。リソースに arn:aws:states:::ecs:runTask (.sync なし) を指定すると、Step Functions はタスクを起動した直後に成功扱いで次へ進みます。タスクがその後で失敗しても、状態機械はそれを知りません。後続のステップや失敗判定が、タスクの実際の結果と無関係に進んでしまいます。
arn:aws:states:::ecs:runTask.sync を使うと、Step Functions はタスクを起動したあと完了まで状態を保持して待ち、タスクの終了コードで成功・失敗を判定します。オンデマンドバッチで完了を見届けたい場合は、.sync を使います。
次は、1 つの Task State で Fargate タスクを起動し、完了まで待ち、失敗を Catch して Fail State へ遷移させる最小の状態機械です。
resource "aws_sfn_state_machine" "batch" {
name = "${var.project}-${var.environment}-batch"
role_arn = aws_iam_role.sfn_execution.arn
definition = jsonencode({
Comment = "オンデマンドバッチのオーケストレーション"
StartAt = "RunBatch"
States = {
RunBatch = {
Type = "Task"
Resource = "arn:aws:states:::ecs:runTask.sync" # 完了まで待つ
Parameters = {
Cluster = var.ecs_cluster_arn
TaskDefinition = aws_ecs_task_definition.batch.arn
LaunchType = "FARGATE"
NetworkConfiguration = {
AwsvpcConfiguration = {
Subnets = var.task_subnet_ids
SecurityGroups = [var.task_security_group_id]
AssignPublicIp = "ENABLED"
}
}
}
End = true
Retry = [{
ErrorEquals = ["States.TaskFailed"]
IntervalSeconds = 10
MaxAttempts = 2
BackoffRate = 2
}]
Catch = [{
ErrorEquals = ["States.ALL"]
Next = "BatchFailed"
}]
}
BatchFailed = {
Type = "Fail"
Error = "BatchError"
Cause = "バッチタスクが失敗しました"
}
}
})
logging_configuration {
level = "ERROR"
include_execution_data = false
log_destination = "${aws_cloudwatch_log_group.sfn.arn}:*"
}
}
Retry と Catch は、失敗時の扱いが異なります。Retry は States.TaskFailed (ECS タスクの失敗として扱われたエラー。コンテナーの非ゼロ終了など) を最大 2 回まで指数バックオフで再試行します。それでも失敗したら、Catch が States.ALL (あらゆるエラー) を捕捉して BatchFailed へ遷移させ、状態機械全体を Fail で終わらせます。これにより、起動・完了待ち・リトライ・失敗確定が 1 つの定義にまとまります。
.sync の完了待ちと長時間実行は、Step Functions の標準ワークフロー (Standard) の特性です。短時間・高頻度向けの Express ワークフローでは .sync による同期統合がサポートされていないため、この記事は Standard を前提にします。
状態機械の入力を ECS タスクの環境変数へ差し込む
1 つのタスク定義を、入力を変えて何度も起動したいことがあります。たとえば対象 ID や URL のリストを実行ごとに変えて、同じコンテナーイメージで処理させる場合です。これは ContainerOverrides の Environment で実現します。
Parameters の中で、キー名を "Value.$" のように末尾 .$ 付きにすると、値を実行入力からの JSONPath として解釈します。"$.targetId" は状態機械の実行入力の targetId フィールドを指します。
Overrides = {
ContainerOverrides = [{
Name = "worker"
Environment = [
{
"Name" = "TARGET_ID"
"Value.$" = "$.targetId"
},
{
"Name" = "TARGET_URLS"
"Value.$" = "States.JsonToString($.urls)"
},
]
}]
}
環境変数の値は文字列でなければなりません。実行入力の urls が配列やオブジェクトの場合、"Value.$" = "$.urls" のまま渡すと型が合わず失敗します。States.JsonToString($.urls) (配列・オブジェクトを JSON 文字列に変換する Step Functions の組み込み関数) で文字列化してから渡し、コンテナー側で受け取った文字列を JSON としてパースします。スカラ値は Value.$ で参照できますが、コンテナー内の環境変数としては文字列として扱います。配列やオブジェクトにはこの変換が必要です。
Step Functions から ECS を起動する最小権限に寄せた IAM
状態機械を動かすには、Step Functions の実行ロールに ECS を起動する権限を与えます。素直に書くと次のようになります。
# 動作確認用の構成: 動くが、権限が広すぎる
resource "aws_iam_role_policy" "sfn_ecs" {
name = "${var.project}-${var.environment}-sfn-ecs"
role = aws_iam_role.sfn_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["ecs:RunTask", "ecs:StopTask", "ecs:DescribeTasks"]
Resource = "*" # すべてのクラスタ・すべてのタスク定義
}]
})
}
これは動きます。ただし Resource = "*" は、このアカウントの任意のクラスタで任意のタスク定義を起動・停止できる権限です。本来はバッチ用の 1 タスク定義を、特定の 1 クラスタで起動したいだけです。状態機械の実行ロールが侵害されたとき、この差がそのまま被害範囲の差になります。
権限を制限する軸は、アクションごとに異なります。Resource で制限できるものと、Resource では制限できず Condition で制限するものに分かれます。
ecs:RunTask は、起動するタスク定義の ARN を Resource で指定できます。この用途では、リビジョン番号を含まないタスク定義 ARN を使います。タスク定義は更新するたびにリビジョン番号 (...:family:3 の 3) が上がります。arn_without_revision (AWS provider が提供する、リビジョンを含まない ...:family までの ARN) に :* を付けて指定します。たとえば ...:task-definition/my-family:3 のようなリビジョン付き ARN ではなく、...:task-definition/my-family:* の形にして、同じ family の各リビジョンを許可します。こうすると、family が一致するどのリビジョンでも起動を許しつつ、他の family は許しません。さらに Condition ArnEquals で ecs:cluster を指定し、起動先クラスタを限定します。
実際の .sync 統合では、タスク完了を待つために EventBridge / CloudWatch Events 関連の権限が必要になる場合があります。ここでは ECS 起動権限と iam:PassRole の制限に焦点を当て、実際のポリシーは Step Functions の統合パターンが要求する権限と合わせて確認します。
ecs:StopTask と ecs:DescribeTasks では、Resource に指定できる対象が異なります。これらが対象とするのは「実行中のタスク」の ARN で、タスク ID は起動のたびに動的な値となります。ポリシーを書く時点ではこの ARN が分からないため、Resource で具体的に制限できません。そこで Resource は "*" のままにし、Condition ArnEquals で ecs:cluster を当該クラスタへ限定して、対象を「このクラスタのタスク」へ制限します。
| アクション | Resource の制限 | Condition |
|---|---|---|
ecs:RunTask |
arn_without_revision:* で family を限定 |
ArnEquals ecs:cluster で起動先クラスタを限定 |
ecs:StopTask |
実行中タスクの ARN は動的で事前に不定なため "*"
|
ArnEquals ecs:cluster で対象クラスタを限定 |
ecs:DescribeTasks |
同上、"*"
|
ArnEquals ecs:cluster で対象クラスタを限定 |
最小権限に寄せると、ポリシーは次のように分けられます。
# 最小権限に寄せた例: task definition family とクラスタを限定する
resource "aws_iam_role_policy" "sfn_ecs" {
name = "${var.project}-${var.environment}-sfn-ecs"
role = aws_iam_role.sfn_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "RunSpecificTaskDefinition"
Effect = "Allow"
Action = "ecs:RunTask"
Resource = "${aws_ecs_task_definition.batch.arn_without_revision}:*"
Condition = {
ArnEquals = { "ecs:cluster" = var.ecs_cluster_arn }
}
},
{
Sid = "ControlTasksInCluster"
Effect = "Allow"
Action = ["ecs:StopTask", "ecs:DescribeTasks"]
Resource = "*"
Condition = {
ArnEquals = { "ecs:cluster" = var.ecs_cluster_arn }
}
},
]
})
}
この arn_without_revision と Condition の挙動は AWS provider と ECS の仕様に依存します。リージョンやパーティション(aws / aws-cn / aws-us-gov)をまたぐ構成では、ARN の組み立て方を見直す必要があります。
iam:PassRole は渡すロールだけに限定する
最小権限に寄せた IAM でもう 1 つ見落としやすいのが iam:PassRole です。Step Functions が ECS タスクを起動するとき、タスクに割り当てるロール (後述の execution role と task role) を ECS へ「渡し」ます。この受け渡しには、状態機械の実行ロールに iam:PassRole 権限が必要です。
ここを Resource = "*" にすると、状態機械の実行ロールは ECS に対して任意のロールを渡せることになります。ECS タスクに本来は無関係な、より強い権限のロールを渡して起動する経路が生まれます。PassRole は、実際に渡す 2 つのロールの ARN だけに限定します。
{
Sid = "PassTaskRolesOnly"
Effect = "Allow"
Action = "iam:PassRole"
Resource = [
aws_iam_role.ecs_execution.arn, # タスク起動時に AWS が使うロール
aws_iam_role.ecs_task.arn, # コンテナ内のアプリが使うロール
]
Condition = {
StringEquals = {
"iam:PassedToService" = "ecs-tasks.amazonaws.com"
}
}
}
この 2 つのロールが何を担い、なぜ分けるべきなのかを次の節で整理します。
execution role と task role を分ける
ECS タスクには 2 種類のロールが関わります。名前が似ているうえ責務が異なるため、混同しやすい箇所です。
- execution role (タスク実行ロール)。タスクの起動フェーズで AWS のエージェントが使うロールです。ECR からのイメージ pull、CloudWatch Logs へのログ送信、SSM や Secrets Manager からのシークレット注入といった「起動のための AWS 側の作業」に使われます。
- task role (タスクロール)。タスクの実行フェーズで、コンテナー内のアプリケーション自身が使うロールです。アプリが DB の認証情報を取得する、別の AWS API を呼ぶ、といった「アプリのコードが実行時に行う AWS 操作」に使われます。
この境界で特に混同しやすいのが、タスク定義の secrets ブロックです。secrets は execution role 経由で注入されます。つまり、シークレットを SSM や Secrets Manager から読み出して環境変数にセットするのは AWS の起動エージェントであって、アプリ自身ではありません。アプリは環境変数として渡された値を読むだけで、SSM を直接読みにいくわけではありません。
resource "aws_ecs_task_definition" "batch" {
family = "${var.project}-${var.environment}-batch"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 1024
memory = 2048
execution_role_arn = aws_iam_role.ecs_execution.arn # 起動フェーズ
task_role_arn = aws_iam_role.ecs_task.arn # 実行フェーズ
container_definitions = jsonencode([{
name = "worker"
# ... image, logConfiguration など
secrets = [
# この注入は execution role の権限で行われる (アプリではない)
{ name = "EXTERNAL_API_KEY", valueFrom = var.api_key_parameter_arn },
]
}])
}
この分け方をすると、それぞれのロールに必要な権限が分かれます。execution role には ECR pull・CloudWatch Logs 書き込み・シークレット注入元(SSM / Secrets Manager)の読み取りだけを与えます。task role にはアプリが実行時に必要とする操作(たとえば DB 認証情報の取得)だけを与えます。一方のロールが侵害されても、もう一方の権限までは及びません。
予算閾値で遮断フラグを立てるサーキットブレーカー
オンデマンドバッチで外部 API を呼ぶと、実行のたびに従量課金が積み上がります。月の途中で予算を大きく超える前に、遮断フラグを立てて止めたいという要件があります。このため、サーキットブレーカーを 1 段組みます。
仕組みは 2 つの部品に分かれます。1 つは、アプリが累計コストを CloudWatch のカスタムメトリクスとして書き込む経路。もう 1 つは、書き込まれたメトリクスを閾値で監視し、超えたら遮断フラグを立てる経路です。
アプリは処理のたびに、推定コストを PutMetricData (CloudWatch にカスタムメトリクスを書き込む API) で送ります。そして、起動時とコスト計上の前に、SSM の tripped フラグ (遮断状態を表す真偽値のパラメーター) を読みます。tripped が立っていたら、タスク起動や課金につながる処理をその時点で止めます。
CloudWatch 側は、メトリクスを 3 段の閾値で監視します。予算の 70% で警告、85% で承認要求、95% で遮断フラグ更新という段階です。
locals {
thresholds = {
warning = 0.70 # 警告通知のみ
approval = 0.85 # 承認が必要な水準
tripped = 0.95 # 遮断フラグを立てる
}
}
resource "aws_cloudwatch_metric_alarm" "budget" {
for_each = local.thresholds
alarm_name = "${var.project}-${var.environment}-budget-${each.key}"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 1
metric_name = "MonthlyCostUsd"
namespace = "App/CircuitBreaker"
period = 300
statistic = "Maximum"
threshold = var.monthly_budget_usd * each.value
treat_missing_data = "notBreaching" # データ点が無い期間を誤って異常としない
dimensions = {
Service = var.service_name
Environment = var.environment
}
alarm_actions = var.sns_topic_arn != "" ? [var.sns_topic_arn] : []
}
treat_missing_data = "notBreaching" (データ点の無い評価期間をどう扱うかの設定) を指定する理由は、バッチの非稼働時間帯にメトリクスが届かない点にあります。データ未到達の期間がアラーム判定に影響し、意図しない状態遷移につながることがあります。バッチのように間欠的なメトリクスを出す処理では、未到達を正常側として扱うため、明示的に notBreaching を指定します。
ここで重要なのは、alarm_actions に SNS topic を指定しただけでは tripped は更新されないことです。アラームは SNS に通知し、購読先の Lambda が tripped パラメーターを true に更新する構成にします。本文の Terraform 例ではアラーム部分だけを示し、SSM を更新する Lambda は省略しています。85% の承認要求についても、通知・手動運用・承認ワークフローのどれで実装するかを別途決めます。
遮断フラグの SSM パラメーターには、Terraform の lifecycle ブロックを使います。
resource "aws_ssm_parameter" "tripped" {
name = "/app/${var.environment}/circuit-breaker/${var.service_name}/tripped"
type = "String"
value = "false"
lifecycle {
ignore_changes = [value] # 手動 override を Terraform が apply で戻さない
}
}
ignore_changes = [value] (Terraform が当該属性の drift を無視し、apply で上書きしない設定) を付ける理由は、運用中にこのフラグを手動で立てたり戻したりするためです。インシデント対応で tripped を手動で true に変更したあと、次の terraform apply が初期値 false に戻してしまっては困ります。ignore_changes によって、このパラメーターの値は Terraform の管理外として扱います。
ただし、この遮断が見ているのは「アプリが自分で計上した推定コスト」です。AWS の実請求額そのものではありません。アプリが各処理にコストを割り当てて積算した近似値です。実際の請求とは、課金の遅延や見積もりの誤差の分だけずれます。
その近似を受け入れる理由は、即時性と粒度です。この遮断は分単位で反応し、サービス単位・テナント単位で個別に止められます。一方、AWS Budgets(予算を設定して実コストやその予測を監視するサービス)は実請求額に基づきますが、更新は日次で、粒度はアカウントやタグ単位です。両者は排他ではありません。分単位・サービス単位の即時遮断はこのアプリ側の仕組みで行い、実請求額に対する最終的な歯止めは AWS Budgets を併用する、という二段構えにします。
NAT Gateway の時間課金を持たないネットワーク構成
このバッチは外向き通信が必要です。クロール対象や外部 API、AWS の各サービスに到達できなければなりません。awsvpc モードの Fargate タスクで外向き通信を確保する方法は主に 2 つです。1 つは private subnet に置いて NAT Gateway (private subnet からの外向き通信を中継するマネージドゲートウェイ。固定費と処理量課金がかかる) 経由で出す方法。もう 1 つは public subnet に置いてパブリック IP を割り当てる方法です。
月に数回だけ起動するバッチなら、本記事では public subnet + パブリック IP を選びます。NAT Gateway は、トラフィックが無い時間も固定費がかかり、通過するデータ量にも課金されます。月に数回しか走らないバッチのために常時の固定費を払うのはコストに見合いません。タスクを public subnet に置き、起動時にパブリック IP を割り当てれば、NAT Gateway の時間課金を持たずに外向き通信を確保できます。
注意すべきは「パブリック IP を持つこと」と「インバウンドを公開すること」は別だという点です。セキュリティグループでインバウンドを一切開けず、アウトバウンド(egress)だけを許可すれば、外から接続を受け付けることなく、外向き通信だけができます。
resource "aws_security_group" "task" {
name = "${var.project}-${var.environment}-task-sg"
description = "On-demand batch task. egress only."
vpc_id = var.vpc_id
# ingress は一切定義しない (外からの接続を受け付けない)
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
| 観点 | private subnet + NAT Gateway | public subnet + パブリック IP(本構成) |
|---|---|---|
| NAT Gateway の固定費 | 時間課金が常時発生 | 不要 |
| データ課金 | NAT Gateway の処理量課金 + データ転送 | データ転送。public IPv4 等の課金は別途確認 |
| インバウンド | 閉じる | egress 専用 SG で外部からの接続を受け付けない |
| 向くケース | 常時稼働・private 配置が要件 | 間欠起動でコストを抑えたい |
AWS の各サービス (SSM、ECR、Logs など) への通信だけを VPC 内で完結させたい場合は、VPC エンドポイント (対象サービスへの通信を VPC 内の経路で行う仕組み) という選択肢もあります。VPC エンドポイントは AWS サービス向けの経路をインターネット経由にせずに済みますが、インターフェース型は本数あたりの時間課金があり、外部 API やクロール対象のような AWS 外への通信には別途出口が要ります。本記事のバッチは AWS 外への通信が主目的のため、public subnet + egress 専用 SG を選んでいます。
実行基盤には ARM64 (Graviton) (AWS が設計した ARM ベースのプロセッサ。Fargate でも利用可能) を選べます。ワークロードによっては x86 よりコストを抑えやすく、ローカルの開発機が Apple Silicon ならネイティブにビルドしやすい、という利点があります。
runtime_platform {
cpu_architecture = "ARM64"
operating_system_family = "LINUX"
}
ただし ARM64 への移行を低コストで済ませられるのは、依存ライブラリが ARM64 のビルドを提供している場合に限ります。ネイティブ拡張を含む依存が x86 前提の場合、ビルドや実行で追加対応が要ることもあります。また、Docker image も linux/arm64 としてビルドし、タスク定義の runtime_platform と合わせる必要があります。「Apple Silicon でそのままネイティブビルドできる」構成であれば、移行コストは小さく収まる、という限定つきの利点です。
ログの保持期間は環境で分けられます。本番は長め、それ以外は短めにして、保管コストを抑えます。
resource "aws_cloudwatch_log_group" "task" {
name = "/ecs/${var.project}-${var.environment}-batch"
retention_in_days = var.environment == "prod" ? 90 : 14
}
SG をルートに引き上げて Terraform の循環参照を避ける
最後に、構成を組むときに Terraform 自体で起きる依存の問題に触れます。バッチタスク用のセキュリティグループ(SG)と、接続先の DB が相互参照すると、terraform plan が循環参照で失敗します。
起きやすいのは次の形です。DB モジュールが「どの SG からの接続を許すか」を表す allowed_sg_ids を持ち、そこにタスクの SG を渡したい。一方でタスクの SG はタスクモジュールの中で定義されている。さらにタスクが DB に接続するため、タスクモジュールは DB の情報を受け取りたい。すると、タスクモジュール → DB モジュール、DB モジュール → タスクモジュールの SG、という両方向の依存ができ、循環します。
解き方は、SG をどの層に置くかを変えることです。SG をタスクモジュールの中ではなく、ルート(両モジュールを呼び出す側)で定義します。そして、その SG の ID を両方のモジュールに変数で渡します。こうすると、両モジュールは「ルートで作られた SG を受け取る」だけになり、モジュール間の直接の相互参照が消えます。
# ルート (main.tf): SG をルートで定義する
resource "aws_security_group" "task" {
name = "${var.project}-${var.environment}-task-sg"
vpc_id = module.vpc.vpc_id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
module "db" {
source = "./modules/db"
allowed_sg_ids = [aws_security_group.task.id] # ルートの SG を受け取る
}
module "batch" {
source = "./modules/batch"
task_sg_id = aws_security_group.task.id # 同じ SG を受け取る
db_host = module.db.address # DB は出力だけ参照
}
ここから一般化できるのは、リソースをどの層(ルート / モジュール)に置くかで依存の向きが決まる、ということです。2 つのモジュールから参照されるリソースは、どちらかのモジュールの内側ではなく、両者を呼び出す側に引き上げると、依存が一方向に揃います。
この構成で得られる性質
- 必要なときだけ Fargate タスクを起動する。Step Functions + Fargate の
.sync統合で、バッチは必要なときだけ起動し、完了後に停止します。Fargate タスクを常駐させる待機コストを払わずに済みます。 - 起動・待機・リトライ・失敗を 1 つの定義にまとめる。状態機械の
Retry/Catch/Failで、処理の流れを宣言的に書けます。リトライやタイムアウトをアプリ側へ散らさずに済みます。 - 起動権限を最小権限に寄せる。
ecs:RunTaskを family + クラスタに限定し、PassRoleを渡すロールだけに限定することで、実行ロールが侵害されたときの被害範囲を狭めます。 - 予算閾値で遮断フラグを立てる。アプリ計上のコストを 3 段閾値で監視し、
trippedフラグで分単位・サービス単位に遮断します。実請求額の歯止めは AWS Budgets を併用します。 - NAT Gateway の時間課金を持たない。public subnet + egress 専用 SG で、間欠起動のバッチに常時の NAT Gateway の時間課金を払わずに外向き通信を確保します。
そして冒頭の主張に戻ると、最小の起動・完了待ちだけなら状態機械は Task State 1 つで表現できます。本番運用では、状態機械側の Retry / Catch / Timeout / 入出力設計に加えて、IAM の権限制限・予算遮断・ネットワークも設計対象になります。
この構成で解けないこと(適用範囲)
「必要なときだけ起動」の利点を活かせるのは、起動頻度が低く、1 回の実行で重い処理を行うバッチに限られます。常時トラフィックの高い処理では、常駐 + autoscaling のほうがコストを抑えられる場合もあります。また、Fargate のタスク起動にはイメージ pull やプロビジョニングの待ち時間があるため、低レイテンシが要るオンライン処理には向きません。オンデマンドバッチの「起動して数分〜数時間動く」想定での選択です。
.sync 統合は長時間の実行を待てますが、標準ワークフローには状態遷移単位の課金とサービスクォータがあります。極端に多数のタスクを並列起動する用途では、Map / 分散 Map や、バッチ専用のサービスが比較対象になります。1 回の実行で起動するタスクが少数、という前提での構成です。
「状態機械だけでは足りない」と書いたのは、状態機械側の設計が不要という意味ではありません。リトライ・Catch・タイムアウト・入出力の設計は状態機械側の責務として残ります。状態機械は最小構成で済ませられる一方、本番運用に必要な設計コストは IAM・予算遮断・ネットワーク側にも大きく乗る、という意味です。
コスト遮断は前述の通りアプリ計上の推定コストに基づく近似で、実請求額そのものではありません。実額に対する最終的な歯止めは AWS Budgets を併用して二段構えにします。IAM の arn_without_revision や Condition の挙動は AWS provider と ECS の仕様に依存し、リージョンやパーティションをまたぐ場合は ARN の組み立てを見直す必要があります。また、Step Functions の .sync 統合で必要となる権限は統合パターンや provider の生成ポリシーと合わせて確認してください。
参考情報
- AWS Step Functions — 開発者ガイド
- Manage Amazon ECS or Fargate tasks with Step Functions (
runTask.syncの同期統合) - Standard と Express ワークフローの比較 — Step Functions
- Intrinsic functions (
States.JsonToStringほか) — Step Functions - Amazon ECS task execution IAM role (execution role)
- Amazon ECS task IAM role (task role)
- Passing secrets to ECS containers (
secretsブロック) - Granting an IAM role permission to pass a role (
iam:PassRole) - aws_ecs_task_definition — Terraform AWS provider (
arn_without_revision) - aws_cloudwatch_metric_alarm (
treat_missing_data) — Terraform AWS provider - The lifecycle Meta-Argument (
ignore_changes) — Terraform - PutMetricData — Amazon CloudWatch API リファレンス
- AWS Budgets でコストを管理する
- NAT gateways — Amazon VPC
- AWS Fargate for Amazon ECS (ARM64 / Graviton 対応)