Claude Media
Claude CodeでKafkaのリトライトピックとDLTを組む手順 — Spring Kafka

Claude CodeでKafkaのリトライトピックとDLTを組む手順 — Spring Kafka

Spring KafkaのRetryableTopicで5秒・1分の段階リトライとDLTを組み、設定の取り違えをPostToolUse hookのテストで止める手順を、CLAUDE.mdの規約と合わせて示します。

Kafkaコンシューマーの失敗を「5秒後」「1分後」「それでもだめならDLT」と段階的に流す構成は、Spring Kafkaの@RetryableTopicで組めます。Claude Codeに任せるときの勘所は、実装そのものより、リトライ間隔・例外の振り分け・DLT失敗時の挙動という「設定の意味」を先に固定しておくことです。そのうえでPostToolUse hookからテストを回せば、Claudeが間隔を書き換えたりアノテーションを落としたりした時点で、失敗が本人に返ります。

ブローカーの状態を調べる側の話はKafka MCPサーバーでトピックとコンシューマーグループを調べるにあります。ここでは、コンシューマーのコードを書いて直す側を扱います。

ノンブロッキングリトライは何を作るのか

Spring Kafkaのノンブロッキングリトライは、処理に失敗したメッセージをバックオフ時刻つきでリトライ用トピックへ転送する仕組みです。リトライトピックのコンシューマーは時刻を確かめ、まだ期限が来ていなければそのトピックのパーティションの消費を一時停止します。期限が来れば再開してメッセージを消費し直します。失敗が続けば次のリトライトピックへ、回数を使い切ればDLT(デッドレタートピック)へ送られます。

トピック名は、元のトピック名に接尾辞と遅延値(またはインデックス)を足して決まります。ドキュメントの例は、バックオフ1000ms・倍率2・最大4回の設定で、main-topic-retry-1000・main-topic-retry-2000・main-topic-retry-4000・main-topic-dltの4トピックができる、というものです。最大回数には初回の処理も含まれるため、4回なら「初回+リトライ3回」になります。

この対応を「5秒→1分→DLT」に当てはめます。リトライトピックが2つなので最大回数は3、初回間隔は5000ms、2回目が60000msです。5000から60000へは12倍なので、倍率は12になります。

手順

5秒・1分・DLTの流れ

  1. 1

    元のトピックで失敗する

    リスナーが例外を投げると、メッセージはバックオフ時刻つきで最初のリトライトピックへ転送されます。

  2. 2

    orders-retry-5000で5秒待つ

    期限前はパーティションの消費が止まり、期限が来ると再開して処理し直します。

  3. 3

    orders-retry-60000で1分待つ

    ここでも失敗すると、回数を使い切ったことになります。

  4. 4

    orders-dltに届く

    DLTハンドラーが保存や通知を受け持ちます。ハンドラーを置かなければ、ログに出すだけの既定ハンドラーが使われます。

注意が一点あります。名前はretry-5sではなく、遅延をミリ秒で足したretry-5000の形です。接尾辞は変えられますが、既定では遅延値が付きます。

@RetryableTopicで段階リトライを書く

設定の本体は、リスナーメソッドに@RetryableTopicと@KafkaListenerを並べるだけです。次の例は、ordersトピックを読むコンシューマーです(クラス名と型は例です)。

@Component
public class OrderListener {
 
    @RetryableTopic(
        backOff = @BackOff(delay = 5000, multiplier = 12, maxDelay = 60000))
    @KafkaListener(topics = "orders", groupId = "order-worker")
    public void process(OrderEvent event) {
        // 失敗時は例外を投げる。握りつぶすとリトライされない
        orderService.handle(event);
    }
 
    @DltHandler
    public void processDlt(OrderEvent event) {
        // 保存・通知など。ここが失敗したときの挙動は次の節
        deadLetterStore.save(event);
    }
}

maxDelayを書いている点が見落としやすい箇所です。指数バックオフには30秒の既定の最大遅延があり、それより長い遅延が必要ならmaxDelayを調整する、とドキュメントにあります。省くと1分のはずの2段目が30秒で頭打ちになります。

回数の指定を省いたときの最大回数は、既定の3回です。試行回数を明示したい場合や、アノテーションではなくBeanで組みたい場合は、RetryTopicConfigurationBuilderのmaxAttemptsなどで同じ設定を表せます。

トピックは、KafkaAdminの仕組みで自動作成されるのが既定です。パーティション数は1、レプリカ数は-1(ブローカーの既定)で作られます。本番で権限やパーティション数を管理しているなら、autoCreateTopics = "false"で止めて、事前に作成しておく構成になります。

例外で行き先を分ける

