Claude Media
ClaudeでDLQの原因を調査 — claude -pでメッセージを仕分ける手順

ClaudeでDLQの原因を調査 — claude -pでメッセージを仕分ける手順

SQSのDLQに溜まったメッセージを取り出し、claude -pと--json-schemaで原因別に仕分けて、再投入できるものだけを選ぶ手順です。redriveがフィルタできない点の回避も含みます。

DLQ(デッドレターキュー)に数百件が溜まったとき、最初に知りたいのは「どれが再投入で直り、どれが直らないか」です。件数が多いと目視では追えません。取り出したメッセージをclaude -pに1件ずつ渡し、スキーマ不一致・依存先障害・データ不正の3つに分けて、再投入の可否まで出させる手順を書きます。例はAmazon SQSですが、メッセージをJSONで取り出せるキューなら考え方は同じです。

結論から言うと、Claudeの役割は「分類と根拠の提示」までです。メッセージの削除や再投入は、分類結果を人が確かめたうえでスクリプトが実行します。

仕分けの全体像と、Claudeに任せる範囲

DLQは、消費側が処理に失敗したメッセージを隔離する場所です。AWSの説明では、DLQは未処理メッセージを切り離して原因を調べるために使い、調べ方として消費側のログの例外確認とメッセージ内容の分析が挙がっています。この「内容の分析」を機械的に回すのが今回の作業です。

手順

仕分けの流れ

  1. 1

    DLQから取り出す

    メッセージ本文とID、受信回数をJSON Linesに書き出します。この段階では削除しません。

  2. 2

    消費側のログを足す

    メッセージIDで引ける例外ログがあれば、1件ずつ添えます。本文だけでは原因が決まらないためです。

  3. 3

    claude -pで分類する

    1件ずつ呼び出し、--json-schemaで原因・根拠・再投入の可否を固定形式で返させます。

  4. 4

    集計して人が確かめる

    原因別の件数を出し、各原因から数件を抜き取って分類が妥当か見ます。

  5. 5

    再投入するものだけ戻す

    再投入可と判断したものだけを元のキューへ送り、DLQから消します。

Claudeに任せるのは3番目だけです。取り出し・削除・再投入はawsコマンドとjqで書き、Claudeには許可ツールを一切渡しません(--allowedToolsを付けません)。本文はキューに入ってきた外部由来のデータなので、そこに紛れた指示文でファイル編集や外部通信が動かないようにする意味もあります。

DLQのメッセージを取り出す

まず、AWS CLIのreceive-messageで取り出します。1回で返るのは最大10件で、有効値は1〜10です。件数が多ければループで繰り返します。

DLQ_URL="https://sqs.ap-northeast-1.amazonaws.com/123456789012/orders-dlq"
 
for i in $(seq 1 30); do
  aws sqs receive-message --queue-url "$DLQ_URL" \
    --max-number-of-messages 10 \
    --attribute-names All --message-attribute-names All \
    --visibility-timeout 600 --output json \
    | jq -c '.Messages[]?' >> dlq.jsonl
done
wc -l dlq.jsonl

--visibility-timeout 600は、取り出している間に同じメッセージが再度見えないようにするための指定です。取り出しだけでは削除されませんが、受信するたびに受信回数は増えます。何度も試し打ちすると、ReceiveCountの値が実態より大きく見える点は覚えておいてください。また、同じメッセージが重複して書き出されることがあるため、MessageIdで重複を除きます。

jq -s 'unique_by(.MessageId)[]' -c dlq.jsonl > dlq.uniq.jsonl

DLQ自体の制約として、DLQは元のキューと同じアカウント・同じリージョンに置く必要があります。別アカウントのキューを覗く場合は、権限とリージョンの指定が合っているかを先に確かめます。

1件ずつ分類するスクリプト

分類にはヘッドレスモードのclaude -pを使います。標準入力にデータを渡せるので、メッセージ1件をそのままパイプで流し込めます。出力は--output-format jsonと--json-schemaで形を固定し、structured_outputフィールドから取り出します。

分類スキーマは次のように置きます。原因を3種類と「不明」に限定し、再投入の可否も列挙値にしておくと、後の集計がjqだけで済みます。

cat > schema.json <<'EOF'
{"type":"object","properties":{
 "cause":{"type":"string",
  "enum":["schema_mismatch","dependency_outage","bad_data","unknown"]},
 "redrive":{"type":"string",
  "enum":["yes","no","after_fix"]},
 "evidence":{"type":"string"}
},"required":["cause","redrive","evidence"]}
EOF

