アーキテクチャ編: SSR と CDN ( Fastly ) とユーザー依存情報の分離(新規開発のメモ書きシリーズ4)

新規開発のメモ書きのラスト

シリーズだったはずなのに、色々あって前回のエントリから1ヶ月あきました。_:(´ཀ`」 ∠):

今回の話の中心は結果的に「Server Side Rendering との折り合いの付け方」と「Fastly を利用した動的コンテンツのキャッシュ戦略」です。

このシリーズの他の記事はこちら。

まずは全体的なアーキテクチャ像

次の図はアーキテクチャの全体像です。クライアントサイド寄りの範囲を中心に書いているため、バックエンドな Microservice 群以降がおざなりな図ではありますがご容赦ください。


GCP を使った Microservice 群と、Backend For Frontend なサーバ、CDN(Fastly, hayabusa)

アーキテクチャの図


主要リソースは Fastly を通じて配信し、入稿画像リソース(コンテンツのサムネとか)は hayabusa という Image Proxy を利用して配信しています。総じてリソースの配信は CDN 利用を前提としたアーキテクチャになっています。hayabusa はリクエスト URL にパラメータ与えるとリサイズや最適化をオンデマンドに適用して返してくれる社内プロダクトで、OSS 化も目指しているらしいです。

アーキテクチャ検討のポイント

最優先はページロードの速度で、その次に怠惰(障害点の削減と管理コストの低減)を重要視しています。

  • SNS 流入が重要なため First Meaningful Paint を確実に速くしたい
  • クラサバ2重テンプレート問題は避けたい
  • Server Side Rendering の CPU-bound な問題を回避したい
  • web が管理するミドルウェアは極力増やしたくない

怠惰は非常に重要です。重要な課題を前にして、それを上回る複雑性で解決してしまうことは短期的には正義ですが中長期で邪悪の根源となりえると考えているからです。

Server Side Rendering − HTML 生成に一貫性をもたせ、FMP を速くする

ここでいう HTML 生成の一貫性とはサーバーとクライアントの双方で行われるHTMLテンプレート(今ドキはコンポーネントというべきか)を元に HTML をレンダリングする処理であり、FMP は First Meaningful Paint という何ともファジーなページロード速度の指標です。

いつぞやの SSR なしの構成 も検討し直しましたが、Service Worker と HTTP/2 Server Push に依存する部分が大きく、後者はともかく前者についてはカバレッジを現実的に考えると残念ながら時期尚早ということで見送りました。と、純 SPA のパフォーマンス対策として AppShell モデルを取り入れるには、他にも検討すべきことがあったというのもあり云々。(一応後述)

Fluxible の採用

SSR ( + SPA ) の実現には、シリーズの冒頭の技術選定でも紹介しましたが fluxible を利用しています。fluxible については過去の紹介記事 などを参考にしてください。今回は以前開発したプロダクトで浮き彫りになった SSR の課題感と正面から向き合うために、このへんは手堅い構成にしています。

CPU-bound な性能面と対障害面の対策

renderToString というか renderToNodeStream というかですが、やはり何の策もなく Node サーバーにリクエストを受けさせるのは望ましくありません。

  • HTML の生成にまつわる処理時間が素直にかさむ(性能面)
  • 素直にリクエストを受けすぎると Node サーバの負荷が高まる(耐障害面)

考えられる戦略としてはやはり「キャッシュ」ということになりますが、どこをどうキャッシュをするかが判断の分かれ目でしょう。

  • コンポーネントのレンダリング結果を中間キャッシュする (walmartlabs/react-ssr-optimizationみたいなの)
  • SSR する BFF の奥にいる API レスポンスを LRU とかでオンメモリキャッシュする
  • サーバーが返した HTML を Web サーバよりあとのミドルウェアでキャッシュする(今回採用した方式)

基本的な戦略やアプローチは同じ会社の @houbin217jzアメブロ: Isomprhicアプリケーションのパフォーマンス・チューニング でまとめてくれています。

多重化されたキャッシュレイヤーとそれを管理する Purge ルールは有効である一方、システム的な複雑性を増やすことにもなってしまいます。しかし HTML まるごとキャッシュとなるとユーザー依存情報を含むページの扱いが悩ましくなるのですが、今回は結果として Fastly を利用して HTML をまるごとキャッシュする戦略をとりました。(詳細後述)

残る負荷の面については、Node.js のイベントループ遅延やら pod のロードアベレージが閾値を超えると SSR を諦めて純 SPA 向けの動的情報を含まない HTML を返却する middleware も書いてみました。hapijs/heavy とか使ってます。これは Fastly の採用で出番がほぼなくなりそうです。正直、ある程度は金の弾丸で水平にスケールさせて解決することも可能ですが、スパイク vs オートスケールでヒヤヒヤするのも精神衛生に良くないので目処がついて一安心。

オマケでついてくる SPA をオフにするか否か

Fluxible を使っている都合、SSR のついでに SPA がついてきています。せっかくなので今のところ有効にしていますが、中長期で誰かに運用をしてもらうことを考えるといずれ無効にしたほうがよいのかもしれません。

SPA (というか pushState というか) を無効にした場合、ナビゲーションするたびに Web ページの初期化処理が乗ってくるため TTI ( Time to Interactive ) が気になってきます。この点は preact への移行など依存パッケージの更なる軽量化をしておかないと心穏やかではいられないかもしれません。

Fastly − CDN にキャッシュを託すための割り切り諸々をする

Fastly というと話題の多くはキャッシュレイヤーとしての役割とそれを支える Instant Purge の機構、次点で柔軟な設定を可能にする VCL の存在が挙げられます。今回自分が CDN への密な依存(分離できなくはないだろうけど)を覚悟で Fastly をアーキテクチャに組み入れたのも、そのあたりの機能を目的としたものです。

他にも Fastly に CDN としての機能が数多くありますが、SSE のファンアウトや動画ストリーミング周りについても、そのうち利用する機会が訪れるような気がしています。画像の最適化配信は内製ツールのほうを優先して使ってるけど...。

先にエクスキューズいれておくと日経新聞社さんみたいな鬼の使い込みはできてないので、色々知りたい方は CDNを活用した日経電子版のネットワーク最適化とサイト高速化 / Nikkei ITPro CDN とかをご覧になったほうがよいかと思われます。

HTML キャッシュと Surrogate-Key の指定、Instant Purge

今回はまるごとサーバーから返す HTML はすべてキャッシュすることにしています。HTML 以外に CSS や JavaScript は CI 上で生成時にファイル名に crypto.createHash('md5').update(content, 'utf8').digest('hex').slice(0, 20) 的な感じで revving してあるので Cache-Control に長めの max-ageimmutable を指定して配信しています。 immutable が効くと問い合わせもなくなるのでコスト的にもちょっと嬉しい。

キャッシュのパージ管理は Surrogate Keys を利用します。その HTML に含まれるデータオブジェクトの id を Surrogate-Key レスポンスヘッダとして返却し、データオブジェクトが更新されたときに Fastly の API に id を送って、その id に紐付くキャッシュエントリを Instant Purge (すごく速い)してもらう、という流れです。

サーバー生成の HTML からユーザー依存情報を分離

前述したとおり HTML をまるごとキャッシュするとユーザー依存情報が含まれるページで悩ましいわけですが、それを避けるために割り切ってサーバーの HTML 生成処理からユーザー依存情報の処理を排斥しました。一方、fluxible がプロキシしているクライアントから API Gateway への問い合わせでユーザー依存情報を含むものは API 単位で Cache-Control: private 指定しています。

  1. サーバーはユーザー依存情報についてまったく不明なものとして扱う
  2. サーバーから生成される HTML 上でユーザー依存情報の部分は「ユーザー状態未定」として出力する
  3. クライアントに HTML が届いて初期のレンダリング時点では Loading 的なアレなどとして表示する
  4. クライアントでユーザー情報を照会して、ユーザー状態が確定したらそれに合わせて再レンダリングする

こうして書いてみると、そのような振る舞いのサービスは昔からいくらでもあってイメージしやすいかと思います。つまり特に新奇性はないのですが、更新頻度が高めの動的コンテンツでもこれを実現できるのは Fastly の Instant Purge の賜物と考えてよいのではないでしょうか。

React のコンポーネント上は次のように表現しています。placeholder がユーザー状態未定のときに表示され、ユーザー状態が確定(initialized フラグがある)したら children 内を順当に判定してレンダリングします。

<UserInitializedBoundary placeholder={<LoadingIndicator />}>
  {isLoggedIn ? <UserAvatar user={userEntity} /> : <LoginButton />}
</UserInitializedBoundary>

このユーザー依存情報の分離はクライアントサイドや web だけで頑張っても無理筋なので、バックエンドの開発者やデザイナーとの連携ないしネゴシエーションが必要です。チームの周辺各位には大変お世話になっており云々...。次のような制約というか条件は、連携して整えていったほうがやりやすいでしょう。

  • API がユーザー依存情報を返すものと、ユーザーに依存しない情報を返すものが明確に分離されていること
  • クライアントサイドの表示時にユーザー依存情報の表示が明確に遅れるので、デザイン的に誤魔化せること
    • 「ユーザー状態未定」として扱う面積が大きかったり、あまりにも多い(サーバー照会コストが高い)とつらい

今後は、React SSR 時の checksum 制約もやさしくなったのでページ全体のキャッシュは継続しつつ、ユーザー依存情報を含むエリアを ESI<esi:include src="/user_depended"/> として HTML ないし JSON 埋め込みにすることも考えています。ただ、これやると Fastly から HTML 返ってくるのがユーザー依存情報の問い合わせの分だけ遅くなるので、全体が分かる HTML をまずは速く返す、という利点を損ないかねないので慎重に検討します。

Vary 関係の Normalization によるキャッシュヒット率の向上

あと最後にキャッシュヒット率を上げるために Vary をなんとかします。ユーザー依存のキャッシュバリエーションはないのと、多言語対応も今のところないので Accept-EncodingUser-Agent を丸め込めばキャッシュヒット率は上がる算段です。

Accept-Encoding は gzip に収束させて Normalization

とりあえず簡単なほうの Accept-Encoding については Best Practices for Using the Vary Header にある VCL に従って次のように指定します。

# do this only once per request
if (req.restarts == 0) {
  # normalize Accept-Encoding to reduce vary
  if (req.http.Accept-Encoding ~ "gzip") {
    set req.http.Accept-Encoding = "gzip";
  } else {
    unset req.http.Accept-Encoding;
  }
}

使用された UA によって Accept-Encoding の記述内容は多少散るので、gzip に対応しているようであればすべて gzip に収束させるようにしています。これでめでたく Vary: Accept-Encoding が付与できるようになります。

現状、brotli は Edge サーバーで対応しておらず Origin サーバーでの圧縮が必要になるため一旦スルーしています

User-Agent はフラグに収束させて Normalization

Caching User-Agent specific responses with Fastly にも書かれていますが、User-Agent はそのままだと膨大な数のバリエーションがあるため Vary: User-Agent という指定は成立しません。しかし現実には、モバイルとデスクトップの区別や、対応ブラウザ的な分岐などやむをえず User-Agent によって異なる HTML を返したくなることもあります。

そこで User-Agent を Fastly ( というか VCL ) 上で必要なフラグに変換してリクエストヘッダに付加し、そのフラグを Vary: UaDependedFlag としてレスポンスヘッダに添えることで User-Agent 依存のキャッシュバリエーションをコントロールします。

次のコードは vcl_recv のタイミングで UA を判定して、フラグ用のリクエストヘッダを新たに付加しているところです。

sub vcl_recv {
  if (req.url ~ "\.(jpg|jpeg|gif|png|webp|svg|ico|js|css|zip|pdf|txt|flv|swf|json|xml)$") {
    set req.http.X-Platform-Type = "na";
  } else {
    if (req.url ~ "[&|?]x-platform-type=([^&\s]+)") {
      set req.http.X-Platform-Type = regsub(req.url, ".*[&|?]x-platform-type=([^&\s]+).*", "\1");
    } else if (req.http.User-Agent ~ "(iPhone|iPad|iPod)") {
      set req.http.X-Platform-Type = "ios";
    } else if (req.http.User-Agent ~ "(Android)") {
      set req.http.X-Platform-Type = "android";
    } else {
      set req.http.X-Platform-Type = "generic";
    }

    if (req.url ~ "[&|?]x-require-compat-build=([^&\s]+)") {
      set req.http.X-Require-Compat-Build = regsub(req.url, ".*[&|?]x-require-compat-build=([^&\s]+).*", "\1");
    } else if (req.http.User-Agent ~ "(MSIE|Trident|Edge|Googlebot)") {
      set req.http.X-Require-Compat-Build = "true";
    } else {
      set req.http.X-Require-Compat-Build = "false";
    }
  }
}

次のコードは vcl_fetch (最近の Varnish だと vcl_backend_response 相当)のタイミングでフラグ用のリクエストヘッダを Vary として追加しているところです。

sub vcl_fetch {
  if (req.http.X-Platform-Type != "na") {

    # for downstream cache server ( without fastly cache server )
    if (!req.http.Fastly-FF) {
      if (beresp.http.Vary) {
        set beresp.http.Vary = beresp.http.Vary + ", " + "User-Agent";
      } else {
        set beresp.http.Vary = "User-Agent";
      }
    }

    # shield to edge vary
    declare local var.VaryHeaders STRING;
    set var.VaryHeaders = "X-Require-Compat-Build, X-Platform-Type";

    if (beresp.http.Vary) {
      set beresp.http.Vary = beresp.http.Vary + ", " + var.VaryHeaders;
    } else {
      set beresp.http.Vary = var.VaryHeaders;
    }
  }
}

こんな感じでリクエストやレスポンスを加工できるのはいかにも VCL らしいところです。Cloudflare が SW 互換コードで CDN を制御する機能を出していましたが、このような設定ファイルの記述も趣深いものです。

余談ですが、開発初期に UA を「OS 名/バージョン」と「ブラウザ名/バージョン」にパースした情報として扱っていたせいで、Fastly 上でも同様にパース済みの文字列 MacOS/10.13*Chrome/65 に変換して扱うという回りくどいこと(専用エンドポイントとか VCL 上の restart とかいる)が必要でした。これでもキャッシュヒット率はあまり良くならないので、UA 依存コードを減らしつつ単純なフラグに収束させています。Financial-Times/polyfill-service 見れば実装できますが、某氏いわく A/B Testing 的にも使える仕組みとのことでナルホド、って感じだったので時が来たら掘り起こすことになるでしょう。

その他クライアントサイドに関わるパフォーマンス対策

シリーズの2回目 でほぼ紹介済みですがクライアントサイドで利用するリソースのビルド周りも、パフォーマンス的に問題になりやすいところに事前対策した構成になっています。

  • Code Splitting と Dynamic Import によるイニシャルに必要なバンドルサイズの削減
  • ES5/ES2015 の出力分岐によるバンドルサイズの削減
  • Service Worker + workbox による precache とキャッシュストラテジーの指定

ルート単位で Code Splitting したチャンクファイルは、ランディングの時点でその URL ならどのチャンクファイルを必要とするか、が自明なので次のような Preload 指定を SSR する HTML に含めています。

<link rel="preload" href="/assets/TopRoute.4c33b2627b13f8eebb9b.js" as="script">

この手の最適化は Fastly が Link ヘッダを処理できるので HTTP/2 Server Push と合わせて色々試していきたいところです。ほか、Resource Hints の dns-prefetchpreconnect もログ計測用エンドポイントや画像配信サーバを対象にして、効果測定しながら後日追加予定です。

オマケ: AppShell についてのまとまらないコメント

です。次回の宿題とします。

  • オンライン前提なら Fastly で SSR された HTML を返却したほうが App Shell の立ち上がりより FMP とかコンテンツ周りのレンダリングは色々速い
  • PRPL パターンありきだが、サーバーが HTML フラグメントを返さないと Push も煩雑になりそう(API とか依存解決のステップがあると Push めんどい)
  • クライアントがサーバーの API から得られたデータとテンプレートで HTML を生成する、というスタイルの SPA と AppShell は構成的に隔たりがあるのでは?
  • サーバーとクライアントを往年の pjax ライクな関係にしておいて、初回訪問初期表示用の SSR も持っておく、とかだと AppShell とハマりそう
  • SPA の複雑性を減らしつつもクライアントサイドの動的な状態変化にそこそこ追従できて、クラサバをまたがるフレームワークとしてキャッシュ戦略が立てやすい仕組みが必要

以上!

今回のプロダクトは Calibre で Synthetic Monitoring していて、キャッシュが効いてる稼働中は西海岸からの計測、Regular 4G (Latency: 20ms, Downstream: 512.00 KB/s, Upstream: 384.00 KB/s) 条件で FMP 1.5s 周辺をキープできるくらいでした。

Service Worker 周りでエグい最適化をすることについて妄想が足りていないので、今後はそっち方面も深掘りして全体を調整できると良いかな、というところ。

またそれとは別に、AppShell 云々のくだりにもにじみ出ていますが次に試したい構築パターンも見えてきているので色々やってみようと思います。

やっとシリーズ完結できた(;´□`)

早すぎる最適化では?という話を見かけたけど、このへんのアーキテクチャは最初にやっておかないと後から対応するには重すぎるベースラインであり、内容的にも機能開発に制約を課すもんではない、という見解です。SSR の出力を中間キャッシュするアプローチを採っていたらm初期は仕組みだけ入れて具体的な設定は手をつけないくらいだったろうけど。