Step Functions JSONata で DevOps Agent の日次予算ゲートを構築してみた

Step Functions JSONata で DevOps Agent の日次予算ゲートを構築してみた

AWS DevOps Agent には spending cap がないため、EventBridge 経由でイベントが大量に流入すると意図しない高額課金が発生します。Step Functions Express Workflow と JSONata で CloudWatch メトリクスを参照し、Lambda なしで日次予算を超えた起動をブロックする仕組みを構築・E2E検証しました。
2026.07.20

はじめに

AWS DevOps Agent は、GuardDuty の検知などを契機として Investigation を自動実行できるサービスです。料金は agent-second 単位の従量課金で、2026年7月20日時点では $0.0083/agent-second です。一方で、利用額を制限する spending cap はありません。

https://aws.amazon.com/devops-agent/pricing/

EventBridge 経由でイベントを自動転送している場合、検知イベントが大量に流入すると Investigation も多数起動します。実際に、GuardDuty のサンプル Finding 406件が DevOps Agent に流れ込み約 $200 の課金が発生した事例があります。

https://dev.classmethod.jp/articles/guardduty-sample-finding-devops-agent-billing-incident/

起動後に止めてもすでに消費時間が発生するため、起動前の入口で制御する必要があります。

本記事では、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": [...]
}

ConsumedInvestigationTimeConsumedEvaluationTimeConsumedChatTimeConsumedOnDemandTime の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)で署名を検証します。

https://docs.aws.amazon.com/devops-agent/latest/userguide/event-channels.html

署名の仕様は以下のとおりです。

  • ヘッダー: 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 の起動や課金を抑制できます。本番環境でも、組織の予算に合わせて閾値を調整することで、シンプルなメトリクスベースの予算ゲートとして利用できます。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事