既定では、すべての例外がリトライ対象で、原因の連鎖までは辿りません。リトライしても直らない例外まで待たせないために、対象を絞れます。

@RetryableTopic(
    backOff = @BackOff(delay = 5000, multiplier = 12, maxDelay = 60000),
    include = {TransientDbException.class},
    traversingCauses = "true")

includeに挙げた例外だけをリトライし、traversingCausesで原因の例外も探します。逆に、リトライしない例外を挙げたいときは、Bean形式のnotRetryOnを使います。

さらに、DeserializationException・MessageConversionException・ConversionException・MethodArgumentResolutionException・NoSuchMethodException・ClassCastExceptionの6つは、致命的な例外として扱われます。これらは、リトライなしでそのままDLTに送られます。同じメッセージを何度読み直しても、結果は変わらないためです。この一覧は変更できます。

DLTを例外の種類で分けたいときは、Spring Kafka 3.2.0以降のexceptionBasedDltRoutingが使えます。接尾辞-deserializationを指定すると、DeserializationExceptionで落ちたメッセージはorders-deserialization-dltへ向かい、汎用のorders-dltには入りません。壊れた入力と、一時的な障害の成れの果てを別々に調べたいときに効きます。

DLTの処理が失敗したらどうなるか

DLTにも失敗はあります。失敗時の挙動は3通りから選びます。

戦略失敗したメッセージの扱い向く場面
ALWAYS_RETRY_ON_ERROR(既定)失敗したメッセージの扱いDLTに戻し、後続のDLTレコードを塞がない向く場面保存先が一時的に落ちうる場合
FAIL_ON_ERROR失敗したメッセージの扱い転送せず、そこで実行を終える向く場面失敗を即座に止めて調べたい場合
NO_DLT失敗したメッセージの扱いDLTを作らず、リトライを使い切って終わる向く場面捨ててよいデータの場合

ALWAYS_RETRY_ON_ERRORでも、先ほどの致命的な例外が原因のときはDLTへ戻されません。デシリアライズ失敗のような例外は何度やっても同じだからです。

DLTを別アプリで消費する構成なら、autoStartDltHandler = "false"でこのアプリでのDLT消費を止め、必要になってからKafkaListenerEndpointRegistryで起動できます。

失敗ヘッダーの扱いも、運用で効いてきます。既定では元のヘッダーは最初の1つだけ、例外ヘッダーは最後の1つだけが残ります。スタックトレースが段ごとに積み上がってメッセージが肥大するのを避けるための設計です。リトライ経路を全部追いたいなら、DeadLetterPublishingRecovererのカスタマイザーでsetAppendOriginalHeaders(true)などに切り替えます。

Claudeに先に伝える制約をCLAUDE.mdに置く

ここまでの内容には、コードを見ただけでは気づきにくい制約が含まれています。Claudeが「リトライを足そう」と考えたとき、前提として読める場所に書いておきます。次はCLAUDE.mdに置く断片の例です。

## Kafkaコンシューマーの規約
 
- リトライは Spring Kafka の @RetryableTopic で組む。自前のsleepやwhileでの再試行は書かない
- 現在の構成は 5秒 → 1分 → DLT。間隔と回数は OrderListenerRetryConfigTest が固定している
- 指数バックオフで30秒を超える遅延には maxDelay を必ず指定する
- リトライ対象外にしたい例外は include / notRetryOn で明示する。握りつぶして正常終了させない
- このトピックはリトライで順序保証を失う。順序に依存する処理は追加しない
- バッチリスナーとコンテナトランザクションはノンブロッキングリトライと併用できない
- 設定を変えたら ./gradlew test を実行し、失敗が残ったまま完了と報告しない

順序保証については、ドキュメントに「この方式を使うとそのトピックのKafkaの順序保証を失う」とあります。同じキーのメッセージが、リトライを挟んで後続のメッセージに追い越されるためです。この一行は、後から入れる機能が順序に依存していないかを判断する材料になります。

AckモードはRECORDが推奨です。手動AckとasyncAcksを組み合わせる場合は、DefaultErrorHandlerのseekAfterErrorをfalseにする必要があります。2.9.10と3.0.8以降は、この構成で自動的にfalseになります。それより前の版を使う環境では、手動でカスタマイズが要ります。

PostToolUse hookでリトライ設定のテストを回す

リトライの設定は、数字が1つ違うだけで挙動が変わります。しかもブローカーを立てなくても、アノテーションの値は読めます。そこで、設定そのものを固定するテストを書き、hookで毎回走らせます。

class OrderListenerRetryConfigTest {
 
