webhook rails sidekiq カラーミーショップ

カラーミーショップのWebhook開発で見えた、届けることと守ることの設計判断

webhook rails sidekiq カラーミーショップ
  1. Webhookとは
  2. カラーミーショップにWebhookを導入した理由
  3. 要件定義で考えたポイント
    1. 通知の取りこぼしを既存APIで補えるか確認する
    2. 登録主体はショップオーナーに限定する
    3. 登録できる配信先URLの数に上限を設ける
  4. 設計方針
    1. Standard Webhooksの採用
  5. カラーミーショップ特有の考慮点
    1. thin payloadの採用
    2. Outboxパターンの採用
    3. ユースケースに合わせたリトライ戦略
    4. 配信状態と試行履歴の記録
    5. 負荷対策と非同期処理ワーカー設計
  6. セキュリティ: 配信先を信用しないHTTPクライアントの防御設計
    1. SSRF(Server-Side Request Forgery)
    2. レスポンス遅延によるスレッド占有
    3. 巨大レスポンス対策: ストリーミング処理とサイズ制限
  7. 参考にした先行事例
    1. Svix
    2. 他サービスのWebhook仕様と比べて
  8. まとめ

こんにちは。GMOペパボが運営するECサイト構築サービス「カラーミーショップ」を開発しているyukyan、kosuke、keiです。

カラーミーショップに、受注が作成されたことを配信先へHTTPで通知するWebhook機能を実装しました。

この記事では、Webhookの送信機能を開発するにあたり、何を検討し、どう設計・実装したかを紹介します。Webhookを開発するときの参考になれば幸いです。


Webhookとは

Webhookは、サーバ側でイベントが発生した際に、あらかじめ登録されたURLへHTTP POSTを送信する仕組みです。配信先がAPIを呼び出して変化を確認する方式に対し、Webhookではイベントが発生した側から配信先へ通知します。

通知を受け取ったシステムは、それをきっかけに次の処理を始めることができます。たとえばGitHubでは、プッシュやプルリクエストの作成をWebhookで通知できます。他にも、外部のCIサーバでテストを実行したり、Slackにメッセージを送ったり、課題管理システムを更新したりできます。

カラーミーショップにWebhookを導入した理由

カラーミーショップには受注や商品を取得する公開APIがありますが、イベントが発生したことを外部へ通知する仕組みがありませんでした。

今回の例では、受注の作成を通知する仕組みはありませんでした。そのため、公開APIを定期的に呼び出すポーリングで、取得済みのデータと比較して新しい受注を検知する必要がありました。この方法では、受注が増えていないときにもリクエストが発生します。ポーリング間隔を短くするとサーバ負荷が増え、長くすると検知が遅れるため、負荷と検知の速さを両立することが課題でした。

Webhookにより、次のポーリングを待たずに受注を検知し、在庫の引き当てや発送の準備を早く始められます。短い間隔のポーリングを置き換えることで、APIリクエストも減らせます。

要件定義で考えたポイント

要件を決めるにあたり、まず、受注の通知を受け取った後に何をするのかを整理しました。社内ヒアリングや、想定される用途をもとに、どの程度の速さで通知が必要か、取りこぼしが業務にどう影響するかを検討しました。

たとえば、複数の販売先で在庫を共有する連携では、受注を早く知り、ほかの販売先の在庫へ反映する必要があります。購入後にライセンスを発行する連携でも、通知の遅れは購入者の待ち時間につながります。

一方、会計連携では、即時性よりも売上を漏れなく取り込めることが重要になると考えました。

用途によって求められる通知の速さ(即時性)は異なりますが、必要な受注を取りこぼしたままにできない点(信頼性)は共通しています。そのため、通知を早く届けるだけでなく、届かなかった場合にも受注を取得できる手段が必要だと考えました。

通知の取りこぼしを既存APIで補えるか確認する

Webhookは変化を早く知らせるために使い、通知の取りこぼしは公開APIでの定期的な照合で補う方針にしました。ただし、照合のたびに受注を全件取得する方法では、受注が増えるほど負担が大きくなります。

カラーミーショップの受注関連APIは、update_date_minとupdate_date_maxで更新日時の範囲を指定して受注を取得できます。前回の照合以降に更新された受注だけを取得して配信先のデータと照合すれば、受注作成通知を受け取れなかった分も補えます。

登録主体はショップオーナーに限定する

初期リリースでは、アプリ開発者がAPI経由で配信先URLを登録する仕組みは見送り、ショップオーナーが管理画面から手動で登録する機能に絞りました。 まずは最小限の形で提供し、実際の使われ方を見ながら、API経由の登録も含めて拡張を検討します。

