Claude Media
Claude CodeでEventBridgeをTerraform管理 — DLQとcronをplanで読む

Claude CodeでEventBridgeをTerraform管理 — DLQとcronをplanで読む

EventBridgeのルールとSchedulerをClaude CodeにTerraformで書かせるとき、イベントパターン、ターゲットのDLQとリトライ、cronのタイムゾーンをplanのJSONから機械的に検査する手順です。

EventBridgeのルールとSchedulerをClaude CodeにTerraformで書かせると、terraform planは通るのに、本番で「イベントが黙って消える」「朝7時のはずが夕方に動く」という事故が起きます。原因はHCLの構文ではなく、書かれていない設定にあります。この記事では、イベントパターン、ターゲットのDLQ(dead-letter queue、配信失敗イベントの退避先)とリトライ、cron式のタイムゾーンの3点を、planのJSONから機械的に読む手順をまとめます。

夜間停止という具体的な用途はClaude Codeで開発環境を夜間停止が扱っています。ここでは用途を問わず、ルールとスケジュールを入れるときの検査の側に絞ります。

事故は「書かれなかった設定」として現れる

plan差分で目に入るのは、追加された属性です。EventBridgeの事故は逆で、無かった属性が原因になります。

欠落

planに現れない3つの欠落

  • DLQがない

    リトライを使い切ったイベントは破棄されます。DLQを付けていなければ、失敗した事実だけがメトリクスに残ります。

  • タイムゾーンがない

    Schedulerのcron式は、タイムゾーンを指定しなければUTCで評価されます。JSTで書いたつもりの時刻が9時間ずれます。

  • パターンが広すぎる・狭すぎる

    パターンは完全一致で評価されます。大文字小文字や数値の表記違いで、一件も一致しないルールが作れます。

どれもplanは成功します。Claude Codeに「planが通るまで直して」と頼むだけでは、この3つは検出されません。

Claude Codeの公式ガイドは、完了を判定できる合否つきのチェックを渡すと、Claudeが自分で修正と再実行を繰り返せると説明しています。チェックの例としてテストファイル、ビルドの終了コード、リンター、フィクスチャと突き合わせるスクリプトが挙がっています。EventBridgeでは、後ろのほうで作る「planのJSONを読むスクリプト」がこの役を担います。

ルールとSchedulerで、書く場所が違う

同じ「定期実行」でも、どちらのリソースを使うかで、検査する属性の名前と場所が変わります。AWSのドキュメントは、スケジュール実行にはEventBridge Schedulerの利用を勧めています。

検査したいことaws_cloudwatch_event_rule + aws_cloudwatch_event_targetaws_scheduler_schedule
時刻の式aws_cloudwatch_event_rule + aws_cloudwatch_event_targetルールのschedule_expression(デフォルトのイベントバスのみ)aws_scheduler_scheduleschedule_expression
タイムゾーンaws_cloudwatch_event_rule + aws_cloudwatch_event_target指定する属性がないaws_scheduler_scheduleschedule_expression_timezone(未指定はUTC)
イベントの条件aws_cloudwatch_event_rule + aws_cloudwatch_event_targetルールのevent_patternaws_scheduler_scheduleなし(時刻で起動)
DLQaws_cloudwatch_event_rule + aws_cloudwatch_event_targetターゲットのdead_letter_configaws_scheduler_scheduletarget内のdead_letter_config
リトライaws_cloudwatch_event_rule + aws_cloudwatch_event_targetターゲットのretry_policyaws_scheduler_scheduletarget内のretry_policy

ルールのcronにはタイムゾーンの属性がなく、ドキュメントの例もすべてUTCで書かれています。時刻を日本時間で扱いたいなら、Schedulerに寄せるのが素直です。一方、AWSサービスのイベントに反応する用途は、event_patternを持つルールの領分です。

イベントパターンはplanではなく実イベントで検証する

event_patternはjsonencodeで書いた時点でただの文字列で、Terraformは中身が何にマッチするかを検証しません。そこで、AWSのCLIにあるtest-event-patternを使い、サンプルイベントと突き合わせます。

