一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

5年間の改善を経て、現在の一休がどうなっているのかを7/4(木)にお話します

レストラン事業本部の田中( id:kentana20 )です。

先週末にDevLOVE Xというイベントで開発組織改善の取り組みについて5年間の取り組みと今後、というテーマでお話しました。

5年間でどれくらい一休の開発組織が変わったのか

  • 技術面
  • 組織面

それぞれで実施した改善について、改善の裏側で起こっていたことや自分の所感も含めてお話しました。

現在の一休について7/4(木)にお話します

来週開催するエンジニア向けの説明会で、上記の5年間の取り組みを経て、現在の一休がどうなっているのかをCTOの伊藤がお話します。

ikyu.connpass.com

説明会と書いていますが、会社・事業や開発体制のお話だけでなく

  • 現在の開発組織やプロダクトの状況がどうなっているか
  • 今後どのように改善を進めていくのか

を含めてお話する予定です。

一休で働くことに興味がある方だけでなく

  • いまの現場を改善するためのヒントがほしい
  • 改善を進めている一休のエンジニアと直接お話したい

などなど、どなたでもご参加いただけます。

また、現場のエンジニアと個別にお話する時間も設けていますので、ご興味のある方はぜひご参加ください!

みなさんとお会いできることを楽しみにしています。

hrmos.co

Go + gRPCによるマイクロサービス構築

f:id:ryo-utsunomiya:20190614171536p:plain:w320

こんにちは。宿泊事業本部の宇都宮です。

最近、とあるマイクロサービスをローンチしました。このアプリケーションの業務的な役割は諸事情により省略しますが、以下のような特性をもっています。

想定されるリクエスト数は、平常時で30req/sec、ピーク時には60req/sec程度になります。行う処理はシンプルで、DBにいくつかSELECT文を投げて、ビジネスロジックに沿った結果を返すことです。

また、基盤系のアプリケーションなので、各開発者の開発環境(WindowsとMacが混在)でも動作する必要があります。

したがって、このアプリケーションに求められる要件は、

  • 高パフォーマンス
  • 高信頼性
  • クロスプラットフォームで動作すること

などになります。

このアプリケーションを構築するにあたって採用した技術と、開発の際に気をつけたこと、運用開始してから直したことなどを紹介します。

採用した技術

Go

開発言語としてはGoを採用しました。

一休のサーバサイドで広く使われている言語/ランタイムは以下の通りです。

  • VB, C#/.NET Framework: 一休.com等
  • C#/.NET Core: メール配信基盤等
  • Python: 一休.comレストランの新アーキテクチャ、一休.comスパ
  • VBScript: 一休.comレストランの旧アーキテクチャ
  • JavaScript/Node.js: 一休.comレストランのBFF
  • Go: 行動ログ収集API

これらのうち、Go以外の言語が選外になった理由は以下の通りです。

  • VBScript, .NET Framework: 新機能の追加が止まっており、将来性がない
  • Python, Node.js: 開発環境にランタイムのインストールが必要なのが微妙(バイナリを配布するだけにしたい)
  • .NET Core: 要件は全て満たしているが、開発者の採用面が…

.NET CoreとGoはどちらも要件を満たしているので、.NET Frameworkの経験を活かしやすい.NET Coreか、採用の強そうなGoかは迷いました。が、As-isではなくTo-beで考えた方がいいかなということで、Goを選定しました。

gRPC

データのやりとりにはgRPCを採用しました。今回のアプリケーションの場合、RESTでも十分だと思っていましたが、より高速に動作しそう、という理由でgRPCを採用しました。

先行して開発されていた別のアプリケーションでもgRPCを採用していて、そちらで技術検証が済んでいた、という点もポイントです。

ただ、一休ではVBScript等レガシーな技術も使っているので、どうしてもgRPCが使えなくなる事態もありうると思っていました。なので、REST APIも並行して開発しました。なお、結果的には、gRPCで問題なかったです。

余談ですが、現在、一休の一部サービスでは、VBScript(Classic ASP)からgRPCを叩いています。C#(.NET Framework)でgRPCクライアントを開発し、このdllをCOM経由で使っています。

擬似コードですが、大体こんな感じです:

Dim client
Dim response

Set client = Server.CreateObject("Ikyu.GrpcServiceClient")
Set response = client.GetMember(memberSeq)

開発の際に気をつけたこと

プレゼンテーションとドメインの分離

前述したとおり、gRPCでいくか、RESTでいくか、迷っていました。gRPCかREST(JSON over HTTP)かは、結局プレゼンテーションの問題なので、プレゼンテーションとドメインをきっちり分けて、プレゼンテーションを簡単に切り換えられるように気をつけました。

擬似コードですが、gRPCのrpcは以下のような実装になっています。

func (s *server) GetMember(ctx context.Context, in *pb.GetMemberRequest) (*pb.Member, error) {
    member, err := memberService.FindByMemberSeq(in.MemberSeq)
    if err != nil {
        // エラー処理
    }
    return &pb.Member{
        MemberSeq: member.MemberSeq,
    }, nil
}

これに対して、RESTのハンドラ(MVCのコントローラー相当)は以下のような実装になっています(フレームワークはgin)。

func (h *MemberHandler) GET(c *gin.Context) {
    member, err := memberService.FindByMemberSeq(c.Param("member-seq"))
    if err != nil {
        // エラー処理
    }
    c.JSON(http.StatusOK, gin.H{
        "memberSeq":      member.MemberSeq,
    })
}

このように、ほとんどの業務処理はドメイン層で行い、プレゼンテーション層はドメインサービスから戻ってきた処理結果を表現することのみを行うようにしました。これによって、gRPCとRESTを容易に併存可能にし、開発終盤まで決断を遅らせられるようにしました。

ユニットテストとインテグレーションテスト

高信頼性のためには、コーナーケースも含めたテストが必要です。そこで、ドメイン層についてはユニットテストを徹底しました。

また、DBと通信するコードはインフラストラクチャー層として分離し、こちらについては実際のDB(SQL Server)をDockerに立ててテストを行うインテグレーションテストを行いました。高速な動作のために、ビジネスロジックをSQLのクエリで表現している部分もあるため、インテグレーションテストもある程度しっかりやっています。

