HTTP/2はロードバランサー越しだとあまり意味がない(翻訳)
私がやりたいのは、Pitchforkに関する記事を書いて、これがどんな理由でできたのか、なぜ現在のような形になったのか、そして今後どうなるのかについて説明することです。しかしその前に解説しておく必要があることがいくつかあります。今回はHTTP/2についてです。
RubyのHTTPサーバーでHTTP/2がサポートされていないという不満をオンラインやカンファレンスの場で時たま耳にします。私はそのたびに「どうしてHTTP/2のサポートが欲しいの?」と尋ねているのですが、HTTP/2のサポートを本当の意味で必要としている人は今のところ誰もいないのです。
私個人はというと、RubyのHTTPサーバーにHTTP/2のサポートがなくても特に問題ないと考えています。ユースケースがあるとすれば、RubyのHTTPをロードバランサーもリバースプロキシも使わずに直接インターネット上に公開する場合しか私には思いつきません。可動部品が1個減ったら嬉しいと思うのは私にもわかりますが、わざわざそうする価値のある選択肢ではありません。
HTTPプロトコルや、HTTP/2(今ならバージョン3も含む)が従来のHTTPとどう違うのかを詳しくご存知ない方は、もしかするとこの話に驚くかもしれないので、本記事でひととおり説明したいと思います。
🔗 HTTP/2は何を解決してくれるのか?
HTTP/2 は、2009年にSPDY(スピーディ)という名前で開始されました。目的はさまざまなですが、主な目的は、より多くのリソースをより速くダウンロード可能にすることで、ページの読み込み待ち時間を短縮することです。
ページの読み込みに時間がかかる主な要因は、1つのページが1件のHTTPリクエストだけでは完了しないことです。ブラウザがHTMLページをダウンロードして解析を開始すると、ページをレンダリングするために他のリソース(スタイルシート、スクリプト、画像など)をダウンロードする必要も生じます。
つまり、1つのページは1件のHTTPリクエストではなく、複数のHTTPリクエストでできているのであり、2000年代後半のWebページでは、リソースの平均個数は増える一方でした。この肥大化はブロードバンドのパフォーマンス向上によってある程度打ち消されたものの、それでもいくつかの理由から、HTTP/1.1は多数の小さなファイルを素早くダウンロードするには力不足でした。
理由の1つは、HTTP/1.1を導入したRFC 2616で、ブラウザが特定のドメインに対して維持すべき同時接続数が最大2つに制限されていたことです。。
8.1.4 実用的な考慮事項
恒久的なコネクションを用いるクライアントは、特定のサーバーに対して維持する同時接続数を制限すべき(SHOULD)である。単一ユーザーのクライアントは、いかなるサーバーやプロキシに対しても接続数を2つ以上維持すべきではない(SHOULD NOT)。
したがって、もしリソースのリクエストを1接続あたり1件しか行えず、接続数が2つまでに制限されているとすれば、通信帯域幅が非常に大きい場合であっても、リソースを2個以上ダウンロードする必要があるときは、常にサーバー待ち時間がパフォーマンスに大きな影響を与えることになります。
100Gbの高速ネットワーク接続が使える環境で、大西洋の向こう側でホストされているWebページを読み込もうとしているとします。そのサーバーへのping
のラウンドトリップ(往復時間)は、おそらく60ミリ秒程度になるでしょう。
しかし、100個の小さなリソースを2つの接続だけでダウンロードする必要がある場合、少なくともping * (リソース / 接続)
、つまり3秒かかります。これはあまり良いことではありません。
そのため、当時はアセットのバンドルのようなフロントエンド最適化手法は絶対不可欠なものとなり、読み込み時間に大きな違いをもたらしました1。同様に、一部のWebサイトでは、ドメインシャーディング(domain sharding)と呼ばれる手法を用いてアセットを複数のドメインに分割することでコンカレンシーを高めていました。
理論上は、リクエストをパイプライン化することで2つの接続の効率を改善できるはずです。RFC 2616には、これに関するセクションが存在しており、これはHTTP/1.1で追加された、HTTP/1.0と比較して大きな機能の1つです。
考え方はシンプルで、あるリクエストを送信した後、さらにリクエストを送信する前にレスポンスを待つ必要はないというものです。1件のレスポンスを受信する前に、すぐに10件のリクエストを送信でき、サーバーはそれらを1つずつ順に送信します。
しかし実際には、振る舞いの正しくないサーバーに遭遇することがほとんどだったため、ブラウザがこの機能をデフォルトで無効にするようになり、この機能は役に立ちませんでした。また、「ヘッドオブラインブロッキング(head-of-line blocking: 行列の先頭が詰まると後ろも詰まる)」が発生する可能性があるため、完璧ではありませんでした。レスポンスには、応答すべきリクエストに対応する識別子が含まれていないため、レスポンスは順番を変えずに送信する必要があります。あるリソースの生成に時間がかかると、生成が終わるまで後続のすべてのリソースは送信できません。
そういうわけで、ブラウザは2008年の早い段階で「同時接続数は2つまで」ルールを順守しなくなりました。Firefox 3で接続数の制限がドメインあたり6に引き上げられると、他のほとんどのブラウザも直ちに追随しました。
ただし、TCP接続には「スロースタート(slow start)」という性質があるため、同時接続数を増やすのは理想的なソリューションではありません。コンピュータがリモートアドレスに接続するとき、他のマシンへのリンクが10 Gbit/sをサポート可能なのか、それとも56 kbit/sしかサポートできないのかを認識できません。したがって、ネットワークが大量のパケットで溢れて廃棄されるのを避けるために、最初は比較的低速で通信し、その後定期的にスループットを上げて、パケット損失通知を受信するまで繰り返します。その時点で、リンクが維持できる最大スループットにほぼ達したことが認識されます。
だからこそ、恒久的な接続が重要になってくるのです。確立された直後の接続は、ある程度の期間使われている接続よりもスループットがはるかに低くなります。
接続数を増やせば、より多くのリソースをより速くダウンロード可能になりますが、TCPのスロースタートの影響を小さくするには、接続数を増やすのではなく、すべてのダウンロードを同じTCP接続の中で行うことが望ましいでしょう。
そして、これがまさにHTTP/2が解決した主な問題なのです。単一のTCP接続内でリクエストを多重化可能にすることで、ヘッドオブラインブロッキングの問題を解決しました2。
HTTP/2では、暗号化の必須化3や、リクエストヘッダーやレスポンスヘッダーのGZip圧縮、「サーバープッシュ」なども行われましたが、多重化は本当に大きな改善です。
🔗 HTTP/2がLAN上では重要でない理由
したがって、HTTP/2を使う主な動機は多重化であり、インターネット上、特に接続がやや不安定なモバイルインターネットでは大きく改善される可能性があります。
ただし、データセンターではそれほど改善されません。考えてみれば、上記の計算で非常に大きな要因となったのは、クライアントとのping
ラウンドトリップ時間でした。インフラストラクチャの設計がひどくない限り、サーバー(Puma など)とそのクライアント(ロードバランサーやリバースプロキシ)間の往復時間は極めて短く、1ミリ秒をはるかに下回り、実際のリクエストのレンダリング時間に比べてもはるかに短いはずです。
インターネット経由で主に静的なアセットを配信する場合は、レイテンシが大きくなる可能性があるため、HTTP/2の多重化は非常に重要です。
しかし、アプリケーションが生成したレスポンスをLAN(またはUNIX ソケット)経由で配信する場合、測定に表れるほどの違いは生じません。
ラウンドトリップ時間が短いことに加えて、ロードバランサーとアプリケーションサーバー間の接続の持続期間が非常に長い可能性が高いため、TCPスロースタートの影響もそれほど受けません(これはOSがスロースタートを完全に無効にしていないことが前提ですが、サーバーでは非常に一般的です)。
🔗 サーバープッシュはうまくいかなかった
かつて多くの人々がHTTP/2をRubyアプリケーションサーバーにまで導入を望んだもう1つの理由は、「サーバープッシュ(server push)」機能でした。
サーバープッシュのアイデアは比較的シンプルで、サーバーはクライアントにHTTPリソースを送信するときにプロンプトを待たなくてもよいというものでした。
この方法では、Webサイトのランディングページをブラウザがリクエストしたときに、サーバーは関連するすべてのリソースを事前に送信できるため、ブラウザはHTMLを解析してリソースが必要であることを認識してからリソースを個別にリクエストする必要がなくなります。
ただし、この機能は実際には仕様から削除され、現在ではすべてのブラウザでも削除されました。その理由は、実際にはメリットよりもデメリットの方が大きかったためです。ブラウザのキャッシュにこれらのリソースが既に存在している場合、それらを再度プッシュすると、ページの読み込み時間がむしろ遅くなることが判明しました。
多くの人々が、リソースがキャッシュ内に存在するかどうかを知るためのスマートなヒューリスティックを見つけようとしましたが、どれも結局うまくいかず、この機能は放棄されました。
今では、サーバープッシュよりもずっとシンプルでエレガントな仕様であり、しかもHTTP/1.1と互換性のある103 Early Hints
に置き換えられました。
したがって、HTTP/1.1とHTTP/2には意味論的な違いはありません。Rack
アプリケーションの立場では、リクエストがHTTP/2接続で発行されたか、HTTP/1.1接続で発行されたかは関係ありません。一方を他方にトンネリングすることは問題なく可能です。
🔗 複雑さを増やしてしまう
HTTP/2は、LAN経由でほとんどメリットがないだけでなく、複雑さも増してしまいます。
第1に、実装が複雑になります。
HTTP/2そのものはさほど複雑ではありませんが、完全に暗号化され、プロトコルの大部分がバイナリであるため、HTTP/1.1などのプレーンテキストプロトコルよりも実装の難易度が著しく上がり、デバッグもはるかに困難です。
第2に、デプロイも複雑になります。HTTP/2は完全に暗号化されているため、すべてのアプリケーションサーバーにキーと証明書が必要になります。これは克服できない問題ではありませんが、HTTP/1.1のみの場合に比べると面倒が増えます(もちろん、何らかの理由でLAN経由でも暗号化接続だけを使う必要に迫られれば別です)。原文訂正: 実はHTTP/2の仕様では暗号化は必須ではなく、必須なのはブラウザや一部のライブラリだけなので、データセンター内では暗号化なしのHTTP/2を利用可能です。
つまり、デプロイ先が単一のマシンではなく複数のマシンの場合(つまりロードバランサーを使う場合)に、HTTP/2がRubyアプリサーバーにまで導入されると、ほとんどメリットがないにもかかわらずインフラストラクチャが大幅に複雑化します。
たとえ単一マシンにデプロイする場合であっても、おそらくリバースプロキシを導入して同様の懸念事項に対処することになるでしょう。リバースプロキシは、静的アセットの配信や受信リクエストの正規化、そして一部の悪意のあるアクターの阻止なども行います。
現場でのバトルテストを経たリバースプロキシはNginxやCaddyなど数多くあり、セットアップも非常に簡単です。単一のRubyアプリケーションですべてを賄うよりも、これらの一般的なミドルウェアを使う方がよいでしょう。
ただし、リバースプロキシは複雑すぎるので不要だと考えている場合は、現在ならthrusterなどのゼロコンフィグでできるソリューションもあります。私は試したことがないので保証できませんが、少なくとも理論上はそのニーズを解決します。
🔗 まとめ
私の考えでは、HTTP/2は、HTTP/1.1のアップグレード版ではなく、同じHTTPリソースをインターネット上でより効率的に転送するための代替プロトコルとして考える方がよいと思います。
ある意味では、HTTPSがHTTPプロトコルのセマンティクスを変更せずにネットワーク上でシリアライズされる方法だけを変更したのと似ています。
そういうわけで、HTTP/2の処理については、TLSの処理が長年ロードバランサーやリバースプロキシに任されてきたのと同じ理由で、インフラストラクチャのエントリポイント(通常はロードバランサーかリバースプロキシ)に任せる方がよいと私は考えています。
HTTP/2のリクエストの処理方法は、リクエストを復号化して解凍するまでわかりません。リクエストをわざわざ再暗号化・再圧縮して、アプリケーションサーバーに転送する理由があるでしょうか。
したがって私の意見では、Ruby HTTPサーバーでHTTP/2機能をサポートすることはそれほど重要ではないと思います。いくつかのニッチなユースケースでサポートされていれば便利な場合があるかもしれませんが、全般に言えば、HTTP/2がサポートされていなくても特に支障はありません。
本記事でHTTP/3に言及していない点にご注意ください。HTTP/3プロトコルはHTTP/2と大きく異なりますが、その目標はHTTP/2とほぼ同じなので、同じ結論が当てはまります。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
参考: HTTP/2 - Wikipedia