21 May 2024

読書メモ: Web配信の技術―HTTPキャッシュ・リバースプロキシ・CDNを活用する

Cache-Control ヘッダから始まり、クライアント (ブラウザ) のローカルのキャッシュから CDN まで、Web システムの「配信」の最適化技術を網羅的に解説してくれている本。

業務の必要に応じて、CDN など個別のソリューション (の必要な一部の機能) は使ったことがあったり、Cache-Control の一部のディレクティブは都度調べて指定したことはあった。こうした「配信」に関する知識は各論の寄せ集めになっていて、体系的に理解できていないなという実感が以前からあった。また例えば HTTP のヘッダの仕様は歴史的経緯もあり複雑で、RFC から追おうと思っても必要な文章が複数あったり、実際の世の中の実装にばらつきがあったりと、キャッチアップコストが高い知識も多い。こうした領域を一冊でカバーしてくれている、稀有な本だった。「配信」はすべての Web システムが多かれ少なかれ必ず行っているので、ニッチな知識ではなく、活用できる場面も多いと思う。

自分の現在の業務を鑑みる。今はモバイルアプリのバックエンド開発が主務で、クライアントはブラウザではなくネイティブアプリ。それは HTTP クライアントライブラリを使ってサーバと通信している。そのため Cache-Control ヘッダ等の知識がすぐにそのまま使えるわけではない。ただベースラインとして標準技術を把握しておくことは重要だと思う。最近、データ転送量削減のため、動画ファイル等サイズが大きいコンテンツをクライアント側で独自にローカルキャッシュする対策が行われた。これを標準技術にあてはめると、TTL をほぼ無限に、キャッシュキーは URL のみ、キャッシュストレージは LRU、更新は実質的に Cache Busting、stale-while-revalidate/stale-if-error などは未実装でエッジケースで問題が出るかどうかは要検証、といった具合に理解を整理できる。

以降は読書メモ。

1. はじめに

  • 定義
    • 配信: Web において主に HTTP でコンテンツをサーバからクライアントに届けること
    • 配信最適化: 配信をより高速・安定・安全にすること
    • 配信システム: Proxy や CDN などの技術を組み合わせたシステム
  • 配信最適化は実施コストに対して 費用削減、UX 改善のベネフィットが大きく、中小規模システムでも取り組む価値がある
  • サービスダウン、情報漏洩といった事故につながるリスクがあり、正しい知識が必要
  • 基本的な構成
    • オリジン
    • キャッシュレイヤー (Proxy, CDN)
    • クライアント

2. 配信の基礎

  • 基本的な考え方
    • 最適化の目標
      • 共通
        • クライアントがコンテンツを高速にダウンロードできる
        • 突発的なリクエストに耐える
        • 低コストで実現する
      • 個別
        • 低遅延など
    • 標準化されたプロトコル + 要件に応じた個別実装という視点を持つ
      • よってプロトコルをまずは押さえる。その実装状況も把握する
      • bandwidth, throughput, latency などの指標を理解する
    • 配信経路を理解する
      • ファースト|ミドル|ラストマイル、DNS、ISP や IX など
        • なお本書では実施が容易なものを扱うので、例えば ISP との直接接続といったトピックは対象外
      • そうすると、ボトルネックがどこか・ある技術はどこを最適化するのかといった見通しが立つ
        • 例えば 5G はラストマイルを高速化する
  • 最適化の方針
    • 各所でのキャッシュ
    • その他: コンテンツの圧縮やオリジンの増強
  • キャッシュの分類
    • 格納場所による分類
      • クライアント (ローカルキャッシュ)
      • 経路上 (CDN, Proxy)
      • オリジン (ゲートウェイキャッシュ)
        • 一般化すると CDN, Proxy と近いが、実運用で細かい差異があるため、別トピックとして本書では扱う
    • 性質による分類
      • private
      • shared
        • 文献によっては格納場所による分類を private/shared と呼ぶこともあるので注意。例えばローカルキャッシュは private、CDN は shared といった具合
  • Topics: EDNS Client Subnet (ECS)
    • CDN は名前解決リクエストを送信した ISP の IP に応じて位置的に近いエッジの IP を返している
    • もし Google Public DNS のような DNS キャッシュサービスを使うと、ナイーブには CDN が最適化できない
    • ECS は DNS 問い合わせ時にクライアントの IP アドレスの一部を添えて送信する。この情報をもとに CDN が最適化した IP を返す
    • Quad9 などプライバシー対策として ECS に対応していない DNS もある

