この記事は 第2のドワンゴ Advent Calendar 2017 最終日の記事です。

はじめに

ウェブ技術を語る上で欠かすことのできない要素として、HTTPがある。
従来のHTTP/1を無くして、ここまでのウェブの発展はなかったといえるだろう。言うまでもなく、HTTP/1が我々人類に齎した功績は大きい。
しかしその一方で、その規格のシンプルな原理原則に縛られた結果、要件を達成するために非効率なネットワーク使用を前提とするシステムが量産されるなど、HTTP/1がもたらした技術的負債も存在する。

その中の一分野として、双方向通信に着目したときに、HTTP/1からHTTP/2へのアップグレードによってどのような変化がもたらされたか。
本稿ではHTTP/2という規格と、それが持つ可能性の一端としてgRPCについての仕組みを紹介し、従来とこれからのWeb開発における双方向通信について述懐する。

TL;DR

HTTP/1における双方向通信には非効率的な要素が多々あり、HTTP/2はそれを解消した。
HTTP/2のセマンティクスは従来のHTTPと互換性があるが、実際は規格内容からして別物である。
HTTP/2は、HTTP/1時代に認識されていた非効率なネットワーク消費を、使い方によっては解消することが可能である。
そしてその規格の改善は、HTTPというプロトコルが、双方向通信分野においても、システム要件によってはそれを担うに必要十分な規格となったことを意味する。
実際、その特性を活かした更に上層の規格として、gRPCがある。

日進月歩のこの業界にあって、技術的選択肢が多岐にわたり、規格すらも新しいものが積み重なる形で生み出されていく中で、それを作る者だけでなく、使う立場にある者もまた、それらの変化に付き従い、適切に技術要素を見定めていく必要がある。

HTTP/1における双方向通信

HTTP/2に触れる前に、HTTP/1時代の双方向通信の手法について振り返る。
HTTP/1とHTTP/2両者における双方向通信の手法について、どのような違いがあるのかを比較するためだ。

双方向通信実現の手法

ポーリング

  1. 状態を含むHTTPリクエストを送る。
  2. サーバに状態の変更があれば、その旨をレスポンスに含めて返却する。

以後、1->2の繰り返し。
Keep-Aliveの指定(詳細はPersistent Connectionを参照)が効かない(数秒でtimeoutしてしまう)レンタルサーバなどで、無理やりこの手法を用いてリアルタイム風な掲示板やチャットを実装しているサービスが多く見られた。

しかしこの手法は、必ずしもKeep-Aliveが無効な環境下でのみ有効というわけではない。
実際には同一のTCPコネクションを使いまわし、1->2を繰り返すことで、TCPにおけるthree-way handshakingなどの通信上のオーバーヘッドを回避することは可能であり、状況によってはこれで必要十分なケースも存在するだろう。

ロングポーリング(Comet)

  1. Keep-AliveなTCPコネクションを確立する(詳細はPersistent Connectionを参照)。
  2. 状態を含むHTTPリクエストを送る。
  3. サーバは状態の変更が発生するまで、Keep-Aliveの許す限りレスポンスを返さずにおく。
  4. timeout、もしくはサーバに状態の変更があれば、その旨をレスポンスに含めて返却する。

以後、1->4を繰り返す。timeoutでTCPコネクションが切断されていない場合は、2->4を繰り返す。
ポーリングと比較すれば、明らかにネットワークトラフィックが減少する。加えて、リアルタイム性についても体感ではWebsocketとも遜色のないレベルのものが実現できる。
実際、Websocketが台頭してくるまでのリアルタイムチャットというのは、この方式が多かった。

問題点

前項ではHTTP/1という規格で双方向通信を実現する際の具体的な手法を示した。
しかしながら、いずれの手法についても、HTTP/1という規格に起因した問題点が存在しており、筆者は主に次の三つの占めるところが大きいと考えている。

  • テキストベースなプロトコル
  • ステートレスなプロトコル
  • 非効率なTCPコネクションの使い回し

これらの要素は、頻繁にパケットがやり取りされる双方向通信においてはボトルネックとなる。
次項では、順番にこれらの具体的な問題点について掘り下げることとする。

テキストベースなプロトコル

HTTP/1は、その規格上やり取りされるデータフォーマットがテキストベースなものと規定されている。
つまり、クライアントからサーバへ送信されるリクエストと、サーバからクライアントに返却されるレスポンスは、予め取り決められたフォーマットに沿ってASCIIエンコードされた単なるテキストデータであり、そのテキストデータを受け渡し・解析することによって、サーバ・クライアント間の通信が成立しているということを意味する。