呼び出し側のスクリプトです。

while IFS= read -r line; do
  id=$(jq -r '.MessageId' <<<"$line")
  out=$(jq '{body:.Body, attrs:.Attributes, log:null}' <<<"$line" \
    | claude --bare -p "$(cat prompt.txt)" \
        --output-format json \
        --json-schema "$(cat schema.json)")
  jq -c --arg id "$id" \
    '{id:$id, result:.structured_output, cost:.total_cost_usd}' \
    <<<"$out" >> triage.jsonl
done < dlq.uniq.jsonl

--bareは、フックやMCPサーバー、CLAUDE.mdを読み込まずに起動するモードです。どのマシンでも同じ結果にしたい自動処理向けで、公式は「スクリプトやSDK呼び出しには--bareを推奨し、将来-pの既定になる」としています。ただしbareモードはOAuthやキーチェーンを読まないため、ANTHROPIC_API_KEYを環境変数で渡す必要があります。サブスクのログインでは動きません。

prompt.txtには、分類の定義を具体的に書きます。ここが精度を左右します。

標準入力は、SQSのDLQから取り出した1件のメッセージです。
消費側の期待スキーマは order-v3(必須: order_id, items[], currency)です。
次の基準で cause を1つ選んでください。
- schema_mismatch: 必須項目の欠落・型違い・未知のバージョン
- dependency_outage: ログに接続拒否・タイムアウト・5xxがあり、
  本文自体は期待スキーマを満たす
- bad_data: スキーマは合うが値が不正(存在しない商品ID、負の数量など)
- unknown: 上のどれとも決められない
redrive は、そのまま再投入して成功しうるなら yes、
再投入しても同じ理由で失敗するなら no、
修正後なら成功しうるなら after_fix です。
evidence には、判断した本文の箇所を1文で引用してください。

期待スキーマや、各原因に当たるログの特徴は、読者の環境に合わせて書き換えてください。dependency_outageの判定に例外ログが必要なのは、本文だけでは「依存先が落ちていた」ことが見えないためです。ログをlogフィールドに入れる処理は、自社の基盤に合わせて足します。

分類結果が返らなかったとき

構造化出力が得られない実行では、応答にstructured_outputフィールドがありません。たとえばClaudeが依頼を断った場合です。上のスクリプトはそのときresultがnullになるだけで止まらないため、集計の前にnullの件数を数えます。

jq -s 'map(select(.result == null)) | length' triage.jsonl

失敗した件はunknown扱いとして再実行するか、人が見る列に回します。実行自体が失敗した場合は、claudeが0以外の終了コードで終わるので、set -eやループ内の||で拾えます。

原因別に集計する

結果がtriage.jsonlに並んだら、jqで原因と再投入可否のクロス集計を出します。

jq -s 'group_by(.result.cause)
  | map({cause: .[0].result.cause, n: length,
         redrive: (group_by(.result.redrive)
           | map({(.[0].result.redrive): length}) | add)})' triage.jsonl

出力のイメージは次のとおりです(数値は例です)。

原因件数redrive: yesredrive: after_fixredrive: no
dependency_outage件数212redrive: yes212redrive: after_fix0redrive: no0
schema_mismatch件数41redrive: yes0redrive: after_fix38redrive: no3
bad_data件数17redrive: yes0redrive: after_fix0redrive: no17
unknown件数5redrive: yes0redrive: after_fix0redrive: no5

この形になれば、作業は原因ごとに分かれます。依存先障害は復旧済みなら再投入するだけです。スキーマ不一致は、送信側か消費側のどちらを直すかを決める必要があります。データ不正は、再投入せずに内容を別の場所へ保管するのが普通です。

抜き取り検証を省かない

分類結果をそのまま信じて再投入すると、誤分類がそのまま本番に戻ります。各原因から数件ずつ抜き取り、本文とevidenceを並べて確認します。

jq -c 'select(.result.cause=="dependency_outage")' triage.jsonl \
  | shuf -n 5

特にdependency_outageのredrive: yesは、全件の本文が期待スキーマを満たしているかを機械で確かめます。Claudeの判定とは別に、JSON Schemaの検証ツールで本文を検査すれば、「スキーマは合っているが依存先が落ちていた」という分類の前提を独立して裏取りできます。

