UZABASE Tech Blog

株式会社ユーザベースの技術チームブログです。 主に週次の持ち回りLTやセミナー・イベント情報について書きます。

Chrome hackingと称しブラウザのレイアウトバグをみんなで調べてみました

はじめまして。プロダクト開発チームの小野寺 (ryoqun)です。

Google Chrome (以下、Chrome)にて、HTMLのレンダリングの回帰バグが紛れ込み、その影響でSPEEDAの一部分のレイアウトが崩れてしまう問題が発生しました。そこで、「Chrome hacking」と称し、数名の希望者を社内で募り、みんなでこのバグを調査、あわよくば解決しChromeのコミットログに@uzabase.comのドメインを刻もうと奮い立ちました。

しかし結論として、別の案件が入り、作業を中断している間に先を越され、名を刻むことはできませんでした。つまりは現在このバグは別の開発者によって修正が完了しています。しかし、結果的にはOSSのソースコードレベルでの調査の実例としては非常に好例となりました。

その活動記録として、SPEEDA上での問題の発覚からChromeのバグであるという原因の特定や調査から収束に至るまでの一連の出来事を共有したいと思います。

前提として、本当にChromeのレイアウトバグでした

最初はChromeのバグだと断言できませんでした。

そのため、本当にChromeがCSS 2.1のレイアウト回帰バグを混入させてしまったことが原因だと分かった時は驚きでした。

当然として、SPEEDAでのレイアウトバグの発覚直後は、SPEEDAのCSSの問題だと考えていました。 というのもCSSは呆れるくらいに枯れたバグの入る余地のないWebの基礎技術だからです。

CSS 2.1として2011年6月にW3C Recommendationとなり、それから5年以上が経過しています。それまでの歩みは決して容易いものではなかったため、CSSは鉄壁の仕様となっています(1990年代の血みどろのブラウザ競争の中で産み落とされたCSS 1.0がのたうちまわり、当時のWebエンジニアたちをInteroperatabilityの名の下苦悩させ、戦禍の反省とでもいうかのごとく「複数レンダリングエンジン上で実装済み」という大義名分の元、W3CによってひねりだされたCSS 2.0が2000年代を通し、これでもかというくらいに精緻に策定され、晴れてCSS 2.1は生み出されました)。

培われた仕様の厳密性、テストケースの網羅性はもはや芸術レベルで、1つの仕様に対しての手厚さとしては数有る仕様の中でもトップクラスにCSSは位置すると思います(特に個人的には9 Visual formatting model10 Visual formatting model detailsあたりは傑作だと思います)。

ということで、2017年の今日において、枯れたCSSに対し、IE 6やガラケーと戯れて涙を飲みながらレイアウトバグの回避を模索していた苦悩の2000年代を彷彿とさせる事象に再び直面し、非常に印象的でした。 どんなに枯れていようが常にソフトウェアにはバグがつきものであり、バグに直面した時、時には自分たちのコードだけでなくミドルウェアも疑う必要性を痛感しました。 また、CSSを正しく実装することがいかに難しいことであるかの証左なのかもしれません。

バグの発覚と内容

今回のバグは、SPEEDAの本番環境にて、デスクトップ向けのChromeのStableチャンネルに59が出始めてからようやく気づきました。

バグの内容は、サイト検索フォームの下に表示されるサジェスト候補の一覧が異様に高くなってしまうというものでした。SPEEDAは一般公開されているサービスではないので見せられるスクリーンショットがかなり限定的でわかりにくいのですが、正しいレンダリング時の画像はこのようになります:

f:id:ryoqun:20170901011237p:plain

逆に、正しくないレンダリング時の画像は、このようにかなり縦長な感じになってしまいます:

f:id:ryoqun:20170901011251p:plain

これだけだとイメージがつきにくいのですが、サジェスト候補が画面表示領域に対してかなりの占有率になってしまい、ユーザーにも違和感を与えるレベルになってしまいました。

とりあえず応急処置