3. HTTPヘッダ・設定とコンテンツの見直し

  • CDN 導入やサーバ増強以前にやるべき細かい改善

  • 総転送量 = リクエスト数 x 平均コンテンツサイズ

    • 前者は標準にのっとったヘッダ設定でローカルキャッシュを適切に効かせる
    • 後者はコンテンツ自体の適切な圧縮やリサイズを行う
  • ローカルキャッシュ

    • RFC 7234 - Hypertext Transfer Protocol (HTTP/1.1): Caching には次のように、クライアントはデフォルトでキャッシュし、それを防ぎたい (あるいは伸ばすなど制御したい) 場合は Cache-Control 等のヘッダを適切に設定するよう記述されている。実装によるがこの原則で様々なクライアントは動作する
      • > Although caching is an entirely OPTIONAL feature of HTTP, it can be assumed that reusing a cached response is desirable and that such reuse is the default behavior when no requirement or local configuration prevents it.
  • HTTP キャッシュ仕様

    • キャッシュをする条件 RFC7234#3 と キャッシュを利用する条件 RFC7234#4

    • デフォルトでキャッシュ可能なメソッド RFC7231#4.2.3

      • GET, HEAD, POST
      • 実態として POST がキャッシュされることは少ない
    • デフォルトでキャッシュ可能なステータスコード RFC7231#6.1

      • 200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501
      • 421 (RFC7540#9.1.2), 451 (RFC7725#3)
    • Cache-control (RFC7234, RFC9111)

      • [memo] 本書は RFC7234 ベースで記述されている。当時ドラフトだった RFC9111 は現在は発行済で、曖昧だった部分が明確化されている。

      • 保存方法の指定 (未指定, public, private) RFC7234#5.2.2.5, RFC7234#5.2.2.6

        • 複数クライアントで共有できる shared キャッシュ か、そうではない private キャッシュかの指定だが、通常キャッシュができないケース (例えばキャッシュできないステータスコード) でもキャッシュをするという強い指定だという点に注意する
        • 通常の shared キャッシュをしたいだけなら未指定でよい。public は一部の CDN の仕様に合わせる場合や、未認証でも見られるコンテンツに Authorization ヘッダが付いてしまい強制的にキャッシュさせたい場合など、限られたケースでしか必要ない。private は private キャッシュ利用の際に必要だが、あらためて通常キャッシュできないケースでもキャッシュされてしまうことに注意する
        指定 キャッシュ許可の対象 クライアントとの対応 経路上への格納 ローカルへの格納 通常キャッシュできないパターンもキャッシュ
        未指定 複数クライアント (shared) に許可 1:n YES YES NO
        public 複数クライアント (shared) に許可 1:n YES YES YES
        private 特定のクライアントのみ (private) 許可 1:1 NO YES YES
      • no-store (RFC7234#5.2.2.3) と no-cache (RFC7234#5.2.2.2)

        • 前者はキャッシュ格納時、後者はキャッシュ利用時に評価される
        • no-store が指定されていると、キャッシュは保存されない
          • 全くキャッシュさせないというユースケースには、仕様上 no-store 指定だけで良いことになるが、実装上走でないケースがあり、後述される
        • no-cache が指定されていると、格納されているキャッシュを利用してよいか、オリジンに条件付きリクエストで問い合わせを行う。最新であれば (302 が返れば) キャッシュから、そうでなければ (200 が返れば) 新たに取得したコンテンツを使う
      • キャッシュの再利用 must-revalidate (RFC7234#5.2.2.1), proxy-revalidate (RFC7234#5.2.2.7), immutable (RFC8246)

        • must-revalidate は有効期限切れ時に再検証を行う
          • 対して no-cache は毎回検証を行う
          • must-revalidate は max-age と共存できる
          • 期限切れ後にオリジンがダウンしている場合、must-revalidate が指定されていると検証失敗でキャッシュは使われない。指定がない場合はキャッシュが使われる
        • proxy-revalidate は private キャッシュに適用されない以外は must-revalidate と同じ
        • immutable はコンテンツが不変であると認識し、再検証は一切行われなくなる。更新したい場合は URL を変更する必要がある。慎重な取り扱いが求められる
      • no-transform (RFC7234#5.2.2.4)

        • proxy などの中間でオブジェクトに対して変更、通信量削減を目的として圧縮等、を許可しない
      • 期限

        • キャッシュの状態遷移
          • Fresh: 有効期限内, Stale: 期限切れだが条件付きで再利用可能
          • Stale なキャッシュは検証に成功すれば Fresh に移行する
        • max-age
          • オリジンがレスポンスを生成した時刻を起点とした相対的なキャッシュの期限
          • 起点となる時刻はクライアントがリクエストを行った時刻とする (RFC7234#4.2.3)
            • Date ヘッダもあるが、クライアントとオリジンの時刻が同期されているとは限らないため
          • CDN 等の中間キャッシュからの取得の場合、Age ヘッダも考慮される。Age は中間に格納されてからの経過時間を示す。クライアントは max-age - age を fresh な時間として扱う
        • s-maxage (RFC7234#5.2.2.9)
          • 経路上の CDN, proxy などにだけ有効なキャッシュ
          • 通常のユースケースでは s-maxage <= max-age となるのがほとんどのはず
            • 逆だと、CDN で max-age 以上経過したコンテンツは、その後クライアントキャッシュが常に stale になる
        • stale-while-revalidate (RFC5861)
          • stale キャッシュの再検証をバックグランドで行っている間、何秒まで stale キャッシュを使ってもいいかという指定
        • stale-if-error (RFC5861)
          • stale キャッシュの再検証がダウンなどで失敗した場合、何秒まで stale キャッシュを使ってもいいかという指定
        • Expires
          • キャッシュの有効期限の絶対時間
          • max-age を解さない古いクライアント向けで、基本的には max-age を使うべき。両方指定があれば max-age が優先される
        • Date, Last-Modified
          • max-age などを指定していない場合、ブラウザがデフォルトでキャッシュを行うことがある
          • その際の TTL は TTL = Date - Last-Modified / ブラウザ実装による定数 (10 が一般的) となる
            • 例えば 10 日間更新のないコンテンツは 1 日キャッシュする
  • 条件付きリクエスト

    • 検証子
      • Last-Modified: 最終更新日時
      • ETag: エンティティタグ (フィンガープリント)
    • If-Modified-Since (RFC7232#3.3)
      • Last-Modified 以降に更新があればコンテンツを送る、そうでなければ 304
    • If-None-Match (RFC7232#3.2)
      • ETag が一致すれば 304、そうでなければコンテンツを送る
    • その他にも If-Unmodified-Since, If-Match, If-Range などがある
    • サーバは 304 を返す際も 200 のときと同じヘッダをきちんと検討して返さないと事故につながる
      • Cache-Control, Content-Location, Date, ETag, Expires, Vary
      • 例えばオリジンは 200 で no-cache を返しており、CDN は 200 の場合は max-age=3600 で上書きしているというケース
        • CDN からは毎回条件付きリクエストで検証してほしいという意図
        • このときオリジンが 304 を返すと、CDN は (200 でないので) Cache-Control を上書きしない
        • 結果としてクライアントは no-cache としてキャッシュを取り扱い、クライアントから毎回検証リクエストが行われてしまう
  • Vary ヘッダ (RFC7234#4.1)

    • クライアントがこのヘッダの値をキャッシュ時のセカンダリーキーとして使う
      • 例えば Accept-encoding が指定されると、クライアントは URL と圧縮方式ごとにキャッシュを持つ
  • 実践時のテクニック

    • Cache-controlが指定されているかの確認
    • ETag がコンテンツの更新がなくても変わるケース
      • Apache が inode ペースで ETag を生成しており、LB 配下に複数のサーバがある場合は、同じコンテンツでもオリジンのサーバによって ETag が変わる
    • ヘッダクレンジング
      • 稼働中のシステムを最適化する場合、ヘッダ設定箇所をすべて把握するのは簡単ではない
      • まずはゲートウェイで一括書き換えを行うのがスタートポイントとして良い
  • コンテンツ圧縮

    • アプリケーションサーバでやるのがおすすめ

      • そこまで CPU リソースは使わず、Proxy などよりアプリケーションサーバの方がスケールアウトしやすいことが多いため
    • S3, GCS 等はデフォルトで圧縮してくれるような機能はないため自身で設定する必要がある

    • 最適化済みの画像や動画は圧縮効果が低いので外す

    • 本画像とサムネイルでサイズを分ける

    • 画像フォーマット

      フォーマット 圧縮 透過 アニメ サポートブラウザ
      jpeg 非可逆 なし なし 主要ブラウザ全て
      png 可逆 あり なし 主要ブラウザ全て
      gif 可逆 あり あり 主要ブラウザ全て
      webp 可逆/非可逆 あり あり 主要ブラウザすべて (一定のOSバージョンが必要)
      avif 可逆/非可逆 あり あり Chrome
    • png

      • bpp: 8bit, 24bit, 32biy (24bit+alpha)
    • jpeg

      • q値でクオリティの変更
      • SSIM でクオリティの数値化
      • ベースラインとプログレッシブ
    • webp

      • 一般的に圧縮率は高いが、古いフォーマットのほうがカバレッジは高い
    • ちょっとした動画でも gif や apng より mp4 など専用フォーマットの方が効率的な場合は多い

  • Topics: HTTP/2 misc

    • ヘッダが小文字になる
      • Cache-Controlcache-control など
      • HTTP/1.1 以前は case-insensitive だったが、サーバ実装によっては大文字小文字を区別してしまっているケースがあるので注意
    • 開始行は存在せず、疑似ヘッダからメッセージが始まる # リクエスト例 :method: GET # 以下4行が疑似ヘッダ :scheme: https :path: / :authority: example.com foo: bar # レスポンス例 :status: 200
  • Topics: リクエスト時の Cache-control

    • Cache-Control: max-age=0 などと、指定された秒数を超えるキャッシュは受け付けないというリクエストを送信することができる。実勢に Chrome のリロードではこのヘッダが付与される
    • 実態としては経路上のキャッシュはこうしたリクエストの指示を無視する
    • ローカルキャッシュには有効で、例えば XHR を使う際にキャッシュを使わないよう no-cache を指定することなどがある
    • Cache-Control - HTTP | MDN
  • Topics: 中間でのデータ変更

    • Chrome の Data saver 機能や通信キャリアによる通信最適化によって、中間でコンテンツが変更されることがある
    • 変更を防ぐには、根本的には HTTPS 通信を行うことが必要だが、それが難しい場合は Cache-Control: no-transform を指定することで変更を禁止できる
    • ただし中間 proxy によっては no-transform を無視することがある
  • Topics: キャッシュをさせたくない場合の厳密な指定

    • Cache-Control: private, no-store, no-cache, must-revalidate
    • 経路上のキャッシュを防ぎ、保存の不許可、利用の不許可、オリジンダウン時の再利用を防ぐための must-revalidate
    • Last-Modified をなくすことで TTL 未指定の挙動を排除でき、より厳密になる
  • Topics: RFC 9205 also known as BCP 56

    • HTTP を使う上でのベストプラクティスで RFC3205の置き換え
    • 本書執筆時点では策定中だったが現在はRFC9205となっている
    • RFC7230-7235など HTTP の仕様が長大になっているので BCP をまず読むのがリーズナブル

4. キャッシュによる負荷対策

  • 最も致命的な事故は、キャッシュが混ざることによる情報漏洩。中間の CDN, Proxy 視点からの、対策の基本的な考え方

    • 適切なキャッシュキーの設定とキャッシュ対象の精査

      キャッシュ ユーザー情報 最低限設定すべきキャッシュキー オリジンへのリクエストヘッダ クライアントへのレスポンスヘッダ
      する 含む ホスト名 + パス + ユーザー情報 そのまま Set-cookie 削除
      する 含まない ホスト名 + パス Cookie, Authorization 削除 Set-cookie 削除
      しない 含む na そのまま そのまま
      しない 含まない na そのまま そのまま
      • Cookie, Authorization 以外にも、ACL 判定結果 (特定の IP 帯のみ許可するケースなど)、端末種別、CORS の Origin ヘッダ、URL スキーム、リクエストメソッドなども注意する
    • オリジンを信用しない

      • 常に適切な実装 (Cache-control ヘッダ等) はされていないと前提する
      • 中間キャッシュ側でコントロールする
    • Allowlist

      • 「デフォルトはすべてキャッシュ」ではなく、キャッシュ対象を Allowlist で明示する
  • キャッシュキーとセカンダリキー (Vary ヘッダ)

    • Vary レスポンスヘッダはキャッシュのセカンダリーキーを指定する。これは次のようにキャッシュキーの小分類としてセカンダリーキーを持つという理解に等しい

      // Vary: Accept-Encoding の場合の概念データ構造
      {
          "host + path": {
              "gzip": "content1",
              "none": "content2"
          }
      }
      
      
      // 仮に Accept-Encoding をキャッシュキーとした場合の概念データ構造
      {
          "host + path + gzip": "content1",
          "host + path + none": "content2",
      }
      
      • 各要素の出所で整理すると
      • キャッシュキー: クライアント (リクエストの host, path)
      • セカンダリキー
        • 指定: サーバ (レスポンスの Vary ヘッダ)
        • 値: クライアント (リクエストの Vary で指定されたヘッダ。Accept-Encoding 等)
      • キャッシュ箇所ごとの、キャッシュキーとセカンダリキーの挙動変更可能性で整理すると
      • 中間 (CDN, Proxy)
        • キャッシュキーを変更できる
        • セカンダリキーを Vary で変更できる
      • ローカルキャッシュ (ブラウザ等)
        • キャッシュキーは変更できず、原則 host + path でキャッシュされる
        • セカンダリキーを Vary で変更できる
      • 以上を踏まえて、host + path (+ method など) 以外の情報でレスポンスが変わるコンテンツの場合、Vary を指定しないとローカルキャッシュがうまく扱えない。反対に (各クライアント視点で) host + path でレスポンスが変わることが無ければキャッシュキーで問題ない
      • 例えば、pc/sp でコンテンツが切り替わるケース。pc/sp は user-agent で判定
        • 中間から見ると pc/sp 区別ごとにコンテンツが変わる
        • ローカルのクライアントから見ると、意図的でなければ pc/sp (user-agent) が変わることはない
        • よって中間で pc/sp の判定結果をキャッシュキーに持たせるといった対応でよい
      • 例えば CORS
        • リクエスト元の Origin ヘッダによってコンテンツが変わる (コンテンツが返されたり拒否されたりする)
        • 中間から見ると Origin によってコンテンツが変わるので、キャッシュキーかセカンダリキーのどちらかの対応が必須
        • ローカルから見ると、Origin はアクセス元によって変わるので、ローカルでも Origin ごとのキャッシュが必要。これが実現できるのは Vary を用いたセカンダリキーの指定のみ
  • TTL

    • クライアントキャッシュの TTL の決め方
      • 短いところから徐々に伸ばしていくのが運用しやすい
      • クライアントのセッションの持続時間と間隔から決めていく
        • まずは平均のセッション継続時間キャッシュが保つように設定
        • 問題なければセッション間隔の時間から、次のセッションでもキャッシュが効くように伸ばす
      • 長時間のキャッシュが不可能な場合
        • 多少古いコンテンツが出ても良いなら短い max-age
        • そうでなければ no-cache
        • いずれも頻繁に条件付きリクエストで検証が行われる動きになる。つまりリクエスト数う自体はかわらなくても、リクエストボディの転送を抑えられる
    • 中間キャッシュの TTL の決め方
      • 全体の rps から決めていく
        • 10rps なら数分のキャッシュでも効果が高い
      • ローカルキャッシュとのバランスを取る
        • 中間でキャッシュしすぎるとローカルキャッシュの残 TTL が減り、結果として中間で受ける総リクエストは増える
        • s-maxage を使いバランスを取る
    • 通常は TTL = max-age - Age と考える。Expires では Expires - Date となるが、基本的には max-age の利用が推奨
    • stale-while-revalidate の決め方
      • TTL (max-age) とあわせて考える。TTL の一部を stale-while-revalidate に割り当て、TTL はその分短くする
      • その他には、リクエストの頻度やコンテンツの生成にかかる時間を考慮して決める
    • stale-if-error の決め方
      • あくまでエラー時の動作なので、TTL から割り当てるのではなく追加で指定する
    • max-age=0 は「常に stale キャッシュを保存する」という意味になる。キャッシュさせたくない場合は前述の Cache-Control: private, no-store, no-cache, must-revalidate を使う
    • エラーキャッシュ
      • バグなどで一時的にコンテンツが 404 になった場合、キャッシュされないとオリジンにリクエストが殺到してしまう
      • エラーキャッシュ・ネガティブキャッシュをするとこうした事態を緩和できる
      • ただしエラーキャッシュの TTL が長すぎると復旧が遅れる
  • Invalidation

    • 無効化と削除
      • 無効化
        • キャッシュを stale にする。stale キャッシュは使われないが、revalidate 中やエラー時には使われる、またキャッシュストアには暫く残る
      • 削除
        • 確実にキャッシュされたコンテンツを削除する
        • ほとんどは無効化で問題ないが、例えば著作権侵害コンテンツの削除など、削除が必要なユースケースもある
    • 手法
      • cache busting
        • パスにタイムスタンプを含めるなど、キャッシュキー事態を更新ごとに変える
        • 多くのケースで必要十分な方法
        • 無効化に属する
      • cdn の機能での削除
        • 無効化なのか削除なのかは確認する
        • 最近は各社高速化されたが、コンピューティングリソース的には重いオペレーションで課金対象であることも多い。削除に依存した運用設計よりも、TTL をうまく調整することでコントロールできるならそのほうが良い
    • 削除時はオリジンへのリクエスト急増に注意する
      • 上流 (オリジン側) から順に削除していく。上段の中間キャッシュが更新されてから下段のキャッシュを削除するなど
      • 一度に複数コンテンツを削除するのではなく、削除タイミングをばらけさせる
  • Topics: POST リクエストのキャッシュ

    • 考慮事項が多く基本的には推奨されない
    • キャッシュする場合の注意点
      • キャッシュキーを注意深く選択することは GET と同じ
      • リクエストボディの取り扱い
        • application/x-www-form-urlencoded、multipart/form-data などパースが (中間では) 容易でなかったりサイズが大きくなりがち
        • 中間ではリクエストボディは無視する、ボディのハッシュ値をキーとする、application/x-www-form-urlencoded の時のみクエリ文字列と同等の扱いでキーとする、といった対応を行う
        • 一定のボディサイズ以上はキャッシュしない
      • multipart/formdata のリクエストはブラウザによって形式が違うので「リクエストボディは無視する」の対応しかできない
      • 100-continue を利用した分割リクエストはキャッシュできないので、そのようなクライアント実装を避ける
        • 100-continue はリクエストボディを送信する前にヘッダだけを送信し、サーバが受け入れ可能かどうかを確認するための仕組み
        • 例えば AWS ELB は 100-continue を受け取ると即座に 100 Continue を返すため、オリジンでは検知できない
  • Topics: キャッシュフレンドリなパス設計

    • /cache/user のようにパス自体にキャッシュキーが何かという情報を含めてしまうとわかりやすい
      • 中間では /cache/user のリクエストは、例えばクッキーから user_id を取得してキャッシュキーに入れるといった実装をする
      • オリジンはその前提で実装する。この知識がパスから明確にわかるのが利点

5. より効果的・大規模な配信とキャッシュ

  • メトリクス
    • キャッシュヒット率が導出しやすく使いやすい指標
      • ただし次のようなケースはヒット率だけを追うと隠されてしまう
        • 全ユーザー共通の静的画像とユーザーごとの動的画像
        • 単純な実装だと当然前者のキャッシュヒット率が高い
        • 後者はよりオリジンの CPU リソースを使うので、ヒット率に寄らずケアすることでシステム全体の安定性を増すことができる可能性がある
  • Proxy/CDN システムの抽象化
    • p210 図5.1 Proxy/CDNの基本的な処理フロー より引用
    • コンポーネント
      • Client Trx, Origin Trx と、対クライアントと対オリジンでトランザクション (プロセスくらいのイメージで良い) が分かれており、それぞれ非同期に動作する。非同期な関係の代表例が stale-while-revalidate
    • 処理ステップ
      • 以下のステップに分かれている。Rx*, Tx* はそれぞれフックポイントのようなもので、個別の処理を記述できる
      • Receiver Request (RxReq)
        • フックしてACL 処理、キャッシュキー操作、セカンダリキー操作、Cookie 削除、クエリソートなど
      • Cache lookup
      • Wait for cache
        • フックポイントではない
        • 一定時間待機することで、キャッシュがない場合の同時リクエストが複数オリジンに飛ぶことを防いでいる
        • 一方で、キャッシュしないリクエストが誤ってこのフローに入ると、不要な wait が生じるため注意する
      • Transmitter Request (TxReq)
        • フックして複数オリジンがある場合の選択、キャッシュキーに影響を与えないオリジン問い合わせのホスト名・パス名変更など
      • Receiver Response (RxResp)
        • フックしてカスタムした TTL の設定、キャッシュ前に Set-Cookie を削除するなどキャッシュするオブジェクトの操作、Vary ヘッダの変更など
      • Transmitter Response (TxResp)
        • CORS などキャッシュは変わらないがクライアントごとにヘッダを変える場合の対応、内部用の不要ヘッダの削除など
      • [memo] これらのフックを組み合わせて、例えばヘッダをパース・カスタムの Vary ヘッダ生成といった、それなりに複雑な処理を実装する例が挙げられているが、第一印象としては認知負荷の高さが気になった。やむを得ないケースを除き、どの程度の処理をどのレイヤーで行うのが妥当なのかの基準があれば知りたい。一方で組織の境界やシステムの境界から、こうした処理を中間側で行った方がベターなケースも容易に想定できる
  • 同一 URL で複数種類のキャッシュがあるケースの対応方法比較
    • キャッシュキーを変える (パスに pc/sp と入れるなど)
      • 素直に単に URL を指定してもキャッシュ削除ができず、工夫が必要
    • Vary でセカンダリキーを指定する (Vary に x-device を入れるなど)
      • RxReq + RxResp + TxResp にてフックの実装が必要
      • 中間で追加したヘッダがオリジンに渡る
      • クライアントにも Vary ヘッダが渡る
    • クエリパラメータを追加する (URL に ?device=pc など)
      • 中間で追加したパラメータがオリジンに渡る
      • 削除時にクエリパラメータの指定も必要。パラメータのカーディナリティが高い場合は注意
  • キャッシュの効率化
    • クエリパラメータの正規化、ソート、不要削除
    • セカンダリキーの正規化
      • 例えば Accept-encoding について、サーバが対応しているのは gzip のみの場合、そのままではなく gzip 値の有無をセカンダリキーにする
    • キャッシュ対象の優先度付け
      • 平均レスポンスタイム x 一定期間でのリクエスト数 が高いものを優先する
  • 動的コンテンツ
    • 定義
      • TTL を概ね1時間以上にできる、またはTTL を明確に定められるものは静的
      • 動的コンテンツは TTL が要件上決めづらい、あるいはそもそもキャッシュができないため難しい
    • キャッシュの難しさの分類
      • コンテンツが個別のライフサイクルを持つサブコンテンツから構成されている
        • 分割してキャッシュする
          • ESI
          • クライアントスクリプトで実装
      • ユーザー単位など同一の URL でもステートフルな別の挙動をする
        • 同上
      • リクエストのたびに取得が必要だったり、極めて高いリアルタイム性が必要
        • キャッシュできないのはこのパターンだけ
      • API で動的生成するコンテンツの場合、検証リクエストへの対応が高コストになることが多い(ETag を生成しようにも、コンテンツ自体を普段通り組み立てないといけないなど)。そのため no-cache + 検証リクエストよりは、ごく短い TTL を設ける、CDN ではキャッシュし終端クライアントには no-cache を返すがオリジンには普通にリクエストする、といったアプローチの方が取りやすい
  • ESI https://www.w3.org/TR/esi-lang/
    • 中間でコンテンツを結合しクライアントに返す
    • 一般的に平均応答時間(TTFB)は短縮し、平均ダウンロード時間が伸びる動きになる
    • アプリケーション側の変更が必要になる、各所での結合処理にかかるオーバーヘッドが発生するといったデメリットがある
    • まずはヘッドとボディを分けるだけでも、ブラウザがヘッドにあるコンテンツを先にロードできてメリットがある
  • Topics: Vary ヘッダが自動付与されるケース
    • 例えば Apache でオリジンへの直接アクセスを禁止するために Require expr %{HTTP_REFERER} == “foobar” と設定すると、Vary: referrer が自動で返される
    • S3 で CORS の設定を行うと Allow origin などが含まれる Vary ヘッダが自動的に返される
  • Topics: ローカルキャッシュの削除
    • 現実的な手法としては Cache Busting
    • no-store で毎回検証リクエストをさせる方法もある
    • 更新が少ない静的ファイルなどは前者、よりリアルタイム性の高いニュースフィードなどは後者が適切
  • Topics: 多段 Proxy
    • 主に CDN を利用せず自前構築する際のノウハウ
    • (オリジンへのリクエスト元を集約することで)キャッシュデータの整合性を保ちやすくする、可用性が高まる、全体としてのストレージ容量を(スケールアウト方向に)増やしやすいといったメリットがある
    • 一方で TTL の取り扱いなど考慮点も増える
  • Topics: ドメイン分割の使い所
    • HTTP/1 時代にあったブラウザのドメインごとの接続数上限を緩和するためのドメインシャーディングは、HTTP/2 以降で不要になった
    • 一方でサービスの拡張しやすさのため静的・動的ファイルでドメインを分けておく(あとから CDN に載せやすい)、ZoneApex 問題対策といった場面でドメイン分割が有効なケースがある

6. CDN を活用する

  • CDN の特徴
    • 「分散されたネットワーク品質の良いコンピューティングリソースのプラットフォーム」
    • キャッシュは機能のうちの一部。キャッシュがなくても、例えば中間 - オリジン間の接続が使い回されることで、キャッシュミスの場合でも直接オリジンアクセスよりも速いこともある
    • DDoS 対策や WAF などセキュリティ機能、エッジコンピューティング等
    • 全面的に導入しなくても、地理的に遠いクライアントへの配信や突発的なトラフィック増加への対応などを賄う目的でも利用できる
  • 選定のポイント
    • 対応地域
      • 特に中国本土やロシア等の旧共産圏は対応の厚さや価格が異なるケースがある
    • ピーク帯域
    • キャッシュ制御の方法 (VCL, Lambda@Edge など)
    • TLS 対応 (Pinning のケアなど)
    • キャッシュ消去の方法、Apex ドメイン対応、多段構成が可能かなど
  • 利用時に気をつけるポイント
    • キャッシュされない設定を把握する
      • Cache-Control の解釈は各ベンダーで異なる。特にキャッシュしないケースは情報漏洩リスクにつながるため念入りに確認する
      • オリジンがダウンしている場合にキャッシュを返さないか、同着リクエストの際にキャッシュを返さないかといったエッジケースも確認すると良い
    • クエリパラメータ、Vary ヘッダの解釈
    • トラフィック (帯域幅) やコンテンツサイズの上限と超えた場合の挙動
    • デバッグ機能
      • 独自のヘッダを付けることでデバッグ可能なベンダーもある
      • 本番ではオフにしないと、例えばオリジンの IP アドレスが漏れるなどのリスクがある
  • 障害
    • 分類
      • ファーストマイル(オリジン、CDN間)
      • ミドルマイル(CDN内)
      • エッジ自体
      • ラストマイル(エッジ、クライアント間)
    • 切り分け
      • キャッシュされているオブジェクトのリクエストに失敗し、かつ別 ISP からのリクエストも失敗するならエッジ障害、別 ISP から成功ならラストマイル
      • キャッシュされていないオブジェクトのリクエストに失敗し、キャッシュされていると成功するならファーストマイルかミドルマイル
      • ファースト、ミドルの切り分けは単純化できない
        • ISP、リージョン、CDN 単位でのメトリクス確認、ステータスページ、SNS で他社障害の確認
    • キャッシュ異常
      • TLS 通信にし経路上の不確定要素を排除
      • キャッシュとオリジンの比較
  • Topics: CPDoS
    • Cache Poisoning Denial of Service
    • CDN 上のキャッシュを汚染しサービスを利用できなくさせる手法
    • 例えば許容できるヘッダサイズが オリジン < CDN だった場合に、大きなヘッダサイズのリクエストを送り、CDN 側にエラーレスポンスをキャッシュさせる
      • RFC 通りであれば 400 Bad Request はキャッシュされないが、キャッシュする CDN ベンダーの場合この攻撃が成立する
  • Topics: 動的コンテンツのキャッシュ
    • キャッシュ戦略を適切に設計すれば問題ない
      • 静的コンテンツでも慎重に検討すべきなので、動的だから特別考え方が変わるわけではない
    • キャッシュせず CDN を通すだけでもセキュリティなどメリットがある

7. 自作 CDN

  • 必要性
    • 超大規模サービスで自社構築の方がコストやカスタマイズ性にメリットがある
    • CDN を契約する予算がなく費用を抑えたい小規模サービス
    • ハイブリッド構成を取りたい場合
      • 静的コンテンツのオフロードのみ CDN を使う
      • 基本は自社配信を使い、突発的なトラフィックのみ CDN にオフロードする
      • コンテンツを一貫させるために CDN と自社配信の多段構成にする