登録できる配信先URLの数に上限を設ける

1つのイベントは登録された配信先の数だけHTTPリクエストになるため、配信先URLを無制限に登録できると、受注が増えたときの送信量を見積もれません。利用者の利便性と送信側の負荷のバランスを取り、登録できる配信先URLの数には初めから上限を設けました。

ただし、上限が低すぎると、本番の連携サービスのほかに、移行時の並行運用テストや開発・検証環境向けの登録ができなくなります。上限の値は、こうした用途も登録できる数にしました。

これらの要件をもとに、以降では配信基盤の設計と実装を紹介します。

設計方針

要件を整理する中で、通知を安心して使えるよう、次の2点を設計方針にしました。

  • 届いた通知がカラーミーショップから送られたものだと確認できるようにする。
  • 通知が届かなかった場合や重複した場合に、配信先が対応できる手段を用意する。

Webhookの配信については、次の方針をとりました。

  • リトライと重複への対応:リトライによって同じ通知が複数回届くことを想定し、配信先で重複を判定できるIDを付ける。
  • 順序の非保証:到達順序は保証しない。イベントの発生順に通知が届くとは限らない。
  • ベストエフォート:at-least-once(少なくとも1回の到達)を目指し、失敗時には所定の条件と期限内でリトライする。ただし、配信先の長期停止などで届けられない場合もあるため、ベストエフォートでの配信としている。

Standard Webhooksの採用

今回提供するWebhookを利用するには、利用者であるショップオーナー様が、Webhookを受け付けるシステムを実装する必要があります。受け取り時の署名の検証を簡単に実装できるよう、送出するWebhookの形式をStandard Webhooksに準拠することとしました。

Standard Webhooksは、サービスごとに異なりがちなWebhookの仕様を共通化する取り組みです。主要なプログラミング言語向けに検証ライブラリが公開されているため、配信先は署名の検証ロジックを自前で実装せずに済みます。

Standard Webhooksには、署名やヘッダに関する規定に加え、ペイロードの構成や配信の運用に関する推奨事項があります。

  • ペイロード:JSON形式や、type、timestamp、dataを使った構成を推奨している。
  • 署名とヘッダ:署名の方式と、webhook-id、webhook-timestamp、webhook-signatureの3つのヘッダを定めている。
  • 配信の運用:リトライ方針や、HTTPステータスコードに応じた扱いなどを示している。

本節では、その中から署名とリトライに焦点を当てて解説します。

署名による送信元の確認

Standard Webhooksでは、通知に付ける署名の方式が定められています。Webhookの配信先URLには、アクセス制限がなければ誰でもリクエストを送れるため、配信先で送信元を確認する必要があります。

配信先は、カラーミーショップから提供された検証用の鍵で署名を検証することで、カラーミーショップからの通知であることと、内容が改ざんされていないことを確認できます。

リトライと冪等性

Standard Webhooksでは、確実にイベントの通知を届けるために、配信に失敗した通知をリトライすることや、リトライ時も同一のメッセージIDを維持することが定められています。

カラーミーショップでも、配信に失敗した通知は間隔を空けてリトライします。配信先は処理済みのメッセージIDを記録し、同じIDの通知を再び処理しないことで、二重処理を防げます。


カラーミーショップ特有の考慮点

通知に含める情報、イベントを配信処理へ渡す方法、具体的なリトライ期間、配信結果の記録方法は、カラーミーショップの要件に合わせて決めました。

thin payloadの採用

Webhookで送る本文(ペイロード)の設計には、変更後のリソース情報をすべて含める方法と、IDなどの識別子だけを含める方法があります。Standard Webhooksの仕様では前者をfull payload、後者をthin payloadと呼び、どちらか一方に決めるものではなく要件に応じて選ぶものとしています。

カラーミーショップではthin payloadを採用し、受注情報としては受注IDなどの識別子だけを含めます。配信先はそのIDを使って公開APIから最新の受注情報を取得します。

配信先ごとに必要な項目が異なるため、通知には識別子だけを含めることにしました。また、通知の項目は後から追加できますが、一度含めた項目を削ると、その項目を使う連携は動かなくなる可能性があります。ペイロードを最小限にしておくことで、将来的な仕様変更の影響を抑えられます。

Outboxパターンの採用

通知の信頼性を高めるため、Transactional Outboxパターンを採用しました。受注作成と同じトランザクションで、イベントの発生事実を記録するOutboxテーブルにイベントを保存します。これにより、トランザクションが確定すればイベントも記録され、ロールバックされればイベントも記録されません。外部へのHTTP通信は、トランザクションの確定後、記録したイベントをもとに非同期で行います。