例として、シンプルなHTTPリクエストとそれに対応するHTTPレスポンスを次に示す。

GET / HTTP/1.1\r\n
Host: localhost:8080\r\n
User-Agent: Go-http-client/1.1\r\n
Accept-Encoding: gzip\r\n
\r\n 

上記のリクエストに対するHTTPレスポンスを再現するテキストデータを次に示す。

HTTP/1.1 200 OK\r\n
Connection: close\r\n
Content-Type: text/plain; charset=utf-8\r\n
Content-Length: 5
\r\n
hello

これらはHTTP/1という規格上でデータを送受信する際に準拠しなければならないHTTP/1のルールに則ったものだ。
しかし双方向通信を担う上では大抵のケースにおいて、これらのテキストデータの中のヘッダやステータスコードなどの情報は、初回通信確立時を除いて不要となるため、必然的にそれらのデータの受け渡しにかかるネットワークリソースの消費や、データの解析にかかる処理コストは不要なものとなる。
なぜなら双方向通信時において、実際に欲している情報はHTTP/1.1のメッセージにおけるボディ部分のみであるから。

これらの余分な情報によって、パケットサイズが大きく肥大化していることはtcpdumpコマンドを用いることで簡単に知ることができる。

そこで本稿では、シンプルな条件下において、HTTP/1でどのような通信が行われているか検証を行った結果を掲載する。
条件は次の通りである。

  • localhost:8080にhelloと返すHTTPサーバを用意する。
  • localhost:8080にGET / HTTP/1.1なリクエストを複数回送信する。

次に示す画像は、上記の条件下で行われた通信をtcpdumpによってダンプし、それをwiresharkによって視覚化したものである。

図1.1
image1.png

図1.1においては、No.1からNo.12、No.13からNo.24をそれぞれ一塊として、HTTP通信が行われている。
注目すべきは緑色で示されているHTTP/1に則ったパケットデータである。以下に示す画像は、この通信行からリクエストとレスポンスに相当する行を一つずつ取り出し、内容を確認したものである。

図1.2

image2.png

図1.3

image3.png

図1.2や図1.3のデータは、実際のユースケースよりも小さいシンプルなものだ。しかしながら、前述のHTTP/1によって取り決められたフォーマットに準拠したテキストデータが、送受信されることが読み取れるだろう。
実際、ポーリングやCometのような手法においては、このデータよりも大きいサイズのデータが送受信されることになる。

繰り返しになるが、これによって次の問題点がHTTP/1上において不可避なものであるということがわかる。

  • 不要な情報によって肥大化したパケットの送受信
  • 不要なデータの解析処理

ステートレスなプロトコル

テキストベースなプロトコルであることとも関連するが、HTTP/1はステートレスなプロトコルである。
つまり、そもそもHTTP/1という規格が、状態を持つステートフルな通信を想定していないということになる。
規格が想定していないユースケースである以上、必然的に、その規格上で想定外のことをやろうとすると無駄が生じてしまう、という単純な話である。

ステートフル?ステートレス?

ステートレス・ステートフルという言葉は誤解を招きやすいが、ここではあえて用いることとする。
これらの単語の示すところはコンテキストによって変わるが、本稿においては次の二つの対象に対して、これらの言葉を区別して用いる。

  • TCP
  • HTTP

HTTPから転じてTCPコネクションに目を向けてみると、実はHTTP/1.1では状態を持つステートフルなTCPを利用することが可能だ。
これをPersistent Connectionと呼ぶ。

Persistent Connection

ところで、仕様と実態を分けて考えれば、「Persistent Connection」、すなわちKeep-Aliveな接続という概念は、HTTP/1.0の頃から後付けされる形ではあるものの、存在していた。
それがHTTP/1.1のリリースと共に仕様に加わり、正式に複数のHTTPリクエストとレスポンスの送受信について、一つのTCPコネクションを使い回す(持続的接続)ことがデフォルトの動作になった。
つまりこの時点で、TCPコネクションの確立にかかるオーバーヘッド(e.g. three-way handshaking)を削減することについては可能だったということだ。

Persistent Connectionを用いてHTTP/1.1上でどのような通信が行われるかを、前項の図1と同様にtcpdumpとwiresharkによって検証したものが次に示す図である。
ただし、基本的なクライアント・サーバの実装は前項のものと同一だが、新たな条件としてConnectionヘッダにKeep-Aliveを指定することによって、Persistent Connectionを実現したものだ。

図2.1
image4.png

図2.1について着目すべきは、二点ある。

