非同期メッセージングの契約テストは、組織がマイクロサービスを分離し、リアルタイムデータストリーミングを可能にするイベント駆動アーキテクチャを採用するにつれて登場しました。契約テストの初期の実装は主にREST APIに焦点を当てていたため、プロデューサーが消費者の意識なしにイベントペイロードを変更した場合のサイレントな破壊的変更に対してメッセージング統合が脆弱でした。複数バージョンの消費者サポートの特定の課題は、Kafkaトピックがしばしば異なるデプロイメントのリズムとアップグレードサイクルを持つ複数の消費者アプリケーションにサービスを提供することにTeamsが気づいたときに生じました。この質問は、支払いサービスにおける単一のイベントスキーマの変更が、同時に分析、通知、および監査サービス全体において失敗を引き起こす実際のシナリオを反映しています。これは、スキーマレジストリの検証と分散ストリーミングプラットフォームにおける振る舞いの契約保証との間の重要なギャップに対処します。
根本的な難しさは、Kafkaプロデューサーがイベントスキーマを進化させることができるようにすることであり、すべての下流消費者を同時に展開しなければならないということは、マイクロサービスの独立性の原則に違反します。Confluentのような従来のスキーマレジストリは、シリアル化レベルでの後方互換性を検証しますが、フィールドをオプションから必須に変更したり、日付形式を変更したりするような消費者のビジネスロジックを壊す意味的変更を検出することはできません。複数の消費者バージョンが本番で共存する場合、プロデューサーは最も古いサポートされている消費者との互換性を維持しながら、新しい消費者が追加のフィールドを期待する必要があり、これにより手動での調整ではスケールで管理できないバージョニングマトリックスが作成されます。これにより、「スキーマドリフト」が発生し、生産イベントがデシリアライズに失敗するか、レガシー消費者での不正な処理を引き起こし、メッセージ処理遅延や潜在的なデータ損失を引き起こします。この問題は、Kafkaのパブリッシュサブスクライブモデルが、RESTとは異なり、すべてのサブスクライバーに対して同時に影響を与えることを意味するため、さらに悪化します。
この解決策には、Pactのメッセージ契約形式を使用した消費者駆動の契約テストと、構造的検証のためのConfluentスキーマレジストリの統合を実装することが含まれます。プロデューサーは、各消費者バージョンの期待されるイベントペイロードを定義するメッセージ契約を生成し、実際のシリアル化ロジックに対して検証します。このプロセスには、動作中のKafkaブローカーは必要ありません。Pact Brokerは、消費者バージョンタグを使用して契約バージョンを管理し、新しいプロデューサーコードの変更が、展開の前にレガシーと現在の両方の消費者の契約を満たすことを確認する「can-i-deploy」チェックを可能にします。スキーマの進化には、「expand-contract」パターンを適用し、プロデューサーはまず古いフィールドを維持しながら新しいフィールドを追加し、その後、すべての消費者がアップグレードし契約を更新した後にのみ非推奨フィールドを削除します。これは、Pact検証がタグ付けされた消費者バージョンに対して失敗する場合にビルドが失敗するCIゲートを通じて自動化されており、単純なスキーマ構造を超える振る舞いの互換性が保証されます。
@PactTestFor(providerName = "payment-service", providerType = ProviderType.ASYNCH) public class PaymentEventContractTest { @Pact(consumer = "analytics-service", consumerVersion = "v2.1.0") public MessagePact paymentProcessedPactV2(MessagePactBuilder builder) { return builder .expectsToReceive("an a payment processed event for analytics") .withContent(new PactDslJsonBody() .uuid("paymentId") .decimalType("amount") .stringType("currency", "USD") .stringType("status") //v2に必要な新しいフィールド .date("timestamp", "yyyy-MM-dd'T'HH:mm:ss")) .toPact(); } @Pact(consumer = "notification-service", consumerVersion = "v1.0.0") public MessagePact paymentProcessedPactV1(MessagePactBuilder builder) { return builder .expectsToReceive("a payment processed event for notifications") .withContent(new PactDslJsonBody() .uuid("paymentId") .decimalType("amount") .stringType("currency", "USD")) .toPact(); } @Test @PactTestFor(pactMethod = "paymentProcessedPactV2") public void verifyV2Contract(List<Interaction> interactions) { byte[] messageBytes = interactions.get(0).getContents().getValue(); PaymentEvent event = deserialize(messageBytes); assertThat(event.getStatus()).isNotNull(); analyticsProcessor.process(event); } }
このコードは、異なるスキーマバージョンに対して複数の消費者契約をテストすることを示しており、プロデューサーがレガシーと現在の両方の要件を同時に満たすことを保証します。
あるeコマースプラットフォームでは、支払い処理チームがKafkaの支払いイベントに「discountApplied」ブールフィールドを追加し、それを必須とした際に重大な障害が発生しました。分析チームはこのフィールドを処理するために消費者を更新しましたが、レガシー通知サービスは不明なフィールドを拒否する厳格なデシリアライズを使用していたため、クラッシュしました。これにより、注文履行パイプライン全体でカスケード失敗が発生しました。障害は2時間続きました。このエラーはイベントバスを通して伝播し、支払いイベントに依存する3つのサービス全体でメッセージ処理の遅延とアラートの嵐を引き起こしました。チームは最初、すべての消費者に柔軟なデシリアライズスキーマを使用させることを検討しましたが、これは将来の破壊的な変更を隠し、統合の不整合をランタイムエラーが発生するまで検出するのを遅らせることを認識しました。
再発を防ぐために三つの潜在的な解決策が評価されました。最初のアプローチは、すべてのサービスバージョンを同時にデプロイした専用の統合テスト環境を作成することでしたが、これには高価なインフラの維持が必要で、テストの実行に40分かかり、継続的なデプロイメントパイプラインを大幅に遅らせるものでした。第二の選択肢は、Confluentスキーマレジストリの後方互換性チェックのみを使用することを提案しましたが、これはスキーマがAvroレベルで後方互換性があることのみを検証するもので、特定のビジネス契約を満たすためのデータがあるか、必須フィールドが存在しているかを確認するものではありませんでした。第三の解決策は、Pact契約テストを既存のスキーマレジストリと組み合わせ、各消費者が必要とするフィールドや期待されるデータ形式を正確に指定した独立した契約を公開できるようにするものでした。
組織はその第三の解決策を選びました。なぜなら、一般的な構造的互換性ではなく、消費者固有の振る舞いの検証を提供したからです。彼らは、Pact Brokerを設定してセマンティックタグを使用して消費者バージョンを追跡し、支払いサービスが展開を進める前に、notification-service-v1とanalytics-service-v2契約の両方に対して検証を行うことを要求しました。支払いチームが再度新しい必須フィールドを追加しようとしたとき、CIパイプラインは即座に失敗しました。これは、v1契約の検証が失敗したためで、彼らはフィールドを最初はオプションにして契約の変更をチームに通知することによって、expand-contractパターンを実装することを余儀なくされました。次の四半期の間に、統合関連の本番インシデントは85%減少し、チームはすべての下流のチームと調整することなく、1日に3回プロデューサーの変更を安全にデプロイできるようになりました。これにより、デプロイメントの速度とシステムの安定性が大幅に向上しました。
なぜスキーマレジストリの検証は、Kafkaのプロデューサーと消費者間のイベント互換性を保証するには不十分であり、具体的にどのような失敗を見逃すのか?
候補者は、Confluentスキーマレジストリの後方互換性モードが本番環境での破壊的変更に対する十分な保護を提供すると仮定することがよくあります。しかし、スキーマレジストリは、データ構造がAvroまたはJSONスキーマ定義に準拠していることを検証するだけであり、値が消費者の期待を満たしているか、意味的な意味がバージョン全体で一貫しているかを検証するものではありません。たとえば、スキーマがタイムスタンプフィールドに対して文字列を許可している場合、消費者はISO8601形式を期待していますが、プロデューサーが突然Unixエポックに切り替えた場合、レジストリは両方を有効な文字列として受け入れますが、消費者はランタイムで解析例外で失敗します。契約テストは、実際の消費者コードを実際のプロデューサー出力に対して実行することによって、意味的および値レベルの互換性をキャッチします。これは、構造的検証を超えて振る舞いの互換性を確保します。
複数のプロデューサーが同じKafkaトピックにパブリッシュし、消費者がすべてのソースから一貫したスキーマを期待する場合、契約テストで「ダイヤモンド問題」をどのように扱いますか?
この質問は、トピックが特定のソースではなく、異なるプロデューサーサービスからのイベントを集約する複雑なイベントソーシングシナリオの理解をテストしています。候補者は、Pactが通常は1対1のプロバイダ–消費者関係をモデル化することを見落とすことが多いですが、Kafkaトピックには異なるコードベースを持つ複数の発行者があります。この解決策は、単一のサービスではなく、トピック自体をプロバイダインターフェイスとして扱うことで、すべての発行サービスからの契約を集約する「メタプロバイダー」を作成し、一貫性を確保します。各プロデューサーは、そのイベントがそのトピックの結合契約を満たすことを確認する必要があり、消費者はどのプロデューサーインスタンスがイベントを発行しても一貫したメッセージ構造を受け取る必要があります。これは、Pact Brokerの機能を使用して、単一の消費者契約に対して複数のプロバイダーを管理するか、一方のチームがトピックのゲートキーパーとして機能し、すべてのプロデューサー間の変更を調整する単一のスキーマ所有モデルを標準化する必要があります。
「expand-contract」パターンとは何ですか?Kafkaのスキーマ進化の文脈において、契約テストはどのようにCI/CD中にこのワークフローを強制しますか?
多くの候補者は、複数のアクティブな消費者バージョンを持つメッセージングシステムにおけるゼロダウンタイムスキーマ変更の実際のメカニズムを説明するのに苦労します。expand-contractパターンは、プロデューサーがまず古いフィールドを保持しながら新しいフィールドを追加する変更を展開(expand)し、その後、すべての消費者が新しいフィールドを使用するように移行した後にのみ非推奨フィールドを削除する(契約契約)ことを必要とします。契約テストは、Pact Broker内の各消費者のための別々の契約バージョンを維持することによってこれを強制します。プロデューサーのCIパイプラインは、展開の承認の前にすべてのアクティブな消費者バージョンに対して互換性を検証しなければなりません。プロデューサーがv1消費者がまだ必要とするフィールドを削除しようとすると、can-i-deployチェックは即座に失敗し、破壊的な変更がKafkaに到達するのを防ぎます。候補者は、これがブローカー内で明示的なバージョンタグ付けを必要とし、パイプラインは最新のものだけでなく、すべてのタグ付けされた消費者バージョンを照会する必要があることを見逃しがちであり、すべての消費者集団にわたる包括的な互換性を確保します。