ぼちぼち日記

おそらくプロトコルネタを書いていることが多いんじゃないかと思います。

SPDYとLinuxの間でGoogleマップがハマった落とし穴

tl;dr

書いていたら思わず長文の大作になってしまいましたので、プロトコルオタ以外の方は文章の多さに退屈されるかと思います。GoogleマップサービスでSPDYの問題が発覚し、GoogleLinuxカーネルに修正を加えて対応したというお話です。将来 Linux + nginx + SPDY を使いリバースプロキシでサービス運用を検討されている方は参考になるかもしれません。

1. はじめに、

プロトコルに執着する年寄りエンジニアの老害が叫ばれて久しい。

年甲斐もなく自分好みのパケットを追っかけるおやじエンジニアの姿を見て眉をひそめる若者も多いと聞く。
そんな批判に目もくれず、今日も一つ、プロトコルオタのネタをブログで公開したいと思いますw

今回はちょうど1年ほど前に書いたブログ記事 「GmailがハマったSPDYの落とし穴」の続編です。といっても今度の舞台は、Googleマップ。ネタ元も同じ Google の Will. Chan のブログ "Prioritization Only Works When There's Pending Data to Prioritize" から取っています。

事の発端は、Googleマップチームの担当者が新バージョンのサイトを試したところ、地図のタイル情報がミニマップの画像データより遅れて受信することがあるとの指摘からでした。

地図のタイル情報は、Googleマップのユーザ体感を高速化するためミニマップの画像データよりできるだけ早く受信することが求められます。SPDYは、0から7まで8段階の優先度設定ができます。Chromeでは、タイル情報は Ajax を利用して優先度2、画像データは優先度3、に指定してリクエストを送っているはずです。しかしGoogleマップチームがGFE(Google Front End)サーバから返ってくるレスポンスを見ると、SPDYの優先度指定が全く反映されていないことが発覚。実際にその試験データが示されて Will. Chan の調査が始まりました。

2. Googleサービスのシステム構成

まずGoogleサービスのシステム構成の概要とSPDY優先度の機能の関係について説明します。W Chan のブログでは、笑えることに自社サービスをスノーデンがNSAから漏洩させたシステム図を引用して説明してたりします。(図1)

GFEは Google Front End の略でクライアントからのSSL接続の終端をします。Googleサービスは外向けにほとんどSSL+SPDY対応になっていますので、GFEでSSL+SPDYのリクエストを処理して、各種サービス向けのバックエンドサーバにリクエストを投げています。NSAは、Googleデータセンター間の光ファイバー網を盗聴しているという暴露もありましたので、今ではきっとGoogle DC内のバックエンド通信もSSL化されているでしょう。

GFEの機能詳細は不明ですが、おそらく複数のバックエンドから各種レスポンスデータを受け取り、いろいろデータ処理した後にSPDYで指定された優先度順でクライアントにデータを送信するといった流れだと思われます。(図2.1, 図2.2)


今回の問題の指摘は、このGFEがSPDYの優先度を正しくハンドリングできていない可能性を疑わせるものでした。

3. Googleマップで起きていたSPDYの問題

Googleマップチームが検証したテストは、高速回線環境下と低速回線環境下の2通りで実施されています。そのどちらの測定結果も優先度が高いタイルデータより低い画像データが先に受信していることを示していました。高速回線環境でのテスト結果では、画像データの受信開始から70ミリ秒程度遅れてタイルデータの受信が始まっていました。しかしこのケースでの現象の説明は簡単です。

一般的にバックエンドサーバが扱うデータのサイズや処理の負荷などは様々で、画像配信を扱うバックエンドサーバの方がタイル配信を扱うバックエンドサーバより早くレスポンスを返す場合もあります。一方GFEは、優先度が高いデータの到着を待つことはなく、GFE内の処理が済み次第優先度の低いデータでも送信キュー入れ込みます。
高速回線環境下では短時間でデータがクライアント側に送られますので、その結果タイルデータがGFEに到着した時には時すでに遅く、画像データがクライアントに送信済になっていたということです。この場合SPDYの優先度に関係なく、先に到着したデータをクライアントに送信することになります。(図3.1)

しかし低速回線環境でのテストデータでは、画像ファイルの受信開始からなんと1.3秒後にタイルファイルの受信が始まったということを示していました。これは先のような説明がつきません。

もちろんバックエンドサーバ間でレスポンスの時間差は多少あるでしょうが、1秒以上の余裕があればGFEは全てのバックエンドからタイルデータの受信を完了しているはずです。そしてその時GFEは、まだクライアントへの画像データを送信している途中です。このテストデータから、GFEは優先度に関係なく先に届いたデータを送信しているように見えます。そうなるとGFEは、SPDYが指定した優先度のハンドリングができていないことになります。(図3.2)

4. 原因は Linux カーネルにあり

W. Chan は、SPDY仕様を作った Robert と GFE担当のGoogleで一番の"おしゃれ"と名高い Hasan と協力して原因が Linuxカーネルにあることを突き止めました。

