Claude Media
Claude CodeでAWS Budgetsと異常検知をTerraformで入れ、SNS権限まで検査

Claude CodeでAWS Budgetsと異常検知をTerraformで入れ、SNS権限まで検査

月次予算の通知とCost Anomaly DetectionをClaude CodeにTerraformで書かせ、SNSトピックポリシーの不足で通知が届かない失敗をPostToolUseフックで止める手順です。

AWS BudgetsとCost Anomaly Detectionの通知は、Claude CodeにTerraformで書かせると数分で形になります。落とし穴は、terraform applyが成功しても通知が届くとは限らない点です。通知先のSNSトピックに、AWS側のサービスがSNS:Publishできるポリシーが無いと、アラートは黙って消えます。本記事は、予算としきい値の書き方に加え、このポリシー不足を.tfの保存直後にフックで止める手順をまとめます。

作るものは3つです。月次予算に予測80%と実績100%の2段階の通知、サービス別の異常検知モニターと購読、そして両方の通知先になるSNSトピックとそのポリシー。請求額の原因を調べる作業はCost ExplorerのCSVで急増を分解する記事が扱っています。ここでは、アラートの仕組みを先に入れておく側を扱います。

予算と異常検知は、見ているものが違う

2つのサービスは名前が似ていますが、役割が重なりません。

観点AWS BudgetsCost Anomaly Detection
発火の条件AWS Budgets自分で決めた金額・使用量のしきい値Cost Anomaly Detection過去の支出パターンから外れた変動
通知の種類AWS Budgets実績(ACTUAL)と予測(FORECASTED)Cost Anomaly Detectionモニター単位の異常検知
SNSに必要な許可AWS Budgetsbudgets.amazonaws.comにSNS:PublishCost Anomaly Detectioncostalerts.amazonaws.comにSNS:Publish
検知の遅れAWS Budgets予測と実績の評価に基づくCost Anomaly DetectionCost Explorerのデータに最大24時間の遅れ

異常検知は、請求データの処理後に1日あたりおよそ3回動きます。Cost Explorerのデータが最大24時間遅れるため、異常の検知まで最大24時間かかります。新しいモニターは、新しい異常を拾い始めるまでに24時間かかることもあります。リアルタイムの歯止めを期待する用途には向きません。

AWS Marketplaceの請求は別扱いです。異常検知はMarketplaceの第三者製品を見ず、例外はAmazon Bedrock上の第三者基盤モデルだけです。Marketplaceの課金も警戒したいなら、Budgetsの請求エンティティ(Billing entity)フィルターで予算を別に立てます。

つまり予算は「決めた線を超えたか」、異常検知は「いつもと違うか」を見ます。両方を同じSNSトピックに流すと受け口は1つで済みますが、許可は2つのサービス名それぞれに必要です。

Terraformに書く内容

SNSトピックとポリシーから書きます。ポリシーの文面は、AWSのドキュメントが示すものに合わせます。Budgets側はaws:SourceAccountとaws:SourceArnの条件を付けた形、異常検知側はaws:SourceAccountだけを付けた形です。

data "aws_caller_identity" "current" {}
 
resource "aws_sns_topic" "cost_alerts" {
  name = "cost-alerts"
}
 