Goは標準でテストのための仕組みが組み込まれているので、テストが書きやすい言語だと思います。

今後の運用を考えると、Consumer-Driven Contractsのようなテストも用意した方がいいのかな、と検討中です。

運用開始してから直したこと

sql.DB を頻繁にオープン・クローズしない

Goのイディオムとして、取得したリソースは defer Close() するというものがあります(Close() のエラーハンドリングが必要な場合を除く)。そのため、最初はDBと通信するコードを以下のように書いていました。

func (s *server) GetMember(ctx context.Context, in *pb.GetMemberRequest) (*pb.Member, error) {
    db, err := sql.Open("sqlserver", dsn)
    if err != nil {
        // エラー処理
    }
    defer db.Close()

    memberService := NewMemberService(db)
    ... 
}

RPCの始めでDB接続をオープンして、終わりでクローズしています。これでもそれなりの性能は出ていたのですが、ドキュメントを確認すると、気になる記述がありました。

The returned DB is safe for concurrent use by multiple goroutines and maintains its own pool of idle connections. Thus, the Open function should be called just once. It is rarely necessary to close a DB.

https://golang.org/pkg/database/sql/#Open

sql.Open() の結果として取得できる sql.DB 構造体は、一度Openした後はプログラム中でずっと使い回すべきで、Closeを呼ばなければならないことはまれである、とされています。

sql.DB はコネクションの実体ではなく、コネクションプールなので、サーバの起動時に一度だけOpenし、その後は使い回すのがよさそうです。

func main() {
    db, err := sql.Open("sqlserver", dsn)
    if err != nil {
        // エラー処理
    }
    // gRPCサーバの実体となる server 構造体にdbフィールドを持たせる
    s := grpc.NewServer()
    pb.RegisterMemberServiceServer(s, &server{db: db})
}

// RPCのハンドラでは server.db を使う
func (s *server) GetMember(ctx context.Context, in *pb.GetMemberRequest) (*pb.Member, error) {
    memberService := NewMemberService(s.db)
    ... 
}

このように変更した結果、低負荷時のレスポンスタイムが改善しました(10~20ms => 5ms)。

あわせて、コネクションプール周りの設定も行いました。こちらは、以下の記事を参考にさせていただきました。

DSAS開発者の部屋:Re: Configuring sql.DB for Better Performance

db.SetMaxOpenConns(50)
db.SetMaxIdleConns(50)                   // MaxOpenConns 以上の値にすること
db.SetConnMaxLifetime(100 * time.Second) // 最大接続数 × 秒数 が目安

コネクションプールの設定によって、高負荷に対する耐性も上がりました。負荷試験を行ったところ、当初の実装では300req/sec程度の負荷をかけるとタイムアウトが頻発するようになっていましたが、sql.DBの使い回しとコネクションプールの設定チューニングによって、300req/secでも正常に結果を返すことができるようになりました。

むすび

今回はGo + gRPCというスタックでアプリケーションを開発した際の知見をいくつか紹介しました。一休では、引き続き、Go言語によるWebアプリケーション開発を進めていきたいと考えています。

doda.jp

API Test ライブラリ Tavern のご紹介

こんにちは。 一休.comの開発基盤を担当しています、akasakasです。

今回は、Tavern という API Test ライブラリ を紹介したいと思います。

一休でAPI Test が必要になった背景

前回のブログでも少し触れましたが、APIのテストを無理やりSeleniumを使ってテストを続けた結果、E2Eが破綻しました。

適切なレイヤーで適切なテストをしようということで、APIに関してはちゃんと API Test ライブラリ の導入を決めました。

user-first.ikyu.co.jp

API Test を導入する上で考えたポイント

API Test を導入する上で考えたポイントは以下の点です。

  • 開発者フレンドリー
  • CI連携

開発者フレンドリー

前回のブログでも書きましたが、一休ではQA・テストエンジニアのようなポジションはいないので、開発者がテストも修正するようになってます。

開発者が気軽にテストを追加・修正することができるような API Test ライブラリが必要だと考えました。

CI連携

一休で扱っているAPIは外部の提携先に提供しているAPIが多いです。

もしリリース後、APIで障害が発生した場合、外部の提携先にも影響が及びます。

リリース前の障害事前検知のため、検証環境で定期的にAPI Testを流して、リリース後の障害を防ぎたいです。

その為には、API Test を CIで回せるようにする必要がありました。

Tavernのご紹介

上記の要件を満たせるAPI Test ライブラリを検討した結果、Tavernと出会いました。

Tavern はAPI Test に特化した PyTest のプラグインです。

テストはYAMLで記述できるので、シンプルでわかりやすく、メンテナンスが簡単ですので、開発者フレンドリーであると感じました。

また、PyTestのプラグインでコマンドラインでテストの実行ができるので、CI連携もしやすいです。

API Testのツールでいうと、PostmanやInsomniaがありますが、GUIとして使うのが一般的で、CI連携が難しかったです(PostmanのCLIでNewmanというのがありますが、テストの記述がTavernよりも難しいと感じたので、Newmanの導入も見送りました)。

Tavern のいいところ

Tavern のいいところとしては以下の3点があると思います。

  • YAMLでテストを記述できる
  • 前のテストの結果を保存できる・次のテストに使える
  • CI連携

YAML でテストを記述できる

Github API を例にして Tavern を使って、API Test をしてみましょう。

Tavern で issue を作れることを確認します。

https://developer.github.com/v3/issues/#create-an-issue

先にテストからお見せします

test_name: Github API Test

includes:
  - !include common.yaml

stages:
  - name: Create Issue
    request:
      url: https://api.github.com/repos/{service.owner:s}/{service.repo:s}/issues
      headers:
        Authorization: "token {service.token:s}"
      method: POST
      json:
        title: "Issue From Tavern Test"
        body: "一休.comをご利用頂き、誠にありがとうございます。"
    response:
      status_code: 201
      body:
        title: "{tavern.request_vars.json.title}"
        body: "{tavern.request_vars.json.body}"
        state: "open"

