30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Cloudflare を使ったキャッシュの基礎

CDN のエッジサーバにコンテンツをキャッシュしておくことで、コンテンツのダウンロードを高速化できる。
エッジサーバは物理的にユーザーに近い場所にあり、レイテンシが小さくなるため。また、エッジサーバは配信に最適化されているため、その点でも高速化が見込める。

しかし、ただ CDN を導入すればそれだけで最適なキャッシュを行ってくれるわけではない。
そもそも、全てのコンテンツをキャッシュしてもらいたいわけではない。ウェブサイトによっては、キャッシュして欲しくないコンテンツ、キャッシュされたら困るコンテンツも存在するはず。
キャッシュする場合も、短期間だけキャッシュさせたいコンテンツもあれば、可能な限り長くキャッシュさせたいコンテンツもある。
そしてそういったことは CDN には判断できないので、開発者が明示的に設定する必要がある。
つまり、CDN を効果的かつ安全に使うためには、CDN の使い方を理解し、エッジサーバへのキャッシュを意図した通りに行えるようになる必要がある。

この記事では、CDN のひとつである Cloudflare の、キャッシュに関するルールや設定方法を見ていく。
なお、全ての設定やその組み合わせを検証したわけではないし、見落としている変数もあるかもしれない。実際に設定を行う際には必ず動作確認を行うべき。
とはいえ、闇雲に試行錯誤するのでは効率が悪すぎる。
実際に設定を行っていく際の足掛かりになるよう基本となる概念や原則を整理したのが、この記事になる。

動作確認は、無料プランの Cloudflare で行った。
ダッシュボードの言語設定は「日本語」にしており、この記事で扱う用語もその表記に準じている。
詳細は後述するが、サイト全体の「キャッシュ レベル」は「標準」で固定してある。
また、リクエストは全て GET メソッドで行い、ステータスコードは全て200になる環境で、検証している。

デフォルトでキャッシュ対象のコンテンツ

Cloudflare においては、デフォルトでキャッシュされるコンテンツと、そうでないコンテンツの 2 つに、コンテンツを分類できる。
どちらのコンテンツであるかによって挙動が変わるので、まずはこの 2 つに分類できるということを、把握しておく必要がある。

あるコンテンツがどちらであるかは、ファイルの拡張子で判断される。どれがキャッシュ対象の拡張子であるかは公式サイトで確認できるので、暗記する必要はない。
https://support.cloudflare.com/hc/ja/articles/200172516#h_a01982d4-d5b6-4744-bb9b-a71da62c160a

txtは対象外だが、robots.txtは例外的にキャッシュ対象となる。

指定した有効期間だけクライアントにキャッシュさせる方法

まずはクライアントにキャッシュさせる方法を見ていく。エッジサーバへのキャッシュは一旦措いておく。

どのようにクライアントにキャッシュするのかを決める要素のひとつに、「ブラウザ キャッシュ TTL」がある。
これは、Cloudflare のダッシュボードで開発者が設定する。
サイト全体の設定と URL 毎の設定があり、URL 毎の設定が優先される。
例えば、サイト全体の設定が「既存のヘッダーを優先」で、https://example.com/foo.jsの設定が「30分」の場合、https://example.com/foo.jsの「ブラウザ キャッシュ TTL」は「30分」に、それ以外の URL は未設定になる。

そしてもうひとつ「キャッシュ レベル」という要素がある。
これも、サイト全体の設定と URL 毎の設定があり、URL 毎の設定が存在すればそれが優先される。
冒頭で説明したように、サイト全体の「キャッシュ レベル」は「標準」になっているという前提で話を進めていく。

クライアントへのキャッシュがどのようになるのかは、「キャッシュ レベル」で大きく分かれる。

「キャッシュ レベル」が「スキップ」の場合

「ブラウザ キャッシュ TTL」は無視される。そのため、オリジンサーバでCache-Controlmax-ageを設定しておけば、その時間だけクライアントでキャッシュされる。

「キャッシュ レベル」が「Cache Everything」の場合

オリジンサーバがmax-ageを指定していなかった場合、「ブラウザ キャッシュ TTL」の時間が採用され、その値をmax-ageとして設定したCache-Controlが、クライアントにレスポンスされる。「ブラウザ キャッシュ TTL」も未設定だった場合、max-ageは何も設定されない。
オリジンサーバがmax-ageを指定していた場合、「ブラウザ キャッシュ TTL」と比較し、長いほうが採用されてクライアントにレスポンスされる。「ブラウザ キャッシュ TTL」が未設定だった場合は、オリジンサーバが指定したmax-ageがそのまま採用される。

「キャッシュ レベル」が「標準」の場合