aws events test-event-pattern \
  --event-pattern file://pattern.json \
  --event file://sample-event.json

応答はResultの真偽値です。--eventに渡すJSONにはid、account、source、time、region、resourcesなどの必須項目が要ります。サンプルは実際に流れたイベントを写すのが確実です。

パターンの評価には、読み違えやすい規則が3つあります。

  • 文字列は一文字ずつの完全一致で、大文字小文字は区別されます。
  • 数値は文字列表現で比べられ、300と300.0は別物です。
  • 同じキーを二重に書くと、最後の値だけが使われます。jsonencodeならHCLの側で防げますが、ヒアドキュメントで生のJSONを書く構成では紛れ込みます。

ARNも同じ扱いで、AWSの多くのサービスが同一視する:と/を、EventBridgeは区別します。Claude Codeに頼むときは、パターンの実物とサンプルイベントをセットで渡し、Resultがtrueになるまで回すよう指示します。外れるべきイベントも用意して、falseになることまで確かめさせると、広すぎるパターンも拾えます。

ターゲットのDLQとリトライを、書かせて検査する

ルールのターゲットは、既定で最大24時間、最大185回までリトライします。待ち時間は指数関数的に伸び、ばらつきも加わります。それでも届かなければイベントは破棄されます。取りこぼしを避けるなら、DLQにSQSの標準キューを指定します。FIFOキューは使えず、キューはルールと同じリージョンに置く必要があります。

権限がなかったり、ターゲットが存在しなかったりするエラーは、リトライされずにDLQへ直接送られます。DLQを付けていないと、この種の失敗は痕跡が残りません。

resource "aws_sqs_queue" "dlq" {
  name = "orders-rule-dlq"
}
 
resource "aws_cloudwatch_event_target" "orders" {
  rule = aws_cloudwatch_event_rule.orders.name
  arn  = aws_lambda_function.handler.arn
 
  dead_letter_config {
    arn = aws_sqs_queue.dlq.arn
  }
 
  retry_policy {
    maximum_event_age_in_seconds = 3600
    maximum_retry_attempts       = 10
  }
}
 
data "aws_iam_policy_document" "dlq" {
  statement {
    effect    = "Allow"
    actions   = ["sqs:SendMessage"]
    resources = [aws_sqs_queue.dlq.arn]
 
    principals {
      type        = "Service"
      identifiers = ["events.amazonaws.com"]
    }
 
    condition {
      test     = "ArnEquals"
      variable = "aws:SourceArn"
      values   = [aws_cloudwatch_event_rule.orders.arn]
    }
  }
}
 
resource "aws_sqs_queue_policy" "dlq" {
  queue_url = aws_sqs_queue.dlq.id
  policy    = data.aws_iam_policy_document.dlq.json
}

コンソールで作ればキューのポリシーは自動で付きますが、APIで設定する場合や別アカウントのキューを使う場合は、リソースベースのポリシーを手動で付ける必要があります。上のaws_sqs_queue_policyが、その手動の部分に当たります。AWSのドキュメントの例と同じく、events.amazonaws.comにsqs:SendMessageを許し、aws:SourceArnでルールを絞っています。

リトライの属性名は、ルールのターゲットがmaximum_event_age_in_secondsとmaximum_retry_attemptsです。Schedulerのretry_policyも同じ名前で、イベントの最大保持が60から86400秒(既定は86400)、リトライ回数が0から185(既定は185)と定義されています。値を小さくしたとき、DLQに届くまでの時間が縮む代わりに、一時的な障害で救えたはずのイベントも早くDLQに入ります。Claude Codeには「なぜこの値か」を、コメントでも説明でも残させておくと、後のレビューが楽になります。

Scheduler側のDLQはtargetブロックの中にdead_letter_config { arn = ... }を書きます。ルールと違ってキュー側のポリシーではなく、スケジュールの実行ロール(上の例のaws_iam_role.scheduler)にsqs:SendMessageを許可します。ロールにこれが無いと、DLQへ送れません。