YAMLで書かれているので、どんなことをしているのか、なんのテストをしているのかがイメージしやすいと思います。

  • request
    • url でテスト対象のエンドポイントを指定
    • header で Authorization Header を設定
    • jsonでポストするデータを指定 etc
  • reponse
    • 201 が返却されること
    • body の内容がrequestの内容と一致していること

などを記述していることがわかります。

共通のデータは common.yaml で記述しています。

common.yaml の内容としては以下のようなイメージです。

description: used for github api testing
name: test includes
variables:
  service:
    token: "token"
    owner: "リポジトリオーナー"
    repo: "リポジトリ"

テストの実行は

 tavern-ci test_github.tavern.yaml

で確認できます。

Tavern のお作法として、テストファイルのyamlは test_*.tavern.yaml という形で統一しているようです。

テストも通りました。

$ tavern-ci test_github.tavern.yaml
================================================== test session starts ==================================================
platform darwin -- Python 3.6.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0
plugins: tavern-0.22.1
collected 1 item                                                                                                        

test_github.tavern.yaml .                                                                                         [100%]

=============================================== 1 passed in 1.66 seconds ================================================

Tavern で Github API を使って、issueを作成することができました。

f:id:akasakas:20190427201406p:plain:w500

前のテストの結果を保存できる・次のテストに使える

Tavern の便利なところとして、前のテストの結果を保存して、それを次のテストに使うことができるというのもあります。

Github API を使って

  • Issue を作成
  • Issue の番号を取得
  • その番号のissueを編集する

ということをやってみましょう。

先にテストからお見せします

test_name: Github API Test

includes:
  - !include common.yaml

stages:
  - name: Create Issue
    request:
      url: https://api.github.com/repos/{service.owner:s}/{service.repo:s}/issues
      headers:
        Authorization: "token {service.token:s}"
      method: POST
      json:
        title: "Issue From Tavern Test"
        body: "一休.comをご利用頂き、誠にありがとうございます。"
    response:
      status_code: 201
      save:
        body:
          issue_id: number

  - name: Edit Issue
    request:
      url: https://api.github.com/repos/{service.owner:s}/{service.repo:s}/issues/{issue_id}
      headers:
        Authorization: "token {service.token:s}"
      method: PATCH
      json:
        title: "Edit Issue From Tavern"
        body: "一休レストランをご利用頂き、誠にありがとうございます。"
    response:
      status_code: 200
      body:
        state: "open"
  • Create issue のresponse body で issue_id を save
  • Edit Issue の request url で issue_id を使って、テスト

ということをやっています。

Tavern で Github API を使って、issueの作成と編集をすることができました。

f:id:akasakas:20190427202300p:plain

CI連携

tavern のインストール自体は、 pip install tavern のみで済みますし、

テストの実行自体も tavern-ci test_*.tavern.yaml で済むので、CI連携も容易です。

Tavern の実運用にまつわる細かい話

Tavern の実運用にまつわる細かい話としては

  • 環境別のテストデータの設定

が気になるところなのかなと思います。

これに関しては、

https://tavern.readthedocs.io/en/latest/basics.html?#multiple-global-configuration-files

に書いてありますが、各環境別で使うYAML を用意します。

まとめ

今回は、 Tavern を使ったAPI Test について紹介しました。

「API Test ライブラリといえば、コレだ!」というものがない印象でしたが、

  • テストを YAML で記述できて、イメージしやすい
  • CI連携の容易

という点で、 Tavern は選択肢の一つとしてアリなのかなと思いました。

参考

github.com

taverntesting.github.io

tavern.readthedocs.io

E2EテストをSelenium Webdriver からCypress.io に移行した話

こんにちは。 一休.comの開発基盤を担当しています、akasakasです。

今回は、E2EテストをSelenium WebdriverからCypress.ioに移行した話をしたいと思います。

一休のE2Eテスト事情

一休では staging/production へのリリース完了をフックにして、主要導線に対してE2Eテストを実施しています。

これを実施している主な理由としては

  • 検証環境での障害の事前検知
  • リリース後も正常に予約ができるかどうかの確認
    • ECサイトで予約を止めるのは致命的なので、これを防ぐ

があります。

詳しくはこちらのスライドに書いてあるので、興味のある方はみてください。

speakerdeck.com

あれから、数年が経過して、、、

完全に動かなくなりました。悲しいです。

f:id:akasakas:20190421204926p:plain

どうしてこうなった???

理由としては

  • SelniumではSPAへの対応が難しくなってきた
  • なんでもかんでもSeleniumに任せようとした弊害

がありました

SeleniumではSPAへの対応が難しくなってきた

一休ではSPA化が徐々に進んできています。

具体的な取り組みについては下記のエントリで紹介しているので、ご興味があれば、ご覧ください。

user-first.ikyu.co.jp

user-first.ikyu.co.jp

Selnieum Webderiver は画面遷移をしていくMPAに対して、効果を発揮するブラウザテストツールであり、

  • 非同期リクエストや動的な画面の書き換え
  • 画面遷移が発生しない

SPAでSelenium Webdriverを使って安定したテストを継続していくのが困難でした。

Wait処理などを上手く使えば、不可能ではないですが、一休ではQA・テストエンジニアのようなポジションはいなく、開発者がテストも修正するようになってます。

開発者にテストを書く負担を減らして、サービス開発に集中して欲しいというのも思いとしてありました。

なんでもかんでもSeleniumで頑張ろうとした弊害

一休.com ではUTが充実していないためか、「なんでもかんでもSeleniumでテストしよう」みたいな雰囲気がありました。

具体的には、APIの疎通確認をしたいが為に、SwaggerUIのようなテスト用の画面を作成し、その画面をSeleniumを使って、APIの疎通確認を行っていました。

APIのテストをわざわざブラウザテストをする必要はないです。 ただでさえ、ブラウザテストは不安定で時間がかかるので、適切なレイヤーで適切なテストができていないというアンチパターンに陥っていました。

いざリプレイスへ・リプレイスをする上で気をつけたこと

上記の理由からSeleniumから別のブラウザテストツールの移行を検討しました