一つ目は、前述の通り一つのTCPコネクションを複数のHTTP通信に跨って使いまわしているということだ。
No.1からNo.3にわたってthree-way handshakingが行われた後に同様のパケットのやり取りは行われていないことに加え、一貫して同一のエフェメラルポート番号が用いられていることが読み取れるだろう。

二つ目は、これも前述の通り、一つのステートフルなTCPコネクションを使い回すことができても、HTTP通信においては、ヘッダを含む状態を表す情報を都度やり取りする必要があるということだ。
詳細な説明は割愛するが、ProtocolHTTPなもののLengthに着目することで読み取れるだろう。

当然のことながら、Persistent Connectionを実現するだけでは、HTTPという規格上での持続的接続が為し得たことを意味しない。
トランスポート層においてはステートフルだが、アプリケーション層(HTTP/1)においてはステートレスなままである。
これも繰り返しになるが、トランスポート層(TCP)で持続的接続が為されていたところで、HTTP/1がステートレスなプロトコルである以上、必ず状態を知るコンピュータがもう片方のコンピュータに対し、状態を含むリクエストを送信する必要があるのは変わらず、依然として無駄が生じているということだ。

非効率なTCPコネクションの使い回し

HTTP/1では、リクエストを送信してからレスポンスが返ってくるという一連の流れについて、必ず一つのTCPコネクションを専有する。
したがって、一つのTCPコネクション上で効率的に並列リクエストを行うというようなことができず、原則的にはシーケンシャルにリクエスト・レスポンスが制御されるため、後続の処理はブロックされる。
これをHTTP HOL Blockingと呼ぶ。

これにより、HTTPに纏わる細やかな並列処理を制御することが困難であり、結果として、ネットワークリソースを効率的に使用できない事態が発生していた。

ただし、原則的と書いたからには例外もあり、HTTP/1.1にて正式に仕様として追加されたパイプライン化を用いることで、リクエストについてのみ、並列に実行することができるようになった。
しかしこれも、並列化できるのはあくまでリクエストに対してのみであり、レスポンスについてはその恩恵を受けることはできず、一つのTCPコネクションを効率的に取り回すには至らなかったといえるだろう。

HTTP/1について振り返る

最初に取り上げた双方向通信実現のための手法は、いずれもHTTP/1という規格に引きずられる形で、いわば力技によってその実現を試みていた。

リアルタイム性を重視する局面における双方向通信についても、Cometは必要十分なもののように思えるが、改めてその通信内容を確認すれば、無駄なパケットが大量にやり取りされていることは明らかである。
モバイル端末とサーバの通信量が肥大化することにより、モバイル端末のデータ通信量制限に引っかかりやすくなるなど、力技の対価をユーザーが支払わされる羽目になるといった事態も発生する。

このような課題から、HTTP/1は2017年現在、リアルタイム性を重視する双方向通信分野にて採用されることは大分少なくなっているものと認識している。

HTTP/2における双方向通信

冒頭にて述べた通り、HTTP/2はHTTP/1時代に課題とされていたネットワークリソースにまつわる課題を解消する。
HTTP/2は、前述のPersistent Connectionを前提としていることに加え、HTTP/1における課題として取り上げた要素に対しても解決策を提供する。

この項では、HTTP/2がそれらの課題に対してどのような解決策を示したかについて解説する。

テキストベースからバイナリフレームベースに

HTTP/1においては、規格がテキストベースであることによって、通信量の増大や無駄な解析処理が発生していた。
HTTP/2ではそれを、バイナリからなるHTTPフレームを策定し、テキストベースからバイナリフレームベースなデータ仕様とすることによって解消した。
煩雑なテキスト解析処理はもはや不要となり、バイナリプロトコルによる効率的なパケットの送受信と解析処理が実現できるようになったことを意味する。

ヘッダの扱いについての変化

HTTP/2においてHTTPヘッダは、HEADERSフレームとして定義されており、その仕様についても次の二つの点で大きく変化している。

  • HPACKという規格による圧縮がデフォルトで有効
  • HPACKはステートフルであり、HTTP/2における接続全体(詳細はStreamingによるステートフルな通信の実現を参照)について、接続の終了まで有効
    • 初回以降は新たなヘッダの差分のみが送受信される。

また筆者の環境にて、同一のヘッダを含む通信について、HTTP/1とHTTP/2のそれぞれでサイズを比較したところ、HTTP/2では数十から数百バイトの圧縮が為されており、パケットサイズに有意な差が見られた(キャプチャデータは後日掲載予定。間に合わずすみません)。

※ただし圧縮効率については、実装依存である点に注意されたい。
参考: Compression Ratio

