問題とユースケース
レシーバーは信頼できないHTTPリクエストを永続的な内部イベントへと変換します。難しいのはこれらの状態間の境界です。イベントを保存する前にプロセスがクラッシュする可能性がある場合、即座に 200 OK を返すのは誤りです。また、応答を返す前に決済ワークフローを実行するのも誤りです。依存関係の遅延によってプロバイダーのリトライが発生し、負荷が増幅されるためです。
面接では以下の前提条件を使用します。
- ピークトラフィックは毎秒2,000リクエストです。平均生ボディサイズは10 KiB、最大は1 MiBであるため、ピーク時の平均ボディIngressは約19.5 MiB/s(ヘッダー、レプリケーション、ストレージオーバーヘッドを除く)となります。
- プロバイダーは2秒以内の応答を期待しています。ヘッドルームを確保するための社内目標は、p99で500 msの受信確認(acknowledgment)です。
- 配信は「少なくとも1回(at-least-once)」かつ順不同です。プロバイダーは同じ論理イベントを並行して送信したり、後からリトライしたり、より新しいオブジェクト状態を先に配信したりする可能性があります。
- 受け入れられたイベントはレシーバーのクラッシュに耐え、最終的に終端状態である
PROCESSEDまたはFAILEDに到達しなければなりません。1つの論理イベントが同じビジネス上の変更を2回適用してはなりません。 - 署名シークレットはダウンタイムなしでローテーションされます。生ペイロードは復旧と監査のために暗号化されて30日間保持され、冪等性レコードは少なくともプロバイダーが規定する再送期間の間保持されます。
主なユースケースは、決済状態の更新、サブスクリプションのライフサイクル変更、返金、異議申し立て(チャージバック)、アカウント通知です。この設計は、プロバイダー間でヘッダー、署名アルゴリズム、リトライ識別子、タイムスタンプ規則が同一であると仮定することなく機能しなければなりません。
面接官が見ているポイント
第1に、面接官は正確な受信確認コントラクトを求めています。2xx は「すべてのダウンストリームの副作用が完了した」ことではなく、「このレシーバーがイベントを永続的に受け入れた」ことを意味します。永続的な書き込みの前に成功を返すと、サイレントなデータ消失が発生します。すでに受け入れ済みの重複イベントに対して成功を返すのは、プロバイダーがリトライを停止できるようにするために正しい挙動です。
第2に、セキュリティ処理が正しい順序で行われているかを見ています。レシーバーはメソッド、コンテンツタイプ、ヘッダー、ボディサイズを制限し、正確な生バイト列を保持し、信頼できるシークレットバージョンを用いてプロバイダー固有の署名を検証し、定数時間でMACを比較し、プロバイダーから提供されている場合は署名された鮮度メタデータをチェックします。検証前にJSONをパースして再シリアライズすると、空白やキーの順序が変わり、正当な署名が無効化される可能性があります。
第3に、リプレイ攻撃の防止とリトライの重複排除を区別できているかを評価します。署名されたタイムスタンプは、キャプチャされた過去のリクエストを拒否します。安定したプロバイダーのイベントIDまたは配信IDは、正当なリトライが2回適用されるのを防ぎます。プロバイダーによっては、論理イベントIDを維持したまま、リトライごとに新しい試行タイムスタンプと署名を生成することがあります。一方のメカニズムでもう一方を安全に代替することはできません。
第4に、データベースとキューの二重書き込み(Dual-write)の不整合がない永続的処理モデルを求めています。データベースのインボックス(Inbox)行とアウトボックス(Outbox)行はまとめてコミットできます。その後、リレーが処理をパブリッシュします。あるいは、ワーカーが直接インボックス行をリースすることもできます。ユニークキーはストレージによって強制され、並行重複に対して脆弱な「読み取ってから書き込む」チェックには依存しません。
最後に、優れた回答は、順不同の状態、外部への副作用、シークレットのローテーション、ポイズンイベント(不正イベント)、バックプレッシャー、可観測性、リコンシリエーション、そしてあらゆるトランザクション境界でのクラッシュに対処します。
回答前の明確化のための質問
2xxは具体的に何を保証するものですか? ここでは、署名検証に合格し、そのイベント(または以前に受け入れられたその重複)が永続化されたことを意味します。メール送信、元帳更新、プロバイダーAPIの呼び出しが完了したことは保証しません。- リトライ間で安定しているプロバイダー識別子は何ですか? 各アダプターは、論理イベントID、試行タイムスタンプ、署名フォーマット、および手動再送が同じIDを維持するかどうかを文書化する必要があります。ペイロードのハッシュ値のみから識別子を導出してはなりません。
- プロバイダーは生ボディとメタデータに署名していますか? アダプターが正規化された署名対象バイト列を定義します。HTTPフレームワークは、JSONミドルウェアが実行される前の手付かずのボディを公開しなければなりません。
- どのような順序情報が存在しますか? 信頼できるオブジェクトバージョンやシーケンス番号を優先します。イベント作成時刻は有用な証拠ですが、自動的に厳密な順序を表すわけではありません。バージョンが存在しない場合、状態を設定するイベントについてはプロバイダーの現在の状態を取得します。
- 再送はどのくらいの期間発生する可能性がありますか? 冪等性の保持期間および古いシークレットのオーバーラップ期間は、プロバイダーの規定の挙動および製品の手動リプレイポリシーをカバーしなければなりません。この問題における30日間の生イベント保持は製品側の前提であり、普遍的なベンダー規則ではありません。
- どの障害がリトライを引き起こすべきですか? 真正性が確立できない場合や、永続ストレージが利用できない場合は、受信確認を返してはなりません。永続的な受け入れが完了した後は、ワーカーの障害によってHTTPレスポンスが変わるべきではありません。
- どのようなデータが機密情報に該当しますか? 生ボディを暗号化し、アクセスを制限し、ログから機密情報をマスクし、削除の例外を定義します。署名は真正性と完全性を検証するものであり、ペイロードを暗号化するものではありません。
30秒の回答フレームワーク
「ボディサイズとレート制限を備えたプロバイダー固有のHTTPSエンドポイントを公開し、正確な生バイト列をキャプチャして、定数時間比較を用いて現在または前バージョンのシークレットで署名済みID、タイムスタンプ、ペイロードを検証します。単一のデータベーストランザクション内で、ユニークなプロバイダーイベントキーの下にインボックス行とアウトボックス行を挿入し、2xx を返します。受け入れ済みの重複に対しても 2xx を返します。リレーとワーカーがリースとリトライを用いて非同期に処理します。ビジネス上の変更と処理済みマーカーは一緒にコミットし、外部への副作用にはアウトボックスと安定した冪等性キーを使用します。順不同の配信に対しては、到着順ではなくオブジェクトバージョンを使用するか、信頼できる現在の状態を取得します。受信確認レイテンシー、拒否理由、インボックスの滞留時間、重複、障害を監視し、並行重複、シークレットのオーバーラップ、期限切れの署名、順不同イベント、コミット前後のクラッシュをテストします。」
ステップごとの詳細解説
プロバイダーごとにアダプターを用意しますが、レシーバーパイプラインは1つに維持します。アダプターは、許可されたイベントタイプ、最大ボディサイズ、ヘッダーのパース、正規化された署名対象バイト列、アルゴリズム、信頼できるシークレットバージョン、鮮度ポリシー、論理イベントIDの抽出を提供します。シークレットはマネージドシークレットストアから取得され、限られた期間のみキャッシュされます。リクエストが信頼できないヘッダーを通じて独自の検証キーを選択できるようにしてはなりません。
Ingressのシーケンスは慎重に設計されています。
1. Require HTTPS POST; apply endpoint and provider rate limits.
2. Validate bounded headers and Content-Length when present.
3. Read at most 1 MiB into raw bytes; reject overflow while streaming.
4. Parse signature metadata without parsing the JSON body.
5. Verify current and previous trusted secret versions in constant time.
6. Check the signed attempt timestamp against the provider-specific tolerance.
7. Parse the verified body and validate the event envelope and allowed type.
8. Durably accept under a unique logical-event key, then acknowledge.タイムスタンプの鮮度と重複排除は異なる攻撃を解決します。攻撃者が正当な署名付きリクエストをキャプチャしたとします。厳格な署名タイムスタンプウィンドウは、ウィンドウ経過後のリプレイをブロックしますが、同じキャプチャされたリクエストがウィンドウ内に2回到着する可能性は残ります。逆に、イベントIDレコードは重複する論理イベントをブロックしますが、未署名のタイムスタンプが新しいものであることを証明できません。またプロバイダーによって仕様が異なり、リトライ時に同じイベントIDを維持したまま新しい署名付き試行タイムスタンプが付与されることがあります。両方のチェックを維持し、その正確なセマンティクスはアダプターが管理するようにします。
真実の唯一の基盤(Source of Truth)としてインボックスを使用します。
WebhookInbox(
inbox_id, provider, endpoint_id, provider_event_id,
event_type, object_id, object_version, provider_created_at,
received_at, raw_payload_ref, payload_hash, matched_secret_version,
status, attempt_count, next_attempt_at, lease_until, last_error
)
WebhookOutbox(outbox_id, inbox_id, topic, created_at, published_at)
UNIQUE(provider, endpoint_id, provider_event_id)単一のデータベーストランザクション内で、インボックス行とそのアウトボックス通知を挿入します。ユニークキーがすでに存在する場合は、その受け入れ状態を読み取り、新たな作業を作成することなく 2xx を返します。これは「クエリしてから挿入」ではなく、アトミックな挿入です。受信確認を返す前にコミットします。データベースが利用できない場合やコミット結果が不明な場合は、リトライ可能な非 2xx を返します。最初のコミットが実際には成功していた場合、後の重複リクエストはユニーク行に収束します。
データベース+アウトボックスの設計は、保存とキューイングの間のギャップを解消します。リレーが未パブリッシュのアウトボックス行を繰り返しパブリッシュし、パブリッシュ済みの印を付けます。パブリッシュは2回行われる可能性があるため、キューコンシューマー側でも inbox_id による重複排除を行います。よりシンプルな実装としては、ブローカーを省略し、ワーカーが FOR UPDATE SKIP LOCKED などのリースを用いて期日の到来したインボックス行を直接要求することもできます。スループットと運用のニーズに基づいて選択しますが、インボックスを永続的な受け入れおよび監査境界として維持してください。
ワーカーは短いリースを取得し、バージョン管理されたイベントをパースして、サポートされているタイプのみをルーティングします。同じデータベース内での変更については、単一のトランザクション内でビジネス行を更新し、処理済みイベントを記録し、インボックスに PROCESSED のマークを付けます。処理済みイベントテーブルは同じ安定したプロバイダーキーを持つため、ワーカーのリトライはノーオペレーション(no-op)になります。別サービスへの呼び出しについては、冪等性キーとして inbox_id を持つローカルアウトボックスエントリを書き込みます。任意のネットワークを越えた厳密に1回(Exactly-once)の配信は依然として不可能です。安定した識別子と冪等なレシーバーによって、少なくとも1回(At-least-once)の実行を安全にします。
到着順序をビジネス上の順序と見なすことはできません。イベントに信頼できるオブジェクトバージョンが含まれている場合は、incoming_version > stored_version などの条件付きで更新します。古いイベントは処理済みno-opとなります。状態を設定する通知のみが存在する場合は、プロバイダーの現在のリソースを取得してローカル状態を収束させます。イベントが反復不可能な差分を表す場合は、シーケンスを要求し、制限付きのギャップをバッファリングし、欠落しているバージョンを調整(リコンサイル)します。タイムスタンプ単体では、同時刻になったり、クロックスキューが発生したり、コミット順ではなく作成順を表しているに過ぎない場合があります。
障害は永続化境界で分けられます。受け入れ前においては、無効な署名、期限切れの署名タイムスタンプ、サイズ超過のボディ、シークレットの利用不可、ストレージの利用不可は、ポリシーに従って拒否またはリトライ可能なレスポンスを生成します。デバッグのためだけに未検証の機密ボディを永続化してはなりません。受け入れ後においては、キューやワーカーに障害が発生していても 2xx を返します。インボックスレコードが蓄積され、復旧時にそれらが処理されます。一時的なワーカー障害はジッター付きでバックオフします。スキーマエラーやリトライ上限に達したものは FAILED に入り、マスクされた診断情報を保持し、オペレーターが視認可能な復旧パスをトリガーします。
シークレットのローテーションでは、プロバイダーの再送動作から導き出された限定的なオーバーラップ期間中、現在および前バージョンの両方を信頼対象として維持します。どのバージョンが一致したかを記録しますが、シークレットや署名をログに出力してはなりません。新しいシークレットは両端で設定され、本番環境で監視された後、古いバージョンが明示的に廃止されます。緊急の漏洩時には即時廃止とプロバイダーからのリプレイが必要になる場合があるため、ローテーション手順書(ランブック)では計画的なオーバーラップとインシデント対応を区別しておく必要があります。
毎秒2,000リクエスト、平均10 KiBの場合、Ingressは約19.5 MiB/sの生ボディを受け取ります。キャパシティプランニングには、TLSおよびHMACのCPU負荷、データベーストランザクションレート、レプリケーション、キューの増幅、バースト期間を含めます。ステートレスなIngressを水平スケールさせ、必要に応じてインボックスインデックスをプロバイダーと時間でパーティショニングし、ユニークキーがその所有境界内でグローバルに強制されるようにし、データベース行が大きくなりすぎる場合は暗号化された生ボディをオブジェクトストレージに配置します。
受け入れ、重複、無効な署名、期限切れ、サイズ超過、サポート外イベントのレートを個別に計測します。受信確認のp50/p95/p99、データベースコミットレイテンシー、最も古い未処理インボックスの滞留時間、ワーカーの成功率とリトライ率、FAILED のカウント、アウトボックスリレーの遅延、一致したシークレットバージョンを追跡します。アラートはキューの深さだけでなく、遅延と永続化状態を使用すべきです。リコンシリエーションジョブは、受け入れられたインボックス行と処理済みレコードおよびアウトボックスのパブリッシュを比較し、安全に欠落している処理を再エンキューします。
生のHTTPバイト列から内側に向かってテストします。プロバイダーが公開している署名テストベクトルを使用し、その後1バイト、空白、ID、またはタイムスタンプを変更してテストします。ヘッダーの欠落や重複、ボディサイズの境界値、クロックスキュー、現在/前バージョンのシークレット、および廃止処理をテストします。1つのイベントの数百の並行コピーを送信し、1つのインボックス行と1つのビジネス変更のみが発生することを証明します。インボックスのコミット後かつレスポンス前、キューへのパブリッシュ後かつアウトボックスへのマーク前、ビジネスコミット後かつワーカーの受信確認前でクラッシュさせます。バージョン 3、1、2 を順不同で配信し、ワーカーを飽和させ、復旧させて、有界な受信確認レイテンシーで最終的な収束が行われることを証明します。
質の高い模範解答
「私は 2xx を永続的受け入れと定義します。Ingressはステートレスであり、アダプター境界でのみプロバイダー固有となります。HTTPS POSTを受け付け、ボディを1 MiBに制限し、正確な生バイト列を保持し、信頼できる現在または前バージョンのシークレットに対してプロバイダーの正規化された署名済みID、試行タイムスタンプ、ペイロードを検証します。HMAC比較は定数時間で行います。その後、検証済みエンベロープをパースし、サポートされているイベントタイプのみを許可します。
単一のデータベーストランザクション内で、UNIQUE(provider, endpoint_id, provider_event_id) の下に WebhookInbox を挿入し、アウトボックス行を挿入します。コミット後にのみ受信確認を返します。並行する重複リクエストはそのキーで競合し、新たな処理を作成することなく同様に 2xx を受け取ります。毎秒2,000リクエスト、平均10 KiBの場合、生Ingressは約19.5 MiB/sとなるため、リクエスト数だけを数えるのではなく、Ingressを水平スケールさせ、署名検証のCPU、データベースコミット、レプリケーション、バーストストレージのサイズを適切に設計します。
アウトボックスリレーが inbox_id をパブリッシュします。重複パブリッシュは安全です。ワーカーはインボックスレコードをリースします。ビジネス上の変更、処理済みイベントマーカー、インボックスの完了は、可能であれば1つのトランザクションを共有します。リモートへの副作用には別のアウトボックスと、冪等性キーとして inbox_id を使用します。これにより、ネットワークを越えた厳密に1回を主張することを避けつつ、リトライによって論理的な効果が重複しないようにします。
到着順序は使用しません。信頼できるオブジェクトバージョンによって更新を制御し、それがなければ状態設定Webhookをトリガーとしてプロバイダーの現在の状態を読み取ります。欠落したシーケンスギャップはリコンシリエーションに入ります。永続的受け入れの前段階では、検証不能なリクエストやストレージ障害に対して受信確認を返しません。受け入れ後は、ワーカーの障害はインボックスによって吸収され、依然として 2xx を返します。
シークレットは、現在/前バージョンの限定的なオーバーラップを持たせてローテーションし、一致したバージョンを記録します。受信確認レイテンシー、拒否分類、重複、インボックス滞留時間、アウトボックス遅延、障害、シークレットバージョンの使用状況を監視します。最後に、公式の署名ベクトル、バイト改変、期限切れタイムスタンプ、シークレットオーバーラップ、並行重複、順不同バージョン、そして各コミット前後のクラッシュをテストします。合格条件は、論理イベントあたり1つの永続行と1つのビジネス変更であること、受け入れ後の消失がないこと、そして復旧後に最終的に収束することです。」
よくある間違い
- 検証前にJSONをパースする → 再シリアライズによって署名対象バイト列が変わる → 最初に正確な生ボディをキャプチャして検証する。
- 永続化の前に
200を返す → クラッシュにより受け入れ済みイベントがサイレントに失われる → 応答する前にインボックスをコミットする。 - データベースに保存してから1回だけパブリッシュする → 書き込みの合間にクラッシュすると処理が取り残される → インボックスとともにアウトボックスをコミットするか、インボックス行を直接リースする。
- 事前の読み取りで重複をチェックする → 並行リクエストが両方とも通過してしまう → ユニークなプロバイダーイベントキーをアトミックに強制する。
- タイムスタンプの鮮度のみを使用する → 同時の重複リクエストが依然として2回適用される → 鮮度チェックと安定したIDによる重複排除を組み合わせる。
- イベントIDのみを使用する → キャプチャされた正当なリクエストが、そのレコードが存在しないか期限切れの間にリプレイされる可能性がある → プロバイダーのコントラクトに従って署名された鮮度も検証する。
- 到着順序をイベント順序と見なす → 遅れて届いたイベントが状態を過去へ巻き戻す → バージョン、単調遷移、または信頼できる状態のリコンシリエーションを使用する。
- HTTPハンドラー内でリモートへの副作用を実行する → レイテンシーがリトライや結果不明な状態を引き起こす → 永続的に受け入れ、その後非同期の冪等な処理を使用する。
- 完全なペイロードと署名をログ出力する → 可観測性がデータおよびシークレットの漏洩元になる → 暗号化された証跡をアクセス制限付きで保存し、マスクされた識別子をログ出力する。
フォローアップの質問と回答
フォローアップ 1: 処理が完了していない重複リクエストに対して、なぜ 2xx を返すのですか?
元のイベントはすでに永続的に受け入れられているため、プロバイダーがさらにリトライしても復旧上の価値はありません。失敗を返すと重複トラフィックがさらに増加します。既存のインボックス行は引き続きワーカーやリコンシリエーションの対象として残ります。これは、その行が永続化されており、受け入れがロールバックされたことを意味する状態でない場合にのみ安全です。
フォローアップ 2: インボックスをコミットした後、2xx を送信する前にプロセスがクラッシュした場合はどうなりますか?
プロバイダーがリトライします。ユニークキーによってコミット済みの行が見つかり、2つ目のジョブは作成されず、レシーバーは 2xx を返します。これは想定されている「少なくとも1回」の経路です。クライアントが 2xx を受信したものの、プロバイダーにとって接続結果が曖昧な場合でも、同じ収束が適用されます。
フォローアップ 3: 冪等性キーをRedisに保持することはできますか?
アクセラレーター(高速化)としては使用できますが、短命なキャッシュ単体では受け入れコントラクトよりも信頼性が低くなります。エビクション(メモリ追い出し)、フェイルオーバー、有効期限切れによって、同じ決済イベントが再度適用されてしまう可能性があります。必要なビジネス期間および再送期間にわたって永続的な処理済み識別子を保持してください。キーの消失がコントラクト違反にならない場合にのみRedisを使用します。
フォローアップ 4: シークレットストアのダウンタイムにはどのように対処しますか?
明示的な有効期限とメトリクスを備えた、すでに信頼されているバージョンの有界な暗号化インメモリキャッシュを使用します。有効なキャッシュキーが存在しない場合は、フェイルクローズ(安全側に倒して拒否)し、プロバイダーが再送できるようにリトライ可能なレスポンスを返します。未署名の処理を受け入れたり、信頼できないリクエストからキー識別子を取得して自動的に信頼したりしてはなりません。
フォローアップ 5: 順不同の決済ストリームをどのように復旧しますか?
プロバイダーのオブジェクトバージョンまたは単調なドメイン遷移を優先し、状態の先祖返り(デグレ)を拒否します。イベントがオブジェクトの変更のみを通知している場合は、その現在の信頼できる状態を取得します。シーケンス化された差分については、制限付きのギャップをバッファリングし、欠落しているバージョンを要求し、ギャップが復旧ウィンドウを超えた場合にアラートを発報します。ローカルの受信時刻のみでソートしてはなりません。
フォローアップ 6: なぜこれは依然として「厳密に1回(Exactly-once)」ではないのですか?
レシーバーは、ローカルの変更と処理済みマーカーをアトミックにすることができます。しかし、任意のリモートのメール、銀行、またはプロバイダーサービスとアトミックにコミットすることはできません。ネットワーク障害により、リモート側がリクエストを適用したかどうかが分からなくなる可能性があります。安定した冪等性キー、トランザクショナルアウトボックス、リトライ、リコンシリエーションにより、リモートAPIが冪等性をサポートしている環境において「実質的に1回(Effectively-once)」のビジネス結果を実現しますが、トランスポートコントラクトは「少なくとも1回」のままです。