単純なツールの乗り換えだけだと、同じ過ちを繰り返す恐れがあったので、下記の点を注意しました。

  • 開発者フレンドリー
  • 安定性
  • 然るべきレイヤーでテストする(何でもかんでもブラウザテストにしない)

開発者フレンドリー

Selneiumの課題として、セットアップが面倒というのがありました。

開発者にテストへの時間を軽減して、サービス開発に集中して欲しいというのも思いがあったので、下記の点を重視しました。

  • セットアップの敷居が低いこと
  • 開発者が容易にテストを作ることができる

安定性

言わずもがなですが、「移行したはいいが、テストが落ちまくっている」というのは有り得ないので、

  • SPAでも安定してテストが動く

ということにフォーカスしました

然るべきレイヤーでテストする(何でもかんでもブラウザテストにしない)

前述でも書きましたが、

  • APIのテストを無理やりSeleniumで書いていた

というのが、テストの安定性を損ねていた原因の一つでした。

この問題に関しては、APIテストライブラリを導入して、ブラウザテストとは切り分けました。

APIテストライブラリに導入については後日、どこかで書きたいと思います。

技術選定

ブラウザテストでSeleniumからどのツールを選ぼうかを考えた際に、以下の3つが選択肢としてありました

  • WebdriverIO
  • Puppeteer
  • Cypress.io

どの技術を採用するかで重要視したポイントが「開発者フレンドリー」であるかです。

具体的には

  • セットアップ
  • 書きやすさ

の2点です。

f:id:akasakas:20190421211638p:plain

セットアップという点だと、Puppeteer・Cypress.ioがいい印象でした。

書きやすさで見た場合、Cypress.ioの方がテストを書くことに集中できると思ったので、Cypress.ioを採用することに決めました。

Cypress.io とは?

JavaScript製のブラウザテストに特化したE2Eテストフレームワークです。

Seleniumはテストを書くこと以外にもスクレイピング等の用途で使うことができますが、 Cypress.ioはテストを書くことに特化したE2Eテストツールです。

Cypress.io のいいところ

Cypress.io の特徴は色々あると思いますが、個人的に感じるところとしては、次の3点が大きいと思います。

  • セットアップが楽
  • テストを書くことだけに集中できる
  • CI連携が楽

セットアップが楽

Cypress.io はセットアップが非常に簡単です。

npm install cypress

これだけで終わりです。

SeleniumだとGeckodriverやChromedriverをインストールしたり、パス設定したりと、 少し手間がかかるので、セットアップの敷居が低いという点で、非常にありがたいです。

テストを書くことだけに集中できる

SeleniumやPuppeteerを選ぶと、

  • テストランナーどれを選ぼう
  • レポーティングはどれにしよう
  • アサートのライブラリはどれにしよう

などといったところも考えると思います。

Cypress.io はオールインワンでサポートしているので、テストを書くことだけに集中することができます。

https://www.cypress.io/how-it-works/ で紹介されている、下記の図のようなイメージです。

f:id:akasakas:20190421205119p:plain

CI連携が楽

CI連携が楽という点も個人的にはありがたかったです。

  • DockerImageが用意されている
  • 各CI Provider に対して、 example project が用意されていて、わかりやすい

こちらに詳細が書かれているので、興味のある方はご覧ください。

https://docs.cypress.io/guides/guides/continuous-integration.html

Cypress.io の頑張って欲しいところ

Cypress.io に対する不満はそんなにありませんが、あえて1点だけ挙げるなら

  • クロスブラウザ未対応

という点です。

一休で、E2Eテストを実施している目的は

  • 主要導線が正常に動くことを確認すること

なので、クロスブラウザで確認する必要性はないです(確認するに越したことはありませんが)

Cypress でもOpen Issue として挙げられているので、今後クロスブラウザ対応がされる日が来るかもしれません(いつになるのかはわかりませんが)

Proposal: Support for Cross Browser Testing · Issue #310 · cypress-io/cypress · GitHub

その他、移行に関しての細かい話

あと、移行に関する細かい話としては以下の3つがあります

  • 重複テストケースの排除
  • Page Object Design Patternで設計
  • 移行に乗じて、CIもJenkinsからCircleCIに変更

重複テストケースの排除

既存のテストケースを見直すと、同じようなことをテストしている部分があったので、 移行の際にテストケースを精査して、必要最低限のテストケースを実施するようにしました。

Page Object Design Patternで設計

既存のSeleiumでもPage Object Design Pattern を採用しましたが、 画面変更に対して強い設計方法なので、ここは変えませんでした。

移行に乗じて、CIもJenkinsからCircleCIに変更

以前はCIのためにオンプレサーバのJenkinsを用意していましたが、Jenkins起因でE2Eテストが失敗することもしばしばありました。 テストの安定性を考えた場合、CIも乗り換えた方がいいと感じていたので、このタイミングでCircleCIで実行するように変更しました。

そして、E2Eは復活し、平和はおとずれた

かくかくしかじかありまして、E2Eテストが復活しました。めでたしめでたし。

f:id:akasakas:20190421205259p:plain

まとめ

今回は、E2EテストをSelenium WebdriverからCypress.ioに移行した話をしました。

Seleniumがよくないとか、Cypress.ioがいいという話ではなく、 一休のサービス開発が進んでいった結果、SeleniumによるE2Eテストが難しくなり、今回Cypress.ioへの移行をしました。

Cypress.io の利点としては、上述でも書いた通り

  • 開発者フレンドリー

であることだと感じます。

一休のようにQAやテストエンジニアがいなく、開発者がE2Eテストを修正するようなワークフローになっている開発現場ではCypress.ioを採用するのは選択肢の一つとして、ありなのかなと思います。

しかし、正直な話、この仕組みも数年後には破綻するかもしれません。 その時はまたサクッと捨てて、その時に一休のサービス・開発現場にマッチする新しい仕組みに乗り換えればいいと思います。 そういうことができるようにブラウザレベルのテストを極力書かないほうがいいのかもしれません。

また、この数年間、一休.com を守ってくれたSeleniumには感謝と敬意を払いながら、未来のために新システムに切り替えていきたいです。

