How Linkerd retries HTTP requests with bodies
#翻訳
https://linkerd.io/2021/10/26/how-linkerd-retries-http-requests-with-bodies/
Linkerd 2.11がここにあり、いくつかのクールな新しいアップデートがあります。 私が特に興奮しているのは(完全な開示:私はそれに取り組みました)、ボディを使用したHTTPリクエストの再試行です。 Linkerdはバージョン2.2以降HTTP再試行をサポートしていますが、これまでは、本文のない要求のみを再試行していました。 ボディを使用してリクエストを再試行することは、gRPCでLinkerdを使用している人にとって特に重要です。 すべてのgRPCリクエストは本文を含むHTTP / 2 POSTリクエストであるため、この機能を使用すると、gRPCトラフィックに対して再試行を設定できます。
ボディを使用してリクエストを再試行するのは簡単に聞こえるかもしれませんが(ボディをもう一度送信するだけですよね?)、それほど簡単ではありません。 ボディを再度送信するには、元のリクエストが完了するまで、ボディ全体をメモリにバッファリングする必要があります。 これは、プロキシがそれらのボディを格納するためにより多くのメモリを使用する必要があることを意味し、ボディをバッファリングするとレイテンシが増加します。 レイテンシとプロキシメモリ使用量への影響を可能な限り低く抑えながら、これらのリクエストを再試行できるようにしたいと考えています。
さらに、HTTP / 2のクライアントストリーミングリクエストやTransfer-Encoding:HTTP / 1.1のチャンクリクエストなどの一部のリクエストには、複数の部分で送信される長いボディが含まれる場合があります。 プロキシがリクエストをサーバーに転送する前にリクエストボディ全体をバッファリングする場合、ボディが完了するまで待機する必要があり、かなりの遅延が発生する可能性があります。 また、多くの場合、サーバーはこれらのリクエストボディをチャンクごとに処理することを期待する場合があります。 マルチギガバイトのビデオファイルまたはクライアントストリーミングgRPCリクエストをアップロードし、クライアントがイベントの発生時にサーバーにイベントをプッシュすることを想像してみてください。 転送する前に本体全体をバッファリングするのを待つと、サーバーが期待する動作が損なわれる可能性があります。 代わりに、リクエストを再試行する必要がある場合に備えて、受信した各チャンクをプロキシにバッファリングしながら転送できるようにする必要があります。
ボディデータのバッファリングのオーバーヘッドを削減するために、あるバッファから別のバッファへのデータのコピー(つまり、memcpy呼び出し)も最小限に抑えたいと考えています。 これは、大量のデータがあり、不要なメモリ割り当て(つまり、malloc呼び出し)を回避したい場合に非常に時間がかかる可能性があります。
さらに、潜在的なエッジケースを正しく処理していることを確認したいと思います。 ボディストリームが終了する前にサーバーがエラーで応答した場合はどうなりますか? クライアントからボディ全体を受け取る前に、リクエストを再試行する必要がある場合があります。 または、クライアントがリクエストのContent-Lengthヘッダーよりも長いボディを送信した場合はどうなりますか? 標準に準拠したクライアントはこれを行うべきではありませんが、バグまたは(要求がクラスター外の信頼できないクライアントからのものである場合)HTTPリクエストの密輸攻撃が原因で発生する可能性があります。 これが発生した場合、プロキシが潜在的に無制限の量のメモリを使用しないようにする必要があります。
要するに、リクエストボディのバッファリングと再送信には、多くの潜在的な課題とエッジケースが関係しているということです。 幸い、Rustエコシステムの優れたライブラリを使用してそれらを解決できます。 特に、bytesとhttp-bodyクレート(どちらも名誉Linkerdメンテナーによって作成されたもの)は、実装の重要な部分でした。
クレート:Rust用語
パッケージとクレート - The Rust Programming Language 日本語版
モジュールシステムの要素は、パッケージとクレートです。
パッケージ はある機能群を提供する1つ以上のクレートです。
Buffering
bytesクレートは、参照カウントされたバイトバッファの実装を提供します。 これにより、すべてのバイトを新しい配列にコピーしなくても、ボディデータのチャンクを複製できるため、非常に便利です。
Rust標準ライブラリの拡張可能な配列タイプであるVecは、メモリ内の配列へのポインタと長さとして単純に表されます。 この表現はシンプルで軽量です。 ただし、HTTPボディチャンクを表すバイトのVecを複製する場合は、新しい配列を割り当て、既存の配列からすべてのバイトをその配列にコピーする必要があることを意味します。 大量のデータがある場合、これは非常に時間がかかります。
https://linkerd.io/uploads/retries-3.png
この問題を解決するために、bytesクレートは、参照カウントされたバイトバッファであるBytesタイプを提供します。 Vecが配列と長さへのポインターである場合、Bytesは配列、長さ、およびアトミック参照カウントへのポインターです。 これは、バイトバッファへの複数の所有参照が同時に存在する可能性があることを意味します。これは、それらの参照がすべてなくなった場合にのみ割り当てが解除されるためです。 現在、バッファのクローンを作成するには、参照カウントをインクリメントしてポインタをコピーするだけで済みます。 これは、すべてのデータをコピーするよりもはるかに高速です— 100%mallocとmemcpyは無料です!
https://linkerd.io/uploads/retries-2.png
プロキシによって使用されるRustHTTP実装であるHyperは、Bytesタイプを使用してHTTPボディデータを表すように構成できます。 これは、クライアントから受信したデータのチャンクを取得し、参照カウントをインクリメントしてクローンを作成し、1つのクローンをサーバーに送信し、もう1つのクローンを保持して再試行できることを意味します。 ただし、バッファの両方のクローンはメモリ内の同じ配列へのポインタにすぎないため、すべてのバイトをコピーする必要はありません。
これで問題の半分が解決します。 ただし、ボディがストリーミングしている可能性があることを思い出してください。時間の経過とともに、データのチャンクをいくつか受信する可能性があります。 バッファに新しいデータを追加するにはどうすればよいですか?
bytesクレートのBytesMut :: extend_from_sliceメソッドは、バイトのスライスからのデータを可変バイトバッファーに追加します。 ただし、共有の参照カウントバイトタイプを使用しているため、これを使用することはできません。 他の場所で参照されている可能性があるときにバイトバッファの内容を変更すると、データ競合が発生する可能性があるため、共有バイトタイプはこのAPIを提供しません。 Bytesから自由に変更できる新しいバッファにデータをコピーすることもできますが、これは実際のバイト配列を割り当ててコピーすることを意味し、そもそも参照カウントバッファを使用する目的に反します。
代わりに、私たちの解決策は、BufListと呼ばれる新しいタイプを実装することでした。 BufListは、受信された順序での複数のバイトバッファのVecです。 これで、Bytesのクローンを作成し、それをBufListのベクトルに追加するだけで、バッファリングされた本体に新しいデータのチャンクを追加できます。 これを行うことで、バイトのコピーを回避でき、(ほとんどの場合)割り当ても回避できます。 BufListのVec配列に容量がある場合は、新しいチャンクを追加するためにサイズを変更する必要がありますが、リクエストボディの一部として受信したすべてのバイトではなく、各バイトバッファーへのポインターのみで構成されているため、割り当ておよびコピーされるのは非常に小さいため、割り当てのオーバーヘッドが大幅に削減されます。
https://linkerd.io/uploads/retries-1.png
http-bodyクレートには、HTTPボディとHTTPボディデータチャンクを表すタイプを実装できるインターフェイスを提供するRustトレイトが含まれています。 これらの特性を実装すると、リクエストを転送するときに、BufListタイプをボディデータとしてハイパーに提供できます。 バイトバッファはメモリ内で連続していませんが、ベクトル化された書き込みを使用することで、1回のシステムコールでネットワーク上に送信できます。
トレイト:Rust用語
トレイト:共通の振る舞いを定義する - The Rust Programming Language 日本語版
トレイトは、Rustコンパイラに、特定の型に存在し、他の型と共有できる機能について知らせます。
なんもわからん
Retrying requests
ボディデータをバッファリングするための効率的な戦略ができたので、実際にリクエストを再試行するにはどうすればよいですか? 最初に、http-bodyのBodyトレイトを実装するReplayBodyと呼ばれる新しいタイプを作成しました。 ReplayBodyは、クライアントから最初のリクエストボディを受信して​​いるか、バッファリングされたボディを再生して再試行するかの2つの状態のいずれかで存在します。 クライアントから最初のボディを受信すると、そのデータをサーバーに転送しながら、各チャンクをBufListに遅延追加します。 リクエストが失敗して再試行する必要がある場合は、「再生」状態に切り替えて、BufListにバッファリングされているデータを再生します。 バッファから再生した後、元のリクエストボディが完了していない場合(つまり、ボディの終わりを受信する前にサーバーがエラーを返した場合)、初期状態に切り替えて、新しいデータをバッファリングしながら、受信したボディから転送を続行します。これは、クライアントの観点からは、すべてが正常であることを意味します。ボディがまだ完了していない場合でも、再試行は完全に透過的に実行されます。
次に、特定のリクエストを再試行できるかどうかを判断する必要があります。 プロキシには、ServiceProfile構成と再試行バジェットに基づいてリクエストを再試行可能かどうかを判断するためのロジックがすでにあります。 以前は、そのロジックは常に、再試行不可能なボディを持つリクエストを決定していました。 このロジックを変更して、ボディを使用してリクエストを再試行できるようにするだけです。 潜在的に無制限のバッファリングを回避するために、プロキシが再試行のためにバッファリングする最大Content-Lengthを設定します。 64 KBを超えるContent-Lengthヘッダーを持つリクエストは、再試行可能とは見なされません。
さらに、Content-Lengthヘッダーが正しくなく、ボディがアドバタイズされた長さよりも長い状況に対する保護手段として、バッファーが制限を超えた場合にバッファーを停止するチェックをReplayBodyタイプに追加しました。 これが発生した場合、以前にバッファリングされたデータは破棄され、リクエストは再試行されなくなります。 これは、クライアントのバグや外部からの悪意のある要求が原因でプロキシがメモリ不足になることがないことを意味します。
Summary
再試行は、Linkerdの最も重要な信頼性機能の1つです。 ただし、Linkerd 2.11より前は、ボディのないリクエストのみを再試行でき、この機能を使用できるケースが制限されていました。 ただし、2.11では、ボディを使用してリクエストを再試行するためのサポートが追加されました。
ボディを使用してリクエストを再試行するには、特にストリーミングボディを考慮に入れる場合、いくつかの興味深い実装上の課題が伴います。
この投稿では、Linkerdプロキシが、コピーと割り当てを減らすことで、リクエストボディのバッファリングのパフォーマンスオーバーヘッドを最小限に抑える方法について説明しました。 また、プロキシがどのリクエストを再試行できるかを決定する方法と、考慮しなければならないいくつかのエッジケースについても説明しました。
この新しいLinkerd機能に私たちと同じように興奮していることを願っています。 Linkerd 2.11にアップグレードし(まだ行っていない場合)、リクエストボディを使用してルートに対して再試行を有効化することで、自分で試すことができます。
#Linkerd