当初、Outboxに記録したイベントを配信処理へ渡す方法としてポーリングとCDC(Change Data Capture)の2つの手法を検討しました。ポーリングは別のワーカーを立てて未処理イベントを定期的に読み出します。CDCは、DBのトランザクションログの差分を検知して外部システムに転送する手法です。

ポーリングはシンプルですが、ポーリング間隔に依存して配信が遅れるため、在庫連携のように受注を早く知りたい用途には課題があります。一方で、CDCは遅延を抑えられますが、導入にはインフラチームとの連携や運用体制の整備が必要になると考えました。小さく開発して段階的に改善するチームの方針に鑑みて、今回は採用を見送りました。

そこで今回は、DBへのコミット直後に、配信ジョブを直接キューへエンキューする方法を採りました。この手法では、ポーリングのように待ち時間が発生せず、CDCのように専用の監視プロセスも用意せずに済みます。

万が一に備えて、補償バッチを2段階で用意しています。1段階目は、コミット後にエンキューまで終わらなかったイベントを補うバッチです。キューの障害などでエンキューに失敗したイベントは、Outboxテーブルに未処理のまま残ります。定期実行しているバッチがこれを拾い直し、配信ジョブをエンキューします。2段階目は、エンキューした後に配信が完了しないまま期限を過ぎたものを補うバッチで、後述する配信状態の記録を使います。

ユースケースに合わせたリトライ戦略

リトライ期間は、カラーミーショップにおけるユースケースから決めました。上述の通り、受注イベントのWebhookは、受注をきっかけに他の販売先の在庫を更新する連携に使われることを想定しており、速く届けることが重要です。一方で、深夜帯の障害やデプロイ時の一時的な障害からの復旧を待つ余地も必要です。即時性と復旧を待てる期間を考慮して、リトライ期限を決めました。そして、その期間中にリクエストが集中しないよう、リトライ間隔にはランダムな揺らぎを加えています。

リトライするかは、一時的な問題で再試行による回復を見込めるか、セキュリティ上、再試行してよいかで判断します。接続失敗やタイムアウトはリトライしますが、後述のSSRFチェックで遮断した配信先への配信はリトライしません。

配信状態と試行履歴の記録

利用者は、届くはずの通知が来ないとき、その原因や配信の状況を自分では確認できません。「通知が届いていない」という問い合わせに答えるには、配信が今どういう状態なのかに加え、リトライを含むこれまでの試行で何が起きたかも分かる必要があります。そのため、配信先ごとに現在の配信状況と、各試行の履歴を分けて記録しています。これにより、現在の配信状況とそこに至る経過を確認できます。

この配信状態の記録は、前述の2段階目の補償バッチでも使っています。バッチはこの記録をもとに、配信が完了していない通知を見つけ、必要に応じて再送します。

負荷対策と非同期処理ワーカー設計

カラーミーショップでは、受注作成イベントが短時間に大量に発生することがあります。そのため、イベントの発生からWebhookの送信までに大きな遅延が出たり、アプリケーションが落ちたりしないように対策する必要がありました。

当初は、Webhook配信用の非同期ワーカーは、すでに他の決済やメール配送などで利用されているものを利用することを考えていました。しかし、Webhookが原因でほかの非同期ジョブが遅れたり、あるいはほかのジョブの影響でWebhookの配信が遅れたりする懸念がありました。

そこで、検証環境で負荷テストを行い、その結果をもとに配信の遅延を抑えるワーカー構成を設計しました。

目標値の算出

負荷テストの目標は「1秒あたりの受注件数 × 1受注あたりの配信先数」で見積もりました。受注件数は分単位でなく、秒単位で集計しました。実際のデータを見ると、秒単位のスパイクが発生していたためです。配信先数は、受注取得APIを使っている外部アプリの接続状況から推定しました。

負荷テストで見えてきたボトルネック

負荷テストには、Grafana Labsが開発しているシナリオベースの負荷試験ツールであるk6を使いました。k6から、受注件数の目標値に達するように受注作成APIを呼び出し、Webhookの配信が完了するまでの時間を測りました。配信ジョブは、決済やメール配送と同じ既存の非同期ジョブワーカーで動かしました。

通知はすべて届き、ほかのジョブへの影響も見られませんでした。ただ、配信ジョブがキューで待たされ、イベントの作成から配信の完了までの時間が大きく伸びました。

配信処理を専用ワーカーとキューに分離する