Rendertron導入でDynamic Renderingしている話

こんにちは。 一休.comの開発基盤を担当しています、akasakasです。

今回は、Rendertronを導入してDynamic Renderingをしている話をしたいと思います。

ここでお話しする内容

  • Dynamic Renderingについて
  • 一休.com/一休レストランでDynamic Renderingが必要になった背景
  • Rendertron とは
  • Rendertron にした理由
  • Rendertron 導入イメージ
  • クローキングの懸念
  • 苦労話
    • Rendertronのモバイル対応がバグってた
    • Rendertronのメモリリーク
    • AMPページに対してDynamic Renderingを適用するとレンダリング後が評価されてしまって正常なAMPとして認識されない
  • 学び
    • できたてのライブラリは不完全(どこかしらにバグは潜んでいる)
    • Dynamic Renderingさせるべき画面・させないほうがいい画面を明確にする
  • おまけ:Dynamic Renderingについて思うこと

Dynamic Renderingについて

Dynamic Renderingは、ユーザ向けのリクエストは正常に処理し、bot向けのリクエストはレンダラを経由し、静的HTMLを配信する方法です。

f:id:akasakas:20190308205632p:plain

Dynamic Renderingについては、いろんなところで言及されているので、詳細については割愛します。

詳しくはこちらに書かれているので、興味のある方はご覧ください。

ダイナミック レンダリングの使用方法  |  検索  |  Google Developers

Google ウェブマスター向け公式ブログ: Rendertron によるダイナミック レンダリング

一休.com/一休レストランでDynamic Renderingが必要になった背景

一休.comも一休レストランもSPA化が進んできたというが大きいです。

一休.comの場合

ホテルリストページ スマホ版の速度改善でmetaタグ等をJSで書いて、Full CSRにして、ページスピードを上げていきたいという背景がありました。

詳しくはこちらで書かれていますので、興味のある方はご覧いただければと思います。

user-first.ikyu.co.jp

一休レストランの場合

  • スマホページのSPA化対応後、SEO対策のためにフロント実装が複雑化し、パフォーマンス劣化を助長する形になっていた
  • 検索結果の取得は 1 つの API で出来るのに SEO 上必要な文字列を SSR で描画するため複数の API の待ち合わせをする必要があった
  • またそれらの SSR する情報は必ずしも Above the fold で表示されるコンテンツではないためパフォーマンスの観点で言えば遅延描画するのが合理的だった
  • SEO とユーザーパフォーマンス改善の 2 つがコンフリクトしている現実を突き付けられた
  • Dynamic Rendering ならそれぞれの用途に最適化した結果を返せる

user-first.ikyu.co.jp

一休.com・一休レストランともにDynamic Rendering の必要性が増してきたため、導入を検討しました。

Rendertronとは

Rendertron は Headless Chrome (Puppeteer) をベースとしたレンダラです。

github.com

Rendertronの役割としては

  • レンダリングさせるURLを受け取る
  • 受け取ったURLのJavaScriptまで実行と描画
  • 静的HTMLをレスポンスとして返す

といったところです。

デモ用のエンドポイントもあるので、これを触ればRendertronのことをある程度知ることができると思います。

https://render-tron.appspot.com/

Rendertronを採用した理由

正直な話、ちゃんとした理由はないです(笑)

  • id:supercalifragilisticexpiali がサクッとDockerfileを作ってくれた
  • ST/PRDまでの環境もサクッとできた
  • とりあえずできたから、本番投入して試してみよう

という勢いです。

Redertron以外の選択肢として、以下の2つもアリだと思うので、興味のある方は試してみていいと思います。

github.com

prerender.io

Rendertron 導入イメージ

導入イメージは下記になります。

f:id:akasakas:20190308205321p:plain

やっていることは2つです。

  • Fastlyでルーティングとレスポンスキャッシュ
  • レンダラはRendertronに任せる

という流れです

クローキングの懸念

Dynamic Rendering自体がGoogleお墨付きの手法なので、問題ないとは思いつつも不安でした。

モバイルフレンドリーテストや Fetch As Googleで問題ないのは確認済みだったのですが、それでもちょっと不安だったので、部分導入でSEO面で問題ないかどうかという検証を行いました。

結果としては、問題なかったです。

苦労話

Rendertronのモバイル対応がバグってた

Rendertron導入検討時はモバイル対応がバグってました。

現在は解消中ですが、下記のプルリクエストがマージされる前は、これを取りこんでいました

Fix mobile rendering by danielpoonwj · Pull Request #234 · GoogleChrome/rendertron · GitHub

Rendertronのメモリリーク

Redertronを数日間、運用してみると、メモリ使用量がどんどん増えていくのがわかりました。

http status code 400 か 403 の場合は puppeteerが起動したままになり、プロセスが残り続け、メモリを食いつぶしていくということになってました。

下記のプルリクエストで対応はしておりますが、 2019/03/12 時点ではまだマージされてないので、これを取り込んで、なんとか乗り切ってます。

Always close page before return to prevent memory leak by ramadimasatria · Pull Request #268 · GoogleChrome/rendertron · GitHub

AMPページに対してDynamic Renderingを適用するとレンダリング後が評価されてしまって正常なAMPとして認識されない

Rendertron導入当初、AMPページもDynamic Renderingの対象ページとして含めていました。

しかし、AMPページに対してDynamic Renderingを適用するとレンダリング後が評価されてしまって正常なAMPとして認識されないので、AMPページはDynamic Renderingさせないようにしました。

学び

できたてのライブラリは不完全(どこかしらにバグは潜んでいる)

単純に rendertronを git clone すれば、バグはないし、当然のように運用できるだろうと思ってましたが、その認識が甘かったです。

Dynamic Renderingさせるべき画面・させないほうがいい画面を明確にする

AMPページもそうですが、Dynamic Renderingさせるべき画面・させないほうがいい画面を明確にして、ルーティングを設定するのが重要だなと思いました。

Dynamice Rendering する必要のない画面に対してやっても、ダウンロード時間が余計にかかるため、SEOのスコアを落としかねないです。

Dynamic Rendering の使用方法 でも書かれています。

