去る2021年10月23日に開催されたKaigi on Rails 2021にて、弊社の@ohbaryeが "Safe Retry with Idempotency-Key Header" というタイトルで発表してきました。
発表スライドはSpeakerDeckにて公開済みです。
後日アーカイブ動画も公開されるとのことですのでそちらも是非ご覧頂きたいのですが、おとなしく30分の動画を視聴できるほど現代人は暇じゃないですよね。なので本記事の前半では忙しい人のために発表内容を1/10ぐらいの密度で解説してみます。
(2021-10-31追記) 動画が公開されました 🎉
(2021-10-31追記おわり)
ただ、それだけでは配信動画をすでに視聴いただいた方への新規情報がゼロになってしまうので、後半では発表に関していただいたtweet・質問・感想へのコメントへのリプライを試みたり、Q&Aブースで話したことを私の抱いた"Kaigi感"とともに共有してみます。発表を見た人でも楽しめる、登壇の外側にあるコンテンツもお伝えできればと思います。
"Safe Retry with Idempotency-Key Header"を3分で振り返る
タイトルの通り、安全なリトライの実現手段である Idempotency-Key Header の話をしました。
冪等性とリトライの話は堅牢なプログラムを記述したい皆さんの大好物だと思うのですが「すでに一通り語られているな」という印象もありました。そこで、今回は比較的ニッチでまだ語り尽くされていないところを狙い、IETFのIdempotency-Key Headerの提案仕様を核にすえた構成でプロポーザルを出してみたところ採択いただきました。
提出したプロポーザルやKaigi on Rails LPでの紹介文がまさに発表のコンテンツの要約なので気になる方はご参照ください。
以下、発表内容をかいつまんでなぞっていきます。
1. 解決したい課題
クライアントとサーバのHTTP通信で日々起きている問題として、ネットワークエラーがあります。クライアントが電波の届かないところへ移動したり、サーバが高負荷で応答できなかったり、さまざまな理由で通信は失敗します。
これは避けようがない事態なのですが、この問題の何が厄介かというと、通信に失敗した時点ではクライアントは「サーバがリクエストを処理したのか」を知ることができないという点です。
サーバが処理を完了しているにも関わらずクライアントがうっかりリトライに進んでしまうと、二重に処理が行われてしまいます。これは意図していない挙動であり、ビジネス上の致命的な問題に繋がることもありえます。
「うかつにリトライをせずに毎回必ず確認すればよい」と思うかもしれません。しかし、副作用を持つすべてのAPIコールのたびにビジネスロジック以外の処理を実装していくのはなかなか大変です。
- 失敗したら最新リソースをサーバに問い合わせる(この通信も失敗するかも...)
- リトライ可能かどうかをチェックする
- リトライ可能ならリトライする、不可ならしない
コードの可読性・保守性・テスト容易性の低下、追加の通信の発生、場合によってはユーザーエクスペリエンスの低下といった別の問題を生み出してしまいます。
また、HTTPクライアントライブラリの挙動によってはプログラマが意図しない自動リトライもありえます*1。
これを解消するアプローチとして同じリクエストを複数回受け付けても結果が変わらない(=冪等な)APIサーバを実装するというアイデアがあります。
何回リクエストしても結果が変わらないなら、クライアントに求められる責務は「成功するまでリトライする」だけで良いからです。
2. HTTPメソッドにおける冪等性
「安全な=冪等なリトライを可能にしてくれるAPIサーバが欲しい」という動機が十全に理解されたところで、そもそも、HTTPメソッドにおける冪等性ってどのように定義されているのか?を確認します。標準仕様に立ち返るムーブは大事です。
RFCやMDNなどウェブ上に存在する文献から、HTTPメソッドによって冪等であるかないかは異なるということがわかります。中でも、重要な操作を担いそうなPOST, PATCHは冪等ではないとの定義が見つかります。
ちなみに、HTTPメソッドにおいて冪等というのは、何回行ってもリソースの状態(結果)が同じであることをいいます。クライアントから見た時の振る舞いが同じかどうか、ではありません。
3. Idempotency-Key Headerの提案仕様
さて、ようやく本題のIdempotency-Key Header draftについてです。draftと題する通りこれはあくまで草案です。標準化を目指して議論が進行している段階のものになります。
もしお時間があれば、ここまでの前提を踏まえたうえでdraftを一読するのもおすすめです。解決したい課題やモチベーションについて大変な分かり手になることができるでしょう。
Idempotency-Key Headerのアイデアはとてもシンプルです。
まず、通常のHTTPリクエストに Idempotency-Key: xyz
のようなヘッダーと値(この例ではxyz
)を追加します。サーバはこの値(以下、キーという)を見ることでリクエストの同一性を判断することができるようになります。
この変更によって、同じキーを付けたリクエストを2回3回65536回と受け取っても、サーバからすれば既知であるそのキーは「もう見た」としてスキップし、処理の重複を避けられます。クライアントは処理成否を気にせず何度でもリトライできます。
加えて、初回のリクエストに対応するレスポンスボディやヘッダーをサーバサイドで保存しておくことで、2回目以降のリクエストに対してレスポンスを"再生-リプレイ-"することができるようになります。
このアイデアは至極シンプルですが実際に実装を試みようとすると分岐やエラーシナリオがそれなりにあります。詳細な仕様はdraftをご覧いただくとして、簡易にまとめたフローチャートが以下です。
現実的な規模ではあるけど若干のボリュームの実装は必要になる、といった感じです。
4. Idempotency-Key Headerを実装してみる
ようやくですが、最終章にてIdempotency-Key Headerの実装について検討してみます。
弊社のプロダクトたるB/43のいくつかのエンドポイントを同ヘッダーに対応させたのですが、先述のフローチャートを実装に書き起こせばそれで良い…というほどコトは単純に片付かず、いくつか検討すべき点があることに気付きました。
1. Keyを保存するストレージとして何を選ぶか
リクエストの同一性判断のため、サーバの責務としてキーを永続化(半永続化)しなければなりません。キーは複数プロセスで参照されるので外部ストレージに保存することが望ましいです。
どのストレージを選択するかの前段として、どのようなデータを保存する必要があるのかを見てみます。
キーそのものに加え、リクエストやレスポンスの情報を記録します。
draftではオプショナルとされていますが、有効期限を設ける場合はそのためのカラムが必要になります。キーのユニーク性のスコープを狭めるためにユーザー(リソースの持ち主)のIDを含めることもあるかもしれません。
これらのデータを保存できるストレージならどこでも構いません。アプリケーションから比較的かんたんに読み書きできるストレージとして選択肢に挙がるのは、MySQLやPostgreSQLのようなRDBMSや、RedisやMongoDBといったNoSQLだと思います。
判断するための問いとして キーの保存とその他の処理をアトミックに行う必要があるか? を考えるとよいと思います。YESであればトランザクションをサポートするストレージを推奨します。
2. どのレイヤーで実装するか
次にアプリケーションのどのレイヤーで実装するかという観点です。Idempotency−Key HeaderはモデルやドメインではなくHTTPの関心事なのでControllerやRack middlewareに記述するのがふさわしいと考えています。
とはいえフローチャートのロジックをそのままController層に書き下すとだいぶ"迫力"のあるaction methodができあがります。
また、リクエストの"再生-リプレイ-"のためのレスポンスを保存するにあたって、action method内では取得するのが難しいデータがあります。serializeされたJSONなど、viewレイヤの情報やaction methodの外側で決定されるものです。
何よりaction methodに記述されるアプリケーション固有の知識と"横断的関心事"であるIdempotency−Keyのロジックが密結合しているのが冴えない設計に思えます。
そこで別解として登場するのがRack middlewareでの実装です。
こうして...
こうじゃろ?
詳細は記事冒頭に貼った資料をご確認ください。
ここで重要なのは、リクエストはenv
Hash、Railsアプリケーションは@app
(callに応答するオブジェクト)、レスポンスは[status, headers, body]
の配列というように、Rack middlewareのレイヤでは様々なものが抽象化されていることです。おかげでアプリケーション固有の知識や関心からIdempotency−Keyのロジックを切り離すことができます。
3. IETF draftに足りないものは何か
ここまでの説明によって全RubyistがIdempotency−Key Headerに対応したrack applicationを実装できるようになったかもしれません。
しかしながら、draftに従って愚直に実装をしたときに気になることがありました。「既存APIエンドポイントがIdempotency−Key Headerを受け付けるようにすると、Headerを付与できないクライアントのリクエストがすべて失敗してしまう」という点です。
この課題はSmartBank社内で議論した際に、Android/iOSエンジニアの @nakamuuu のシャープな指摘によって浮き彫りになりました。
加えてAPIの外形的な振る舞い以外についてはdraftでは特に言及していません。有効期限が切れたキーのレコードを削除するとか、キーの保存・更新に失敗した場合にどうするとかいった課題は残ります。
draftはあくまでdraftであるため今後も改定される可能性が十分にあります。そのため、実装者はdraftの記述内容に固執せずにユースケースに応じて実装を調整する必要があります。
B/43の事例
では、SmartBankではどのような調整が行われたのかを見ていきます。
SmartBankが提供するプロダクトのB/43では、ユーザーが持つ口座に対して入出金ができたり、口座に紐づくカードで決済を行ったり、パートナーの口座間で送金したりできます。*2
お金を扱ったり、そのために外部システムとの通信を行ったりするシーンが多いプロダクトといえます。B/43ではお金を扱うリクエストを安全に行い、データ整合性を守るためIdempotency−Keyをほぼ初期から導入しています*3。
先述した実装における考慮ポイントや要調整事項については以下の選択をしています。
- キーを保存するストレージはRDBMS
- 実装時点では他の処理とatomicである必要はなかったが、拡張性を考慮してRDBMSを選択
- 実装するレイヤーはRack middleware
- draftで定義されていないこと・独自に判断してよいこと
- keyはUUID v4
- keyはnot required (あとからIdempotency−Key Headerに対応することもあるため)
- keyは一定期間でexpiredになる
- keyの削除は定期バッチで行うが、調査で参照する可能性があるのでexpiredになってからしばらく残す
発表内容まとめ
最終的なまとめです。
HTTPリクエストの安全なリトライを可能にする鍵は冪等性です。
HTTPリクエストを冪等にして安全なリトライを可能にするIdempotency-Key Headerという仕組みが現在標準化の過程にあり、言語やフレームワークを問わず実装することができます。SmartBankがすでに実現しているように、Ruby on Railsでももちろん実装可能です。
ただし、先述のようにそれなりに実装者やチームで意思決定しなければならないことがありますし、アプリケーションの持つ複雑さは多少なりとも増加します。
本当に守りたいデータ、防ぎたいデータ不正がわずかであれば、ユニーク制約を一つ設ける程度の局所的な対応でも十分かもしれません。
あくまでIdempotency−Key Headerは手段の1つであるということを最後に強調させてもらいました。
Idempotency−Key Headerに関連して掘り下げられるトピックとしては「REST API以外での実装」「マイクロサービスの話」「クライアントサイドでの実装」等々あるのですが、それはまたの機会に…という形で締めさせてもらいました。
登壇の外側にあるコンテンツ
さて、ここからが本番です。
今回のKaigi on Rails 2021にあたり、オーガナイザーの@okuramasafumiさんが「Kaigi感へのチャレンジ」と仰っていたのを何度か目にしました。オフラインイベントに存在していた空気感や廊下*4を再現したいということかなと私は勝手に解釈しており、「オンラインイベントに参加/登壇したあとの虚無感」を味わったことのある私のような人間にとってもこれは意義ある挑戦に感じられました。
実際、Kaigi on Rails 2021ではreBakoというバーチャル空間にて旧知の方と見知らぬ方を交えてお話することができ、その体験はKaigiの肌触りを感じさせるものでした。
運営チームの狙い通りであったかは私の知るよしもないものの、こんなことがあったというイベントログを残すことがフィードバックにもなると考え、本記事のこれ以降は登壇の外側の話をしてみたいと思います。
Tweetへのリプライ
まずはTwitterでいただいた質問や感想に反応していくパートです。
これは「Kaigi感」か?って気も一見しますが、Twitterの盛り上がりとhallway trackの盛り上がりには相関がある気がします。オフラインイベントの頃、参加者はオンラインより少なくてもTwitterの熱や密度は高かったのではないでしょうか*5。私の記憶が美化されているのかもしれませんが…。
時系列はバラバラですがご容赦ください。*6
冪等を実現するのは実装依存だからこれを標準化するの?と思ったけど、たしかに標準化したことで各言語のミドルウェアレベルで実装してくれると助かりそうですね #kaigionrails
— Hiroshi Shimoju (@shimoju_) October 23, 2021
まさしく標準化のモチベーションはそこです!ってIETFの第111回ミーティング議事録に書いてありました。
各言語でミドルウェアやツールが生まれて便利な世界になるには指針となる標準仕様が欲しい…! けどIETFはリアルワールドの事例から標準仕様を磨いていくので実装が欲しい…!
鶏と卵どちらが先か?的な話でもありますね。
他にも、NewRelic APMとかサービスメッシュとかそういうレイヤでリトライ回数・割合≒ネットワークの安定性を計測できるようになったりとか。
ActionController::Base#idempotency_key(key, &block) とかあると良いのかな。 #kaigionrails
— 神速 (@sinsoku_listy) October 23, 2021
欲しいですねぇ。 フレームワークに追加すべき根拠とするためにも標準化に期待したいところです。
Idempotency-Key をランダムにしてアクセスしまくったときに、第三者に向けたレスポンスを取得できないのかな? #kaigionrails
— ゆうあん⋈ (@yuuAn) October 23, 2021
認証機構の外側でキー関連の操作をやっている場合はキーさえあればレスポンスが得られる状態ですので可能性はあります。
なので推測可能なキーやリクエストボディから導出するキーを避け、アクセストークン等と同等なレベルで推測が難しいものが推奨されているのだと思います。そうすればランダムに生成したアクセストークンがたまたま一致したらどうする?問題と同レベルのリスクで考えられるかなと。
キーを盗めれば、サーバーからレスポンスも盗めたりしませんかね #kaigionrails
— kinoppyd (@GhostBrain) October 23, 2021
前述の通りキーさえあればレスポンスが得られる実装はありえます。
キーは推測不可能かつクライアント→サーバへのリクエストでしか使われない(外に露出しない)場合、キーが盗まれるケースとしてパッと思いつくのはクライアントの脆弱性、通信の盗聴・解析、サーバクラックやデータ流出あたりですかねぇ。
いずれのケースもキーどころかアクセストークンや更に重要度高いものが盗まれそうか。
質疑応答 Q&Aブースこぼれ話
今回のKaigi on Rails 2021では登壇者にQ&Aできるブースがバーチャル空間に設置されていたのでそちらを活用した話です。
バーチャル空間をさまようだけならいざしらず、Q&Aブースに入るのは勇気が要ると思うのですけど、ありがたいことに @tricknotes さんに先陣切って"特攻"んでいただいたのを呼び水に複数名に来ていただけました。来られた方から「同じような課題でちょうどいま悩んでたんですよね」という"生の声"もいただけて大変ありがたかったです。
また、話している最中に気付いたのですがブース付近で複数名に通信が"傍受"されていたようでした。このような偶発的な出会いや立ち聞きが生まれるのはとてもhallway trackぽいし、優れたイベント設計だと思いました。
これが盛況な質疑応答の姿…ッ 🥺 #kaigionrails https://t.co/weMmfaPvTT pic.twitter.com/1lNYBFG34C
— Kakutani Shintaro (@kakutani) October 23, 2021
ブースでいただいた質問や飛び出たこぼれ話もいくつか紹介します。
実装してみて、IETF draftにフィードバックしたいことはある?
先述したIdempotency-Key Header導入時の後方互換性についてはフィードバックしてもいいかなと振り返って思いました。
とあるAPIエンドポイントを公開してしばらくしたあとに「やっぱり冪等にしたい」と思ってIdempotency−Key Headerを受け付けるようにする。draftに従うと「キーがないリクエストは400」なので、同エンドポイントにリクエストするクライアントのリクエストが軒並みfailするかもしれない…という問題です。クライアントがモバイルアプリだったり変更できない環境だと絶望的ですよね。
なんでdraftの記述ではキーがrequiredなの?
あとから「やっぱり冪等にしたい」シーンはありそうだけど、なんで後方互換性がなくなるような提案仕様なんだろう?という質問をいただきました。
少なくとも2020年のdraft初出の時点でそうなっていてその背景までは読み取れなかったのですが、シンプルにそのほうが堅牢だから、かもしれません。
とあるエンドポイントへのリクエストが冪等だったり冪等でなかったりすることで生じるトラブルもありえます。「事前条件を満たさなければこのAPIは処理を行わない」といった防御的な設計も合理的といえます。
以下は私の勝手な推測ですが...
- draftを提出したのはPayPalの方であり、リアルワールドでのIdempotency−Key Headerの実例としてSaaSの公開APIを多く挙げている
- StripeやPayPalといったSaaSにはAPIクライアントgemやSDKがある
- こうしたライブラリたちは利用者が意識しなくても勝手にヘッダーを設定したりする
- 99.99999%のクライアントはヘッダーをちゃんと送る前提で実装している
のような背景かもしれません。
Idempotency-Key Header導入時の後方互換性、バージョニングで回避すればよいのでは?
よいです。
よいのですが…APIのバージョニングをちゃんと運用するの難しい問題、ありませんか?といった別の話題に発展。
類似した機能を提供する既存のgemとかある?
あります。
しかしながら更新が止まっていたり、draftとは異なる実装だったり、ストレージの選択やエラー処理周りで独自のハンドリングが必要だったり、うまくハマらなかったので自作する道を選びました。
Rack middlewareで認証しなくてOK?キーが漏れたり盗まれたりしたら?
tweetへの回答の通りです。
標準化過程を眺めるの面白いですね
天上でどこかの誰かがよしなに決めていると思われがちな標準仕様ですが、過程を見ると利害関係ある人・会社がポジショントークしていたり、折衷案や落とし所を見つけるリコンサイルをがんばっていたり、非英語話者ががんばってコメントしていたり、わりと現実的な"生活臭"がしますね〜等々。
GitHubのレポジトリで議論を眺めてみると面白いです。
REST以外のAPIではどうしよう
gRPCだとメッセージ型にいちいちキーを記述するんですかねぇ。Rack middlewareが使えない問題があるのでどのレイヤーに記述するかは再検討が必要そうです。
GraphQLも1エンドポイントで多様なリクエストを受け付けるわけなので「対象のエンドポイントかどうか」や「リクエストの同一性判断」あたりに工夫が要りそうです。
どちらも本番運用未経験でイメージがさほど湧いておらずふわっとした感想になってしまい恐縮です。
パフォーマンスへの影響
Rack middleware層で複数回のDB読み書きが発生することがパフォーマンスに影響を与えるか?という質問がありました。
現時点では高々3回のクエリ発行であり、我々のサービスでは問題にはなっていません。大規模・高負荷なサービスでも問題ないと断言することはできません。
一つ言えるのは、Idempotency−Key Headerを適用するようなエンドポイントの場合、「堅牢性 > パフォーマンス」の優先度が確立することが多いのではないかということです。弊社のような決済サービスであれば多少の性能劣化があったとしても外すという判断はしないと考えています。
動画・楽曲制作の裏側
動画を見られた方はお気づきと思いますが今回は動画を事前に録画して提出するスタイルで臨みました。初めての試みだったのでプレゼンテーションの準備や発表だけでなく動画制作もけっこう気合い入れて頑張りました。
このあたりの知見も得られたので紹介したいのですが、かなり長くなってしまうので後日に別記事として公開してみたいと思います。
登壇してみての感想
個人的には約2年ぶりの大型カンファレンス登壇、かつ、プロポーザルを通った時点でもニッチすぎるテーマかなと不安だったのですが、思ったよりも多くの反響があり登壇してよかったなと思いました。
今回のKaigi on Rails 2021、事前もイベント中も登壇者としてまったく文句ないレベルでスムーズに進行しておりましたが、裏側では運営の皆さんが尽力されていたのだと推察します。Kaigi on Rails運営の皆さんお疲れさまでした! 🙏
途中配信が1,2分止まるトラブルがありましたが、発表内容とあいまって奇跡的な面白さが生まれたのでオールオッケーです。何よりすぐに復旧いただけたので本当に感謝です。
冪等性キーの解説中に配信止まったけど配信リトライで冪等に再現できたの面白すぎる #kaigionrails pic.twitter.com/UwS2wqzyAg
— ohbarye (@ohbarye) October 23, 2021
最後に
Idempotency−Key Headerを使って堅牢性を高めているアプリで決済やリアルタイムな支出管理をしてみたいと思ったことはありませんか?それ、B/43でできます。
📝 現在はiOS版のみですが年内を目処にAndroid版の提供ができるよう凄腕モバイルエンジニアが絶賛進捗中ですので今しばらくお待ち下さい 🙏
弊社ないし同発表に興味が湧きましたら、カジュアル面談の応募フォームからIdempotency-Key Header付きのHTTPリクエストお待ちしています。
*1:本来なら挙動を理解してちゃんと使うべきではあるが、予期しないリトライの可能性として挙げています
*2:SmartBankは並大抵ではない努力をして資金移動業者になったためこうした資金移動ができるわけですが、その努力はまた別の記事で解説されることでしょう
*3:B/43が世に出たのは今年2021年の1月のことです
*4:いわゆるhallway trackというやつです。セッションそのものだけでなく、ホールや廊下にて参加者同士で話し合えることがカンファレンスの価値であるという話。
*5:定性的意見ではありますが、後述するQ&Aブースでも「Twitterでこんな感想見たのですけど…」といった派生的な会話が生まれており、余計にそう感じました
*6:本記事ではtweetを"引用"として使用させていただいております