GFEのSPDYサーバのプロセスは、クライアントへデータを送信する際、SPDYの優先度設定に従って(おそらくプライオリティキューを使い)カーネルのソケットにデータを書き込みます。書き込まれたデータは、Linuxカーネル内のソケットバッファに蓄えられ、順次クライアントへ送信されます。

ソケットバッファ内には、途中でロストした場合に備えて再送用に既に送信済のデータを保持しています。(ackが届いたら解放します) 他方ソケットバッファ内のデータは、一度に全部のデータを送信できるわけではなくまだ未送信のデータも一部残ります。この未送信のデータ少ないと、高速通信環境下ではウィンドウサイズが短時間に増加するので一時的に送信データが枯渇し、せっかくデータを送る余地があるのにデータが送れないといった状況が発生します。
そのため一般的にOSのカーネルは、送信パフォーマンスを最大化するためこの未送信のデータサイズを自動でチューニングして増加させています。そのサイズはLinuxのデフォルトでは無制限(0xFFFFFFFF)です。今回これが裏目に出ました。(図4)

低速環境下でこの未送信のデータサイズが大きいと、SPDYサーバのプロセスは、まだソケットバッファに余裕があると判断し、このデータが未送信状態で保持されるとも知らず送信データを次々とカーネルに書き込んでしまいます。その結果、先に届いた画像データは直ちにSPDYサーバプロセスの送信キューから吐き出され、タイルデータが届いた時には画像データは、すっかりソケットバッファ内に全部格納されてしまっていた状態だったのです。
こうなるとSPDYサーバプロセスは優先度の送信順番調整などできません。しかもTLSの暗号化処理がされた後では、送信データの順番の入れ替えなどできる余地は全くありません。よってタイルデータはいくら優先度が高くても、画像データが全部送信し終わるまでじっと送信を待たないといけなくなったのです。

5. Linux の新しいソケットオプション TCP_NOTSENT_LOWAT

これを解決する手段はさすがGoogleLinuxカーネルコミッタを揃えています。

ソケットバッファ内の未送信データサイズを指定する新たなソケットオプション "TCP_NOTSENT_LOWAT" を新たに導入しました。
このTCP_NOTSENT_LOWATの値を設定すると未送信のデータサイズを明示的に制限することができます。そのため、低速回線環境下でもカーネルに送信データを飲み込まれることなく、SPDYサーバプロセスが優先度に応じたデータ送信を制御できることになります。(図5)

高速回線環境下ではこのTCP_NOTSENT_LOWAT の値を大きく、低速回線環境下では TCP_NOTSENT_LOWAT の値を小さくすればいいのです。

この新ソケットオプション(TCP_NOTSENT_LOWAT)は既に Linuxカーネルにコミットされ、kernel-3.12 から利用することができます。Googleさん、めでたしめでたしです。

6. Googleマップ問題の再現と解決方法の検証

ここで単なるブログの紹介記事に終わらないのがプロトコルオタのおやじエンジニアです。実際にSPDY優先度とLinuxカーネルのソケットバッファの問題を再現して、TCP_NOTSENT_LOWATがどのように効くのか検証して見てみましょう。

検証環境は以下の通り SPDY/3.1に対応した新バージョンの nginx とバックエンドに Node.js の HTTPサーバを用いました。*1 Linuxの kernelは TCP_NOTSETN_LOWAT に対応した kernel-3.12.9 を利用しています。(図6)

Chrome は JSファイルのリクエストは優先度2、画像ファイルのリンクによるリクエストは優先度3になります。この2つのリンクを含むHTMLファイルにアクセスし、JSファイルはリクエストを受けてから 0.2秒後にレスポンスを返すように調整します。画像ファイルは待ちはありません。 サイズの影響をなくすため画像ファイルとJSファイルはほぼ同じサイズのファイルにしています。(JS中のコメントサイズを増やして調整しています。約700K)バックエンドサーバ用のNode.jsのコードを以下に示します。

/ Googleマップ問題再現用バックエンドサーバ
var http = require('http'),fs = require('fs');

// HTMLコンテンツ画像とJSファイルへのリンクのみ
var main_html = '<!DOCTYPE html><html>';
main_html += '<head><title>Test page</title></head>';
main_html += '<body>This is a test page.<div id="finish"></div>';
main_html += '<img src="photo.jpg">';
main_html += '<script src="foo.js"></script>';
main_html += '</body>';
main_html += '</html>';

// 遅延レスポンスを返す関数
function delayedResponse(res, type, length, content, delay) {
  setTimeout(function() {
    res.writeHead(200, {
      'content-type': type,
      'content-length': length
    });
    res.end(content);
  }, delay);
}