DLQに入ったメッセージには、ERROR_CODE、ERROR_MESSAGE、EXHAUSTED_RETRY_CONDITION(MaximumRetryAttemptsかMaximumEventAgeInSeconds)、RETRY_ATTEMPTSといった属性が付きます。リトライの設定が原因で落ちたのか、権限の問題かを切り分ける手がかりになります。

cron式のタイムゾーン誤りは、式と属性をセットで読む

Schedulerのcronは6フィールド(分、時、日、月、曜日、年)で、schedule_expression_timezoneを指定しなければUTCで評価されます。日本時間の午前7時に動かしたいなら、次のように書きます。

resource "aws_scheduler_schedule" "morning" {
  name = "morning-batch"
 
  flexible_time_window {
    mode = "OFF"
  }
 
  schedule_expression          = "cron(0 7 ? * MON-FRI *)"
  schedule_expression_timezone = "Asia/Tokyo"
 
  target {
    arn      = aws_lambda_function.batch.arn
    role_arn = aws_iam_role.scheduler.arn
 
    dead_letter_config {
      arn = aws_sqs_queue.dlq.arn
    }
  }
}

timezoneの行を落とすと、同じ式が日本時間の16時に動きます。planの差分ではschedule_expressionの行しか目に入りにくく、属性が無いこと自体は差分に出ません。だからこそ、式の隣にタイムゾーンを並べて読む必要があります。

ほかにも見ておく点があります。

  • flexible_time_windowのmodeがFLEXIBLEだと、maximum_window_in_minutes(1から1440分)の範囲で起動時刻がずれます。時刻ぴったりを期待する用途はOFFにします。
  • 夏時間のあるタイムゾーンでは、春に存在しない時刻のcronは、その日の実行がスキップされます。秋に時刻が戻る日は、1回だけ動きます。Asia/Tokyoのように夏時間のない地域には当てはまりません。
  • 実行時刻の精度は60秒です。1:00の指定は1:00:00から1:00:59のあいだに起動します。

planのJSONから3点を拾うスクリプト

ここまでの3点のうち、DLQの欠落とタイムゾーンの欠落は、terraform show -jsonの出力をjqで読めば機械的に検査できます。イベントパターンの検証はCLIの実行が要るので、別に回します。

#!/bin/bash
# scripts/check-eventbridge-plan.sh
PLAN=${1:-tfplan}
[ -f "$PLAN" ] || exit 0   # planファイルが無いターン(Stopフック)は何もしない
terraform show -json "$PLAN" > "$PLAN.json" || { echo "planを読めないため止めます" >&2; exit 2; }
 