デフォルトでキャッシュ対象のコンテンツの場合、「Cache Everything」のケースと同様の挙動になる。
そうでない場合、「スキップ」のケースと同様の挙動になる。

エッジサーバにキャッシュさせる方法

次に、Cloudflare のエッジサーバへのキャッシュについて見ていく。
エッジサーバにキャッシュされているかどうかは、レスポンスヘッダのCF-Cache-Statusフィールドを見るとわかる。
各値の意味は公式サイトで確認できる。
https://support.cloudflare.com/hc/ja/articles/200172516#h_bd959d6a-39c0-4786-9bcd-6e6504dcdb97

これも、「キャッシュ レベル」で大きく挙動が分かれる。
そして、先程の「ブラウザ キャッシュ TTL」に相当する概念として「エッジ キャッシュ TTL」がある。これは、サイト全体の設定は存在せず、URL に対してしか設定できない。

「キャッシュ レベル」を「スキップ」にしている場合、CF-Cache-Statusは必ずDYNAMICになる。
つまり、エッジサーバへのキャッシュは行われない。

「キャッシュ レベル」が「スキップ」以外の場合は、「キャッシュ レベル」、「エッジ キャッシュ TTL」、そしてデフォルトでキャッシュ対象のコンテンツであるかどうかで、挙動が変わる。

「エッジ キャッシュ TTL」が設定されておらず、「キャッシュ レベル」が「Cache Everything」の場合

この場合、オリジンサーバがCache-Controlフィールドに設定した値によって挙動が変化する。
no-storeprivateを設定しておくと、CF-Cache-StatusBYPASSになり、エッジサーバへのキャッシュは行われない。
s-maxage=nを設定すると、n秒間だけエッジサーバにキャッシュされる。

Cache-Controlを指定しなかった場合も、エッジサーバへのキャッシュが行われる。その場合キャッシュされる時間は恐らく、ステータスコードによって決まる。
https://support.cloudflare.com/hc/ja/articles/200172516#h_51422705-42d0-450d-8eb1-5321dcadb5bc

「エッジ キャッシュ TTL」が設定されておらず、「キャッシュ レベル」が「標準」の場合

この場合、デフォルトでキャッシュ対象のコンテンツであるかどうかで、挙動が変化する。

対象の場合、オリジンサーバがCache-Controlフィールドに設定した値によって、挙動が決まる。
つまり、「キャッシュ レベル」が「Cache Everything」のときと同じ挙動になる。

対象でない場合、エッジサーバにはキャッシュされない。CF-Cache-Statusは必ずDYNAMICになる。
オリジンサーバがmax-ages-maxageを設定していたとしても、それは変わらない。
つまり、「キャッシュ レベル」を「スキップ」にしたときと同じ挙動になる。

「エッジ キャッシュ TTL」が設定されており、「キャッシュ レベル」が「Cache Everything」の場合

全てのコンテンツが、必ずエッジサーバにキャッシュされるようになる。
オリジンサーバがno-storeprivateを設定していたとしても、キャッシュされてしまう。それでいて、Cache-Controlフィールドの値を書き換えることはない。
そのため、クライアントに届いたレスポンスヘッダのCache-Controlフィールドにはprivateが設定されているのに、エッジサーバにキャッシュされてしまっている、という状況が発生し得る。

「エッジ キャッシュ TTL」が設定されており、「キャッシュ レベル」が「標準」の場合

デフォルトでキャッシュ対象のコンテンツである場合、「キャッシュ レベル」が「Cache Everything」のときと同じ挙動になる。
つまり、必ずエッジサーバにキャッシュされるようになる。

デフォルトでキャッシュ対象のコンテンツでない場合、エッジサーバにはキャッシュされない。CF-Cache-Statusは必ずDYNAMICになる。
オリジンサーバがmax-ages-maxageを設定していたとしても、それは変わらない。

まとめ

ここまでの内容を踏まえて、結局どうすればいいのかを整理する。

エッジサーバにキャッシュさせたい場合

デフォルトでキャッシュ対象であるコンテンツをエッジサーバにキャッシュするには、s-maxageを設定すればよい。
その名の通りデフォルトでもキャッシュされるのだが、s-maxageを設定しておけばコンテンツ毎に時間を指定できる。

デフォルトではキャッシュ対象外のコンテンツをエッジサーバにキャッシュさせるには、「キャッシュ レベル」を「Cache Everything」に設定する必要がある。
そうしておかないと、s-maxagemax-ageを付与しても無視されてしまう。
「Cache Everything」にした上でs-maxage=nとすれば、n秒だけエッジサーバにキャッシュされる。

「エッジ キャッシュ TTL」を設定すると、no-storeprivateを無視してエッジサーバにキャッシュされてしまうので、注意する。
しかもCache-Controlを書き換えないので、レスポンスヘッダだけを見てもなぜエッジサーバにキャッシュされたのかが分からない。
そのため、デバックや動作確認が行いにくくなる可能性がある。