ダイナミック レンダリングは、JavaScript で生成される変更頻度の高いインデックス登録可能な一般公開コンテンツや、サイト運営が重視するクローラではサポートされていない JavaScript の機能を使用するコンテンツに適しています。 すべてのサイトでダイナミック レンダリングを使用する必要はありません。 ダイナミック レンダリングはクローラ向けの回避策であることに注意してください。

おまけ:Dynamic Renderingについて思うこと

Dynamic Renderingという手法自体が過渡期の技術という印象を受けました。

個人的に思うDynamic Renderingに関する違和感として

  • ユーザーに見せる画面とクローラに見せる画面を異なるように扱う
  • Googleが技術的にできないことを、こちら側でカバーしなければいけない

というのがあるのかなと思います。

Chrome Dev Summit 2018 でFuture Enhancement として古い(Chrome41相当)レンダリングエンジンを解消すると言ってましたが、将来的には、Googlebotが最新のChromeになって、普通にJSを実行しているという未来が早く来て欲しいです。

preloadで画像の表示速度を改善する

宿泊事業本部フロントエンドエンジニアの宇都宮です。先日、ホテルリストページの高速化に関する記事を書きましたが、Resource Hintsのpreloadを利用することで、さらに高速化できました。そこで、preloadによる画像読み込みの最適化方法を紹介します。

以前の記事はこちら:

一休.comホテルリストの表示速度を従来比2倍にしました - 一休.com Developers Blog

また、今回改善対象としたページには下記URLからアクセスできます(スマホでアクセスするか、PCからの場合はUAを偽装する必要があります)。

https://www.ikyu.com/sd/tokyo/140000/

改善前

f:id:ryo-utsunomiya:20190227154455p:plain:w480
PageSpeed Insights: 改善前

f:id:ryo-utsunomiya:20190227155059p:plain:w480
Calibre: 改善前

改善後

f:id:ryo-utsunomiya:20190304172545p:plain:w480
PageSpeed Insights: 改善後

f:id:ryo-utsunomiya:20190304175204p:plain:w480
Calibre: 改善後

今回の改善のターゲットは、施設の画像を早く取得して、画面の主要部分を素早く描画することです。したがって、見るべき指標はSpeed Indexで、ここは約0.7秒改善しています!

多少のブレはありますが、PageSpeed Insights/WebPageTest/Calibreでの複数回の計測でいずれも改善という結果だったので、画像のpreloadは「効果あり」と見てよいと思います。

やったこと

改善前のリストページでは、ページ表示の最終段階で、各施設の大きめの画像を取得するリクエストが走っていました。

f:id:ryo-utsunomiya:20190301172328p:plain:w480
改善前の施設画像取得リクエスト

このようになっている理由は、施設画像の取得リクエストが走るまでに以下のステップを踏む必要があったからです。

  1. 検索APIのレスポンス取得
  2. 検索結果のレンダリング完了
  3. lazyloadの発火

そこで、検索APIのレスポンス取得が終わったタイミングで、施設画像のpreloadを行っては? と思い、実装してみました。

preloadは、リソースの取得処理が実際に発火するよりも先に、ブラウザに「将来このリソースを取得します」と教えることで、ブラウザがリソースを先読みしてキャッシュできるようにする機能です。

developer.mozilla.org

具体的には、HTMLの <link rel="preload"> という要素にpreloadしたいリソースの種別(as)とURL(href)を書いておくと、ブラウザがこのリソースを先に読んでおいてくれます。

JavaScriptでpreloadを実行する場合、以下のような実装になると思います。preloadをサポートしているのはiOS 11.3以上なので、feature detectionは必須です。

// preloadのfeature detection
const supportsPreload = (() => {
  try {
    return document.createElement('link').relList.supports('preload');
  } catch (e) {
    return false;
  }
})();

/**
 * 指定したリソースをpreloadする
 * @param {string} href
 * @param {string} as
 */
function preload(href, as) {
  if (!supportsPreload) return;

  const link = document.createElement('link');
  link.setAttribute('rel', 'preload');
  link.setAttribute('as', as);
  link.setAttribute('href', href);
  link.onload = () => document.head.removeChild(link);
  document.head.appendChild(link);
}

/**
 * 画像をpreloadする
 * @param {string} href
 */
function preloadImage(href) {
  preload(href, 'image');
}

preloadの呼び出し側はこんな感じです。

// ファーストビューに入る施設の画像をpreload
searchResult.accommodationList
  .slice(0, Math.round(window.innerHeight / 300))
  .forEach(a => preloadImage(a.imageUrl));

これによって、以下のように、preloadした画像が優先的に読み込まれるようになりました。

f:id:ryo-utsunomiya:20190304173712p:plain:w480
改善後の施設画像取得リクエスト

この結果、ファーストビューが完全に描画されるまでの時間が短くなりました。

なお、まだviewportに入っていない施設の画像は従来通りlazyloadしているため、リクエストの終盤になっています。

「大きめの画像を全てpreloadする」 vs 「ファーストビューで見える画像のみpreloadする」で比較すると、後者の方が低速回線時のSpeed Indexが良くなったため、ファーストビューで見える画像のみpreloadしています。

まとめ

今回のように、大きめの画像をページ表示の後半で取得しているような場合には、preloadによって一定のパフォーマンス改善効果が得られることがわかりました。 preloadすることでパフォーマンスが改善されるかはアプリケーションの要件次第ですが、簡単に実装できるので、引き出しに入れておくと良いと思います。

一休.comホテルリストの表示速度を従来比2倍にしました

宿泊事業本部フロントエンドエンジニアの宇都宮です。

2018年度下期は、一休.comホテルリストページ スマホ版の速度改善に取り組んできました。その結果、ページのデザインはそのまま、機能面はリッチにしつつ、プロジェクト開始前の約2倍のスピードでページが表示されるようになりました。

本記事では、高速化のためにどのような施策を行ったのか紹介します。

なお、Webサイトの高速化手法については、ホテル詳細ページ高速化プロジェクトを実施した際にも記事を書いています。これらの記事で紹介している手法(たとえば、Imgixによる画像最適化等)については、記述を省略しています。あわせてご覧ください。