--output-format jsonの応答にはtotal_cost_usdが入ります。1件あたりの費用はtriage.jsonlのcostに残しているので、全件の合計を出して、本番の件数でいくらかかるかを先に見積もれます。この値はクライアント側の推定で、実際の請求とは差が出うる点は公式も注記しています。

再投入は「全部」か「選んで」かを決める

ここがDLQ運用で最も間違えやすい点です。SQSにはDLQ redrive(StartMessageMoveTask)があり、DLQのメッセージを元のキューへ非同期に移せます。ただし、redrive中にメッセージをフィルタしたり書き換えたりすることはできません。DLQ全体を古い順に移す操作です。

そのため、分類結果の使い方は2通りに分かれます。

くらべる

再投入の方法

redrive

DLQを丸ごと戻す

分類後のDLQが「戻してよいものだけ」になっているなら、StartMessageMoveTaskで一括して戻せます。事前に他の原因のメッセージを退避しておく必要があります。

send + delete

1件ずつ戻す

再投入可のIDだけを元のキューへsend-messageし、成功したらDLQからdelete-messageします。選別はできますが、スクリプトの保守が要ります。

一括のredriveを使う場合の流れは、次のようになります。

aws sqs start-message-move-task \
  --source-arn arn:aws:sqs:ap-northeast-1:123456789012:orders-dlq \
  --max-number-of-messages-per-second 50

--max-number-of-messages-per-secondは移す速度の上限です。復旧直後の依存先に一気に流し込むと再び落ちるため、速度は絞っておきます。

redriveの挙動には、先に知っておくべき点がいくつかあります。

  • redrive taskの実行は最長36時間で、アカウントあたりの同時実行は最大100件です。
  • 移したメッセージは新しいメッセージとして扱われ、新しいMessageIdとenqueueTimeが付きます。保持期間もリセットされます。
  • 移動先がFIFOキューなら、移したメッセージは生産者の新しいメッセージと混ざります。順序が重要な処理では、この混ざり方が問題になります。
  • 取り消しはCancelMessageMoveTaskでできますが、それまでに移ったメッセージは移動先に残ります。

選んで戻す場合は、MessageIdが新しく採番される点に注意します。冪等性のキーにMessageIdを使っている消費側では、再投入したメッセージが別物として二重に処理される可能性があります。注文IDのような業務側のキーで重複を判定しているかを、再投入の前に確かめてください。

つまずきやすい点

  • 標準入力の上限: claude -pへパイプで渡せるのは10MBまでです。1件のメッセージが巨大でなければ問題ありませんが、ログを大量に添えるときはclaude -pにログを渡すと10MB上限で止まるの絞り方が使えます。
  • 保持期間: DLQの保持期間は、元のキューより長く設定するのがAWSの推奨です。標準キューではDLQへ移ってもエンキュー時刻は変わらないので、元のキューで長く待った分だけ、DLQで残る日数が短くなります。仕分けに時間をかけすぎると、調べる前にメッセージが期限切れで消えます。
  • 分類の一貫性: 同じメッセージでも実行ごとに分類が揺れる可能性があります。件数が多いときは、unknownとredrive: no以外を採用する前に、別の実行でも同じ結果になるか抜き取りで確認します。
  • 権限: redriveを実行するには、DLQと移動先のキューに対するsqs:ReceiveMessageなどの権限が必要です。AWS側の必要権限は、redriveの公式ページで確認してください。

同じ「Claudeに分類させてから人が確かめる」型は、CVE対応でも使えます。複数リポジトリで影響の有無を仕分ける例はClaude CodeでCVEの影響調査にあります。

まとめ

DLQの仕分けでは、Claudeに任せる範囲を「1件ずつの原因分類と根拠の提示」に限り、取り出し・削除・再投入はスクリプトに置きます。redriveがフィルタできない以上、再投入の前に「戻してよいメッセージだけがDLQに残る状態」を作れるかが分かれ目です。作れないなら、1件ずつ戻す方式にして、消費側の冪等性キーを先に確かめます。分類の精度は、プロンプトに書く期待スキーマとログの特徴で決まるので、最初の数十件で抜き取り検証をしてから全件に広げるのが無難です。

参照した一次ソース

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

  1. code.claude.com/docs/en/headless
  2. docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-le…
  3. docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configu…
この記事を共有:XはてブLinkedIn