Webhookとコールバック

このページの目次

WebhookはHTTPベースのコールバック統合方式です:イベントソースは受信者が設定したアドレスへ通知を送信し、受信者はその内容に基づいて処理をトリガーします。これは能動的なポーリングによる待機時間を削減しますが、信頼性の高いメッセージプロトコルの統一標準ではありません。リトライ、署名、順序、履歴照会、および成功ステータスコードは、すべて各プラットフォームの契約によって決定されます。

注文ステータスの通知を例に挙げます。ソースはイベントE42を作成し、HTTP経由で受信側に送信します。受信側はまずイベントを信頼性高く保存し、その後ワーカーがローカルビューを更新します。ソースが後にE42を再送信した場合でも、あるいはE43がE42より先に到着した場合でも、処理は正しく維持されなければなりません。

問題対応する設計
誰から送られてきたか、本文は改ざんされていないか生バイト列の署名検証と鍵管理
いつ成功を返してよいか信頼性の高い受信の境界
1つのイベントが2回通知された場合どうするか安定したイベントIDとビジネスロジックの冪等性
順序が一致しない場合どうするかリソースバージョン、ステータス条件、または現在の状態の照会
プラットフォームのリトライ終了後に漏れた場合どうするか配信履歴、補償、および照合
コールバックアドレスやリダイレクトリンクを信頼できるかアドレスの送信元、権限、および出口制約

受信側:HTTP応答とビジネス処理の完了を分離する

まず信頼性高く受信し、非同期で処理する;各確認には明確な責任が対応する イベントソース プラットフォーム契約に従って配信 受信側 署名検証 / 検証 / Inbox ワーカー ビジネスロジックを実行し、結果を記録 Inboxへの永続化成功 → 2xxを返す ここで約束するのは「受信済み」であり、「ビジネス処理の完全な完了」ではない ビジネストランザクション成功 → 完了としてマーク 失敗時は再試行待ちまたは手動処理状態を保持 2xxを返した後にメモリキューへの書き込みのみを行うと、プロセスのクラッシュによりイベントが消失する窓が残る。

受信パスでは、リクエストのサイズとタイプを制限し、署名と必要な有効期限を検証し、イベントを解析・検証してから、生データと処理状態を永続的なInboxに書き込む。成功してコミットされたら、プラットフォームが認める2xxを素早く返し、ワーカーが非同期で消費する。これにより、HTTPタイムアウト内ですべての複雑な処理を実行する必要がなくなり、クラッシュ後の回復入口も確保される。

先に成功を返し、その後メモリキューにイベントを追加しようとした場合、プロセスがその間に終了すると、イベントが永久に漏れる可能性がある。データベースやキューへの永続化に依存して回復する必要がある場合、コミットに失敗したときに受信成功を装ってはならない。一方、すでに信頼性高くキューに入れられた重複イベントについては成功を返し、ソースが無意味な再試行を続けるのを防ぐ。

GitHubのドキュメントでは、10秒以内にレスポンスがない場合を1回の配信失敗と見なしており、⁠失敗したWebhookは自動的に再送しないと明記している。再送は手動または自作プログラムで行う必要がある。Stripeには独自の自動リトライと補送ルールがある。「すべての2xx以外を5、25、125秒のバックオフで扱い、失敗が一定回数に達すると自動的に無効化する」といった実装はできない。GitHub 失敗配信、Stripe Webhooks

署名検証:生バイト列を先に処理し、JSONを理解する

HMACは共有シークレットをキーとし、プラットフォームが指定する署名対象バイト列をメッセージとして使用する。GitHubのX-Hub-Signature-256はsha256=に続けて16進ダジェストを使用する。他のプラットフォームでは、タイムスタンプ、バージョン、またはその他のフィールドを一緒に署名に含めることがある。したがって、すべてのプラットフォームをHMAC(body)として統一して実装することはできない。GitHub 署名検証

