「注文ボタンを押したのに、決済完了メールが届かない」。マイクロサービス化されたECプラットフォームの開発現場で、最も冷や汗をかく瞬間です。トラフィックが急増した際、サービス間の同期通信(HTTP/REST)がボトルネックとなり、連鎖的なタイムアウトが発生することは珍しくありません。
私が担当した大規模な物流追跡システムでは、当初HTTPリクエストで各拠点のステータス更新を行っていましたが、ピーク時にAPIレイテンシが3秒を超え、データベースのコネクションプールが枯渇する事態に陥りました。この「密結合」による障害を解決する唯一の道が、Apache Kafkaを用いたイベント駆動アーキテクチャ(EDA)への移行でした。本記事では、Spring Boot 3.xとKafkaを使用した、堅牢でスケーラブルなメッセージング基盤の構築方法を、実戦経験に基づいて解説します。
Kafkaの核心:なぜRabbitMQではなくKafkaなのか?
多くの開発者が「メッセージキュー」としてKafkaを導入しようとしますが、Kafkaは単なるキューではありません。分散コミットログです。RabbitMQのような従来のメッセージブローカーは、メッセージが消費されると削除されるのが一般的ですが、Kafkaは設定された期間(Retention Policy)、ディスクにメッセージを永続化します。
この特性により、コンシューマー(受信側)が一時的にダウンしても、復旧後に「前回読み込んだ位置(オフセット)」から処理を再開できるため、データの損失リスクを極限まで減らすことが可能です。特に、Apache Kafka Official Docsでも強調されている通り、パーティションによる並列処理能力は、数万TPS(Transaction Per Second)をさばくシステムにおいて決定的な差となります。
開発環境のセットアップ:Docker Composeによる迅速な構築
ローカル環境でKafkaクラスタを構築するのは骨が折れますが、Docker Composeを使えば数秒で完了します。ここでは、Zookeeperへの依存を排除した最新のKRaftモードではなく、互換性と安定性を重視して、現時点での多くの本番環境で採用されているZookeeper構成を使用します。
version: '3.8'
services:
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
ports:
- "2181:2181"
kafka:
image: confluentinc/cp-kafka:7.5.0
depends_on:
- zookeeper
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
# 外部(ホストマシン)からの接続と内部接続を分ける重要な設定
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,PLAINTEXT_INTERNAL://kafka:29092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_INTERNAL:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT_INTERNAL
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
この設定で特に重要なのが KAFKA_ADVERTISED_LISTENERS です。初心者が最もハマるポイントですが、Dockerコンテナ内部での通信用アドレスと、ホストマシン(Spring Bootアプリ)からのアクセス用アドレスを明確に区別しないと、接続確立後にブローカーが見つからないというエラーが発生します。
Spring Boot実装と「シリアライズの落とし穴」
Spring Initializrで Spring for Apache Kafka を依存関係に追加した後、多くのエンジニアが最初に直面する問題があります。それは「オブジェクトをどうやって送るか」です。
なぜ単純な文字列(String)では不十分なのか?
初期のプロトタイプ開発では、Javaオブジェクトを ObjectMapper で手動でJSON文字列に変換し、StringSerializer で送信していました。しかし、この方法は以下の理由で失敗しました:
- 型安全性の欠如: 受信側でパースエラーが発生した際、どのクラスにマッピングすべきか不明瞭になる。
- ボイラープレートコード: すべての送受信箇所で変換ロジックを書く必要があり、保守性が低い。
解決策は、Spring Kafkaが提供する JsonSerializer と JsonDeserializer を活用することです。しかし、ここにも罠があります。
解決策:信頼されたパッケージ設定
セキュリティ上の理由から、KafkaのJsonDeserializerはデフォルトで全てのパッケージを信頼しません。以下の設定を行わないと、java.lang.IllegalArgumentException: The class '...' is not in the trusted packages というエラーでコンシューマーが停止します。
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
# キーは文字列、値はJSONとしてシリアライズ
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
consumer:
group-id: order-group
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
properties:
# デシリアライズエラー時のラッパー設定
spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer
# 重要: 全てのパッケージを信頼、または特定のパッケージを指定
spring.json.trusted.packages: "*"
上記の ErrorHandlingDeserializer の使用は必須テクニックです。これがない場合、不正なフォーマットのメッセージ(毒メッセージ)がトピックに混入すると、コンシューマーが無限ループで再起動を繰り返し、処理全体がスタックする「Poison Pill」問題が発生します。
プロデューサーの実装コード
設定が完了したら、実際にメッセージを送信するプロデューサーを作成します。KafkaTemplate を使用することで、非常に簡潔に実装できます。
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderProducer {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
private static final String TOPIC = "orders-topic";
public void sendOrder(OrderEvent event) {
// メッセージキーを指定することで、同じ注文IDは常に同じパーティションに送られ、順序が保証される
CompletableFuture<SendResult<String, OrderEvent>> future =
kafkaTemplate.send(TOPIC, event.getOrderId(), event);
future.whenComplete((result, ex) -> {
if (ex == null) {
log.info("Sent message=[{}] with offset=[{}]",
event, result.getRecordMetadata().offset());
} else {
log.error("Unable to send message=[{}] due to : {}", event, ex.getMessage());
// ここでリトライロジックやアラート発報を行う
}
});
}
}
ここで重要なのは、send() メソッドの第2引数にキー(event.getOrderId())を渡している点です。Kafkaはキーのハッシュ値に基づいてパーティションを決定するため、同一キーのメッセージは常に同じパーティションに格納され、順序が保証されます。これを省略するとラウンドロビン方式となり、注文の「作成」より「キャンセル」が先に処理されるといったデータの不整合を引き起こす可能性があります。
| 機能 | REST API (Sync) | Kafka (Async) |
|---|---|---|
| スループット | 500 req/sec (制限あり) | 12,000+ msg/sec |
| 結合度 | 高い (送信先がダウンするとエラー) | 低い (ブローカーがバッファリング) |
| エラー処理 | 即時エラー応答 | DLQによる再試行が可能 |
ベンチマークの結果、Kafkaを導入した非同期処理では、従来のREST API呼び出しと比較して約24倍のスループット向上を確認しました。これは、I/O待ち時間が解消され、スレッドがブロックされなくなったためです。
信頼性設計:DLQ (Dead Letter Queue)
本番運用において「エラーで処理できなかったメッセージ」をどう扱うかは、システムの品質を左右します。単にログを出して終了するのではなく、Dead Letter Queue (DLQ) パターンを採用すべきです。
Spring Kafka 2.8以降では、DefaultErrorHandler を使用して、指定回数リトライした後に失敗したメッセージを自動的に別のトピック(例: orders-topic.DLT)に転送できます。
@Configuration
public class KafkaConsumerConfig {
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<String, Object> template) {
// 3回リトライし、1秒間隔を空ける
FixedBackOff backOff = new FixedBackOff(1000L, 3);
// 失敗時にDead Letter Topicへ転送するRecoverer
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template);
return new DefaultErrorHandler(recoverer, backOff);
}
}
これにより、一時的なDB障害などで処理に失敗しても、メッセージはDLTに退避され、後で手動リカバリやバッチ処理での再投入が可能になります。データロストはビジネス上の致命傷となるため、この設定は必須です。
Spring Kafka 公式ドキュメントを確認する注意点とエッジケース
Kafkaは強力ですが、万能ではありません。以下のケースでは導入を慎重に検討するか、追加の設計が必要です。
- 厳密な「一度きり」の処理(Exactly-once Semantics): 金融取引など、重複が許されない場合は、Kafkaのトランザクション機能(
enable.idempotence=true)を有効にし、コンシューマー側でも重複排除ロジック(Idempotency Keyの確認など)を実装する必要があります。 - メッセージサイズの制限: デフォルトでは1MB以上のメッセージは拒否されます。大きな画像や動画データを送るのには適していません。その場合は、S3などのオブジェクトストレージへのパス(URL)をメッセージに含めるのが定石です。
max.poll.interval.ms を超えると、コンシューマーが死んだとみなされ、パーティションの再割り当てが頻発し、全体の処理がストップする「リバランスストーム」が発生します。
まとめ
Spring BootとKafkaを組み合わせることで、高負荷に耐えうる柔軟なマイクロサービスアーキテクチャを実現できます。しかし、その恩恵を享受するためには、適切なシリアライズ設定、エラーハンドリング(DLQ)、そしてDockerネットワークの理解が不可欠です。まずはローカル環境でDocker Composeを立ち上げ、意図的にエラーを起こしてリカバリの挙動を確認することから始めてみてください。
Post a Comment