配信のキュー待ちを抑えるため、配信ジョブを既存のワーカーから外し、専用の非同期ジョブワーカーとキューに分離しました。

既存のワーカーでは、決済やメール配送などのジョブと配信ジョブが、限られた数の処理スレッドを共有しています。配信ジョブは、配信先から応答が返るまでスレッドを1つ使い続けます。そのため、応答の遅い配信先があると、決済やメール配送のジョブに回せるスレッドが減ります。反対に、決済やメール配送のジョブが多いときは、配信ジョブが空いたスレッドを待つことになります。

ワーカーを分離すると、配信先の応答待ちが決済やメール配送のスレッドを占有しなくなります。決済やメール配送は既存のワーカーで処理を続け、Webhookの配信は専用ワーカーで処理します。

ワーカーを分離した構成。決済とメール配送は既存ワーカーで処理し、遅い配信先へのWebhookの応答待ちは専用ワーカーで処理する

並行数は配信処理に合わせ、台数は固定にする

配信ジョブは、処理時間の大半を配信先の応答待ちに使います。待っている間はCPUをほとんど使いません。そのため、1台のワーカーが同時に待てる配信の数、つまり並行数を増やすほど、少ない台数で多くの配信をさばけます。専用ワーカーに分けたことで、この並行数をほかのジョブへの影響を気にせず、配信処理に合わせて決められるようになりました。

カラーミーショップでは、非同期ジョブの基盤にSidekiqを使っています。Sidekiqは、1つのワーカープロセスが複数のスレッドを持ち、スレッドごとにジョブを処理する構成で、スレッド数はconcurrencyオプションで決まります。CRubyには、同時に1つのスレッドしかRubyのコードを実行できない仕組み(GVL)があります。ただしHTTP通信の応答を待つ間は解放されるため、スレッドを増やした分だけ同時に待てる配信が増えます。

一方で、ワーカーの台数は固定にし、オートスケールは採用しませんでした。応答待ちの間はCPUをほとんど使わないため、キューに配信ジョブが溜まっていてもCPU使用率は上がらず、CPUやメモリの使用率は負荷の指標になりません。並行数を確保すれば固定台数でも目標の性能を満たせたことも理由です。利用者が増えてオートスケールが必要になったら、専用ワーカーはKubernetes上で動いているため、キューの待ち時間などを指標にスケールできるKEDAを改めて検討します。

セキュリティ: 配信先を信用しないHTTPクライアントの防御設計

Webhookでは、利用者が登録した外部のサーバにリクエストを送るため、セキュリティ面で考慮するべき箇所がいくつかありました。

SSRF(Server-Side Request Forgery)

SSRFとは、攻撃者がサーバを悪用し、本来アクセスさせるべきではない宛先にリクエストを送信させる脆弱性です。「クラウドのメタデータサービスから設定情報を取得する」、「内部のデータベースや管理用のサービスにアクセスする」、といった攻撃の例が知られています。

Webhookの送信は、利用者が登録した任意のURLへサーバからリクエストを送る機能そのものです。対策をしなければ、そのままSSRFの入り口になります。Standard Webhooksの仕様でも、WebhookはSSRFを特に受けやすいとして注意を促しています。

そこで、配信先URLの登録時と配信時の両方でバリデーションを行っています。登録時は、内部ネットワークを指すIPアドレスの直書きと、localhostなどのループバックホスト名だけを弾き、ドメイン名の名前解決はしません。ドメイン名の向き先は登録後にいつでも変えられるため、登録時に確認しても、配信時に内部IPを指していないという保証にならないからです。配信時には、名前解決したIPアドレスがプライベートIP等でないかを確認し、確認したIPアドレスへそのまま接続しています。確認と接続で名前解決を分けると、その間に向き先を差し替えられてしまうためです(DNS rebinding)。

SSRF対策の検証フロー。登録時は内部ネットワークを指すIPの直書きやループバックを拒否し、配信時は名前解決したIPを検証してから送信する

レスポンス遅延によるスレッド占有

配信先の応答が遅いと、送信側の接続やスレッドが長時間占有されます。そのため、HTTP通信の各工程で待てる時間と、リクエスト全体にかけられる時間の両方に上限を設けています。

HTTP通信では、配信先に接続し、リクエストを書き込み、レスポンスを読み取ります。接続の完了、1回の書き込み、1回の読み取りでそれぞれ待てる時間を制限するため、各工程にタイムアウトを設定しています。

ただし、読み取りタイムアウトで制限できるのは、1回の読み取りで待つ時間だけです。配信先が制限時間より短い間隔で少量ずつデータを返し続けると、タイムアウトにかからずスレッドを占有できます。そのため、リクエスト全体の合計時間にも上限を設けています。