同じJSONオブジェクトでも、空白、キーの順序、エスケープ表現が異なる場合がある。フレームワークが先に解析してからシリアライズする場合、オブジェクトの内容が同じでもバイト列が異なる可能性がある。署名検証時は、プロトコルが要求する生リクエストボディを保持する必要がある。以下に、GitHubが公開しているテストベクトルを用いてHMACアルゴリズムを検証し、本文が改ざんされた場合に拒否する例を示す。

import hashlib, hmac, json
secret = b"It's a Secret to Everybody"
body = b'Hello, World!'
expected = '757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17'
assert hmac.new(secret, body, hashlib.sha256).hexdigest() == expected

def verify(raw, signature):
    if not signature.startswith('sha256='):
        return False
    value = signature[7:]
    if len(value) != 64 or any(ch not in '0123456789abcdef' for ch in value):
        return False
    calculated = hmac.new(secret, raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(calculated, value)

assert verify(body, 'sha256=' + expected)
assert not verify(body + b' ', 'sha256=' + expected)
a, b = b'{"a":1,"b":2}', b'{ "b": 2, "a": 1 }'
assert json.loads(a) == json.loads(b)
assert hmac.digest(secret, a, 'sha256') != hmac.digest(secret, b, 'sha256')
print('公開HMACベクトル、改ざん拒否、JSONバイト列の違いの検証に成功')

このコードは特定の署名形式のみを示すものであり、すべてのプロバイダに対する汎用SDKではない。実際の受信側では、重複する署名ヘッダーの処理、キーローテーション期間中の複数の有効な鍵の処理、およびプラットフォーム固有の署名バージョンの処理を行う必要がある。比較時には、プレフィックス一致の時間をリークする可能性のある単純なループの使用を避ける。

署名が有効な場合でも、リプレイ攻撃防止が必要なのはなぜか

攻撃者が有効な古いリクエストを取得した場合、HMAC検証は依然として成功する可能性がある。プラットフォームが配信時間を署名に含めている場合、信頼できるクロックを用いてタイムウィンドウをチェックする必要がある。プロトコルでタイムスタンプが署名されていない場合、署名されていないヘッダーを証明として勝手に作成してはならない。メッセージイベントの発生時刻と今回の配信時刻は異なる場合もあり、正当な再送では非常に以前に発生したイベントを伴うことがある。

リトライウィンドウ、手動リプレイウィンドウ、および重複排除レコードの保持期間は連携する必要がある。重複排除キーを5分しか保持しないのに、管理者が1ヶ月後に同じイベントを補送できるようにすると、重複したビジネス効果を阻止できるとは限らない。

バイト列の真正性、イベントの重複排除、ビジネスバージョン処理の3つのチェックがそれぞれの役割を果たす 署名検証と有効期限 生バイト列が信頼できる鍵からのものであることを確認し、契約に準拠しないリクエストを拒否 Inboxでの重複排除 同じソース/テナント/イベントIDは重複してキューに入れない ビジネスステータス条件 古いバージョンで新しいバージョンを上書きできないようにし、外部副作用には冪等契約が必要 HMAC自体はリプレイ攻撃を防ぎません。配信IDが必ずしも安定したビジネスイベントIDであるとは限りません。

3種類のID:ビジネスイベント、配信試行、リソース

イベントIDは1つのイベントの事実を表し、配信試行IDはHTTP送信を追跡するために使用され、リソースIDは影響を受ける注文やアラートを表す。1つのイベントに複数の試行がある場合もある。1つのリソースから複数の異なるイベントが発生することもある。特定のフィールドが再送時に不変であるかどうかはプラットフォームによるため、名前にdeliveryが含まれているからといって判断してはならない。

GitHubのX-GitHub-Deliveryは、同じ配信とそのリトライを識別するために使用できる。Stripeは処理済みのevent IDを記録することを提案しており、特定のシナリオでは異なるEventオブジェクトが関連するビジネス変化に対応する可能性があることを指摘している。冪等キーは、プラットフォームのイベントセマンティクスとビジネスアクションに基づいて決定する必要がある。すべてのソースをスコープのないUUIDの集合に結合してはならない。GitHub ベストプラクティス、Stripe 重複イベント

GitHubのこの種の本文HMACは任意のHTTPヘッダーをカバーしないため、Deliveryヘッダーは通常の重複配信の処理に役立つが、有効な古い本文を保持した攻撃者がヘッダーを変えてリプレイするのを単独で阻止することはできない。機密性の高いアクションについては、署名された本文内のビジネスアイデンティティと操作条件に基づいて重複排除を行う必要がある。署名されていないヘッダーに、それがない暗号学的保証を与えてはならない。

重複排除とビジネスコミット:クラッシュウィンドウをカバーする必要がある

ローカルデータベース内でビューを更新する場合、Inboxの状態変更とビジネスの変更を同じトランザクションに含めることができる。以下のローカルモデルはユニークキーで重複排除を行い、注入されたクラッシュ時に「イベントが処理済み」がビジネス効果よりも先に独立してコミットされないことを確認する。例はメモリSQLiteのトランザクションセマンティクスの検証であり、ディスク障害や実際の並行デプロイメントはシミュレートしていない。

import sqlite3
con = sqlite3.connect(':memory:')
con.execute('CREATE TABLE inbox(source TEXT, event TEXT, PRIMARY KEY(source,event))')
con.execute('CREATE TABLE counter(value INTEGER NOT NULL)')
con.execute('INSERT INTO counter VALUES (0)')
con.commit()

def apply(source, event, crash=False):
    with con:
        cursor = con.execute('INSERT OR IGNORE INTO inbox VALUES (?,?)', (source, event))
        if cursor.rowcount == 0:
            return 'duplicate'
        con.execute('UPDATE counter SET value=value+1')
        if crash:
            raise RuntimeError('コミット前のクラッシュモデル')
    return 'applied'

try:
    apply('provider:tenant-a', 'E42', crash=True)
except RuntimeError:
    pass
assert con.execute('SELECT count(*) FROM inbox').fetchone()[0] == 0
assert con.execute('SELECT value FROM counter').fetchone()[0] == 0
assert apply('provider:tenant-a', 'E42') == 'applied'
assert apply('provider:tenant-a', 'E42') == 'duplicate'
assert con.execute('SELECT value FROM counter').fetchone()[0] == 1
print('重複排除とビジネス効果の共同ロールバック、再試行で有効化に成功')

本番環境の非同期Inboxには通常、pending/processing/doneなどの状態とリトライ回数が存在し、行が見つかったからといって未完了のタスクをスキップしてはならない。並行ワーカーでは、同じメッセージを同時に実行しないよう、タスクの取得、リース、または行ロックなどのメカニズムが必要である。

外部の引き落とし、SMS送信、またはデバイス制御を呼び出す場合、相手の副作用はローカルデータベーストランザクションの外にある。相手のサポートする冪等キー、操作照会、および補償を使用する必要がある。Outboxはローカル状態の更新後にコマンドの送信漏れを防ぐことができるが、任意の外部HTTP操作を自動的にexactly-once(正確に1回)実行させるものではない。

クラッシュが2回の書き込みの間に発生した場合、再実行されるか、それとも漏れるか?

3つの戦略はいずれも同じクラッシュポイントで遭遇するが、回復後にはそれぞれ2回効果が発生、0回効果が発生、1回効果が発生する可能性がある。重要なのは重複排除表を「前」に置くか「後」に置くかではなく、2つの永続状態が共通のコミット境界を持っているかどうかである。

図を準備しています
クラッシュが2回の書き込みの間に発生した場合、再実行されるか、それとも漏れるか?

2つの状態は共同コミットまたは共同ロールバックされなければならない。ビジネスを先に書くと重複する可能性があり、マーカーを先に書くと実行されていない操作を飲み込む可能性がある。トランザクションは同じローカルデータベースのみをカバーする。

順序の乱れ:イベントがスナップショットか変更かを決める

E42が注文バージョン8を、E43がバージョン9を表す場合、E43が先に到着してローカルにバージョン9が記録された後、バージョン8が到着しても、古い状態に直接上書きしてはならない。条件付きバージョン更新を採用できる。ソースが「リソースに変更あり」という通知のみを提供する場合、信頼できるリソースIDに基づいて現在の状態を照会してから更新する。

イベントタイムスタンプは常に厳密に増分するバージョン番号ではない。異なるリソースの順序にも関連性があるとは限らない。削除、キャンセル、復旧、再構築のセマンティクスはビジネスステートマシンで明確にする必要があり、すべてのイベントを単に「最後に受信したものが勝つ」として処理してはならない。Stripeはイベントが生成順に配信されることを保証していないと明記している。Stripe イベント順序

Webhook以外にも修復パスを維持する必要がある。定期的にソースの状態または履歴イベントと照合し、ギャップを見つけて再送する。ポーリングが必ずしも非効率であるとは限らず、低頻度の照合は、Webhookのリトライ枯渇、設定ミス、および一時的な停止を補完できる。

送信側:イベント作成、バックオフ、およびアドレス境界

送信サービスを独自に設計する場合、ビジネス変更と送信待ちイベントを共同で永続化し、送信器がHTTPを送信するようにする。タイムアウトは確認応答が見えなかったことを意味するだけで、ターゲットはすでに完了している可能性がある。各試行では、イベントID、試行回数、応答、および次回時刻を記録し、上限付きでジッター付きのバックオフを使用し、障害回復後に同時に再試行しないようにする。

設定可能なcallback URLはサーバーのアウトバウンドアクセス能力であり、製品が許可するターゲット範囲に従ってスキーム、解決結果、およびリダイレクト後の実際のアドレスを検証し、出口制約を通じて実行する必要がある。登録時に文字列を1回だけチェックするだけでは、DNSアドレスの変化に対応できない。コールバック応答を任意の新しいURLとして継続的にアクセスさせてはならない。

Grafanaからメッセージボットへ:アラートセマンティクスを保持し、カードに変換する

アラート通知は、firingとresolved、ラベル、注釈、およびソースリンクを含む一連のalertsである可能性がある。これをボットカードにマッピングする場合、集約方法、復旧通知、ボタンのターゲット、および本文の長さを決定する必要がある。単に配列の最初の項目を取って、それ全体のアラートグループとして扱うべきではない。Grafana 通知テンプレート

ターゲットがボタンをLokiクエリに指向させる場合、制御可能なログクエリリンクを明確なフィールドに配置し、テンプレートまたはアダプタサービスによってターゲットJSONにエンコードする必要がある。現在のGrafanaドキュメントではwebhook Custom Payloadテンプレートがサポートされているが、具体的な可用性はバージョンとデプロイメントに依存する。テンプレートのシリアライズ関数は、手動で引用符を連結するよりも信頼性が高い。ボットは独立したトークン、タイムスタンプ署名、またはビジネス応答コードを要求する場合があり、GrafanaのHMAC設定が別のプラットフォームと自動的に互換性があるとは限らない。カスタムJSONペイロード

既存のDingDingボタンの動作は具体的な統合実装に属しており、アップグレード後は実際のバージョンを確認する必要がある。「singleURLは常にハードコードされる」というのはプロトコルの性質として記述してはならない。組み込みのエンドポイントが必要なURLや署名を満たせない場合、アダプタサービスによってフィールドマッピングを行い、ボットAPIの非成功ビジネス結果をリトライ状態に組み込むことができる。HTTP 200にもアプリケーションレベルのエラーが含まれている可能性がある。

通知をトラブルシューティングする場合、イベントIDに沿って、ソースの作成、HTTP配信、Inboxコミット、ワーカーの状態、および最終的な外部結果を追跡する。受信側が200を返すことだけを見ても、ユーザーが最終的に目にするカードが正しいかどうかを判断することはできない。