皆さんこんにちは。セガゲームス、開発技術部の山田です。
以前は OpenGL の話を本ブログで紹介したのですが、今回は Unite Tokyo で講演してきたお話です。
本記事は講演の時と同じく、前半は山田、後半は竹原でお送りします。
目次
Unite Tokyo 2019
Unityユーザーのためのテクニカルな講演やブース出展が数多く行われる、国内最大のUnityカンファレンスイベント「Unite Tokyo 2019」が 2019年9月25日、26日に開催されました。そこで、私と竹原より、「大量のアセットも怖くない~HTTP/2による高速な通信の実装例」という講演を行いました。
お越しいただいた方々ありがとうございました。
講演の結論は「Unityで標準的な作り方をした場合において、 HTTP/2を使ってアセットバンドルをダウンロードすることは相性がよいです」 と話しました。
資料など
資料や講演ムービーは既に公開されており、次のページでアクセスできます。
learning.unity3d.jp
質疑中の様子
予想していたよりも多くの方に質疑コーナーに来て頂けまして、色々と質問や情報交換ができました。
とても有意義な時間でした。ありがとうございました。
講演中に語られなかったこと
講演では説明できなかった色々なことを本ブログで補足したいと思います。
Keep Alive の話
Keep Alive を使った HTTP/1.1 なら HTTP/2 との速度差はそれほどつかないのでは?と思う方もいるでしょう。自分もそう考えていた時期があったので、この点に悩む方は多いのではないかと考えています。
前提として、https での通信を考えます。
Keep Alive で削減できるのは TCP の再接続のコストです。実際にデータを送受信するまでの前準備の部分です。
HTTP/1.1 では、複数のコネクションを張って並列にデータのダウンロードを行う実装とします。
このとき多くの場合で3~6が使われることと思います。しかし、それぞれのコネクションで輻輳制御が行われるために、帯域を有効活用することができません。
これを分かりやすく説明してくれているのが 「IIJ Technical WEEK 2015」においての「HTTP/2からQUICへ続く Webプロトコルの進化」という講演内容です。
www.iij.ad.jp
「HTTP/2からQUICへ続く Webプロトコルの進化」という講演資料の19ページから21ページにて解説をされています。
次の図は、この資料より引用させていただきました。
HTTP/1.1 ではファイルごとに1つのコネクションを使います。Unity での標準的な作りをする場合においてファイルサイズは小さめであり、1コネクションにおいて輻輳ウィンドウサイズが十分に大きい状態で通信を行い続けることができません。 HTTP/2 ではストリームの機能を用いて複数ファイルのダウンロードを1つのコネクションに多重化するため、使用できる帯域を最大限使うことができます。
RTTの観点からも HTTP/2 ほうが有利です。
HTTP/1.1 の場合、Keep-Alive で再接続のコストを抑えていても、ファイルを取得するときに出すリクエストで RTT 分の時間が掛かります。
この間は、そのコネクションにおいてファイルのダウンロードは進行していません。仮に 100 ファイルあったとすると 100 *0.5RTT の時間分はファイルのデータダウンロードに使えていないことになります。HTTP/2 では多重化により他のデータやりとりの1部としてファイルのリクエストも紛れ込んでいるので、通信路としての無駄が少なくなっています。
ただし、どのような条件下でも HTTP/2 のほうが速いかというとそうでもないので状況に応じて考える必要があります。
必要なファイルを結合してアーカイブファイルを作成し、それをダウンロードするというフローであれば HTTP/1.1 でも効率の良い通信が実現できます。
Unite Tokyo 2019 の KLab さんの講演「「禍つヴァールハイト」最大100人同時プレイ!モバイルオンラインゲームの実装テクニック」の中でも語られておりました。
tar 形式と Range パラメータを使ってというアイデアはとても面白いと思います。
KLab さんの方式は事前に必要なデータをガッツリダウンロードするような MO/MMO のようなものに向いているのではないかと考えております。
learning.unity3d.jp
同時ファイルオープン数について
講演中では印象優先で Windows ではファイル数 512 という表現をさせてもらいました。
しかし、その制限は実は Windows そのものによる制限ではなく、 Visual C++ が提供している C Runtime (CRT) による制限となっています。
Windows 自体はもっと多くのハンドルを同時に扱うことができます。
fopen/fclose といった C言語の世界の関数が CRT に属します。
Windows で生のファイルを操作する場合は Win32API に属する CreateFile 関数を使用します。
Windows 専用ということもあって、ファイルの共有Read/Write, 256文字を超えるファイルパスによるアクセスなど色々とできることが多いです。
(Windows API (Win32API) や、Windows カーネルの話も機会があったら本ブログで書けたらと思います。)
講演で語られなかったこと (竹原より)
それではここからは竹原が担当させて頂きます。
私のパートでは HTTP/2 に関する仕様や libcurl の実装面でのもう少しディープな解説をさせて頂こうと思います。
HPACK の圧縮率を確認する
HTTP/1.1 までの通信では、HTTP ヘッダはプレーンテキスト形式で記述され、クッキー等により時にはその合計サイズは 1 キロバイトを超えることもありました。
HTTP/2 ではこうしたヘッダのサイズ問題を解消するべく HPACK という専用の圧縮機構が採用されています。
講演を聞いてこの HPACK で実際どれくらいヘッダが圧縮されるのか、気になった方もいると思います。
そこで、この項では HPACK の仕組みを、「どれくらいヘッダサイズが削減されるか」についても触れながら簡単に解説してみます。
かなり効果があるので、独自拡張等ヘッダに色々仕込んでいる場合は導入を検討してみても面白いと思います。
[HPACK]HTTP/2 のヘッダ管理
講演でも触れたように、 HTTP/2 のヘッダはバイナリとして扱われるようになりました。
更にパラメータは 全て小文字で表現されるようになった のに加え、従来のリクエストラインやヘッダの表現形式も若干変更されています。
例えば、 HTTP/1.1 の典型的なリクエストラインとヘッダの組み合わせである以下は
GET /resource HTTP/1.1
Host: sega.com
Accept: text/html
HTTP/2 では次のような集合として表現されます。
:method = GET
:scheme = https
:path = /resource
host = sega.com
accept = text/html
HPACK ではこれらの Header Name/Value の組み合わせをヘッダフィールドと呼び、文字列で表すか、独自のインデックス値でエンコードして表すか選択することが可能です。
更に、文字列で表す場合は Huffman Coding(ハフマン符号) で圧縮するかどうかも選択することができます。
[HPACK]Huffman Coding での圧縮
Huffman Coding は、出現頻度の高い文字には短いビット列を割り当て、低い文字には長いビット列を割り当てることにより圧縮を図る方式です。
例えば、文字 A が 1 回、B が 3 回、C が 5 回、D が 2 回、E が 1 回出現するような文字列「ABBBCCCCCDDE」に対して以下のように符号を割り当てます。
この割り当てられた符号を使って前述の文字列「ABBBCCCCCDDE」を表現すると、
1110 10 10 10 0 0 0 0 0 110 110 1111
となり、全体を 25bit で表現することができるようになりました。
実際の符号の割り当てには、圧縮したいデータ内の文字の出現頻度をベースにして構築した Huffman Tree という二分木を利用します。
Huffman Tree の構造はベースにしたデータ内の文字の出現頻度により変化するので、適用したいデータのパターン毎に用意する必要があります。
そこで、 HPACK では過去に利用された HTTP のリクエスト・レスポンスのヘッダを解析した結果を基にして構築した専用の Huffman Tree が採用されています。
RFC 7541 - HPACK: Header Compression for HTTP/2
それでは、Huffman Coding でどの程度のデータの圧縮が可能なのか、先ほどのヘッダフィールドの内容を例に確認してみましょう。
まずは :method を Huffman Coding してみると
":method" 1011100 101001 00101 01001 100111 00111 100100
"GET" 1100010 1100000 1101111
となるので、これを合算して 61bit に圧縮できることがわかります。
※ヘッダフィールドは Name/Value でそれぞれ管理されるので "=" は不要となります
HPACK では Huffman Coding されたデータがオクテット単位に揃わない場合、余ったデータ部は 1 でパディングされるというルールがあるので実際には
":method" 1011100 101001 00101 01001 100111 00111 100100
"GET" 1100010 1100000 1101111 {111}
となり、 64bit(8byte) のデータ長となります。
元々の文字列は ":method" → 7byte, "GET" → 3byte で 10byte ですので、 20% 圧縮されたことが分かります。
※ HTTP/3 に使われている QPACK では、このパディングが不要となり、さらに圧縮率が上がっています!
このように、 Huffman Coding を用いると、頻出文字で構成されるデータであれば大幅にデータを圧縮可能です。
上記よりもう少し頻出文字が多い文字列だと 30% を超える圧縮率になることもあります。
反面、バックスラッシュ(19bit)やアンダースコア(15bit)といった出現頻度の低い文字については、元の8bitより遙かに大きくなってしまうので注意が必要です。
実装によっては、 Huffman Coding を掛けた前後のサイズを比較し小さい方を採用する、というような工夫もしているものもあるようです。
[HPACK]インデックス値を用いた圧縮 : Static Table
HPACK には Huffman Coding の他にも、Header Name と Header Value を対応させた辞書である Indexing Table という独自の圧縮機構が用意されています。
Indexing Table には Static Table と Dynamic Table の二種類が存在しています。
Static Table は Huffman Tree のように過去の利用頻度の高いヘッダが予め登録されているテーブルで、HTTP/2 の RFC に全 61 種類が定義されています。
RFC 7541 - HPACK: Header Compression for HTTP/2
先ほど例に挙げたヘッダを上記の RFC の Static Table で表現しようとすると
:method → 2
:scheme → 7
:path → 4
host → 38
accept → 19
と対応していますので、以下のように表現できるようになります。
2
7
4 /resource
38 sega.com
19 text/html
※インデックスは 1byte で扱われます
結果、 63byte → 31byte となり、約 51% 削減という Huffman Coding を大幅に超えた値で圧縮することができました。
※実際には Literal Header Field という独自の形式でヘッダフィールドを表現する為に、圧縮率はこの値より少し小さくなります。詳細は RFC 7541 - HPACK: Header Compression for HTTP/2 を参照ください
更にテーブルを適用できなかった文字列部には Huffman Coding を掛けてさらにデータを圧縮することも可能です。
[HPACK]インデックス値を用いた圧縮 : Dynamic Table
Dynamic Table は、通信に使用したヘッダフィールドを動的にテーブルに登録していき、 Static Table のようにインデックス値で指定できるようにする圧縮機構です。
テーブルへの登録可能な上限サイズの初期値は 4096 オクテットで、上限を超えた場合はファー ストインファーストアウトのルールで登録が抹消されていきます。
Dynamic Table のインデックスは Static Table のインデックスの続きの 62 から始まり、追加する毎にインクリメントされていきます。
先ほどの Static Table で表現したヘッダフィールドをさらに Dynamic Table に登録してみましょう。
2 → 2 のまま
7 → 7 のまま
4 /resource → 新規に 63 に登録
38 sega.com → 新規に 64 に登録
19 text/html → 新規に 65 に登録
すると、以下のようにかなりすっきりとした形で表現できるようになります。
2
7
62
63
64
結果、 63byte → 5byte となり、圧縮率は驚異の約 92% まで上がりました。
Dynamic Table のサイズは前述した通り 4096 オクテット(変更は可能です)なので、なんでもかんでも登録するわけにはいきません。
しかし、同じヘッダのリクエストを何度も送信する場合にはかなりの効果をあげますので、こうした事例では Dynamic Table を積極的に利用するのをお勧めします。
[HPACK]まとめ
若干長くなってしまいましたが、 HPACK を利用すると大幅にヘッダを圧縮することができることを理解して頂けたと思います。
最近のゲームは API リクエストをかなり頻繁に送信するものや独自にヘッダを拡張しているケースも多く、これらの積み重ねによって結構なデータ送信量になってしまうこともあります。
こうしたデータを削減するにも HTTP/2 はかなり効果的です。
アセットのダウンロードだけでなく API 通信にも HTTP/2 をお勧めします。
HTTP/2 特有の設定を libcurl から行うには?
HPACK の高い圧縮率を見ると使ってみたくなるのはプログラマの性ですよね。
それでは libcurl から HPACK はどのように操作できるのでしょうか。……と期待を煽っておいて申し訳ありませんが、実は libcurl を用いた場合は HPACK についてのパラメータを変更することはできません。
libcurl が依存している nghttp2 という OSS のレイヤーで「Huffman Coding」及び「Static/Dynamic Table」の利用については以下のようにそれぞれよろしくやってくれます。
- Huffman Coding
エンコード実施後、元の値より小さい場合のみ適用されます。
- Static Table
自動的に適用されます。
- Dynamic Table
特定の条件を満たすもののみ登録されます。
Dynamic Table への登録条件は以下の通りです。
/* Don't index authorization header field since it may contain low
entropy secret data (e.g., id/password). Also cookie header
field with less than 20 bytes value is also never indexed. This
is the same criteria used in Firefox codebase. */
※ nghttp2 のソースコードコメントより
要約すると、
- id や password, authorization 等のセキュリティ上問題になるようなヘッダーフィールドはインデックス化しない
- 20 byte 未満の Cookie ヘッダーフィールドはインデックス化しない
- これらは Firefox で使用されている基準と同じである
という内容です。
この基準以外で Dynamic Table を利用したい場合は、 nghttp2 に用意されている HPACK API を利用すると良いでしょう。
Tutorial: HPACK API — nghttp2 1.41.0-DEV documentation
※筆者も libcurl との併用が可能かどうか等については未検証です
また、 libcurl から制御できない HTTP/2 のパラメータは、実は HPACK だけではなく他にもいくつかあります。
例えば、
- クライアントが指定する「最大ストリーム数の設定」 : SETTINGS_MAX_CONCURRENT_STREAMS
- Dynamic Table の最大サイズ : SETTINGS_HEADER_TABLE_SIZE
- ストリームの初期ウィンドウサイズ : SETTINGS_INITIAL_WINDOW_SIZE
等はすべて libcurl 内で隠蔽されています。
この辺りの HTTP/2 独自のパラメータを細かく制御したい人は nghttp2 をそのまま使うのをお勧めします。
ただし、 libcurl はクッキーの制御や HTTP/1 系への対応等、実装に手間のかかる所をケアしてくれています。
HTTP/2 のパラメータをどこまで制御したいか、プロジェクトの都合に合わせてどちらを使うか決めるのが良いと思います。
優先度制御の現在とこれから
講演中で HTTP/2 の優先度については仕様が見直し中、という話をさせて頂きました。
それでは、具体的にどのような仕様に見直し中なのか、少し補足しようと思います。
まず、三行でまとめると以下の通りです。
- 優先度は現在 HTTP バージョンに紐づかない 仕様として現在審議中です
- 従来の HTTP/2 の優先度に関しては 優先度制御しない 機能が追加予定です
- 現在策定中の HTTP/3 では、優先度の機能を持たないことに決まりました
これだけではつまらないので、新しく提案されている優先度がどのようなものなのかについても軽く説明しようと思います。
※あくまで議論中の仕様なので今後変更される可能性があります
新しく提案された優先度はとてもシンプルです。
まず、Stream Weight は urgency level (緊急度) に置き換わり、全 8 段階へと縮小されました。
※ここで言う「ファイルダウンロード」とはブラウザ経由での大きなサイズのファイルのダウンロードなどを指します
その他の変更点や特徴は以下の通りです。
- 制御は HTTP ヘッダで行います
- ヘッダで設定した値をさらに変更したい場合は HTTP/2,3 共に Priority フレームを利用する方向で話が進んでいるようです
- クライアントからの urgency はあくまで hint であり、サーバ側の都合で urgency の値を上書き可能なようです
- progressive jpeg 等のリソースの識別用のフラグも用意されます
- HTTP/2 との優先度の互換性についてはこれから検討するようです
新しい優先度は基本的には web ページを基準に議論が進んでいる仕様のようですが、ゲームでも似たような区分でデータ分けは可能なので、割と使い易いのではないかと思います。
優先度についてより詳細が気になる方は、以下のページも参照してみてください
- 最近実施された QUIC Working Group Interim Meeting - October 2019 で使用された優先度の資料
- 現在議論が進められているリポジトリ
PKP と Expect-CT
講演の中で Public Key Pinning (PKP) について触れました。
しかしこの PKP は、実は世の中的には古い仕様として非推奨となりつつあります。
理由は、講演でも触れたように、証明書の更新タイミングに運用側に負荷が掛かるからです。
例えば、 2019/1/29 にリリースされた Chrome 72 では PKP は既に廃止となっています。
それでは、中間者攻撃への対策はどうすれば良いのでしょうか?
そこで出番となるのが Expect-CT ヘッダです。
Expect-CT ヘッダーは、サーバが Certificate Transparency (CT) に対応していることをクライアントに伝える為に使用されます。
Certificate Transparency とは(ざっくり説明)
CT は証明書の発行のログと突き合わせることで、対象の証明書が不正なものであるかどうかを検知する機構です。
CT には Signed Certificate Timestamp (SCT) という、上記のログを管理するサーバに対象の証明書が登録された時刻を保証するタイムスタンプを利用します。
(利用の仕方は証明書に埋め込んだり、TLS Extension として送信したり、色々あります)
この値からログ管理サーバに問い合わせを行うことで、自分たちの知らない所で証明書が発行されていないか(偽装されていないか)を確認します。
これ以上深堀りするととても長くなりますので、詳細が気になる方は RFC 6962 - Certificate Transparency を参照してください。
Expect-CT ヘッダの現状
リーグ・オブ・ワンダーランド導入時にもこの Expect-CT の検討は行ったのですが、まだ draft であった為に導入には早いとして最終的に PKP を選択しました。
それでは現在はどうなのか……というと現状 draft で止まっている状態なようです。
GitHub - httpwg/http-extensions: HTTP Extensions in progress
しかし、 Nginx や Apache では対応されているようですので、将来を見据えて PKP ではなく Expect-CT を採用してみるのも面白いかと思います。
最後に
私たちは、新しい技術でユーザの体験を変えていける、チャレンジ精神のある方を求めています。
当記事で弊社に興味を持った方は是非下のリンクをクリックしてください。
www.sega.co.jp