動的コンテンツをCDNでキャッシュすること

## 初めに

最近訳あって、CDNで動的コンテンツを結構長めにキャッシュさせるための設定をすることになりました。

私が普段回遊するサイトでは、CDNで画像や動画と言った静的コンテンツをキャッシュしているのを見ることはあれど、通常のページ、API等の動的コンテンツをキャッシュしているサイトはあまり見ないなぁと感じています。  
おそらく昨今では以下のような理由があり、そのような戦略は取らないのかなと思います。

パフォーマンス都合

- 国内限定サービスだからサーバーとユーザーは基本的に近い
- 国別にサーバー(もしくはドメインも)を持っているから問題無し

コスト都合

- そこまでリクエストが多くないからサーバーからのデータ転送量は問題無い
- サーバーもCDNも同じベンダだからオリジンリクエスト送りたい放題なんだ

コンテンツ都合

- キャッシュして良い動的コンテンツがそもそも無い
- 全部SSRしちゃっていて、今更キャッシュして良い物悪い物を分離するのが難しい、やるにはコスパが悪い

その他

- そもそもウチはCDNのエッジサーバーで処理して配信してます(Lambda@Edge, Cloudflare Worker等を利用)  
  ↑全部これで賄えたら最高！
- 「いやぁ～動的コンテンツはキャッシュしないでしょ！」

残念ながらこれらに該当しないorどれかが許容出来なく、どうしても動的コンテンツをキャッシュしないといけなくなった時  
気にしなければいけないこと、そしてそれをなんとかするためにどうするべきかを考えます。

### 前提知識

- CDNとは何ぞや
- CDNに関連する用語(キャッシュ、オリジン等)

## キャッシュの設定で気にすること

そもそもCDNでコンテンツをキャッシュするのはオリジンリクエストを減らすためです。  
それによって以下のようなメリットを得たい訳です。

- コスト(お金)を抑える  
- オリジンサーバーの負荷を抑える
- サービスのパフォーマンス向上