また、今回高速化の対象としたホテルリストページには以下のURLでアクセスできます(スマホで開くか、PCの場合はUAをスマホに偽装する必要があります)

https://www.ikyu.com/sd/tokyo/140000/

f:id:ryo-utsunomiya:20190227130107p:plain:w320
ホテルリストページ

プロジェクト開始前の状況

下記画像は、プロジェクト開始前の、PageSpeed Insightsの計測結果です。

f:id:ryo-utsunomiya:20190227094518p:plain:w480
PageSpeed Insights: プロジェクト開始前

パフォーマンス監視SaaSのCalibreでは、計測結果は以下のようになっていました。

f:id:ryo-utsunomiya:20190227094944p:plain:w480
Calibre: プロジェクト開始前

遅い4G回線相当の設定(下り1.4Mbps)とはいえ、Time to Interactiveに10秒以上かかっているのは遅いです。主要な指標(First Meaningful Paint、Speed Index、Time to Interactive)をそれぞれ半分にして、FMP 2秒、Speed Index 2.5秒、Time To Interactive 5.5秒くらいになれば、低速回線でもある程度快適に使えるサイトといえるでしょう。

改善結果

PageSpeed Insightsのスコアは従来の2倍以上になりました。

f:id:ryo-utsunomiya:20190227154455p:plain:w480
PageSpeed Insights: 改善後

Calibreでも指標が軒並み改善しています。TTIがFMPやSpeed Indexよりも早くなっているのは、APIレスポンス待ちでCPUがIdleになっているタイミングがあるからだと思われます。FMPまでの時間は半減しています。Speed Indexも改善していますが、もう一声ほしいところ。

f:id:ryo-utsunomiya:20190227155059p:plain:w480
Calibre: 改善後