HPACKコンテキストを持つステートフルな通信の実現

HTTP/2は一つのTCPコネクション上に、一つ以上の仮想的な双方向シーケンス(以降、ストリームと呼ぶ)を持つ。

さらに、ストリームはHPACK圧縮によるコンテキストを接続全体で共有するため、ストリームはそれを前提としたステートフルなものとなる。
ストリームはそれを確立する際に、(HTTP/1.1からのアップグレード時にはSETTINGSフレームも加わえて)HPACKによって圧縮されたHEADERSフレームを送信することにより、その状態を前提としたステートフルなデータの送受信が可能となる。

また、ストリームは前述のバイナリフレームによって状態遷移する。
次の図は、RFCより引用したストリームのライフサイクルを簡潔に示したものである。

図3.1

                        +--------+
                send PP |        | recv PP
               ,--------|  idle  |--------.
              /         |        |         \
             v          +--------+          v
      +----------+          |           +----------+
      |          |          | send H/   |          |
,-----| reserved |          | recv H    | reserved |-----.
|     | (local)  |          |           | (remote) |     |
|     +----------+          v           +----------+     |
|         |             +--------+             |         |
|         |     recv ES |        | send ES     |         |
|  send H |     ,-------|  open  |-------.     | recv H  |
|         |    /        |        |        \    |         |
|         v   v         +--------+         v   v         |
|     +----------+          |           +----------+     |
|     |   half   |          |           |   half   |     |
|     |  closed  |          | send R/   |  closed  |     |
|     | (remote) |          | recv R    | (local)  |     |
|     +----------+          |           +----------+     |
|          |                |                 |          |
|          | send ES/       |        recv ES/ |          |
|          | send R/        v         send R/ |          |
|          | recv R     +--------+    recv R  |          |
| send R/  `----------->|        |<-----------'  send R/ |
| recv R                | closed |               recv R  |
`---------------------->|        |<----------------------'
                        +--------+

  send: エンドポイントがこのフレームを送信
  recv: エンドポイントがこのフレームを受信

  H:  HEADERS フレーム (CONTINUATION が続く可能性がある)
  PP: PUSH_PROMISE フレーム (CONTINUATION が続く可能性がある)
  ES: END_STREAM フラグ
  R:  RST_STREAM フレーム

図中にあるように、idle状態にあるストリームはヘッダフレームを受け取ることによりopen状態となる。
一般的なユースケースとして、open状態にあるストリームがhalf-closedもしくはclosedに遷移するまでの間には、DATAフレームが送受信される。

フロー制御によるストリーム多重化の競合制御

ストリームは一つのTCPコネクション上に複数個存在することができ、これをストリームの多重化と呼ぶ。
HTTP/2では、同時に複数存在するストリームが互いに干渉し合わないことをフロー制御によって保証している。

これにより、一つのTCPコネクションを安全に、かつHTTP/1と比較して効率的に利用することができる

HTTP/2と双方向通信のこれから

HTTP/2は双方向通信を担う上でHTTP/1時代に冗長だとされていた課題を、規格を刷新することによって解決した。
データ転送量に敏感なモバイル分野においては特に、この規格を上手く活用することで得られる恩恵が大きい。

既にgRPCのように、HTTP/2をベースにネットワークリソースを効率的に活用することのできるアプリケーションスペシフィックな規格の実例も登場し、今後のウェブテクノロジーを用いた開発における流れが変わってきているように思う。

HTTP/2を前提とする規格「gRPC」

HTTP/2上でRPCを実現するための規格として、新たに発表されたのがgRPCである。

gRPCについての詳細な説明は割愛するが、この規格におけるIDLの位置付けについてのみ、簡単に説明する。

gRPCはサーバ・クライアント間のIDLを必要とし、デフォルトではProtocol Buffersを使用するが、そのIDLのフォーマットについてはなんら強制しない。
あくまでgRPCはHTTP/2の上に定義されたRPCのための規格であり、状況に応じて柔軟にフォーマッタを採用することができることを意味する。
実際、Flatbuffersのようなメッセージフォーマッタを用いた通信も可能だ。

Bidirectional Streaming

双方向ストリーミングの名の通り、サーバ・クライアントで双方向な通信を担うことを目的とした規格である。
HTTP/2にて触れた「ステートフルなストリーム」と「多重化されたストリームを複数個制御すること」により、双方向なデータ送受信を行うことができる。

次に示す図はgRPCのBidirectional Streamingでなされている通信を一部キャプチャしたものだが、HTTP/2に準拠する形で、パケットが悉に送受信されていることがわかる。

図4.1
image5.png

実用例