var jsfile = fs.readFileSync(__dirname + '/foo.js');
var photo = fs.readFileSync(__dirname + '/photo.jpg');
var server = http.createServer(function(req, res) {
  var ip = req.socket.remoteAddress;
  var now = (new Date()).toString();
  var url = req.url;
  var ua = req.headers['user-agent'];
  var remote_ip = req.headers['x-real-ip'];
  console.log(ip, now, url, ua, remote_ip);
  switch(url) {
    case '/photo.jpg': // 画像ファイルは即時レスポンスを返す
      res.writeHead(200, {
        'content-type': 'image/jpg',
        'content-length': photo.length
      });
      res.end(photo);
    break;
    case '/foo.js':
      var delay = 2 * 100; // JSファイルは 0.2秒後にレスポンスを返す
      delayedResponse(res, 'text/javascript', jsfile.length, jsfile, delay);
    break;
    default: // 上記以外のURLは常にHTMLファイルを返す
    res.writeHead(200, {
      'content-type': 'text/html',
      'content-length': Buffer.byteLength(main_html)
    });
    res.end(main_html);
  }

});

server.listen(8080, function() {
  console.log('Listening on 8080');
});

7. 検証結果

まずはSPDYのリクエストで優先度設定がきちんと設定されているかの確認です。 Chrome の SPDYセッション情報を見るとちゃんとJSファイルは優先度2、画像ファイルは優先度3が設定されています。(図7.1)しかもあえてハンデを付けて画像ファイルのリンクをJSファイルより前に記載しています。

何もソケットオプションを指定していない時の Chrome での受信リクエストタイムラインを示します。(図7.2)

クライアントとnginx間が高速であるので 0.2秒遅れてJSファイルのレスポンスが届く前に画像ファイルがクライアントに送信完了しています。Googleの高速化環境下の試験結果が再現されています。

続いて低速環境下での試験です。nginx上でtcを使って 128Kbps で 300msec 遅延のネットワークを人為的に設定してクライアントから接続します。結果は、次の図7.3です。

やっほ!低速環境下試験でGoogleマップ問題の再現ができました。JSファイルは画像ファイルの0.2秒遅延でレスポンスを返しているのですが、JSファイルがクライアントで受信するのは画像ファイルがほとんど受信した数秒後です。これはカーネルのソケットバッファ内の未送信データの影響によるものです。ただしこれは何回かリロードしてカーネルの自動チューニングが発生した後でないとこのようなタイムライにはなりませんでした。

次に新ソケットオプション TCP_NOTSENT_LOWAT を使ってみます。果たしてこれで無事問題が解消できるでしょうか? TCP_NOTSENT_LOWATを 128Kbyte の131072に設定してみます。タイムラインは次の図7.4です。

おぉ! すごい。ちゃんと 0.2秒後ぐらいに JSファイルの受信が始まって最終的に画像ファイルより先にJSファイルの受信が完了しています。これこそSPDY優先度処理の効果がテキメンに現れている状態です。まさしくTCP_NOTSENT_LOWATの設定が、ちゃんと有効な解決策であることを示しています。

8. どうやって決める? TCP_NOTSENT_LOWAT

さぁGoogleマップの問題を解決するには、この TCP_NOTSENT_LOWAT を適切に設定すれば良いと分かったのですが、それでは具体的にどんな値に設定すればいいでしょうか?
GoogleLinux Kernel エンジニアは、この値を余り小さくするとシステムコールを処理するコンテキストスイッチのオーバヘッドが増え、未送信データが枯渇し十分なTCPの送信スループが落ちる場合があると警告しています。

W. Chanがブログで書いているようクライアントのネットワーク環境に依存するんですが、いったいどうやって決めたらいいでしょうか。 ちょうど先日チューリッヒIETF httpbis WG の HTTP/2.0に関する中間会議があり W. Chan と直接会う機会がありましたので聞いてみました。

私:  「ブログに書いてあるTCP_NOTSENT_LOWATの値って簡単に決められないよね。」
Chan: 「そう、ネットワーク環境によって違うからちゃんとモニターして決めないと。」
私:  「Googleではどうしているの?」
Chan: 「データ送信キューの遅延をモニターして値を決めてるよ。」
私:  「えっ! どうやって? ユーザーランドからはそんなのわかんないよ。」
Chan: 「そりゃカーネルに手を入れて測定しているさ。」

さすがGoogle・・・

今回もパフォーマンスに問題が発生するとOSカーネルの変更まで巻き込んで対応してしまう、そんなGoogleのWeb高速化にかける意気込みみたいなものを感じました。
将来Linuxサーバの nginx を使ってSPDYのリバースプロキシ構成でサービス運用しようと計画されている方も多いかと思います。そんな時、ここで書いたSPDYの優先度と TCP_NOTSENT_LOWATの設定を頭の隅いれて運用しましょう。

*1:実は最初検証がうまくいかなかったのでいろいろ調べたところ nginx の SPDY/3.1実装に優先度値読み込みのバグがあることがわかりました。これを修正する1行パッチを送付したところすぐ採用され、直後に1.5.10がリリースされました。