読み取りタイムアウトのみでは小分けの応答が届くたびに待機が続き、1つのジョブがワーカーを占有する。全体のタイムアウトを設けると、上限時間で通信を打ち切り、次のジョブを処理できる

巨大レスポンス対策: ストリーミング処理とサイズ制限

Webhookの配信先から巨大なレスポンスや高圧縮データを返されると、非同期ワーカーのメモリが逼迫しかねません。

そこで、レスポンスボディは上限サイズを超えた時点で読み込みを打ち切って破棄するようにしています。Net::HTTPでは、ボディを読み込むメソッドにブロックを渡さない場合、ボディ全体を1つの文字列としてメモリに読み込みます。そのため、ブロックを渡してチャンクごとに受け取り、受信サイズを数えながら、上限を超えたら切断することで対策しています。

受信サイズの制限に加え、圧縮された応答の自動展開も防ぎます。リクエストヘッダにAccept-Encoding: identityを指定し、圧縮しない形での応答を求めています。この指定がないと、Net::HTTPは圧縮された応答も受け取れるとサーバへ伝え、圧縮された応答が届くと自動で展開します。受け取ったデータが小さくても、展開した結果が大きければ、その分のメモリと処理時間を使います。呼び出し側がAccept-Encodingを指定した場合、Net::HTTPは自動で展開しません。そのため、配信先が圧縮した応答を返してきても展開は起きず、前述のサイズの上限も受け取ったデータの量に対してそのまま効きます。

参考にした先行事例

これらの設計判断にあたり、開発チームではWebhookを提供する各社の仕様や設計事例を持ち寄り、自分たちの要件にどう適用できるかを議論しました。

Svix

設計にあたって最も参考にしたのがSvixです。SvixはWebhook送信基盤をSaaSとして提供する企業で、Standard Webhooksの策定も主導しています。

参考にした理由は2つあります。1つは、実運用に基づく知見が公式ブログで共有されていることです。もう1つは、送信基盤をオープンソースとして公開しており、設計思想と実装を照らし合わせて確認できることです。

特に次の3本の記事は大きなヒントになりました。

  • Webhook Architecture Design:メッセージをキューへ積む前に、データベースへ永続化する設計が紹介されている。
  • Why You Can’t Guarantee Webhook Ordering:順序を守るために前の通知の応答を待つと、配信の速さが配信先の応答時間に制約される。この記事は、通知の到達順に依存せず、APIから最新の状態を取得するthin payloadの構成を選ぶ参考になった。
  • Common Webhook Signatures Failure Modes:HMAC署名に加え、タイムスタンプと一意なIDを署名対象に含め、配信先で時刻の妥当性と重複を確認する必要がある。また、署名検証にはJSONとして解析する前の生のバイト列を使う必要がある。この記事では、こうした署名の設計と検証の落とし穴がまとめられている。

他サービスのWebhook仕様と比べて

決済や会計などのSaaSが公開しているWebhookの仕様も調査し、自分たちの設計の位置づけを確認しました。Webhookで変化を知らせ、最新の状態はAPIから取得してもらうという私たちの方針は、調査した設計事例と同じ方向でした。一方で、事業者ごとに判断が分かれている観点もあります。カラーミーショップの選択とあわせて表にまとめます。

観点 他サービスで見られたパターン カラーミーショップ
ペイロードの情報量 変更後のリソース情報を含める / IDなどの識別子に絞る / 両方の形式を提供する 識別子のみ。最新の状態は公開APIから取得してもらう
送信元の確認方法 HMAC署名(ヘッダ名や署名対象は事業者ごとに異なる) / 登録時に決めた固定トークンの一致 Standard Webhooksに沿ったHMAC-SHA256。id、timestamp、payloadを署名対象にする
リプレイ対策 タイムスタンプを署名対象に含めて配信先で時刻を検証する / 明記なし タイムスタンプを署名対象に含め、配信先で時刻の妥当性と処理済みIDを確認してもらう
順序保証 保証しないと明記 / 明記なし 保証しない
自動リトライ 数時間で打ち切る / 数日かけて再試行する / 明記なし 期限内で間隔を広げながらリトライする

まとめ

Webhookの開発では、通知を届けるための信頼性と、外部のサーバへ送る際の安全性を考えました。さらに、カラーミーショップでの使われ方を踏まえ、速く届けるための工夫も必要でした。

これからWebhookを開発する方が、自分たちのサービスに合う設計を考える際の参考になれば幸いです。