他のブラウザや以前のバージョンのChromeでは問題が起きなかったことから、どう考えてもChromeのバグらしいというのが判明してきました。そうなってくるとChromeはすぐには修正されないのでまずは応急処置です。 レンダリングエンジンがどう動いているかを想像しつつ、クロスブラウザで無害で等価なCSSを色々と試行錯誤した結果、結局は以下の変更だけで直ってしまいました。

 .g-search-suggest li .suggestItem {
-  display: block;
+  display: inline-block;
   padding-left: 90px;
   color: #555;

CSS的にはほとんど等価なはずなので、やはりどう考えてもChromeのバグのようでした(ちなみに、こういうレイアウトバグの回避策なんてものは、すっかり失われし技術となってしまいました)。

この応急処置をSPEEDAに反映し、次にChromeを直そうということになりました。

ミニマルテストケースの作成

ともかくも最初はミニマルテストケースを作りました。そうすることによって社内に公開しても大丈夫でGoogleにもバグレポートを送れるようになります。 作ったミニマルテストケースは↓の通りです。

<!DOCTYPE html>
<html>
<head>
<style>
.suggestItemOk1 {
  display: inline;
}
.suggestItemOk2 {
  display: inline-block;
}
.suggestItemNg {
  display: block;
}
.item {
  overflow: hidden;
  display: inline-block;
}
</style>
</head>
<body>
<ul>
<li><span class="suggestItemOk1"><span class="item">AAA</span></span></li>
<li><span class="suggestItemOk1"><span class="item">BBB</span></span></li>
<li><span class="suggestItemOk1"><span class="item">CCC</span></span></li>
<li><span class="suggestItemOk2"><span class="item">AAA</span></span></li>
<li><span class="suggestItemOk2"><span class="item">BBB</span></span></li>
<li><span class="suggestItemOk2"><span class="item">CCC</span></span></li>
<li><span class="suggestItemNg"><span class="item">AAA</span></span></li>
<li><span class="suggestItemNg"><span class="item">BBB</span></span></li>
<li><span class="suggestItemNg"><span class="item">CCC</span></span></li>
</ul>
</body>
</html>

ミニマルテストケースの正しいレンダリング時の画像はこうなります:

f:id:ryoqun:20170901013353p:plain

逆に、正しくないレンダリング時の画像はこうなります:

f:id:ryoqun:20170901013155p:plain

SPEEDA上でのレイアウトバグとなんとなく似ているのは想像できるかと思います。

このミニマルテストケースから分かることは、display: list-itemoverflow: hidden,display: blockが組み合わさるとどうやらまずいということです。その情報を元にChromiumのバグを検索してみましたが、同様のバグが見当たらなかったため、自分たちで直してみようということになりました(ちなみに、今現在はこのキーワードで検索すると、今回の回帰バグのレポートを見つけることができます)。

Chromiumのビルド

Chromeは、オープンソースであるChromiumから作られています。そこでオープンソースの真価を発揮ということで、手元のマシンでビルドしてみました。Chromiumは相当な数のサードパーティーライブラリに依存していますが、独自ツール(gyp)を使って比較的簡単にビルド環境を構築できます。ただストレージ容量は結構必要で、例えば私の場合は50GBは必要でした。

また、今回は回帰バグなのでChromium 58とChromium 59のどちらも並行させてビルドし、比較調査しやすいようにしました。

実際にHackして怪しい箇所を見つける

ChromeのDeveloper Toolsから得られる情報だけではレイアウトバグの状況が分からなかったので、ブラウザの真骨頂であるレンダリングの中のレイアウト(Reflow)コードを読む必要があります。大抵の大規模ソフトウェアは開発目的で色々な内部状態をダンプする機能があり、Chromiumも例外ではありません。ですが、今回の参加メンバーはChromeにはそれほど詳しくないため、当初はそのやり方が分からず、ソースコードとWebと変更履歴をつっつきまわり、最終的にはデバッグ情報を出力させることができました。

具体的には下のように、デバッグ関数をよく通るであろうコードパスから呼び出してみました。

diff --git a/third_party/WebKit/Source/core/layout/LayoutListItem.cpp b/third_party/WebKit/Source/core/layout/LayoutListItem.cpp
index 92af305..946e7c9 100644
--- a/third_party/WebKit/Source/core/layout/LayoutListItem.cpp
+++ b/third_party/WebKit/Source/core/layout/LayoutListItem.cpp
@@ -466,6 +466,10 @@ void LayoutListItem::PositionListMarker() {
 
 void LayoutListItem::Paint(const PaintInfo& paint_info,
                            const LayoutPoint& paint_offset) const {
+  this->ShowTreeForThis();
+  this->ShowLayoutTreeForThis();
+  this->ShowLineTreeForThis();
+  //this->ShowDebugData();
   ListItemPainter(*this).Paint(paint_info, paint_offset);
 }

そして、レイアウトバグの有無によって、↓のような違う2つのレイアウトツリーが生成されていることが分かりました。

正しいレイアウトツリー:

LayoutListItem 0x19a65d2243d0   LI
  LayoutListMarker (anonymous) 0x19a65d218df0
  LayoutBlockFlow 0x19a65d218f18        SPAN class="suggestItemOk2"
    LayoutBlockFlow 0x19a65d219040      SPAN class="item"
      LayoutText 0x19a65d2415f0 #text "CCC"
LayoutListItem 0x19a65d224290   LI
  LayoutBlockFlow 0x19a65d218828        SPAN class="suggestItemNg"
    LayoutListMarker (anonymous) 0x19a65d2184b0
    LayoutBlockFlow 0x19a65d218cc8      SPAN class="item"
      LayoutText 0x19a65d241528 #text "AAA"  

正しくないレイアウトツリー(suggestItemNgLayoutListMarkerが外出しされてしまっている):

LayoutListItem 0x19a65d224650   LI
  LayoutListMarker (anonymous) 0x19a65d219168
  LayoutBlockFlow 0x19a65d219290        SPAN class="suggestItemOk2"
    LayoutBlockFlow 0x19a65d2193b8      SPAN class="item"
      LayoutText 0x19a65d240ee8 #text "CCC"
LayoutListItem 0x19a65d224790   LI
  LayoutBlockFlow (anonymous) 0x19a65d218a78
    LayoutListMarker (anonymous) 0x19a65d219608
  LayoutBlockFlow 0x19a65d219e20        SPAN class="suggestItemNg"
    LayoutBlockFlow 0x19a65d219cf8      SPAN class="item"
      LayoutText 0x19a65d241910 #text "AAA"

正しくないレイアウトツリー中で、アドレスが0x19a65d218a78LayoutBlockFlowが余計に生成されています。これによって余計な論理的な行が追加され、意図せず高さがおかしくなってしまうというからくりのようでした。

ここまでくればもう峠を越していて、あとはこの差異をとことん調べ込んでいけばよくなります。

今まではまったくの五里霧中で、どこにバグがあるのか分からず怪しそうなところをとにかく広く浅く探す必要がありました。胸をなでおろせた瞬間でした。

コミットの特定

<li>関連の実装が肝になっているようだったので、LayoutListItemのソースコードを入念に見ました。調べた結果、回帰バグを混入させたコミットを特定することができ、それをgit revertしてビルドし直したらバグが発生しなくなりました! いろいろな切り口で調べたのですが、結果的にはgit annotateが決め手でした。比較的浅い回帰バグにはgit annotateは有効です。

変更内容としては非常に小さいです。ちなみにこの変更を見てみると、もともとはまた別のレイアウトのバグを直そうとしていたようです。

diff --git a/third_party/WebKit/Source/core/layout/LayoutListItem.cpp b/third_party/WebKit/Source/core/layout/LayoutListItem.cpp
index 6c98974..4dbf2a7 100644
--- a/third_party/WebKit/Source/core/layout/LayoutListItem.cpp
+++ b/third_party/WebKit/Source/core/layout/LayoutListItem.cpp
@@ -257,6 +257,11 @@ static LayoutObject* getParentOfFirstLineBox(LayoutBlockFlow* curr,
     if (currChild == marker)
       continue;

+    // Shouldn't add marker into Overflow box, instead, add marker
+    // into listitem
+    if (currChild->hasOverflowClip())
+      break;
+
     if (currChild->isInline() &&
         (!currChild->isLayoutInline() ||
          curr->generatesLineBoxesForInlineChild(currChild)))

いざ修正!(は叶わず…)

直そう!と思って一旦保留していたら、先を越され、その間にupstreamで修正されてしまいました。非常に残念です。

修正に必要なコードはたったの一行でした。もともとが2行を追加しただけで回帰バグが発生したのですから、その2行のどちらかを直せば回帰バグは直るというわけです。

diff --git a/third_party/WebKit/Source/core/layout/LayoutListItem.cpp b/third_party/WebKit/Source/core/layout/LayoutListItem.cpp
index 18e98a78..893ee6e 100644
--- a/third_party/WebKit/Source/core/layout/LayoutListItem.cpp
+++ b/third_party/WebKit/Source/core/layout/LayoutListItem.cpp
@@ -259,7 +259,7 @@ static LayoutObject* GetParentOfFirstLineBox(LayoutBlockFlow* curr,
 
     // Shouldn't add marker into Overflow box, instead, add marker
     // into listitem
-    if (curr_child->HasOverflowClip())
+    if (curr->HasOverflowClip())
       break;
 
     if (curr_child->IsInline() &&

コードリーディング

今回の一連の修正で問題なのは、ListMarkerをLayout Treeに配置する場所です。 回帰バグの発生前後に関わらず、大前提としてLayoutBlockFlow(class=suggeestItemNg)の子としてListMarkerを追加する必要があります。しかし、その前提が1つ目の修正で崩れてしまいました。回帰バグによりLayoutListItem(<li>)の子として追加するように意図せず実装が変わってしまいました。

その原因を少し説明します。

まず、1つ目の修正で本当に直したかったことは、<li>overflow: hiddenな子要素があるとき、その子としてListMarkerを追加するとclipされ、表示されないという問題でした。

その場合はListItemの子としてListMarkerを入れる必要があります。なので1つ目の修正ではそういうロジックをGetParentOfFirstLineBox()に新規に追加しました。

具体的には、特定条件時にGetParentOfFirstLineBox()からはbreak経由でnullptrを返し、呼び元であるUpdateMarkerLocation()ListItemの子としてListMarkerを追加するというものです。しかし、その判定条件が正しくなく回帰バグが発生してしまいました。

ちなみに、この処理の副作用として改行が必然的に発生します(これがCSS的に正しいかは微妙です)。これは1つ目の修正としては許容するようですが、我々のミニマルテストケースでは許容されません。ミニマルテストケースの正しいレイアウトの挙動はLayoutBlockFlow(class=suggeestItemNg)の子としてListMarkerを追加することです。

追加した判定条件中でHasOverflowClip()が判定すべき対象はcurr(つまりはLayoutBlockFlow(class=suggeestItemNg))でありcurr_childではありません。まさに2つ目の修正ではそうなっています。 というのも、curr_childをどうこうというよりもまずはcurrHasOverflowClip()でないならば、currListMarkerの親として適切なので、currListMarkerを追加すべきだからです。

上のミニマルテストケースは<span class="suggeestItemNg">の子として<span class="item">がいます。正しくない条件では、overflow: hidden<span class="item"><span class="suggeestItemNg">の子要素となっているために、判定結果が誤って真になり、ListMarkerListItemの子として追加されてしまいました。繰り返しますが、本来はoverflow: hiddenでないLayoutBlockFlow(class=suggeestItemNg)の子として追加すべきです。

結果、不要なLayoutBlockFlowができたことで論理改行が発生し、最終的には高さが意図せず高くなってしまうというレイアウト崩れが発生しました。

感想

参加したメンバーの感想です。

小野寺: 複数人でレイアウトロジックの動きを追うのは難しかったです。当社のSPEEDA開発グループではペアプロを積極採用しているのでペアプロの応用実践として何かいい解決案を考えてみたいと思いました。

北内: レンダリングエンジンのソースコードを追うのは骨の折れる作業でしたが、複数人で協力しながら作業したおかげで根気よく進めることができました。また、Appleと共同で開発していたWebKitからフォークしてBlinkに移行したことにともない、メンバ関数の名前をlower-camelcaseからupper-camelcaseに変更するといった変更履歴を見ることができたのも興味深かったです。

鈴木: Chromeがマルチプロセスで動いているからかデバッガでうまくプロセスにattachできなかったため、git grepとデバッグプリントを利用した最終的かつ原始的な手法でバグを調査しましたが、結果的に、これは思いの外有効な策となりました。また、複数人でバグ調査を行う場合、様々な視点・観点を得られ、またメンタル的にもメリットがあるので、機会があればおすすめしたいです。

久保: SREチームでインフラエンジニアとして普段業務をしているため、Chromeのバグ改修は自分には非常にハードルが高く、先輩方についていくだけで必死でしたが、Chromeのような超大なソースのバグの原因を特定する際に、どのようにあたりをつけていくのかについて少し掴めたように思います。今後のSREチームとしての業務に活かせると思いました(Uzabaseのinfraチームは今年の7月よりinfraチームからSREチームに変わり、4Q(10月)以降本格的にSREチームとしてサービス改善にコミットし、バグの改修やレスポンス改善などこれまでのインフラレイヤーにとどまらない業務範囲になります)。

まとめ

今回はソースコードレベルまでの調査を業務で行いました。当社では今後もOSSにも積極的に取り組んでいきたいと思います。Chrome内のソースコードが原因の修正までは特定できたのはよかったのですが、別件の案件が入り、Chrome hackingを一旦保留にしていたら、upstreamでその間に修正されてしまい惜しかったです(本来は自分たちでバグレポートを立てて、テストケース込みでパッチを提出しようとしていたのですが……)。

長くなりましたが、最後にまとめでこの記事を終わりたいと思います。

  • 天下のGoogleのしかもChromeでさえも回帰バグが紛れ込んでしまうことがある。
  • オープンソースだと簡単なバグは自分たちで調査&修正はやろうと思えばできて、みんなでOSSに貢献できる。
  • 弊社では、時にはミドルウェアへのソースコード調査&解決も厭わない情熱あふれる問題解決大好きエンジニアを募集しています。

www.wantedly.com

www.wantedly.com

www.wantedly.com

Gauge Test Automation Toolとアジャイル開発

こんにちはSPEEDAのQAチームの工藤です。
最近ではテスト自動化周りのツールが数多く存在していますが、英語でのみ提供されていて日本で多くの人に知られていないサービスも多いと思います。
そんな中、Gaugeという自動化のツールがイケてるという情報を発見したので実際に調べてみました。

はじめに

GaugeとはThoughtWorks社が開発しているオープンソースのテスト自動化ツールです(2017年7月現在でベータ版)。
もっと具体的に言うと様々なロールのメンバーが自動テストのスクリプトを理解できるようにするためのspecificationツール(恐らく立ち位置的にはCucumber/Gherkinの代替)になります。
ざっくりGaugeの良いところを挙げると下記になります。

  • Selenium Webdriverと一緒に使える
  • マークダウン形式で記述できる
  • ビジネス言語でテスト仕様を記述できる(実行可能な仕様書の概念をサポート)
  • 多言語、マルチプラットフォームをサポート
  • 外部データソースからテストデータを読み込める
  • 拡張可能(自分でpluginを開発できる)
  • IDEのサポートが充実している

Gaugeで使う用語

下記がGaugeで使用する基本的な用語(概念)です。
使い始めるのに最低限必要そうなものをピックアップしているので、当然他にもあります。

  • Specifications(spec)
  • Scenarios
  • Steps
  • Tags

もう少し詳しく説明していきます。

Specification(spec)

テスト対象アプリケーションの特定の機能の仕様を説明しています。

Scenarios

各Scenarioは、特定の仕様の1つのフローを表しています。仕様には少なくとも1つのScenarioが含まれている必要があります。

Steps

仕様を実行可能なコンポーネントに分けたものがStepになります。マークダウン形式のunordered list items(bulleted points)として記述されます。
(大きくContext Steps、Tear Down Steps、Scenarioやconcepts内のStepsに分けられますが、今回はそこらへんの説明は割愛します)。

Tags

TagはspecやScenarioを関連付けするために使用します。タグを用いてspecやScenarioをフィルタリングすることができ、後々便利になります。

Gaugeのインストール

下記ページからダウンロードできます。インストール方法も簡単で、下記ページに書いてある通りに進めていけば5分もかからずにGauge自体はインストールできます。
https://getgauge.io/get-started.html

サンプルコードを見てみる

下記ページから自分の好みの言語のSampleコードのGithubリンクへ飛ぶことができます。
https://docs.getgauge.io/examples.html

私はWeb app using SeleniumのJavaのSampleコードを選択しました。
https://github.com/getgauge/gauge-example-java

下記がSpecificationのファイルになります。
上記で説明した用語はこんな形で使われます。

user.spec(自然言語でテストケースを記述していくファイル)

Signup   //Specification
======

Register a customer  //Scenario
-------------------
tags: user, signup, high, final, smoke  //Tags

* Sign up a new customer                  //Step1
* On the customer page                    //Step2
* Just registered customer is listed      //Step3

上記のSpecファイルはIDEでタブ切り替えでHTML Previewを閲覧できます。Specファイルがそのまま仕様書として使えます。 (自分の場合はIntelliJを使いました。IDE側でプラグインをインストールしてやる必要があります)   f:id:kudogen:20170718122930p:plain

下記がSeleniumのテストコードになります。 各Stepに対してのテストコードが@Stepという形で実装されているのが分かります。
UserSpec.java

public class UserSpec {
    private final WebDriver driver;

    public UserSpec() {
        this.driver = DriverFactory.getDriver();
    }

    public String localPart() {
        // Creating a random local part of an email address also used as username
        return UUID.randomUUID().toString();
    }

    @Step("On signup page")    //Stepsの実装部分
    public void navigateToSignUpPage() {
        driver.get(SignUpPage.SignUpUrl);
    }

レポート機能

プラグインをインストールしてやるだけで、テスト流す度にHTMLのテストレポートを吐き出してくれます(下記画像参照)
実行したテストをSpecificationやTagで検索できて、Spec毎、Scenario毎、Step毎にどれくらい実行時間がかかったかも簡単にわかります。
f:id:kudogen:20170718123453p:plain

今回カバーしていない機能

これまでに紹介したのはあくまでも超基本的なTerminologyです。 他にも下記のようなものがあるのですが、今回は触れられていません。

  • Concepts
  • Parameters
  • Stepの種類(Contexts, Tear Down Steps)

またかなり役立ちそうな下記機能もありますが、今回はカバーしていません。

  • Data driven execution
  • Parallel Execution

まとめ

弊社ではテストケース=仕様書という考え方から、ほぼすべてのプロジェクトでUATを書いています。
元々上記のような取り組みをしていたのですが、もっといいやり方がないか探していたところに見つけたのがGaugeでした。
Gaugeは実行可能なドキュメントという概念("the concept of executable documentation")をサポートしています。
また自動テストのケースを誰でも読めるようにするという考え方も弊社の開発チームが今まで取り組んできていたことですが、Gaugeを使えばよりスマートに実現できそうです。
今後は様々なプロジェクトでGaugeを導入していこうと考えています。

次回は今回カバーできなかったGaugeのAdvancedな機能を中心に取り上げたブログ記事を執筆予定です。


株式会社ユーザベースでは、より良い開発プロセスを共に作り上げていきたいエンジニアを大募集中です!

TCP Fast Open

はじめまして。プロダクト開発チームの小野寺 (ryoqun)です。

今回は最近少しずつ浸透し始めてきた「TCP Fast Open(以下、Fast Open)」という最新技術についてTCP/IPのおさらいを踏まえながら紹介したいと思います。ちなみに、この技術はTCPを高速化するもので、Google、Facebook、Appleなどでも本番投入され初めているものの、まだ国内では浸透していなくだいぶ先取りな紹介となります。

Fast Openという技術は比較的枯れたTCPに対してプロトコルレベルで変更を加える比較的インパクトが大きいと勝手に思っている技術です。 最近は低レイヤーの技術はアプリケーション・サービス開発エンジニアだとあまり意識しないとは思いますが、基礎は大事なので 最新動向を掴むと共に、TCP/IPの良い復習ということで少しの間ですがお付き合い下さい。

Fast Openとは?

一言でいうとTCPのレイテンシーを改善する新しい拡張技術です。具体的には接続確立時(= Open)のレイテンシーを軽減(= Fast)します(よってFast Openという名前の由来となっています)。正確には、TCPのThree-way handshakeを省略することで1 RTT分のレイテンシーを削減できます。

Three-way handshakeを明示的に省略する必要性から、クライアント側とサーバー側の両方がFast Openに対応して初めて有効になります。

注意点としては、僅かに接続確立時の信頼性が犠牲となることです。これについては後述します。また、各ホストへの初回接続時には使えません。理由は、セキュリティのため認証情報(Cookie)が必要になるためです。これはTCPのSYN flooding攻撃に似た危険性を軽減する対策余地をプロトコル上に残すためです。

Three-way handshakeとFast Open

本来ならばTCPだとThree-way handshakeが必要になります。つまりは接続確立時に実際の通信ができるまでに、まずクライアント側からSYNフラグが立ったペイロードが無い小さいパケットを送り、サーバー側からSYN+ACKフラグが立ったペイロードが無い小さいパケットを受け取らなければなりません。これがまさしく1 RTT分のレイテンシーに相当するわけです。

他方でFast Openが有効な場合、接続確立時にいきなりSYNフラグを立てた上で、ペイロードを乗せることができます。例えば、HTTPリクエストがTCPのMSS以下の場合は、0 RTTでnginxなどのHTTPサーバーはリクエストの内容をアプリケーションプロセスに渡すことが実現可能です。ただ、現在だとHTTPというよりかはHTTPS(=TLS)が主流です。その場合はTLS handshakeを即座に開始できるというわけです。なお、Fast OpenとTLS 1.3を組み合わせるとTLSを含めて0-RTTを実現できるようになるようです。

デモ

百聞は一見にしかずということで、まずは実際の動きを見てみましょう。

Fast Openが有効になっているサイトから、Fast Openが有効になっているブラウザでファイルChromiumでダウンロードしてみました。その時のChromiumのDeveloper toolsのNetworkのtimingWiresharkで状態を確認してみました。ちなみにテストに使ったサービスはWikimediaですが、Fast Openが有効なサービスにはGoogleなどもありますが、日本国内からのアクセス時にレイテンシーが発生しFast Openの効果がわかりやすくなるようにWikimediaを選んでいます。

ChromiumのDeveloper tools

Fast Openが無効と有効になっている時の違いを比べてみました。

無効時:

f:id:ryoqun:20170803105742p:plain

有効時:

f:id:ryoqun:20170803105730p:plain

赤の矢印()のところに着目するとわかるように、Initial connection(= TCPのThree-way handshakeのこと)がFast Open有効時は必要なくなっていて、SSLのhandshakeが前倒しになって開始されているのがよくわかります。ただ、SSLのhandshakeにかかった時間や実際のデータの転送にかかった時間はそれほど変わっていません。結果としては、Initial connection分の時間(レイテンシー)だけが綺麗に全体のレスポンス時間から減っています。

Wireshark

Fast Openが無効と有効になっている時の違いを比べてみました。

無効時:

f:id:ryoqun:20170803111734p:plain

有効時:

f:id:ryoqun:20170803111746p:plain

赤枠()のところに着目するとわかるように、Fast Open無効時は、TCPのThree-way handshakeをちゃんとやっています。しかしFast Open有効時は、Three-way handshakeをスキップしていきなりペイロード(Client Hello; TLS handshakeの最初のパケット)があるパケットを送っているのがわかります。結果、パケットのLengthが大きくなっています。なお、このスクリーンショットからはわからないのですが、この最初のパケットにはSYNフラッグがきちんと立っています。その後のやり取りの流れは無効時と変わりありません。

長所と短所

  • ○ 接続確立時のRTTが減る!!!!(これは前述の通りですね)
  • △ 接続確立後のデータ通信のレイテンシーは向上しない(Fast Openは接続確立時だけの話です)
  • ✕ アプリケーション層での冪等性が必要(これについては後述します)

短所: アプリケーション層での冪等性が必要

これが、この記事の冒頭で書いたFast Openを有効にすることによって犠牲にされた信頼性となります。しかしながら、この懸念を考慮する必要はインターネットから到達可能なWebサービスにおいてはほとんどありません。より正確にいうと、そのような前提の場合はブラウザの多重Submit問題と同じ問題であり、ネットワーク起因やユーザー操作起因の違いはあれど、Fast Open抜きにしても必ず考慮する必要があるからです。しかし、TLSを使っていなかったり、あるいは、イントラネット内で完結するWebサービス、またはTCP本来の高信頼性を要求する通信にてFast Openを有効にする場合には懸念となります。

そのFast Openを有効にした際の信頼性の問題というのは、1つのTCP上のアプリケーションリクエストがIP通信網の品質の問題により、2つになりえるという理論的な問題となります。そのためアプリケーション層において、リクエストが2つになっても問題無いという冪等性が必要になります。

その問題の原因を説明する前にまずは、前提となる背景を説明します。TCPの役割というのは、信頼性が担保されないIP通信上に、信頼できる双方向通信路を実現するためのプロトコルです。Three-way handshakeはその信頼性の担保のために接続確立時に必要となります。当時のTCPの設計に問題があったわけでもなく、現時点に至るまでの技術的発展があったわけでもなく、昔も今も、レイテンシーと信頼性のトレードオフの結果、本来のTCPは信頼性を重視するためにはThree-way handshakeを必要としています。反面、IP通信は、設計思想として、耐障害性、冗長性を重視し、ごく稀なパケットロスやパケット重複は許容しています。

というわけで、Fast Openを有効にした上で、Three-way handshakeが省かれ、パケット重複が万が一発生した場合、サーバーにとってはあたかもTCP接続確立が2つ来たかのように見えるので、2回リクエスト処理をしてしまうということになるわけです。

普及状況

繰り返しになりますが、Fast Openはクライアント側とサーバー側がどちらも対応して初めて使えるようになります。具体的にいうと、TCP/IPスタックは多くの場合、OSが実装しているので、OSがFast Openを実装し、それが有効になっている事と、ミドルウェアやアプリケーションが対応している事が必要になります。

クライアント側とサーバー側に分けて、2017年9月時点での普及状況について説明します。

サーバー

Google, Wikipedia, CloudFront, Facebookで有効になっているようです。Fast Openはどちらかというと一般的なWebサービスでは効果が出にくく、CDNや広告配信等の1ショットのHTTPリクエストのトラフィックが大量に発生する場合に効果的です。日本サイトは筆者が簡単に確認する範囲では見つけられませんでした。

クライアント

Webブラウザはそれぞれ対応が始まっています。筆者はUbuntu 16.04/LTSChromiumで動作確認をしています。 OS的にも、iOS、Android、Windowsでそれぞれ動きがあるようです。

ツール/ミドルウェア

ちらほらとサポートが始まっています。網羅的に調べたわけではありませんが、nettyRubycurlなどで対応情報を見つけることができます。

他の技術との関連

最後に、Fast Openがそれぞれ他の技術とどのような関連を持つのかを見てみて、Fast Openの理解を多角的に深めていきたいと思います。

NAT

NATの実装によっては、Fast Openとの相性が良くない場合があります。これは当然で、NATはその原理上TCP接続の状態遷移をトラッキングしなければならないのですが、NATの実装が厳しすぎると、Fast OpenによってThree-way handshakeが省かれれば十分に正しくトラッキングできなくなる場合はあります。

HTTP keep-alive

HTTP通信におけるTCPの接続確立時のレイテンシーを軽減するという目的の上では、Fast OpenもHTTP keep-aliveも同じ立ち位置です。HTTP keep-aliveによってだいぶレイテンシーは改善されます。なので通常のブラウジングでは、keep-aliveに比べてFast Openというのはそれほど如実に効果があるわけではありません。ただ、Fast Openの策定背景としては、HTTP keep-aliveはモバイル回線網にてあまり機能していないという指摘もあります。

スマートフォン向けのHTTP APIのエンドポイント

アプリがkeep-aliveしていないならば効果はあります。ただし、まずはkeep-aliveを対応したほうがいいのは言うまでもありません。

ラストワンマイル回線網(FTTHと4G)

宅地のラストワンマイル回線網というと今で言えば日本国内で言えば、FTTHが圧倒的なシェアになっています。FTTHで通信ホストが国内の場合はそもそものレイテンシーは数msなのでFast Openの効果はほとんどありません。ADSLならば多少は効果はあります。しかし、ラストワンマイルが何にしろ、海外ホストへのアクセスの場合には効果が望めます。

モバイルのラストワンマイル回線網というと今で言えば4G回線が全盛となっています。こちらの場合は国内ホスト、海外ホストに限らず一定のレイテンシー向上が望めます。

Supercookie

Fast Openの際に使われるCookieの転用例としては、Supercookieが挙げられます。セキュリティ上、DOS攻撃対策としてCookieが必要なわけですが、別のセキュリティ観点では、匿名性が犠牲となっています。特にE-tagHSTS同様、passiveでドメインをまたいだ(=クロスドメイン)トラッキングが実現できてしまいます。今回新たにFast Openがトラッキング手段として加わったわけですが、他の既存の代替手段も存在するのも含め、残念ながらトラッキングを現在において完全に抑制することは非常に困難となっています。

HTTP/2

HTTP/2になってもTCPベースなので引き続きFast Openは意味があります。しかしQUICに対してはこちらはUDPベースなので関係なくなります。

参考情報

まとめ


株式会社ユーザベースでは、ローレベル含め技術が大好きなエンジニアを大募集中です!