data "aws_iam_policy_document" "cost_alerts" {
  statement {
    sid       = "AWSBudgetsSNSPublishingPermissions"
    actions   = ["SNS:Publish"]
    resources = [aws_sns_topic.cost_alerts.arn]
 
    principals {
      type        = "Service"
      identifiers = ["budgets.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "aws:SourceAccount"
      values   = [data.aws_caller_identity.current.account_id]
    }
    condition {
      test     = "ArnLike"
      variable = "aws:SourceArn"
      values   = ["arn:aws:budgets::${data.aws_caller_identity.current.account_id}:*"]
    }
  }
 
  statement {
    sid       = "AWSAnomalyDetectionSNSPublishingPermissions"
    actions   = ["SNS:Publish"]
    resources = [aws_sns_topic.cost_alerts.arn]
 
    principals {
      type        = "Service"
      identifiers = ["costalerts.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "aws:SourceAccount"
      values   = [data.aws_caller_identity.current.account_id]
    }
  }
}
 
resource "aws_sns_topic_policy" "cost_alerts" {
  arn    = aws_sns_topic.cost_alerts.arn
  policy = data.aws_iam_policy_document.cost_alerts.json
}

Sidはポリシー内で一意にします。プロバイダーのドキュメントにある異常検知の例は、このほかにトピック所有者向けの既定の文(__default_statement_ID)も同じポリシーに書いています。既存のトピックにポリシーが付いている場合は、その文も含めて書かないと意図せず外れるので、Claudeに既存の文を先に読ませてから差分を書かせます。

次が予算です。予測80%と実績100%の通知を、同じSNSトピックに向けます。

resource "aws_budgets_budget" "monthly" {
  name         = "monthly-cost"
  budget_type  = "COST"
  time_unit    = "MONTHLY"
  limit_amount = var.monthly_limit_usd
  limit_unit   = "USD"
 
  notification {
    comparison_operator       = "GREATER_THAN"
    threshold                 = 80
    threshold_type            = "PERCENTAGE"
    notification_type         = "FORECASTED"
    subscriber_sns_topic_arns = [aws_sns_topic.cost_alerts.arn]
  }
 
  notification {
    comparison_operator       = "GREATER_THAN"
    threshold                 = 100
    threshold_type            = "PERCENTAGE"
    notification_type         = "ACTUAL"
    subscriber_sns_topic_arns = [aws_sns_topic.cost_alerts.arn]
  }
 
  depends_on = [aws_sns_topic_policy.cost_alerts]
}

depends_onでポリシーの作成を待たせているのは、Budgetsがトピックへのアクセスを作成時に確かめるからです。ドキュメントのトラブルシューティングには、アクセスが無いと「Invalid SNS topic」になると書かれています。順序を保証しないと、初回のapplyだけ失敗する形になります。

最後に異常検知です。モニターをサービス別(DIMENSIONAL、SERVICE)で作り、購読でSNSへ流します。

resource "aws_ce_anomaly_monitor" "service" {
  name              = "service-monitor"
  monitor_type      = "DIMENSIONAL"
  monitor_dimension = "SERVICE"
}
 
resource "aws_ce_anomaly_subscription" "realtime" {
  name      = "realtime-anomaly"
  frequency = "IMMEDIATE"
 
  monitor_arn_list = [aws_ce_anomaly_monitor.service.arn]
 
  subscriber {
    type    = "SNS"
    address = aws_sns_topic.cost_alerts.arn
  }
 
  threshold_expression {
    dimension {
      key           = "ANOMALY_TOTAL_IMPACT_ABSOLUTE"
      match_options = ["GREATER_THAN_OR_EQUAL"]
      values        = ["100"]
    }
  }
 
  depends_on = [aws_sns_topic_policy.cost_alerts]
}

しきい値は影響額の絶対値(ANOMALY_TOTAL_IMPACT_ABSOLUTE)で100を指定しています。プロバイダーのドキュメントには、割合(ANOMALY_TOTAL_IMPACT_PERCENTAGE)や、andで両方を満たす条件の例もあります。請求規模が小さいアカウントでは、金額と割合をandで組み合わせると小さな変動を拾いにくくなります。

人間が受け取る口には、メールのSNS購読を足します。

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.cost_alerts.arn
  protocol  = "email"
  endpoint  = var.alert_email
}

ポリシー不足をフックで止める

ここまでの.tfは、ポリシーの文を1つ書き忘れてもterraform validateを通ります。aws_sns_topic_policyが別のリソースなので、予算や購読の定義だけを見ても足りないものは分からないからです。

Budgets側は、前節のとおり作成時に「Invalid SNS topic」で弾かれることがあります。異常検知側のドキュメントには、同じ作成時エラーの記載がありません。ポリシーが無いまま作られ、通知だけが届かない形になる可能性があります。気づくのは、実際に異常が起きたときです。

そこで、.tfを書いた直後に簡易検査を走らせます。Claude CodeのPostToolUseフックは、ツールが成功した直後に動き、終了コード2を返すとstderrをClaudeに見せます。ツールは既に実行済みなので書き込みは取り消されませんが、Claudeが次の手で直すための材料になります。

検査スクリプトを.claude/hooks/check-cost-alerts.shに置きます。

#!/bin/bash
# PostToolUse(Edit|Write): .tf を書いた直後に、通知先 SNS トピックの権限不足を検査する
FILE=$(jq -r '.tool_input.file_path // empty')
case "$FILE" in *.tf) ;; *) exit 0 ;; esac
DIR=$(dirname "$FILE")
ALL=$(cat "$DIR"/*.tf)
ERRORS=""
 
# 予算の通知先に SNS を指定しているのに、budgets.amazonaws.com の許可が無い
if grep -q 'subscriber_sns_topic_arns' <<<"$ALL" \
   && ! grep -q 'budgets.amazonaws.com' <<<"$ALL"; then
  ERRORS+="- aws_budgets_budget が SNS を通知先にしていますが、トピックポリシーに budgets.amazonaws.com の SNS:Publish がありません"$'\n'
fi
 
# 異常検知の購読先に SNS を指定しているのに、costalerts.amazonaws.com の許可が無い
if grep -A3 'subscriber {' <<<"$ALL" | grep -q 'type *= *"SNS"' \
   && ! grep -q 'costalerts.amazonaws.com' <<<"$ALL"; then
  ERRORS+="- aws_ce_anomaly_subscription が SNS を購読先にしていますが、トピックポリシーに costalerts.amazonaws.com の SNS:Publish がありません"$'\n'
fi
 
if [ -n "$ERRORS" ]; then
  printf '通知先の権限が不足しています。\n%s' "$ERRORS" >&2
  exit 2
fi
exit 0

chmod +xで実行権限を付け、.claude/settings.jsonに登録します。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-cost-alerts.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

ifフィールドでEdit(*.tf)のように絞る手もありますが、ifに書けるルールは1つだけです。EditとWriteの両方を対象にしたいので、拡張子の判定はスクリプト側に持たせています。

サンプルの.tfで動かすと、出力は次のとおりです。ポリシーを何も書いていないmain.tfに対して、フックのJSONを標準入力に渡しました。

echo '{"tool_input":{"file_path":"'$PWD'/main.tf"}}' \
  | .claude/hooks/check-cost-alerts.sh; echo "exit=$?"
通知先の権限が不足しています。
- aws_budgets_budget が SNS を通知先にしていますが、トピックポリシーに budgets.amazonaws.com の SNS:Publish がありません
- aws_ce_anomaly_subscription が SNS を購読先にしていますが、トピックポリシーに costalerts.amazonaws.com の SNS:Publish がありません
exit=2

Budgets用の許可だけを書いたpolicy.tfを足すと、異常検知側の1行だけが残ります。両方の許可を書くと終了コード0で静かに通ります。

フックが拾えない場面もあります。PostToolUseのEdit|Writeは、Bashコマンドや外部のプロセスが同じファイルを書き換えたときには動きません。sed -iでポリシーを消す指示を出したときは素通りします。この経路を塞ぎたいときは、terraform applyの直前に同じスクリプトをPreToolUseのBashフックで走らせ、permissionDecisionをdenyにする構成が現実的です。

通知の宛先と予算の範囲を広げる

通知の宛先は、SNS以外にも選べます。予算のnotificationブロックでは、subscriber_email_addressesでメールへ直接送れます。subscriber_sns_topic_arnsと並べて、どちらか一方は必須です。メール直送ならSNSのポリシーは要らず、本記事のフックも対象外になります。ただし宛先を増やすたびに.tfを直すことになるため、複数の宛先に流したいときはSNSトピックを1つ置き、購読者を足していく形が扱いやすくなります。

異常検知のSNSトピックは、Amazon Q Developerのチャットアプリケーション設定(Amazon Q Developer in chat applications)でSlackのチャンネルやAmazon Chimeのチャットルームに対応づけられます。異常検知のドキュメントが挙げる使い方で、トピックを共通にしておけば、チャットへの転送を後から足せます。

予算の範囲はcost_filterで絞れます。名前に指定できる値にはService、LinkedAccount、Region、TagKeyValueなどがあります。サービス単位の予算は次のように書きます。

resource "aws_budgets_budget" "ec2" {
  name         = "ec2-monthly"
  budget_type  = "COST"
  time_unit    = "MONTHLY"
  limit_amount = var.ec2_limit_usd
  limit_unit   = "USD"
 
  cost_filter {
    name   = "Service"
    values = ["Amazon Elastic Compute Cloud - Compute"]
  }
}

全体の予算を1つ置き、重いサービスだけ個別の予算を足す構成にすると、超過した場所が通知の名前から分かります。nameが予算の識別名になるため、Claudeに「どの予算が何を見ているか」を名前に出させる指示も、CLAUDE.mdに書いておくと効きます。

Claudeに渡す権限と指示

書かせるだけなら、読み取りと計画の範囲に絞れます。.claude/settings.jsonの権限は、terraform applyを確認付きにして、トピック削除やdestroyを止める形にします。

{
  "permissions": {
    "allow": [
      "Bash(terraform fmt *)",
      "Bash(terraform validate *)",
      "Bash(terraform plan *)",
      "Bash(aws sns get-topic-attributes *)",
      "Bash(aws sns list-subscriptions-by-topic *)"
    ],
    "ask": [
      "Bash(terraform apply *)"
    ],
    "deny": [
      "Bash(terraform destroy *)",
      "Bash(aws sns delete-topic *)"
    ]
  }
}

評価の順序は、deny、ask、allowの順で、最初に一致したものが結果を決めます。PreToolUseフックがallowを返しても、denyやaskのルールは外れません。

CLAUDE.mdには、手順と検査の約束を数行で書きます。

## コスト通知のTerraform
- 予算は aws_budgets_budget、異常検知は aws_ce_anomaly_monitor と
  aws_ce_anomaly_subscription に分けて書く
- SNSを通知先にするときは、同じ変更の中で aws_sns_topic_policy を書き、
  budgets.amazonaws.com と costalerts.amazonaws.com の許可を入れる
- 予算としきい値の金額は変数にし、値を勝手に決めない
- フックが「通知先の権限が不足」と返したら、まずポリシーの文を直す

実際の指示は「月次予算に、予測80%と実績100%の2通知を入れ、異常検知はサービス別で。通知はSNSトピックにまとめて」といった粒度で足ります。しきい値の金額は、Claudeに推測させず人が渡します。月額の目安を決めるには、Infracostで見積もりを出す記事の数字が材料になります。

applyの後に、手で確かめる4点

フックが見るのは.tfの中身だけです。AWS側の状態は、apply後に別途確かめます。

  1. トピックのポリシー: aws sns get-topic-attributesでPolicyを取り出し、2つのサービス名とSidが入っているか見ます。結果は--query Attributes.Policy --output textで絞れます。
  2. メール購読の状態: SNSのメール購読は、受信者が確認リンクを押すまでPendingConfirmationのままです。aws sns list-subscriptions-by-topicで状態を見ます。確認メールの再送は、SNSコンソールの購読一覧で「Request confirmation」を選びます。
  3. トピックの暗号化: Budgetsは暗号化されたトピックを、追加の許可が無いまま受け付けません。エラーメッセージは「The SNS topic is encrypted」です。暗号化するならKMSキーのポリシーにも、Budgetsに公開を許す文が要ります。
  4. アカウントの一致: BudgetsのSNSトピックは、予算と同じアカウントにある必要があります。クロスアカウントのSNSは対応していません。
aws sns get-topic-attributes \
  --topic-arn "$TOPIC_ARN" \
  --query Attributes.Policy --output text
aws sns list-subscriptions-by-topic --topic-arn "$TOPIC_ARN"

この2コマンドは読み取りだけで、前節のallowに入れてあります。Claudeに実行させ、出力のPolicyにbudgets.amazonaws.comとcostalerts.amazonaws.comが両方あるか、購読の状態がPendingConfirmationでないかを読ませると、確認がセッション内で閉じます。

つまずきの早見表

症状見る場所
初回のapplyでBudgetsが「Invalid SNS topic」で失敗する見る場所ポリシーの作成順(depends_on)とbudgets.amazonaws.comの許可
apply成功だが、異常検知の通知が1通も来ない見る場所costalerts.amazonaws.comの許可、検知までの最大24時間の遅れ
メールが届かない見る場所購読がPendingConfirmationのまま。確認リンクが未クリック
新サービスの異常が出ない見る場所新しいサービス購読には10日分の履歴が要る
「The SNS topic is encrypted」になる見る場所トピックのSSEを外すか、KMSキーのポリシーにBudgetsの許可を足す

異常検知は「通知が来ない」ことを失敗と判断しにくい仕組みです。異常が起きなければ静かなのは正常動作でもあるため、届かないことに気づくのは実際に異常が起きた後になりがちです。ポリシーの有無はデプロイ前に機械で見ておく価値があります。

まとめ

予算と異常検知は、Terraformで書くこと自体は短く済みます。壊れやすいのは、SNSトピックポリシーが別リソースで、片方の許可が抜けてもvalidateが通る点です。PostToolUseフックで書き込み直後に存在を検査し、apply後にget-topic-attributesと購読の状態を読ませれば、書き忘れと確認漏れの両方を塞げます。

AWSの課金まわりをClaude Codeに任せる範囲を広げるなら、請求データを読ませるAWS Billing MCPサーバーの手順と、未使用リソースの削除を差分に回すAWSの未使用リソースの棚卸しが、この通知の設計と対になります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks
  2. code.claude.com/docs/en/permissions
  3. docs.aws.amazon.com/cost-management/latest/userguide/budgets-sns-policy.html
  4. docs.aws.amazon.com/cost-management/latest/userguide/ad-SNS.html
  5. docs.aws.amazon.com/cost-management/latest/userguide/manage-ad.html
  6. registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/budgets_budg…
  7. registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ce_anomaly_s…
この記事を共有:XはてブLinkedIn