エッジサーバにキャッシュさせたくない場合

既述した通り、「キャッシュ レベル」を「スキップ」にしておけば、エッジサーバへのキャッシュは行われない。オリジンサーバでs-maxageを設定していたとしても、無視される。
Cache-Controlフィールドを書き換えられてしまうわけではないので、max-ageを設定しておけば、エッジサーバにはキャッシュさせずクライアントにはキャッシュさせる、ということが可能になる。

Cache-Controlno-storeprivateを設定しておけば、「エッジ キャッシュ TTL」が設定されていない限り、エッジサーバへのキャッシュは行われない。
no-storeなら、クライアントへのキャッシュも行われない。

エッジサーバにもクライアントにもキャッシュさせたい場合

デフォルトでキャッシュ対象のコンテンツの場合は、s-maxagemax-ageを設定すればよい。

デフォルトでキャッシュ対象ではないコンテンツの場合は、取り敢えず「Cache Everything」を設定しないとエッジサーバにキャッシュできない。
その上でs-maxagemax-ageを設定すればよい。

「ブラウザ キャッシュ TTL」を設定すれば、その値とオリジンサーバが指定したmax-ageを比較し、長い方が採用される。
そして採用された値がCache-Controlmax-ageとしてクライアントにレスポンスされるので、「エッジ キャッシュ TTL」と違ってクライアントから確認しやすい。

参考資料

HTTP ヘッダを使ったキャッシュと Service Worker を使ったキャッシュの関係

ブラウザは HTTP ヘッダを使ってキャッシュの制御を行うが、それ以外にも Service Worker と CacheStorage を使ったキャッシュも存在する。
Service Worker はリクエストを制御し書き換えることが可能なので、HTTP ヘッダの指定を無視した振る舞いをさせることができる。
例えば HTTP ヘッダを使ってキャッシュしないように設定したとしても、Service Worker でキャッシュしてそれを返してしまえば、サーバへの問い合わせは行われない。

この記事では、実際にコードを書いてどのような挙動になるのかを確認していく。

動作確認に使った環境は以下の通り。

  • ウェブサーバ
    • Deno v1.10.3
  • ウェブクライアント
    • Google Chrome 91.0.4472.77

環境構築

まずは検証用の環境を用意する。

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Cache</title>
</head>
<body>
  <p id="result">No Data</p>
  <script>
  if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker.register('/service-worker.js');
      });
    }
  </script>
  <script src="/create-dom.js"></script>
</body>
</html>

このなかで/service-worker.jsを Service Worker として登録しているが、このファイルはまだ空でよい。

同じく HTML ファイルのなかで読み込まれているcreate-dom.jsは以下の内容にする。

function appendButton(text, eventListener) {
  const button = document.createElement("button");
  button.type = "button";
  button.textContent = text;
  button.addEventListener("click", eventListener);
  const paragraph = document.createElement("p");
  paragraph.appendChild(button);
  document.body.appendChild(paragraph);
}

async function fetchScore(url) {
  const res = await fetch(url);
  const json = await res.json();
  return { name: json.name, score: json.score };
}

function showResult({ name, score }) {
  const result = document.querySelector("#result");
  result.textContent = `${name}: ${score}`;
}

appendButton("Alice", async () => {
  showResult(await fetchScore("/api/alice"));
});

appendButton("Bob", async () => {
  showResult(await fetchScore("/api/bob"));
});

これで 2 つのボタンがページに追加される。どちらも API を叩き、その結果をページに表示する。

最後に、これらのファイルや API を配信するサーバを立てる。
server.jsという名前で以下のファイルを作る。

for await (const conn of Deno.listen({ port: 8080 })) {
  handleConn(conn);
}

async function handleConn(conn) {
  const httpConn = Deno.serveHttp(conn);
  for await (const { request, respondWith } of httpConn) {
    const path = new URL(request.url).pathname;
    switch (true) {
      case /^\/$/.test(path): {
        const html = await Deno.readFile(`./index.html`);
        respondWith(
          new Response(html, {
            status: 200,
            headers: { "Content-Type": "text/html" },
          })
        );
        break;
      }

      case /\.js$/.test(path): {
        const jsFile = await Deno.readFile(`.${path}`);
        respondWith(
          new Response(jsFile, {
            status: 200,
            headers: { "Content-Type": "text/javascript" },
          })
        );
        break;
      }

      case /^\/api\/alice$/.test(path): {
        respondWith(
          new Response(JSON.stringify({ name: "Alice", score: 90 }), {
            status: 200,
            headers: {
              "Content-Type": "application/json",
            },
          })
        );
        break;
      }

      case /^\/api\/bob$/.test(path): {
        respondWith(
          new Response(JSON.stringify({ name: "Bob", score: 80 }), {
            status: 200,
            headers: {
              "Content-Type": "application/json",
            },
          })
        );
        break;
      }

      default: {
        respondWith(
          new Response("Not Found\n", {
            status: 404,
            headers: { "Content-Type": "text/plain" },
          })
        );
        break;
      }
    }
  }
}

