内容
フレームワークではなく、午後ひと休みのあいだに読み切れる完成品のサービスです。自分で書くロジックは handle() の一つの関数だけ。周りの処理はすべて実装とテストが済んでいます。
sender --POST--> /webhook/{source}
1. read the body (size limit)
2. verify the HMAC signature -> 401 if wrong, nothing stored
3. dedupe by delivery id -> the same event twice is handled once
4. insert into SQLite -> 202 Accepted, fast
worker ----------> handle(event) retries with backoff, dead after N attempts
機能
- 三種類の署名方式。 GitHub 形式、Stripe 形式(リプレイ対策の許容時間つき)、Polar が採用している Standard Webhooks に対応します。比較は定数時間で行います。
- シークレットのローテーション。
WEBHOOK_SECRETにカンマ区切りで複数指定できるので、ローテーション中は新旧どちらも受け付け、イベントを取りこぼしません。 - 重複排除。 配信 ID で行います(Standard Webhooks では
webhook-idヘッダーを自動で使います)。ID がない場合はボディのハッシュで判定します。一意制約で保証しているので、再起動をまたいでも有効です。 - 永続キュー。 SQLite の WAL モードを使い、リクエストは一回のコミットのあとに受理を返します。指数バックオフ、
MAX_ATTEMPTS回を超えたらデッドレターへ、クラッシュでprocessingのまま残ったイベントの復旧も備えます。 - 並行処理。
WORKERS=Nでワーカーを増やせます。同じイベントが二つのワーカーに渡ることはありません。 - 運用機能。 デッドになったイベントを再投入する
server replay、古い行を消すserver purgeとRETENTION_DAYS、Prometheus 形式の/metrics、リクエスト ID つきの JSON アクセスログ(ペイロード、ヘッダー、シークレットは出力しません)。 - 安全なハンドラー。
worker.Permanent(err)を返すと、何度やっても成功しないペイロードを、リトライを消費せずに退避できます。ハンドラーの実行中にシャットダウンしても、試行回数を減らさずにイベントをキューへ戻します。 - サンプル。 Polar の
order.paidハンドラーと汎用の JSON ルーターがあり、どちらもテスト付きです。Dockerfile と systemd ユニットも入っています。
pure Go(cgo なし)です。依存は標準ライブラリと modernc.org/sqlite だけです。
計測結果
方法は次のとおりです。付属の負荷テスター(make loadtest)を、コンパイル済みバイナリに対して Apple Silicon のノート PC で実行しました。接続数 32、ペイロード 512 バイト、ループバック上の実 HTTP、既定の耐久性設定(WAL、synchronous=FULL)、一回の実行で 5,000 イベント、実行は三回です。
| 項目 | 結果 |
|---|---|
| HTTP で受理できたイベント数(検証、永続化つきの挿入、202)、GitHub 形式 | 毎秒約 7,200〜9,200(p50 2.7 ms、p99 約 17 ms) |
| 同じく Standard Webhooks | 毎秒約 9,400 |
| 最初のリクエストからキューが空になるまで(何もしないハンドラー)、ワーカー 1 | 毎秒約 3,100〜3,200 |
| キューの消化、5 ms スリープするハンドラー(ネットワーク呼び出し相当)、ワーカー 1 / 8 / 32 | 毎秒約 163 / 1,463 / 5,264 |
主なコストは HTTP ではなくデータベースです。SQLite へのアクセスはすべて一本の接続を通ります。そのため、ワーカーを増やして効果が出るのは、ハンドラーがネットワーク呼び出しや別のデータベースなど、何かを待つ場合です。
どうテストしたか
make test は go vet と、レースディテクター付きの 41 テストを実行します。対象は、改ざんされた、古い、未来の、不正な形式の署名、リファレンス Go ライブラリにある Standard Webhooks の既知解ベクター、シークレットのローテーション、401 のとき何も保存しないこと、重複配信、サイズ超過とメソッド違いのリクエスト、リトライとバックオフ、デッドレター、クラッシュからの復旧、順序、多数のワーカーが同じイベントを二度処理しないこと、リプレイ、パージ、0.1.0 のデータベースからのアップグレードです。パッケージ化した zip からビルドしたバイナリにも、三方式すべてで署名つきの実 HTTP リクエストを送って動作を確認しました。同じ配信のリプレイも含みます。
想定している使い手
Polar、Stripe、GitHub などから Webhook を受け取りたい開発者向けです。署名、リトライ、重複排除、クラッシュに耐えるキューといった、地味だけれど外せない部分をきちんと済ませたい方に向いています。
サーバー単体での利用を前提にしています。プロセスは一つ、SQLite への書き込みも一つで、イベントは既定で順番に処理されます。配信は at-least-once なので、ハンドラーは冪等にしてください。
よくある質問
Polar の Webhook を検証できますか?
はい。Polar は Standard Webhooks を使っており、これに対応しています。Polar の注文イベント用のサンプルハンドラーも付いています。
署名検証のコードだけ使えますか?
はい。MIT ライセンスの小さなモジュール webhooksig として別に配布しています。無料ツールのページをご覧ください。
CGO や C コンパイラーは必要ですか?
いいえ。SQLite ドライバーは pure Go です。
Go のバージョンは?
Go 1.24 以降です。
ライセンスはどうなっていますか?
ご自身のプロジェクトで使えます。競合するスターターとして、ソースを再販することはできません。
変更履歴
- 0.2.1: SQLite ドライバーを v1.44.0 に更新(Go 1.24 以上が必要)。
- 0.2.0: Standard Webhooks、シークレットのローテーション、複数ワーカー、replay と purge のコマンド、
/metrics、アクセスログ、Polar のサンプル、計測値を追加。 - 0.1.0: 初回リリース。
続きを読む
- Webhook signature checks that fail or quietly pass in Go (英語)
- A durable webhook retry queue in SQLite (英語)
- Webhook idempotency and dedupe for SQLite and Postgres (英語)
- Verify Standard Webhooks (Polar) signatures in Go (英語)
- Receiving signed webhooks in Go with a durable queue (英語)
入手する
支払い、税、請求書は販売代行事業者の Polar が処理します。決済後にダウンロードリンクが届きます。