
Step Functions JSONata で DevOps Agent の日次予算ゲートを構築してみた
はじめに
AWS DevOps Agent は、GuardDuty の検知などを契機として Investigation を自動実行できるサービスです。料金は agent-second 単位の従量課金で、2026年7月20日時点では $0.0083/agent-second です。一方で、利用額を制限する spending cap はありません。
EventBridge 経由でイベントを自動転送している場合、検知イベントが大量に流入すると Investigation も多数起動します。実際に、GuardDuty のサンプル Finding 406件が DevOps Agent に流れ込み約 $200 の課金が発生した事例があります。
起動後に止めてもすでに消費時間が発生するため、起動前の入口で制御する必要があります。
本記事では、Step Functions Express Workflow と JSONata を使い、CloudWatch に記録された消費時間を参照する予算ゲートを作成しました。日次の利用量が閾値を超えている場合は、DevOps Agent へ渡す前にイベントをブロックします。
GuardDuty Finding
→ EventBridge Rule
→ Step Functions (Express Workflow)
1. GetDailyUsage (aws-sdk:cloudwatch:getMetricData)
→ 直近24時間の累積 agent-seconds を取得
2. CheckBudget (Choice / JSONata: $totalSeconds >= $budgetLimit)
├─ 予算内 → AllowForwarding (Pass: ALLOWED)
└─ 予算超過 → BudgetExceeded (Pass: BLOCKED)
検証は Step Functions 単体 → E2E の2段階で行います。
検証内容
検証環境
| 項目 | 値 |
|---|---|
| デプロイ手段 | CloudFormation |
| テスト 1・2 のリージョン | us-east-1 |
| テスト 3・4 のリージョン | us-west-2(Agent Space を us-west-2 で作成したため) |
| テスト用閾値 | DailyBudgetSeconds = 10 |
| 本番想定の閾値 | 3600秒(1時間 ≈ $30/日) |
本文中の時刻はすべて JST(+09:00)で表記しています。AWS CLI のパラメータでは UTC(Z)表記を使用しています。
テンプレート解説
予算ゲートは1本の CloudFormation テンプレートで完結します。Step Functions ステートマシン(Express)、実行ログ用の CloudWatch Logs、EventBridge ルールとそれぞれの IAM ロールを含みます。
template.yaml(全文)
AWSTemplateFormatVersion: "2010-09-09"
Description: >
DevOps Agent Budget Gate - Step Functions (Express, JSONata)
EventBridge -> Step Functions -> Budget check via CloudWatch GetMetricData
Blocks forwarding when daily consumed time exceeds threshold.
Parameters:
AgentSpaceUUID:
Type: String
Description: DevOps Agent Space UUID (for CloudWatch metric dimension)
Default: "test-space-placeholder"
DailyBudgetSeconds:
Type: Number
Default: 10
Description: >
Threshold in agent-seconds for the last 24 hours (rolling window). When consumed time exceeds this value,
the workflow blocks event forwarding. Default 10s for testing.
Production example: 3600 (1 hour = ~$30)
WebhookUrl:
Type: String
Default: "https://example.com/devops-agent-webhook"
Description: DevOps Agent Webhook URL (used in production)
Resources:
# ─── IAM Role for Step Functions ───
BudgetGateRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub "DevOpsAgentBudgetGate-SFn-${AWS::StackName}"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: states.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: CloudWatchGetMetricData
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- cloudwatch:GetMetricData
Resource: "*"
- PolicyName: CloudWatchLogs
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- logs:CreateLogDelivery
- logs:CreateLogStream
- logs:GetLogDelivery
- logs:UpdateLogDelivery
- logs:DeleteLogDelivery
- logs:ListLogDeliveries
- logs:PutLogEvents
- logs:PutResourcePolicy
- logs:DescribeResourcePolicies
- logs:DescribeLogGroups
Resource: "*"
# ─── Step Functions State Machine (Express) ───
BudgetGateStateMachine:
Type: AWS::StepFunctions::StateMachine
Properties:
StateMachineName: !Sub "DevOpsAgentBudgetGate-${AWS::StackName}"
StateMachineType: EXPRESS
RoleArn: !GetAtt BudgetGateRole.Arn
LoggingConfiguration:
Level: ALL
IncludeExecutionData: true
Destinations:
- CloudWatchLogsLogGroup:
LogGroupArn: !GetAtt StateMachineLogGroup.Arn
DefinitionString: !Sub |
{
"Comment": "DevOps Agent Budget Gate: check daily consumed time before forwarding",
"QueryLanguage": "JSONata",
"StartAt": "GetDailyUsage",
"States": {
"GetDailyUsage": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:cloudwatch:getMetricData",
"Arguments": {
"StartTime": "{% $fromMillis($millis() - 86400000) %}",
"EndTime": "{% $fromMillis($millis()) %}",
"MetricDataQueries": [
{
"Id": "investigation",
"MetricStat": {
"Metric": {
"Namespace": "AWS/AIDevOps",
"MetricName": "ConsumedInvestigationTime",
"Dimensions": [
{
"Name": "AgentSpaceUUID",
"Value": "${AgentSpaceUUID}"
}
]
},
"Period": 86400,
"Stat": "Sum"
}
},
{
"Id": "evaluation",
"MetricStat": {
"Metric": {
"Namespace": "AWS/AIDevOps",
"MetricName": "ConsumedEvaluationTime",
"Dimensions": [
{
"Name": "AgentSpaceUUID",
"Value": "${AgentSpaceUUID}"
}
]
},
"Period": 86400,
"Stat": "Sum"
}
},
{
"Id": "chat",
"MetricStat": {
"Metric": {
"Namespace": "AWS/AIDevOps",
"MetricName": "ConsumedChatTime",
"Dimensions": [
{
"Name": "AgentSpaceUUID",
"Value": "${AgentSpaceUUID}"
}
]
},
"Period": 86400,
"Stat": "Sum"
}
},
{
"Id": "ondemand",
"MetricStat": {
"Metric": {
"Namespace": "AWS/AIDevOps",
"MetricName": "ConsumedOnDemandTime",
"Dimensions": [
{
"Name": "AgentSpaceUUID",
"Value": "${AgentSpaceUUID}"
}
]
},
"Period": 86400,
"Stat": "Sum"
}
}
]
},
"Assign": {
"totalSeconds": "{% ( $vals := $states.result.MetricDataResults.Values[]; $type($vals) = 'array' ? $sum($vals) : 0 ) %}",
"budgetLimit": ${DailyBudgetSeconds},
"finding": "{% $states.input %}"
},
"Output": "{% $states.input %}",
"Next": "CheckBudget"
},
"CheckBudget": {
"Type": "Choice",
"Choices": [
{
"Condition": "{% $totalSeconds >= $budgetLimit %}",
"Next": "BudgetExceeded"
}
],
"Default": "AllowForwarding"
},
"AllowForwarding": {
"Type": "Pass",
"Output": {
"status": "ALLOWED",
"message": "Budget within limit. Event would be forwarded to DevOps Agent.",
"dailyUsageSeconds": "{% $totalSeconds %}",
"budgetLimitSeconds": "{% $budgetLimit %}",
"finding": "{% $finding %}"
},
"End": true
},
"BudgetExceeded": {
"Type": "Pass",
"Output": {
"status": "BLOCKED",
"message": "Daily budget exceeded. Event NOT forwarded to DevOps Agent.",
"dailyUsageSeconds": "{% $totalSeconds %}",
"budgetLimitSeconds": "{% $budgetLimit %}",
"finding": "{% $finding %}"
},
"End": true
}
}
}
# ─── CloudWatch Logs for Express Workflow ───
StateMachineLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/stepfunctions/DevOpsAgentBudgetGate-${AWS::StackName}"
RetentionInDays: 7
# ─── IAM Role for EventBridge to invoke Step Functions ───
EventBridgeRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub "DevOpsAgentBudgetGate-EB-${AWS::StackName}"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: events.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: InvokeStepFunctions
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- states:StartExecution
- states:StartSyncExecution
Resource: !GetAtt BudgetGateStateMachine.Arn
# ─── EventBridge Rule (GuardDuty Findings) ───
GuardDutyFindingRule:
Type: AWS::Events::Rule
Properties:
Name: !Sub "DevOpsAgentBudgetGate-GuardDuty-${AWS::StackName}"
Description: "Route GuardDuty findings through budget gate before DevOps Agent"
State: ENABLED
EventPattern:
source:
- aws.guardduty
detail-type:
- "GuardDuty Finding"
Targets:
- Id: BudgetGateTarget
Arn: !GetAtt BudgetGateStateMachine.Arn
RoleArn: !GetAtt EventBridgeRole.Arn
Outputs:
StateMachineArn:
Description: Budget Gate State Machine ARN
Value: !GetAtt BudgetGateStateMachine.Arn
StateMachineName:
Description: Budget Gate State Machine Name
Value: !GetAtt BudgetGateStateMachine.Name
DailyBudgetSeconds:
Description: Configured daily budget threshold (seconds)
Value: !Ref DailyBudgetSeconds
LogGroupName:
Description: CloudWatch Logs group for execution logs
Value: !Ref StateMachineLogGroup
GetDailyUsage では、JSONata 式の $fromMillis で開始・終了時刻を作成しています。$millis() - 86400000 は実行時刻から24時間前を表します。カレンダー日ではなく実行時点から遡る24時間のローリングウィンドウです。aws-sdk:cloudwatch:getMetricData 統合により、Lambda を使わずに CloudWatch のメトリクスを参照できます。
"Resource": "arn:aws:states:::aws-sdk:cloudwatch:getMetricData",
"Arguments": {
"StartTime": "{% $fromMillis($millis() - 86400000) %}",
"EndTime": "{% $fromMillis($millis()) %}",
"MetricDataQueries": [...]
}
ConsumedInvestigationTime、ConsumedEvaluationTime、ConsumedChatTime、ConsumedOnDemandTime の4種類をクエリしています。Assign ブロックで MetricDataResults.Values[] を展開し $sum で合計します。
"Assign": {
"totalSeconds": "{% ( $vals := $states.result.MetricDataResults.Values[]; $type($vals) = 'array' ? $sum($vals) : 0 ) %}",
"budgetLimit": ${DailyBudgetSeconds},
"finding": "{% $states.input %}"
}
$type チェックはメトリクスが存在しない場合の undefined 対策です。配列でなければ 0 にフォールバックしています(詳細はテスト 1)。
最後に、CheckBudget の Choice State です。Assign で設定した変数 $totalSeconds と $budgetLimit を JSONata 式で直接比較します。予算超過なら BudgetExceeded へ、予算内なら AllowForwarding へ分岐します。>= のため、閾値と同値の場合もブロックされます。
"CheckBudget": {
"Type": "Choice",
"Choices": [
{
"Condition": "{% $totalSeconds >= $budgetLimit %}",
"Next": "BudgetExceeded"
}
],
"Default": "AllowForwarding"
}
テスト 1: メトリクスなし → ALLOWED
CloudWatch に対象 Agent Space のメトリクスが存在しない初回状態で Step Functions を同期実行しました。
aws stepfunctions start-sync-execution \
--state-machine-arn "arn:aws:states:us-east-1:123456789012:stateMachine:DevOpsAgentBudgetGate-devops-agent-budget-gate" \
--input '{"source":"aws.guardduty","detail-type":"GuardDuty Finding","detail":{"type":"Recon:EC2/PortProbeUnprotectedPort","severity":5,"title":"Test finding for budget gate verification"}}' \
--region us-east-1
{
"status": "ALLOWED",
"message": "Budget within limit. Event would be forwarded to DevOps Agent.",
"dailyUsageSeconds": 0,
"budgetLimitSeconds": 10
}
実行結果(全文)
{
"executionArn": "arn:aws:states:us-east-1:123456789012:express:DevOpsAgentBudgetGate-devops-agent-budget-gate:ca4594da-cc85-4be6-9129-0cebae19d949:d2bd1e1c-1a01-47a6-a037-0502dbfa0bba",
"stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:DevOpsAgentBudgetGate-devops-agent-budget-gate",
"name": "ca4594da-cc85-4be6-9129-0cebae19d949",
"startDate": "2026-07-20T17:46:23.788000+09:00",
"stopDate": "2026-07-20T17:46:23.896000+09:00",
"status": "SUCCEEDED",
"input": "{\"source\":\"aws.guardduty\",\"detail-type\":\"GuardDuty Finding\",\"detail\":{\"type\":\"Recon:EC2/PortProbeUnprotectedPort\",\"severity\":5,\"title\":\"Test finding for budget gate verification\"}}",
"output": "{\"status\":\"ALLOWED\",\"message\":\"Budget within limit. Event would be forwarded to DevOps Agent.\",\"dailyUsageSeconds\":0,\"budgetLimitSeconds\":10,\"finding\":{\"source\":\"aws.guardduty\",\"detail-type\":\"GuardDuty Finding\",\"detail\":{\"type\":\"Recon:EC2/PortProbeUnprotectedPort\",\"severity\":5,\"title\":\"Test finding for budget gate verification\"}}}",
"billingDetails": {
"billedMemoryUsedInMB": 64,
"billedDurationInMilliseconds": 200
}
}
dailyUsageSeconds: 0 < budgetLimitSeconds: 10 のため ALLOWED でした。実行時間は 108ms(billedDuration は 200ms)でした。
ここで JSONata の undefined 問題について補足します。当初の集計式は $sum($states.result.MetricDataResults.Values[]) でしたが、初回実行で States.QueryEvaluationError になりました。
メトリクスが存在しない場合、GetMetricData のレスポンスで Values が空配列 [] になります。JSONata の $sum([]) は 0 を返します。しかしパス式 MetricDataResults.Values[] がマッチしない場合は undefined となり、$sum(undefined) がエラーになります。
修正前:
"totalSeconds": "{% $sum($states.result.MetricDataResults.Values[]) %}"
修正後:
"totalSeconds": "{% ( $vals := $states.result.MetricDataResults.Values[]; $type($vals) = 'array' ? $sum($vals) : 0 ) %}"
$type($vals) = 'array' で値の型をチェックし、undefined の場合は 0 へフォールバックするようにしました。
テスト 2: メトリクス投入 → BLOCKED
閾値(10秒)を超える15秒のメトリクスを手動投入し、ブロックされることを確認しました。
aws cloudwatch put-metric-data \
--namespace AWS/AIDevOps \
--metric-data '[{
"MetricName": "ConsumedInvestigationTime",
"Dimensions": [{"Name": "AgentSpaceUUID", "Value": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}],
"Value": 15,
"Unit": "Seconds"
}]' \
--region us-east-1
aws stepfunctions start-sync-execution \
--state-machine-arn "arn:aws:states:us-east-1:123456789012:stateMachine:DevOpsAgentBudgetGate-devops-agent-budget-gate" \
--input '{"source":"aws.guardduty","detail-type":"GuardDuty Finding","detail":{"type":"UnauthorizedAccess:EC2/TorClient","severity":8,"title":"Second test finding - should be blocked by budget gate"}}' \
--region us-east-1
{
"status": "BLOCKED",
"message": "Daily budget exceeded. Event NOT forwarded to DevOps Agent.",
"dailyUsageSeconds": 15,
"budgetLimitSeconds": 10
}
予想どおり BLOCKED。実行時間は 151ms でした。
テスト 3(E2E): Webhook → DevOps Agent Investigation 起動
テスト 3 では、DevOps Agent を実際に起動し、CloudWatch に消費時間メトリクスが記録されることを確認します。
us-west-2 に budget-gate-test という Agent Space を作成しました。さらに EventChannel のサービス登録と Association を作成し、Webhook URL を取得しました。
aws devops-agent create-agent-space \
--agent-space-name "budget-gate-test" \
--region us-west-2
Webhook URL: https://event-ai.us-west-2.api.aws/webhook/generic/<WEBHOOK_ID>
DevOps Agent の Generic Webhook は HMAC(SHA-256)で署名を検証します。
署名の仕様は以下のとおりです。
- ヘッダー:
x-amzn-event-signature(署名)、x-amzn-event-timestamp(タイムスタンプ) - タイムスタンプ形式: ISO 8601(
%Y-%m-%dT%H:%M:%S.000Z) - 署名計算:
HMAC-SHA256("${TIMESTAMP}:${PAYLOAD}", SECRET)を base64 エンコード
以下のスクリプトで署名を計算し、curl で送信しました。
#!/usr/bin/env bash
set -euo pipefail
WEBHOOK_URL="https://event-ai.us-west-2.api.aws/webhook/generic/<WEBHOOK_ID>"
SECRET="<HMAC_SECRET>"
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"
PAYLOAD='{
"eventType": "security_alert",
"incidentId": "test-budget-gate-e2e-001",
"action": "investigate",
"priority": "HIGH",
"title": "E2E Test: GuardDuty PortProbe Finding",
"description": "EC2 instance i-0123456789abcdef0 is being probed on port 22 from 198.51.100.0. Source is known malicious."
}'
SIGNATURE="$(
printf '%s:%s' "$TIMESTAMP" "$PAYLOAD" \
| openssl dgst -sha256 -hmac "$SECRET" -binary \
| base64
)"
curl -s -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-H "x-amzn-event-timestamp: ${TIMESTAMP}" \
-H "x-amzn-event-signature: ${SIGNATURE}" \
-d "$PAYLOAD"
レスポンスは HTTP 200 で {"message": "Webhook received"} が返りました。
Investigation のステータスを確認します。
aws devops-agent list-investigations \
--agent-space-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
--region us-west-2
Investigation は 2026-07-20T17:55:32+09:00 に開始し、2026-07-20T17:56:44+09:00 に COMPLETED(所要時間 約72秒)となりました。
CloudWatch メトリクスに消費時間が記録されていることを確認しました。
aws cloudwatch get-metric-data \
--metric-data-queries '[{
"Id": "investigation",
"MetricStat": {
"Metric": {
"Namespace": "AWS/AIDevOps",
"MetricName": "ConsumedInvestigationTime",
"Dimensions": [{"Name": "AgentSpaceUUID", "Value": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}]
},
"Period": 86400,
"Stat": "Sum"
}
}]' \
--start-time "2026-07-20T00:00:00Z" \
--end-time "2026-07-20T23:59:59Z" \
--region us-west-2
所要時間72秒に対し、ConsumedInvestigationTime として CloudWatch に記録されたのは 39 秒でした。$0.0083/agent-second で計算すると約 $0.32 に相当します。今回の観測では wall-clock time と一致しなかったため、予算ゲートの閾値は wall-clock time ではなく CloudWatch に記録される agent-second を基準に設計するのが安全です。
テスト 4(E2E): 実メトリクスでブロック
テスト 3 で DevOps Agent が消費した実際のメトリクス(39秒)が CloudWatch に存在する状態で、予算ゲートの動作を確認しました。実際の AgentSpaceUUID を指定して us-west-2 にスタックを再デプロイし(閾値 10秒)、Step Functions を実行しました。
aws stepfunctions start-sync-execution \
--state-machine-arn "arn:aws:states:us-west-2:123456789012:stateMachine:DevOpsAgentBudgetGate-devops-agent-budget-gate-e2e" \
--input '{"source":"aws.guardduty","detail-type":"GuardDuty Finding","detail":{"type":"Recon:EC2/PortProbeUnprotectedPort","severity":5,"title":"E2E budget gate test with real metrics"}}' \
--region us-west-2
{
"status": "BLOCKED",
"message": "Daily budget exceeded. Event NOT forwarded to DevOps Agent.",
"dailyUsageSeconds": 39,
"budgetLimitSeconds": 10
}
GetMetricData で取得した dailyUsageSeconds: 39 >= budgetLimitSeconds: 10 のため BLOCKED でした。DevOps Agent が実際に消費して CloudWatch に記録した時間で予算ゲートが機能することを確認できました。
結果サマリー
| テスト | 条件 | 結果 | 判定 |
|---|---|---|---|
| 1 | メトリクスなし(累積 0 秒) | ALLOWED | ✅ PASS |
| 2 | メトリクスあり(手動投入 15 秒 > 閾値 10 秒) | BLOCKED | ✅ PASS |
| 3 | Webhook → DevOps Agent | Investigation COMPLETED(72秒) | ✅ PASS |
| 4 | 実際の DevOps Agent メトリクス(39秒)で予算ゲート判定 | BLOCKED(39 ≥ 10) | ✅ PASS |
Express Workflow はリクエスト数と実行時間で課金されます。今回の4テストはいずれも課金対象時間 200ms 程度で、予算ゲート自体のコストは実質的に無視できる水準でした。
まとめ
spending cap のない DevOps Agent では、EventBridge などからの大量イベント流入に備えて、起動前に予算ゲートを挟む構成が有効です。
本記事では、Step Functions Express Workflow と JSONata を使い、CloudWatch に記録された DevOps Agent の消費時間メトリクスを参照して、閾値超過時に BLOCKED、予算内であれば ALLOWED を返す仕組みを Lambda なしで構築しました。
検証では、メトリクスなしの状態、手動投入したメトリクス、実際の DevOps Agent Investigation によって記録されたメトリクスのそれぞれで、期待どおりに判定できることを確認しました。
今回の判定はカレンダー日ではなく、実行時点から遡る直近24時間のローリングウィンドウです。また、閾値は Investigation の wall-clock time ではなく、CloudWatch に記録される agent-second を基準に設定するのが安全です。
開発・検証環境では低めの DailyBudgetSeconds を設定しておくことで、意図しない DevOps Agent の起動や課金を抑制できます。本番環境でも、組織の予算に合わせて閾値を調整することで、シンプルなメトリクスベースの予算ゲートとして利用できます。