計測が難しいため直接の指標にはしていませんでしたが、トップ(https://www.ikyu.com/sd/) => ホテルリストの遷移スピードは、体感的にはかなり速くなったように感じられます。これはFirst Contentful Paintの大幅改善(3.69s => 0.65s)が効いていそうです。


(2019-03-04 追記)本記事の「改善後」よりさらに高速化しました。2019-03-04現在のパフォーマンスは下記記事を参照してください:

user-first.ikyu.co.jp

やったこと

パフォーマンス目標値の設定

高速化を実施するには、まず目標となる値を設定する必要があります。目標設定に際しては、(1) 自分たちのサイトの要件から実現可能な数値であること (2) 競合と比較して遅くないこと の2点が重要だと考えています。また、可能であれば、競合よりも速くして、速度で差別化できると、なお良いでしょう。

今回のプロジェクトでは、Expediaのスマホ向けリストページをベンチマークにして、高速化に取り組みました。

このページは高度な最適化を施されており、PageSpeed Insightsのスコアは63点と、競合の中でも群を抜いて速いページです。

f:id:ryo-utsunomiya:20190227095620p:plain:w480
PageSpeed Insights: Expedia

そこで、今回のプロジェクトでは、以下の2段階のゴールを設けて高速化に取り組みました。

  1. PageSpeed Insights 50点以上、主要指標(FMP/Speed Index/TTI)で20%以上の改善
  2. PageSpeed Insights 65点以上、TTI 5秒以内

1は最低限達成したいゴール、2はややチャレンジングなゴールです。

施策1: ikyu-analytics-clientの最適化

一休では、アクセス解析ツールを内製しています。これはikyu-analyticsと呼ばれ、一休.com等では、ikyu-analyticsのクライアントライブラリを読み込んで使用しています。

パフォーマンスの観点で、ikyu-analytics-clientには大きな問題がありました。アクセスログの記録を 同期XHRで 行っていたのです。

私自身、Firefox等で、メインスレッド同期XHRのDeprecation Warningが出ていることは以前から認識していました。が、これがどの程度悪影響を及ぼしているのかは、分かっていませんでした。

しかし、昨年12月、WebPageTestのBlock機能を使ってikyu-analytics-clientの読み込みを行わないようにしたところ、ページの読み込み完了までの時間が約1秒短くなることに気づきました。

ikyu-analytics-clientのJSはサイズも小さく、やってることもアクセスログの送信程度です。したがって、ikyu-analytics-clientがもたらしている遅延のほとんどは、同期XHRのレスポンス待ち時間だと推測しました。

そこで、データサイエンス部と連携して、ikyu-analytics-clientのアクセスログ送信の非同期化に取り組みました。

具体的には、 navigator.sendBeacon()が使える場合はこれを使い、使えない場合は非同期XHRを行うようにしました。

navigator.sendBeacon() は比較的新しいAPIで、iOSでは11.1以上でないと使えません。非同期なデータ送信を確実に行えるという、非同期XHRにはない特長を持っています。一方、非同期XHRは送信中に画面を遷移したりページを閉じたりすると送信がキャンセルされます。これについては、データサイエンス部と協議し、非同期XHRのキャンセルによるログの送信失敗は許容する、という合意を取りました。

ikyu-analytics-clientの非同期化後、PageSpeed Insightsのスコアは10点改善しました。

f:id:ryo-utsunomiya:20190227103703p:plain:w480
PageSpeed Insights: ikyu-analytics-client非同期化後

ikyu-analytics-clientは、一休.comの全ページのみならず、一休レストランなどでも使用されているため、一休が運営しているサービス全体で、読み込み完了が1秒速くなりました。

逆にいうと、ikyu-analytics-clientが同期XHRを使っていたことで、一休のサービス全体が1秒遅くなっていたということです。 ブラウザのWarningにはちゃんと耳を傾けるべし という教訓を得られました。

施策2: コードの大幅な書き直し

ホテルリストページは、従来、ASP.NET WebForms + jQueryというスタックで実装されていました。これらを、ホテルページと同様、ASP.NET MVC(+Web API) + Vue.jsというスタックに置き換えました。

また、機能面では、検索実行時に毎回画面遷移していたのを改め、ページ内で再検索が行われるようにしました。

速いJavaScript/Vue.jsアプリケーションを書くための方法については、下記記事に書いた内容を踏襲しているので、省略します。

一休.comスマホサイトのパフォーマンス改善(JavaScript編) - 一休.com Developers Blog

これに加えて、リストページの実装において特徴的なこととして、今回、Vuexは使いませんでした。

Vuexを使わなかった理由は、リストページはデータの流れがシンプル(検索APIからレスポンスを受け取り、描画するだけ)かつ、コンポーネントが素直なピラミッド構造になっていて、props down/events upで必要なデータの受け渡しを全て表現できたためです。また、Vuex Storeのコードは肥大化しがちなため、パフォーマンスの観点からの懸念もありました。

ただし、Vuexを完全に捨てたわけではなく、今後の改修で必要になれば、Vuexを導入する可能性はあります。

この書き直しによって、パフォーマンスは大きく改善しました。

f:id:ryo-utsunomiya:20190227105859p:plain:w480
PageSpeed Insights: リライト後

この時点で、ストレッチゴールの「PageSpeed Insights 65点以上」を達成できました 💪

施策3: 初回検索のAjax化

当初の目標を超えることができましたが、もう一押し改善できそうなポイントが残っていました。

施策2までの段階では、ページ初回表示時の検索処理は、サーバサイドで行っていました。SEOのためのtitleタグ、metaタグや、SNS等で共有する際に必要な情報(twitter card、facebook OGP等)を書くには検索結果を知っている必要があります。また、Botの中にはJavaScriptを実行しないものもあります。したがって、SEO関係のタグは、サーバサイドで書いて、初回レスポンスのHTMLに含める必要がありました。

一方、パフォーマンス改善の実験として、画面の初期表示時にサーバサイドで検索を行わずAjaxで行うようにしたところ、FCP/FMP/Time to Interactiveのそれぞれについて0.5秒程度の改善が見込めることがわかりました。

SEO関係のタグもJavaScriptで書くようにできれば、初回表示時に検索をサーバサイドで行う必要がなくなり、画面の初期表示はさらに速くできます。この問題の解決のためには、2つのアプローチが考えられました。1つはSSR、もう1つはDynamic Renderingです。

SSR(Nuxt.js)を採用しなかった理由

SSRを行って、JSの初回レンダリングの終わったHTMLを返すようにすれば、SEOの問題は解決します。

しかし、結論からいうと、SSR(Nuxt.js)は採用しませんでした。一休.comのアプリケーションの特性を考えると、SSRの導入によって遅くなる可能性が高いと考えたためです。

下記画像は先日Googleが公開したRendering on the Webというドキュメントから抜粋したものです。

f:id:ryo-utsunomiya:20190227112031p:plain
Rendering on the Web

Nuxt.jsを使ったSSRは、この表の「SSR with (Re)hydration」に該当します。一方、現行の実装は「Full CSR」です。この2つを見比べると、「SSR with (Re)hydration」の方がConsが増えているのがわかります。

SSR with (Re)hydrationは、サーバサイドでレンダリングを行い、フロントエンドでも、Full CSRと同等のリソースを読み込んで、状態の引き継ぎ(Rehydration)を行います。単純に考えると、Rehydrationの分、Full CSRと比べて計算量が増えます。

実際には、SSRを入れると遅くなるという単純な話ではなく、SSRでしかできない最適化を入れることで、Full CSRより速くできます。

しかし、「SSR後のHTMLをCDNでキャッシュする」という強力な最適化手法は、一休.comのアプリケーション要件では、あまり効果的ではありません。宿泊日程等の検索条件に応じて細かく画面を出し分ける必要があるため、同じHTMLを返却できるリクエストの数が多くないためです。

アプリケーション要件の見直しを行い、キャッシュフレンドリーな設計にすればSSRを導入する余地があります。しかし、今回のプロジェクトのスコープにはUIの刷新は含まれていません。現状の画面仕様では、Full CSRの方がパフォーマンスが出ると判断しました。

Dynamic Renderingの導入

Dynamic Renderingとは、bot向けに静的HTMLを配信する方法です。これによって、JS描画済みの静的HTMLをGooglebot等が取得するようになるので、metaタグ等をJSで書いても問題なくなります。

詳細は下記ドキュメントを参照してください。

ダイナミック レンダリングの使用方法  |  検索  |  Google Developers

一休では、Rendertronを使ってDynamic Renderingを行っています。 Rendertronの導入にあたっては色々苦労もあったようですが、これについては akasakas さんが書いてくれると思うので、ここでは詳しく触れません。

Dynamic Renderingによって、SEO関係のタグをJSで書く準備が整ったため、初回検索のAjax化に至りました。

この結果、冒頭の「改善結果」で紹介しているパフォーマンスが実現できました。

今後の展望

フロントエンドに関しては、パフォーマンス上のボトルネックのほとんどを解消した状態にもっていきました。一方、サーバサイドの検索APIについては、レスポンス速度がまちまちで、遅いときは1.5秒ほどかかることがあります。さらなる高速化のためには、検索APIの速度改善が必要そうです。

また、スムーズに宿泊施設を探すには、検索導線(トップ・リスト・ホテル)の全体的な回遊性が重要です。検索導線のSPA化等によって、ページ間のスムーズな移動を実現するような施策も検討しています。

We are hiring

hrmos.co

クラウド移行とSREについて講演をしました。

当社のクラウド移行とSREについて講演をしました

2019/1/30にitsearch+様で当社のクラウド移行とSREについて講演をしました。

news.mynavi.jp

発表資料はこちらです。ぜひ、ご覧ください。

speakerdeck.com

昨年11月に書いた以下の記事の内容に具体的な事例を交えつつ、当社のSREの取り組み方について発表をしました。

user-first.ikyu.co.jp

発表にも書きました通り、今後もコンテナ技術等、新しい技術を活用しつつ、ビジネスの成長を支える技術基盤開発、SREを実践していきたいと思います。We are hiring!!

hrmos.co

hrmos.co

おまけ

最近勉強になったSRE関連のリソース


この記事の筆者について

  • システム本部CTO室所属の 徳武 です。
  • サービスの技術基盤の開発運用、開発支援、SREを行なっています。