gRPCのBidirectional Streamingを採用したプロダクトをいくつか紹介する。

Google Cloud PubSub

Google Cloud PubSubはGoogle Cloud Platformにおけるメッセージングサービスだ。
メッセージングサービスとしてのアーキテクチャは公式サイトに譲るとして、本稿ではこのプロダクトの特徴的な機能として、StreamingPullというAPIを紹介する。

StreamingPullは、メッセージをサブスクライブするSubscriber(ここではクライアントとする)から、Subscriptionを通じてリアルタイムなメッセージの受信を可能とする。
この機能はBidirectional Streamingを元に作られており、googleが公開するスキーマからもそのことを確認できる。

Google Firestore

Google Firestoreは、Firebaseの一機能として知られるFirebase Realtime Databaseの後継を謳われるストレージサービスである。
実際には、データ構造・アーキテクチャ共に、大きくFirebase Realtime Databaseのそれとは異なるものの、同様のカバー範囲を持つ。

Firebase Realtime DatabaseとFirestoreのリアルタイム機能について着目すると、googleがgRPCをどういう位置づけで使用しているかがわかる。

  • Firebase Realtime DatabaseはWebsocket(と、fall-back or Websocket未対応端末のためのロングポーリング)によるリアルタイム通信を実現している。
  • 一方、FirestoreはgRPCのBidirectional Streamingによってリアルタイム通信を実現している。

いずれも、クライアントが対象とするデータが更新された際に、リアルタイムにその変更を通知する双方向通信を実現しているが、その手段が異なっていることがわかる。

gRPCについての所感

最後にgRPCについての印象としては、HTTP/2を用いた効率的なネットワークリソースの活用が可能となっただけではなく、IDLを持つという性質から、アプリケーション開発のドメインに寄った採用がし易いことが大きなメリットであるように感じられる。

さらなる効率的な双方向通信への展望

Quic

モバイル端末においては、従来の一般家庭用のパーソナルコンピュータとは異なり、物理的に通信が遮断されたり、あるいは電車に乗っている最中の通信にてパケットロスが発生するという状況が容易に起こりうる。
そうした背景の中で、TCPであるが故の接続の確立にかかるオーバーヘッドと、それによって得られる通信の信頼性について、我々は再考すべきなのかもしれない。

Quicは、googleを中心に規格・開発が進められているUDPベースの通信規格である。
TCPではなくUDPを用いることで、接続確立時のオーバーヘッドを小さくした上で、ストリームの多重化・効率的なデータの送受信を可能とするものだ。
現時点ではgoogleの一部サービスにてQuicによる通信が行われており、今後の発展が期待される分野だと考えられる。

参考:

所感

モバイル端末とサーバにおける双方向通信は、ミッションクリティカルな要素を担うことが多いように思う。
双方向通信の実現にあたり、2017年現在の選択肢としては、本稿で触れた事例のように、規格から新しい手法を採用しすることもあれば、Websocketを採用すべき局面もあるだろう。

本稿は、筆者が、今の時代においても「HTTPは無駄が多い・遅い」という考えが適切かどうかについて、検証したものをまとめたものである。
この文書を書き進める中で、HTTP/1とHTTP/2における規格・実装の違いを調査することにより、HTTP/2という規格がもたらすメリットとその可能性は、通常のウェブアプリケーション開発に留まるものではなく、双方向通信分野においても発揮し得るものであると感じた。

おわりに

このように、従来使われていた規格のバージョンが変わる際には、それに付き従うようにして周辺の技術はもとより、それを用いる技術者の知識のアップデートが必要不可欠である。

技術は目的を達成するための手段であると考えるとすれば、自らが置かれる状況に応じてそれらの選択肢を公平に見定め、検証し、適切な技術選定をしなければならない。
昨今のクラウドの勢いづく様を見るにつけ、サーバエンジニアにおける技術の空洞化が進むのではないか、とも考えたが、実際にはそれらの便利なソリューションを適切に選ぶには、根底にある規格や技術を理解し、判断する能力が必要となる。

ところで、十二月は師走と呼ばれる。
師も走り回ると書いて師走である。つまり忙しい。師でない者ならば尚更だ。
後半になるに連れて情報量が少なくなっているのは筆者多忙の故である。

というわけで、現在2017年12月25日0時、性の六時間も残すところを後三時間としたところで、筆を置くこととする。
メリークリスマス。良いお年を。

ニコニコ(く)に関わる皆様方におかれましては、きっとこんな長文なぞ読む暇などないでしょうが、元社員として心から応援しています。

以上、渋谷ヒカリエ勤務でした。