    @Test
    void retryIs5sThen1minThenDlt() throws Exception {
        Method m = OrderListener.class.getMethod("process", OrderEvent.class);
        RetryableTopic rt = m.getAnnotation(RetryableTopic.class);
 
        assertNotNull(rt, "@RetryableTopic が外れている");
        assertEquals(5000, rt.backOff().delay());
        assertEquals(12.0, rt.backOff().multiplier());
        assertEquals(60000, rt.backOff().maxDelay());
    }
}

このテストが見ているのは設定値です。実際にトピックができて、メッセージが5秒後に戻ってくるかは別の話なので、ブローカーを使う検証はTestcontainersをsandbox越しに動かす手順のように、別に走らせます。hookに載せるのは、数秒で終わる側だけにします。

次は.claude/settings.jsonの設定です。PostToolUseのmatcherにはツール名を書くので、ファイル編集を拾うならEdit|Writeです。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/kafka-retry-test.sh",
            "timeout": 300
          }
        ]
      }
    ]
  }
}

timeoutの既定はコマンドhookで600秒です。ビルドが遅い環境でも長すぎるので、実測に合わせて短く切っておきます。

スクリプトは、標準入力のJSONから編集されたファイルのパスを読み、Javaファイルのときだけテストを走らせます。

#!/bin/bash
f=$(jq -r '.tool_input.file_path // empty')
 
case "$f" in
  *.java|*.kt) ;;
  *) exit 0 ;;
esac
 
cd "$CLAUDE_PROJECT_DIR" || exit 0
out=$(./gradlew test -q --console=plain 2>&1)
if [ $? -ne 0 ]; then
  echo "テストが失敗しました。リトライ設定を確認してください。" >&2
  echo "$out" | tail -n 40 >&2
  exit 2
fi

終了コードは2にします。PostToolUseでは、終了コード2は「ツールはすでに実行済み」のまま、stderrをClaudeに見せる動作だからです。終了コード0ならstderrはClaudeに届きません。出力を絞る-qと--console=plainの理由はClaude CodeでGradleビルドを回すに、MavenならClaude CodeでMavenビルドを回すにあります。

同じ型のhookは、他の言語やテストランナーでも組めます。たとえばLWCのJestテストをhooksで自動実行する設定は、検査コマンドが違うだけで骨格が同じです。

つまずきやすい点

落とし穴

Bashで書き換えたファイルではhookが動かない

Edit|Writeに一致するPostToolUse hookは、BashコマンドやClaude Code外のプロセスが同じファイルを書き換えたときには動きません。Claudeがsedでリスナーを直した場合は拾われないので、matcherを*にしてスクリプト側でgit status --porcelainを見る方法や、ファイル変更を見るFileChangedイベントを組み合わせます。

落とし穴

1秒前後の遅延は精度が出ない

遅延の処理はコンシューマースレッドが受け持つため、精度は最善努力です。1秒以下の短い遅延では、オフセットのコミットなどの作業で処理が遅れることがあります。期限より前に処理されることはありません。5秒・1分なら問題になりにくいですが、1つのリトライトピックで複数パーティションを担当すると、さらにずれます。

落とし穴

リトライ設定の変更で古いトピックが残る

独自のバックオフに上限があるのに最大回数がそれより多いと、上限までしかリトライトピックは作られません。以前の版ではトピックが余分に作られていたため、アップグレードで名前が変わることがあります。ドキュメントは、その場合は古いリトライトピックを先に空にするよう案内しています。

同じ「リトライと行き着く先」の設計は、他のキューにもあります。Sidekiqなら、Sidekiqのジョブを冪等にしてdead setを確かめるが参考になります。Kafkaでもリトライトピックを経由するとメッセージが再度処理されるため、処理側が二重に呼ばれても結果が変わらない作りが前提になります。

まとめ

段階リトライの設計判断は、@RetryableTopicの数字、例外の振り分け、DLT失敗時の戦略の3か所に集中しています。この3つをCLAUDE.mdとテストで固定し、PostToolUse hookからテストを回すと、Claudeの編集が設定を崩したときに、その場で本人に失敗が返ります。順序保証の喪失は設定では防げないため、リトライを入れる前に、そのトピックが順序に依存していないかを先に決めておく必要があります。

参照した一次ソース

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

  1. code.claude.com/docs/en/hooks
  2. docs.spring.io/spring-kafka/reference/retrytopic/how-the-pattern-works.ht…
  3. docs.spring.io/spring-kafka/reference/retrytopic/features.html
  4. docs.spring.io/spring-kafka/reference/retrytopic/dlt-strategies.html
  5. docs.spring.io/spring-kafka/reference/retrytopic/topic-naming.html
この記事を共有:XはてブLinkedIn