これらのメリットを出来るだけ多く得るために[キャッシュヒット率を上げようとする](#キャッシュヒット率を上げるには)ことでしょう。

ただしCDNでコンテンツをキャッシュする時、以下のことに気を付けなければなりません。

- [個人情報が漏れないようにする](#個人情報が漏れないようにする)
- [コンテンツの更新を適切に反映する](#コンテンツの更新を適切に反映する)

## キャッシュヒット率を上げるには

初めに、CDNでのキャッシュだけでなくブラウザでもキャッシュさせることも勿論効果的ではありますが、ここでは考えないこととします。

ブラウザのキャッシュ時間をあまり長くしすぎると
ユーザー「画面に○○が出てきません」→サポート「ブラウザキャッシュを削除してください」
というやりとりが増えることになるので、
やるならば、JS、CSS等の静的ファイルや動的コンテンツなら一律数分程度にしておき、
画像等の静的ファイルなら数十分から数日程度が良いと思います。

PCはともかく、スマホでのキャッシュ削除は手間でしょうから。

さて、CDNのキャッシュ効率を上げる方法ですが、パッと思いつくのはキャッシュ時間を出来る限り長くすることです。

### キャッシュ時間を長くすることによる効果

そもそもキャッシュ時間を長くすることでどれだけキャッシュヒット率は上げられるのだろうか？

長いのでこちらをご覧ください。↓↓↓

https://qiita.com/shinjiroy/items/68dc46c20d770569dad3

回答としては「キャッシュが既に効いているなら、そこから更にキャッシュ時間を長くしただけではあんまり変わりません」となります。  
また、コンテンツの内容によってはキャッシュ時間に限界があるのでキャッシュ時間を長くするのは努力目標程度にしましょう。

### キャッシュ時間の調整以外の方法

そもそもキャッシュが切れた直後に瞬間的に沢山発生するオリジンリクエストが悪いので、それを抑えれば良いのです。

#### 方法1:リレーサーバーを置く

エッジサーバーのキャッシュが切れた時の動作について、大体こんな感じになると思われますね。

```mermaid
graph TD
  subgraph クライアント
      A1["クライアント (地域A)"]
      A2["クライアント (地域B)"]
      A3["クライアント (地域C)"]
  end

  subgraph エッジサーバー
      E1["エッジサーバー (地域A)"]
      E2["エッジサーバー (地域B)"]
      E3["エッジサーバー (地域C)"]
  end

  OS[オリジンサーバー]

  A1 --> E1
  A2 --> E2
  A3 --> E3

  E1 --> OS
  E2 --> OS
  E3 --> OS
```

これだと複数のエッジサーバーでキャッシュが切れた時、それぞれのエッジサーバーからドカッとオリジンリクエストが来てつらいです。

なので以下のようにエッジとオリジンの間に更にキャッシュサーバー[^1]を建てるわけですね。

```mermaid
graph TD
  subgraph クライアント
      A1["クライアント (地域A)"]
      A2["クライアント (地域B)"]
      A3["クライアント (地域C)"]
  end

  subgraph エッジサーバー
      E1["エッジサーバー (地域A)"]
      E2["エッジサーバー (地域B)"]
      E3["エッジサーバー (地域C)"]
  end

  RS[リレーサーバー]

  OS[オリジンサーバー]

  A1 --> E1
  A2 --> E2
  A3 --> E3

  E1 --> RS
  E2 --> RS
  E3 --> RS

  RS --> OS
```

こういう方法は基本的に自分たちで準備する物ではなく、CDNベンダが準備している物なので採用したorこれから採用するCDNにあるかを確認してみましょう。

例

- CloudflareのTiered Cache[^2]
- CloudFrontのOrigin Shield[^3]

:::note warn
注意  
エッジサーバーとリレーサーバーで持っているキャッシュの鮮度を考慮しなければならないかもしれません。  
採用するならば、キャッシュ時間を設定した時の実際のキャッシュ時間がどうなるかを必ず動作検証、必要ならサポートへの確認等をしましょう。
:::

#### 方法2:Cache-Controlヘッダをうまく使う

オリジンからキャッシュに関する設定をするのにレスポンスヘッダのCache-Controlヘッダ[^4]が使われます。  
代表的なのは `max-age` や `s-maxage` でしょうが、これらだけではリクエストがひっきりなしに来ている場合、キャッシュが切れた直後にオリジンリクエストがドカッときてしまいます。

そこで、採用するCDNが `stale-while-revalidate` や `stale-if-error` に対応している場合、それらをセットで付けてあげるとオリジンリクエストをCDNが非同期で送りつつ、それが返ってくるまではクライアントにはキャッシュを返してくれます。[^5]

### キャッシュヒット率を上げるために何より最も重要なこと

**そもそも、ちゃんとキャッシュする想定のコンテンツがキャッシュされているか**というのを確認すべきです。  
CDNを適用した後、必ずCDNのアクセスログ、メトリクスを確認してキャッシュステータスを確認しましょう。

キャッシュしてるつもりなのに思っていたようにキャッシュ出来ていなかったという場合があります。  
例えば以下のケースが挙げられます。

#### ケース1:レスポンスにSet-Cookieヘッダが含まれており、CDNが自動的にキャッシュしない判断をした  

例えばLaravel等のフレームワークを初期設定のまま使用すると、ユーザー情報を使う必要がないURLでもセッションが開始、またはセッションIDの振り直しをしてしまい、セッションID用のSet-Cookieヘッダがレスポンスに含まれてしまいます。  
何かしらフレームワークを使っていたりする場合は、ユーザー情報を使う必要が無いページを特定しセッションを開始しないように設定しましょう。

特に、Bot(GoogleBot等)にスクレイピングなりをされている場合、大抵リクエスト元はCookieヘッダを付けてきません。  
そのため、リクエストが来る度に新しいセッションを開始してしまい、キャッシュヒット率の悪化は勿論、セッションDB(大体Redisか何かを使っていると思います)に余計なデータが沢山できてしまうことにもなります。

:::note warn
注意  
CDNに依っては、Set-Cookieヘッダがあろうが無かろうが構わずキャッシュしてしまいます。  
それはそれで事故に繋がり得ますので、やはりキャッシュステータスを確認すべきでしょう。
:::

#### ケース2:知らないクエリパラメータが付いたリクエストが沢山来ていた

大体広告系の何か(GA等)を利用していると色んなクエリパラメータが付いたリクエストがやって来がちです。  
特に各リクエストでランダムな値が設定されると全然キャッシュヒットしてくれません。  
ドメイン全体、あるいは特定のURL群でシステム上想定されうるクエリパラメータでのみキャッシュが分岐するよう設定(キャッシュキー)するか、特定のクエリパラメータはキャッシュキーから除くかしましょう。

ただし、システム上想定されうるクエリパラメータの特定は、そのサービスの規模が大きくなると結構大変です。  
しんどくならない程度にドメイン、サービスを分ける等検討すると、想定されるクエリパラメータを制限しやすくなるでしょう。

#### ケース3:設定し忘れてました

キャッシュする設定をそもそも適切に出来ておらず、意図せずキャッシュする時間が極端に短くなっていたり、そもそもしていなかった場合です。  
例えばCDNが「常にオリジンからのCache-Controlヘッダに従う」設定になっていた場合にオリジン側でヘッダをつけ忘れていた時位でしょうか。

とは言え、間違ってキャッシュしてはいけない物をキャッシュするよりずっとマシなので、「あー忘れてた。設定しとくか」程度に捉えても良いのではないかと思います。

### 補足:オリジンサーバーからデータを取り放題な時

オリジンサーバーがどこにあってどのCDNを使うのかに依っては、オリジンリクエストによるデータ転送料金を無視出来る場合があります。

オリジンサーバーが、使いたいCDNのベンダの外にある場合、オリジンから出た分のデータ転送料金がかかります。(例えばALB+Cloudflare CDNとか)  
ベンダの管轄内にある場合、ベンダや契約内容によっては転送料金がかからないかもしれません。(例えばALB+Amazon CloudFrontとか)

もし、オリジンからデータ取り放題であればサーバー負荷の許す限り、キャッシュしたいコンテンツを一律短めの時間キャッシュするといった思い切ったキャッシュ戦略が取れるかもしれません。

## 個人情報が漏れないようにする

個人情報というか、ユーザー個別の情報が載っているデータをCDNでキャッシュしてはいけません。  
具体的な例は以下です。

- 氏名、住所、クレジットカード情報等のユーザーが登録した個人情報
- ユーザー毎の購入データ、お気に入り等のサービス利用によって出来た情報
- クッキー(Set-Cookieヘッダ)  
  ※特にセッションID
- レコメンド等のユーザー別に配信している情報

これらはCDNのバグとかではなく、どれも人為的なミスによるものです。  
そういった人為的なミスが極力起こらないようにするためにも、**動的コンテンツをキャッシュするしないの設定は絶対にオリジンであるアプリケーションサーバーに任せる方針を取ってください**。  
それもNginxやApache等のミドルウェアではなく、アプリケーションコードで設定を管理した方が良いです。

というのも、CDNをメインで触るインフラ担当的な人は「アプリケーションのどのURLがキャッシュしたらマズいといった情報を知らない」という前提で事を進めるべきです。  
そしてそのCDN担当の人は「どのくらいキャッシュするかはオリジンであるアプリケーションに従う」ように設定することだけ気にすれば良いです。

特に、この方針の良い所は設定漏れがあったらキャッシュされないという所にもあります。これで設定漏れ(ミス)に依ってキャッシュしてはならないコンテンツをキャッシュしてしまうといった状況を極力防げます。

### 補足:キャッシュミスとバイパス

オリジン側で少し気にした方が良いこととしては、「キャッシュミス」と「バイパス」です。

「キャッシュミス」は「本来キャッシュする想定だったコンテンツのキャッシュの有効期限が切れた」ことを表します。  
「バイパス」は「そもそもキャッシュする想定ではない」ことを表します。

CDNに依ってはこれを区別していたりしていなかったりします。  
例えば、CloudflareはCache-Controlヘッダの内容に依ってはMISS、BYPASSとキャッシュステータスが分かれますが、  
CloudFrontは、理由は何であれキャッシュが返らなければ一律MISSとなります。  

`Cache-Control:max-age=0` と指定していて「0秒キャッシュする」と解釈されてしまい、同時にリクエストされた時にキャッシュが返ってしまっていないか？といったことを気にする必要があります。  
採用するCDNの仕様をよく読み、検証をしたり、あるいはサポートに確認する等しましょう。

## コンテンツの更新を適切に反映する

いくらキャッシュすればするほどコストや負荷が減るからと言っても、サーバー側でのデータの更新が、配信されるデータに反映されないのではそのサービスを提供している意味がありません。  
キャッシュの恩恵を受けつつも、そのキャッシュを適切に更新することを考えなければなりません。

キャッシュの更新は

- 時間経過でキャッシュを揮発させる(自然揮発)
- 能動的にキャッシュを削除する

ことで行えます。  
配信するコンテンツの性質に応じて選択出来る方法が限られたり、また、キャッシュを揮発させる場合はキャッシュ出来る時間も限られてきます。

### コンテンツ別のキャッシュ戦略の例

これはサービスに依って様々ですが、ここでは一例を紹介します。

| 性質 | キャッシュ時間 | コンテンツの例 | 説明 |
| --- | --- | --- | --- |
| ユーザーの行動によって変動する値を含む | 0分～10分 | 商品の販売数<br>評価<br>「先着〇名様」と言った情報 | 特に「先着〇名様」と言った、確実なリアルタイム性を求められる情報を含む場合は一切キャッシュをしないという選択もする必要があります。<br>必ずしもそうでなければ多少長めにキャッシュしても良いかもしれませんが、要件次第です。 |
| 複数のデータに依存する | 30分～1日 | 商品一覧 | コンテンツに含まれる各データが更新される度にキャッシュを削除すると、削除する頻度が上がりキャッシュ効率が悪くなります。<br>基本的には自然揮発するのに頼りつつ、特定の条件(特定の時間帯や時限式に開始終了する等)を満たした場合は纏めて能動的にキャッシュを削除することになります。 |
| 単一のデータに依存する | 数時間～1日 | 商品詳細 | 長めに取りつつ、そのデータが更新されたら都度狙い撃ちでキャッシュを削除する戦略がとれるかもしれません。 |

いずれにせよ、あくまで動的コンテンツであるため、何日もキャッシュするのはリスクがあります。  
リクエストの間隔が1, 2日以上空くようなURLはそもそもキャッシュしてもパフォーマンスやデータ転送量にそれほど影響がないと考えられますし、  
実は影響がかなりあるというのであれば、別のやり方で配信することを検討すべきでしょう。(遷移元のページを短めにキャッシュしつつ、データが更新される度にクエリパラメータを変えたURLを載せる等)

### 補足:自然揮発の場合のキャッシュ効率

どのパターンにおいても、[先に紹介した](#方法2cache-controlヘッダをうまく使う) `stale-while-revalidate` を付けるようにすることでキャッシュ効率が良くなります。  
ただし、能動的にキャッシュを削除した場合はキャッシュが無くなってしまうので `stale-while-revalidate` が効かなくなります。  
出来るだけ自然揮発に寄せると良いでしょう。

### 補足:能動的にキャッシュを削除するリスク

CDNへのキャッシュ削除リクエストはネットワーク等何らかの理由で失敗する可能性があったり、実行回数に制限があったりします。  
あまり長すぎる時間に設定すると取り返しがつかなくなるかもしれません。  
※削除リクエストのリトライ処理も適切にエラーハンドリングしないと実行可能な回数を消費して、よりリスクが高まるので注意しましょう。

## 最後に

動的コンテンツをCDNでキャッシュするのは確かにメリットはありますが、とても難しいと思います。  
難しいので、ある程度属人化も幾らか発生してしまいますし、出来ることならキャッシュをしなくて済む方法を検討した方が良いと思います。

どうしてもキャッシュをしなければならなくなった場合は

- アプリケーションの性質(どういうコンテンツを配信しているか、どのコンテンツがキャッシュしてはならないか等)を良く理解しておく
- 情報を鵜呑みにせず動作確認、検証をしっかりやる
- 運用開始後もメトリクス、アクセスログをよく監視し、想定外の動きをしていないかを確認する

を徹底しましょう。(当たり前ですし、CDN関係無く意識することですね！)

[^1]: 「リレーサーバー」と書いてはいますが、そういう正式名称というわけではないです。
[^2]: [https://developers.cloudflare.com/cache/how-to/tiered-cache/](https://developers.cloudflare.com/cache/how-to/tiered-cache/)
[^3]: [https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/origin-shield.html](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/origin-shield.html)
[^4]: `max-age` 等のディレクティブの仕様も含めて以下をご覧ください  
[https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Cache-Control](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Cache-Control)
[^5]: CDNによっては微妙に動作が異なるので仕様書やキャッシュステータスをよく確認しましょう。SWRの検証はちょっと大変かもしれませんが頑張りましょう。