OUT=$(jq -r '
  .resource_changes[]
  | select(.change.after != null)
  | .address as $a | .change.after as $c
  | if .type == "aws_cloudwatch_event_target" then
      (select(($c.dead_letter_config // []) | length == 0)
        | "DLQなし: \($a)")
    elif .type == "aws_scheduler_schedule" then
      (select(($c.target[0].dead_letter_config // []) | length == 0)
        | "DLQなし: \($a)"),
      (select(($c.schedule_expression | startswith("cron("))
          and (($c.schedule_expression_timezone // "UTC") == "UTC"))
        | "cronがUTC評価: \($a)")
    else empty end' "$PLAN.json") || exit 2
 
# ルール側のcronはタイムゾーンを指定できないので、警告として出すだけにする
jq -r '.resource_changes[]
  | select(.type == "aws_cloudwatch_event_rule" and .change.after != null)
  | select((.change.after.schedule_expression // "") | startswith("cron("))
  | "注意 ルールのcronはUTC評価: \(.address)"' "$PLAN.json"
 
if [ -n "$OUT" ]; then
  printf '%s\n' "$OUT" >&2
  exit 2
fi

削除されるリソースは.change.afterがnullになるため、先に除いています。DLQなしとcronがUTC評価が1行でもあれば終了コード2で終わるので、これをClaude Codeに実行させると、指摘がそのまま修正の材料になります。ルール側のcronは「注意」として標準出力に出すだけで、終了コードには含めません。タイムゾーンを指定できないルールでは直しようがないため、完了条件に入れると終わらなくなるからです。あくまで例なので、リソース名や条件は自分のモジュールに合わせて調整してください。

terraform show -jsonが読むのは保存したplanなので、terraform plan -out=tfplanのあとで走らせます。データソースの値が計画時に決まらない属性は、afterではなくafter_unknown側に出ます。このスクリプトはafterだけを見るので、そうした属性は拾えません。そのときは手元でterraform showの出力を目で見ます。

Claude Codeに回させる設定

Terraform全般の進め方はClaude CodeでTerraformのコードを書く手順にまとめてあります。ここではEventBridge固有の部分だけを足します。

スクリプトを置いたら、読み取り系だけを許可して、applyは確認に残します。公式ガイドの書式と同じく、allow、ask、denyで分けます。

{
  "permissions": {
    "allow": [
      "Bash(terraform plan *)",
      "Bash(terraform show *)",
      "Bash(aws events test-event-pattern *)",
      "Bash(./scripts/check-eventbridge-plan.sh *)"
    ],
    "ask": ["Bash(terraform apply *)"]
  }
}

CLAUDE.mdには、完了の条件を書きます。

# EventBridge の変更
- 変更後は terraform plan -out=tfplan のあと scripts/check-eventbridge-plan.sh を実行し、「DLQなし」「cronがUTC評価」の出力が出なくなるまで直す
- event_pattern を変えたら test-event-pattern を実サンプルと外れるべきサンプルの両方で実行し、結果を貼る
- DLQ と retry_policy の値は理由を 1 行コメントで残す

公式ガイドは、Claudeに「実行したコマンドとその出力」という証拠を求めるよう勧めています。「通りました」の一言では、確かめようがないからです。上のCLAUDE.mdが結果の貼り付けを求めているのは、そのためです。

さらに強制したい場合は、Stopフックが使えます。Claudeがターンを終えようとするたびにスクリプトを実行し、終了コードが2なら作業に戻らせる仕組みで、ガイドでは型チェックを通るまで終わらせない例として紹介されています。ただしplanファイルが無い状態でも走るため、上のスクリプトは冒頭でtfplanが無ければ0で抜けるようにしてあります。

運用でつまずきやすい点

  • DLQに入ったのに誰も気づかない: キューを作るだけでは通知されません。配信に失敗するとCloudWatchメトリクスに失敗が記録され、DLQを使うとInvocationsSentToDLQとInvocationsFailedToBeSentToDLQも加わります。DLQへの送信が失敗していないかも含めて、これらへのアラームを別途Terraformで入れます。
  • ルール側のcronを日本時間のつもりで書く: UTCの9時間前を自分で計算するしかありません。可能なら、時刻を扱うものはSchedulerへ移します。
  • is_enabledを書き続ける: ルールのis_enabledは非推奨で、stateに置き換わっています。stateのENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTSは、schedule_expressionと併用できません。
  • パターンが長い: event_patternのサイズは、既定で2048文字までです。上限の引き上げにはクォータの申請が要ります。

まとめ

EventBridgeをClaude Codeに任せるときの要点は、planが通ることを完了の条件にしないことです。DLQとタイムゾーンはplanのJSONで、イベントパターンはtest-event-patternで、それぞれ合否の出るチェックにして渡します。設定を書き足すより、書かれていない設定を検出する側に手間をかけたほうが、ルールが増えても事故が減ります。

参照した一次ソース

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

  1. code.claude.com/docs/en/best-practices
  2. docs.aws.amazon.com/eventbridge/latest/userguide/eb-rule-dlq.html
  3. docs.aws.amazon.com/eventbridge/latest/userguide/eb-rule-retry-policy.html
  4. docs.aws.amazon.com/eventbridge/latest/userguide/eb-create-pattern.html
  5. docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html
  6. registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/scheduler_sc…
  7. registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_e…
この記事を共有:XはてブLinkedIn