この状態で以下のコマンドを実行し、http://localhost:8080/にアクセスする。

$ deno run --allow-net --allow-read --unstable --watch server.js

Aliceボタンを押下するとAlice: 90が、Bobボタンを押下するとBob: 80が表示されるはず。
これで検証用の環境を用意できた。

HTTP ヘッダによるキャッシュ

まず、Cache-Controlフィールドを使ってキャッシュしてみる。

/api/aliceheaders"Cache-Control": "max-age=30",を追加する。

             status: 200,
             headers: {
               "Content-Type": "application/json",
+              "Cache-Control": "max-age=30",
             },
           })
         );

そうすると、/api/aliceのレスポンスヘッダにcache-control: max-age=30が追加され、30秒間キャッシュが有効になる。
そのためその間は、server.jsで定義しているAlicescoreをいくら変更しても、それをクライアントが取得することはない。
Bobについてはキャッシュが有効になっていないので、scoreを変更したあとにフェッチすれば、最新の値を取得できる。

Service Worker を使ったキャッシュ

空のファイルだったservice-worker.jsを以下の内容にする。

const VERSION = "v1";

self.addEventListener("install", (e) => {
  e.waitUntil(
    new Promise(async (resolve) => {
      const cache = await caches.open(VERSION);
      cache.addAll(["/api/bob"]);
      self.skipWaiting();
      resolve();
    })
  );
});

self.addEventListener("activate", (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      self.clients.claim();
      resolve();
    })
  );
});

self.addEventListener("fetch", (e) => {
  e.respondWith(
    caches.match(e.request).then((response) => {
      if (response) {
        return response;
      }
      return fetch(e.request);
    })
  );
});

こうすると、Service Worker のインストール時に/api/bobにリクエストを飛ばし、その結果をキャッシュするようになる。
また、fetchイベント内で、キャッシュがないかを確認している。そのため/api/bobにリクエストがあった場合は、Service Worker がキャッシュの内容を返す。サーバへの問い合わせは発生しない。
そのため、Service Worker のインストール時のレスポンスがそのまま使われ続ける。server.jsによる定義をいくら変更しても、それは反映されない。

これは、HTTP ヘッダでキャッシュしないように設定しても、Service Worker でキャッシュを返してしまえばサーバへのアクセスが発生しないことを意味する。

例えば、/api/bobCache-Controlを以下のように設定して、サーバへのアクセスが必ず発生するようにしてみる。

             status: 200,
             headers: {
               "Content-Type": "application/json",
+              "Cache-Control": "no-store, no-cache, must-revalidate",
             },
           })
         );

その後ブラウザの開発者ツールで Service Worker や CacheStorage を削除した上で、再度ページを読み込む。

そうすると、先程と同様に Service Worker のインストール時に/api/bobにリクエストを飛ばすが、そのレスポンスにはcache-control: no-store, no-cache, must-revalidateが含まれている。
この場合、通常は再アクセス時にサーバに問い合わせてレスポンスを取得するのだが、CacheStorage に保存したキャッシュを返すように Service Worker で設定しているため、サーバへの問い合わせが行われなくなってしまう。

ちなみに/api/aliceへのリクエストもfetchイベントを経由するのだが、マッチするキャッシュが CacheStorage に存在しないため、サーバへの問い合わせを行おうとする。
このとき、HTTP ヘッダによる有効なキャッシュが存在すれば、それが使われる。この記事の例だと、30秒間はキャッシュが使われ続ける。Service Worker のfetchイベントを経由したからといって挙動が変わってしまうということはない。

CacheStorage の削除

service-worker.jsの内容を以下のようにすると、ページのリロード時や再訪時に Service Worker が更新され、CacheStorage からv1と名付けられたキャッシュが削除される。

const VERSION = "v1";

self.addEventListener("install", (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      self.skipWaiting();
      resolve();
    })
  );
});

self.addEventListener("activate", (e) => {
  e.waitUntil(
    new Promise(async (resolve) => {
      await caches.delete(VERSION);
      self.clients.claim();
      resolve();
    })
  );
});

self.addEventListener("fetch", (e) => {
  e.respondWith(
    caches.match(e.request).then((response) => {
      if (response) {
        return response;
      }
      return fetch(e.request);
    })
  );
});

この状態でBobボタンを押下すると、常に最新の値をサーバから取得する。