Webhook解説:署名付きペイロードとリトライ
決済ゲートウェイやCI/CDサービスを統合し、リアルタイムのイベントを受信する必要があるとします。Webhookは標準的なソリューションですが、2つの大きな課題があります。リクエストが本当に期待される送信元から来たかを検証すること、そしてエンドポイントがダウンしたり遅くなったりしたときの障害を処理することです。この記事では、署名付きWebhookペイロードと堅牢なリトライロジックを実装する方法を、実用的なコード例とともに説明します。
Webhookとは?
Webhookはユーザー定義のHTTPコールバックです。アプリケーションがAPIをポーリングして更新を確認する代わりに、イベントが発生するたびにプロバイダーが指定したURLにHTTP POSTリクエストを送信します。これにより、統合が効率的でほぼリアルタイムになります。
しかし、Webhookはプッシュベースであるため、エンドポイントは公開されている必要があり、予期しない負荷に対処し、重複したリクエストや悪意のあるリクエストを処理する必要があります。ここで署名とリトライが重要になります。
Webhookペイロードに署名する理由
検証がなければ、Webhook URLを知っている誰でも偽のイベントを送信できます。共有シークレットでペイロードに署名することで、送信者を認証し、ペイロードが改ざんされていないことを確認できます。
最も一般的なアプローチは、SHA-256を使用したHMAC(Hash-based Message Authentication Code)です。プロバイダーは秘密鍵を使用して生のリクエストボディに対して署名を計算し、ヘッダー(例:X-Signature)に含めます。サーバーは署名を再計算して比較します。
HMAC検証の実装
Expressを使用したNode.jsの簡単な例を次に示します。
const crypto = require('crypto');
const express = require('express');
const app = express();
// 署名検証には生のボディを使用
app.use(express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const payload = req.body; // Buffer
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
// イベントを処理
const event = JSON.parse(payload);
console.log('Received event:', event.type);
res.status(200).send('OK');
});
app.listen(3000, () => console.log('Webhook server running'));
重要なポイント:
- 生のボディを使用: ボディをJSONではなく生のバイトとして解析します。変換が加わると署名が変わるためです。
- タイミングセーフな比較: タイミング攻撃を防ぐために
crypto.timingSafeEqualを使用します。 - シークレット管理: シークレットを安全に保管します(環境変数、シークレットマネージャーなど)。
リトライの処理:Webhookを信頼性の高いものにする
ネットワークは失敗し、サーバーはクラッシュし、デプロイによってダウンタイムが発生します。信頼性の高いWebhookシステムは、失敗した配信をリトライする必要があります。しかし、単純なリトライは重複処理を引き起こし、システムに過負荷をかける可能性があります。
リトライ戦略
一般的なリトライパターンには次のものがあります。
- 指数バックオフ: 各リトライ間の待機時間を長くします(例:1秒、2秒、4秒、8秒...)。
- 最大リトライ回数: N回(例:5回)試行した後に停止し、配信を失敗としてマークします。
- ジッター: サンダリングハード問題を避けるためにランダム性を追加します。
ほとんどのプロバイダーは、24時間にわたって指数バックオフで最大5回リトライするなどを実装しています。
冪等性:安全なリトライの鍵
リトライは同じイベントを複数回配信する可能性があるため、エンドポイントは冪等である必要があります。ペイロードに一意のイベントIDを含め、処理済みのIDを保存して重複をスキップします。
簡単なインメモリセットを使用した例(本番環境ではデータベースを使用してください):
const processedEvents = new Set();
app.post('/webhook', (req, res) => {
// ... 署名検証 ...
const event = JSON.parse(payload);
if (processedEvents.has(event.id)) {
return res.status(200).send('Already processed');
}
// イベントを処理
processedEvents.add(event.id);
res.status(200).send('OK');
});
本番環境では、イベントIDに一意制約を設定したデータベースを使用してください。
Webhookコンシューマーのベストプラクティス
- 迅速に応答する: 重い処理を行う前に、2xxステータスで受信を確認します。イベントを非同期処理のためにキューに入れます。
- タイムスタンプを検証する: プロバイダーがタイムスタンプを含める場合、数分以上古いリクエストを拒否してリプレイ攻撃を防ぎます。
- HTTPSを使用する: 転送中のペイロードを保護するために、常にWebhookエンドポイントをHTTPSで公開します。
- すべてをログに記録する: デバッグのために受信リクエスト、署名、処理結果をログに記録します。
- 失敗を監視する: 配信の繰り返し失敗に対するアラートを設定します。
リトライポリシーの比較
| ポリシー | 長所 | 短所 |
|---|---|---|
| 固定間隔 | 実装が簡単 | 復旧中のシステムに過負荷をかける可能性 |
| 指数バックオフ | 障害時の負荷を軽減 | 一時的な失敗の場合、配信が遅れる |
| ジッター付き指数バックオフ | 同期したリトライを防止 | やや複雑 |
FAQ
Webhook署名を検証するにはどうすればよいですか?
共有シークレットを使用して生のリクエストボディのHMACを計算し、タイミングセーフな関数を使用して署名ヘッダーと比較します。単純な等価比較は決して使用しないでください。
Webhookエンドポイントがダウンしている場合はどうすればよいですか?
プロバイダーは指数バックオフでリトライする必要があります。ダウンタイムの影響を最小限に抑えるために、エンドポイントが迅速に2xxを返し、イベントを非同期で処理するようにしてください。
署名なしでWebhookを使用できますか?
推奨されません。署名がないと送信者を検証できず、エンドポイントが偽のイベントに対して脆弱になります。利用可能な場合は常に署名を使用してください。
Webhookペイロードをテストする際には、JSONデータを検査する必要があるかもしれません。JSON Formatterを使用して、ペイロードをすばやく整形して検証できます。