幼女の技術ブログ

こちらでは技術ネタをつらつらと

はげったーを支える技術(CDN編)

ご無沙汰しております。 前回の更新から2年が経過しておりますが、みなさまはいかがお過ごしでしたでしょうか。 僕はななさいになりました。

Mastodon(主にhandon.club)向けのTogetterライクなサービスとして、hagetter(はげったー)*1というサービスを開発・運用しており、気がむいた時にその中身の技術について紹介しています。

今回はCDN編となります。はげったーはCloudRunでホスティングしているためコールドスタートの立ち上がりに時間がかかり、レスポンスが遅いとのコメントをたくさん頂いていたので、今回の更新でCDNを使ったレスポンスの改善を行いました。

はげったーは皆様知っての通り1日十数アクセスの超人気サイトで、さらに不定期に記事が更新されるので、CDNキャッシュのメリットを活かしにくいという課題がありました。 CDNキャッシュのパージを用いて、キャッシュヒット率を上げつつコンテンツ更新が即時に反映される仕組みを作ったのでご紹介します。

前半ではCDNを用いたキャッシュの一般的な仕組み、後半でははげったーでの実装についてご説明します。

2022/7/20追記:プロキシサーバーの共有キャッシュが長時間残ってしまう問題をうまく回避する方法が見付からなかったので、若干不完全な内容となります。。(プロキシサーバーなしでは問題なく動作します)

過去の記事はこちら

目次

CDNの基本的な仕組み

CDNはContent Delivery Networkの略で、地理的に分散したサーバーにWebサイトのコンテンツをキャッシュすることで、Webアクセスを高速化します。また、高速化だけでなく、運営するWebサービスの負荷低減やDDoS対策などのセキュリティ上のメリットなどもあります。

CDNを利用する際には、運営するWebサイトのDNSレコードをCDNに向けるなどして、Webサイトのアクセスの際にCDNサーバーを経由するようにします。CDNを経由することでWebサイトのレスポンスが自動的にCDNのサービスにキャッシュされ、新たなリクエストが来た時にはCDNがWebサイトにリクエストすることなくキャッシュから返却します。

CDNの仕組み

ただし、Webサイトのレスポンスは静的なものだけでなく動的に変化するものも混在しているため、ただ一律にキャッシュすれば良いというわけにはいきません。そこでWebサイトがレスポンスを返却する際にCache-ControlヘッダなどのHTTPヘッダを用いてキャッシュの挙動を変化させることが出来ます。

キャッシュまわりの設定はかなり複雑なので、今回は関係ありそうな部分のみかいつまんで説明します。

Cache-Controlヘッダ

キャッシュ制御に関するヘッダでリクエストやレスポンスに付与します。Cache-Controlはキャッシュの保存可否や保存期間などの挙動を複数のディレクティブを用いて指定します。

ここでは主にレスポンスに付与されたCache-Controlヘッダについて述べます。

詳細はMDMの仕様説明をご参照下さい。

キャッシュの種類

キャッシュの種類は大きく分けて「プライベートキャッシュ」と「共有キャッシュ」に分けられます。

キャッシュ

プライベートキャッシュ

ブラウザなどのクライアント環境内に存在するキャッシュです。ログイン後のページなどのパーソナライズドされたコンテンツもキャッシュすることが可能です。Cache-Controlにprivateを指定したレスポンスはプライベートキャッシュのみに保存可能で、CDNやプロキシサーバーには保存されません。

Cache-Control: private

Cache-Controlでpublic/privateを明示しなかった場合は、デフォルトではprivateキャッシュとなるようです。ただし、s-maxageなどの一部の共有キャッシュに関する設定値によってデフォルト値がpublicに変わることもあります。

共有キャッシュ

CDNやプロキシサーバーなどに保存されたキャッシュです。キャッシュは他のユーザーも利用することが可能です。そのためパーソナライズドされたコンテンツはキャッシュしてはいけません。共有キャッシュとしての保存可否は他のヘッダやCache-controlのパラメータによって変化しますが、publicを指定することでキャッシュ可能なことを明示出来ます(長くなるので詳しくは仕様書を参照下さい)。

Cache-Control: public

キャッシュの生存期間

キャッシュが「新鮮」である期間を「max-age」で指定可能です(プライベート・共有キャッシュ共通)。また、「s-maxage」を指定することで共有キャッシュの設定値のみを変化させることも可能です。「新鮮」であるとはキャッシュしたコンテンツをオリジンに問い合わせることなくそのまま利用可能であるということを意味します(≠キャッシュの保存期間)。max-ageで指定した期間を過ぎると、キャッシュを更新する必要があります。

ただし、max-ageの期間を過ぎたらコンテンツを必ず再ダウンロードするというわけではなく、リクエストの際にIf-Modified-Sinceのパラメータを指定して、コンテンツが更新されていた際のみダウンロードします。サーバーはクライアントのキャッシュがそのまま利用可能な場合は、コンテンツの本体を送信せずに304 Not Modifedの応答を返します。これによって巨大なデータなどのキャッシュの再ダウンロードを避けることが出来ます。

パラメータの例

Cache-Control: public, max-age=10

キャッシュはpublicかつ、プライベート・共有キャッシュともに10秒まで利用可能です。

Cache-Control: max-age=10, s-maxage=100

プライベートキャッシュは10秒、共有キャッシュは100秒まで利用可能です。

Cache-Control: max-age=0

これはキャッシュを保存しないという意味ではなく、キャッシュとして保存した上で毎回コンテンツが最新かどうかの問い合わせを行います。コンテンツが更新されていた場合のみコンテンツを再ダウンロードします。コンテンツが巨大かつある程度の頻度で更新される可能性がある場合に有用です。

ちなみにキャッシュを一切保存させたくない場合はno-storeを指定します。(no-cacheはキャッシュを保存しないという意味ではなく、max-age=0, must-revalidate相当らしいです)

キャッシュが更新出来ない場合の挙動

max-ageで設定した時間を超えて例外的に古いキャッシュが利用される場合があります。例えばオリジンサーバーが503 Service Unavailableなどのエラーを返却して最新のコンテンツを取得出来なかった場合です。

エラー時にキャッシュが利用されないようにしたい場合は下記のようにmust-revalidateを追加すると、上記のような場合にはエラーを返却します。

Cache-Control: max-age=100, must-revalidate

参考:

同じURLで返却するコンテンツを変化させたい時どうするか?

例えばPCとスマホで返却するHTMLを変えたい時など、クライアント環境毎に異なるレスポンスを返却している場合があるかと思います。 この場合CDNでキャッシュするとクライアント環境によらず同じレスポンスが返却されることになるので不都合が生じます。 CDN毎にも異なるのですが、Cache-Control以外にもいくつかキャッシュの挙動に影響を受けるヘッダがあります。

例えばVaryヘッダです。Varyで指定されたリクエストヘッダの値をキーとして異なるコンテンツをキャッシュすることが可能です。ただし、HTTPリクエストのヘッダはブラウザが付けるものですので、Varyでの制御はなかなか難しいです。Fastlyの挙動に関する説明ですが下記サイトが参考になります。

qiita.com

他にも、Firebase HostingではCookieの__sessionというキーの値によって返却するレスポンスを変えることが可能です。例えばPCやモバイル、タイムゾーンなどをCookieに保存しておけば環境毎に異なるレスポンスを返す事は可能です。Firebase Hostingが__session以外のCookieを削除してしまうのですが、安直にここにセッションIDなどを入れてしまうと、セッションID毎に異なるキャッシュが生成されてしまい、ちゃんとキャッシュがきかなくなるので要注意です。

CDNのキャッシュのみに影響を与えたい時はどうするか?

Cache-Controlのs-maxageはCDNだけでなく通信経路上に存在するProxyサーバーにも影響が及びます。 今回のキャッシュパージのようにCDNのみにキャッシュしたいユースケースも存在します。

CDNのみにキャッシュをさせたい時は、CDN-Cache-Controlというヘッダが提案されていますが、現在実装しているのはCloudflareのみのようです。 他にも例えばFastlyならSurrogate-Controlというヘッダも利用可能です。

例えばFastlyに下記のようなヘッダを送った場合は、CDN(Fastly)では100秒、Proxyサーバーでは50秒、ブラウザでは10秒キャッシュが有効となります。

Surrogate-Control: max-age=100
Cache-Control: max-age=10, s-maxage=50

リクエストにおけるCache-Controlについて

最初にご紹介したようにCache-Controlはレスポンスだけでなくリクエストヘッダにも付与することが可能です。 クライアント側がCDNのキャッシュを無視して最新のコンテンツを取得したい場合などに利用します。

例えば、ブラウザでは通常のリロードの際には max-age=0、スーパーリロードの際には no-cacheをリクエストに付与するそうです。

他にも、CDNに長期間保存されうるjavascriptファイルなどには myscript.js?id=12345678 のように乱数をつけたクエリを付与することによって、CDNから古いバージョンのjavascriptファイルのキャッシュなどが返却されないようにするCache Bustingと呼ばれる手法も存在します。(Next.jsのSPAまわりでこの仕組みが利用されているので後述します)

動的コンテンツにおけるキャッシュ戦略

ここまではキャッシュの仕組みについて紹介しました。静的コンテンツの場合はキャッシュ期間を長めにするだけですので割とシンプルですが、動的コンテンツの場合はそう簡単にはいきません。

動的コンテンツをキャッシュすると、当然ですがコンテンツが更新されてもキャッシュの期限が切れるまではキャッシュされたコンテンツが返却されることになります。例えばキャッシュ期間が1時間なら最大で1時間はコンテンツが配信されない可能性があります。(更新頻度が高くないかつリアルタイムの更新が重要でない個人ブログなどはこの方法でも良いかもしれません)

一方、10秒などの短かい時間を設定するとキャッシュ効率が下がります(アクセス数の多いサイトはこれでもかなり有効ですが、はげったーは1日十数アクセスなのでキャッシュが無いものとほぼ同じ状態になります)。また、運悪くキャッシュ更新のタイミングでアクセスしたユーザーはキャッシュを使えないのでレスポンスが遅くなります。

レスポンス高速化とキャッシュ効率を両立するための手法はいくつか存在しています。

コンテンツ差分の動的読み込み

動的コンテンツを静的にキャッシュして、更新部分だけをAPIなどから取得してコンテンツを更新する方法です。CDNの弱点を実装でカバー!

この場合はWebサービス側に差分を取得するロジックとコンテンツを更新するロジックを組込まないといけないので実装面では若干面倒です。また、単純に実装するとコンテンツが後からぬるっと書き変わったりするのでUIの実装も多少気をつかいます。

Pros: CDNに特殊な機能が不要
Cons: 実装がやや大変。UXに若干の影響あり。

stale-while-revalidate

stale-while-revalidateはCache-Controlの仕組みの一つで、コンテンツのキャッシュ期限が切れていた場合には、現在の古いキャッシュをいったん返却しておき、裏でキャッシュを更新するという方法です。この機能を用いることにより、キャッシュ更新のタイミングにおけるレスポンス遅延を回避することが出来ます。

例えば下記のように設定するとキャッシュが生成されてから100秒間はそのままキャッシュを利用し、100〜1100秒にアクセスがあると古いキャッシュを返却しつつ、裏でオリジンサーバーにリクエストを送りキャッシュの更新を図ります。

Cache-Control: max-age=100, stale-while-revalidate=1000

ただし、stale-while-revalidateは比較的新しい仕組みなのでCDNが対応している必要があります(有名どころは大体対応しているっぽい?)。また、max-ageのようにどの程度の期間キャッシュを保持するかの問題もあります。

stale-while-revalidateと同様の機能としてNext.jsではISR(Incremental Static Regeneration)と呼ばれる機能があります。実質的にVercel限定の機能とはなりますが、ISRを用いることでかなりシンプルに実装することが可能です。

Pros: キャッシュ更新時のレスポンスを高速化出来る。ほぼCDNの機能のみで完結する。
Cons: stale-while-revalidateに対応しているCDNを利用する必要がある。キャッシュ時間が短かすぎると活用出来ず、長すぎると古いコンテンツが再利用される。

CDNキャッシュパージ

コンテンツの更新時に対応するURLのCDNのキャッシュをクリアするという方法です。この方法であればキャッシュ期間をどれだけ長く設定していたとしても、コンテンツの更新のタイミングでキャッシュを削除することでリアルタイムでの反映が可能となります。

同様の機能をNext.js v12.1でOn-Demand ISRとして提供した時には少し話題になりました。

VercelでOn-Demand ISRを利用する場合であれば一瞬で実装可能ですが、この方法を一般的なCDNで利用するには結構骨が折れます。まずはURL毎のキャッシュパージに対応したCDNを利用する必要があることと、stale-while-revalidateのように共通規格が存在しない方法なのでCDN毎に実装方法が異なります。また、あるデータを更新した際に影響を受けるページを把握しておき、その全てのページのキャッシュをパージする必要があります(これはOn-Demand ISRでも同じ)。少しでもミスるとその長いキャッシュ期間の間、ずっとコンテンツが更新されないことになります。

キャッシュパージに対応したCDNは例えば下記のようなものがあります。

料金や制約などは下記サイトが情報がまとまってて参考になります。
CDNのパージとかを調べてみたまとめ(Cloudflare/Cloud CDN/Fastly) - くらげになりたい。

Pros: キャッシュ期間をどれだけ長くしてもコンテンツ更新がキャッシュにリアルタイムに反映される
Cons: キャッシュパージを利用可能なCDNが限られる。CDNによってキャッシュパージの方法が異なる。データ更新時に影響を受けるページを把握しておく必要がある。実装がとにかく大変。

はげったーでのキャッシュパージによる高速化

ここまでで一般的なキャッシュまわりの技術について簡単に紹介しました。ここからはやっとはげったーにおける実装の話です。

はげったーはこれまでCloud Runを単体で利用していましたが、CDNによるキャッシュとキャッシュパージを利用するために以下のような構成にしました。 CDNとしてFirebase Hostingを使ったのは、同じGoogleのサービスであることとCDNやキャッシュパージの機能を無料で利用出来るという理由からです。

  • CDN:Firebase Hosting
  • サーバー:Cloud Run
  • ソフトウェア:Next.js

各部分の実装について説明していきます。

はげったーのキャッシュ戦略

はげったーで高速化のためにキャッシュしたいページは下記2種類です。

  • トップページ
  • 各記事のページ

上記のページはSSRで静的コンテンツを出力し、Cache-Controlヘッダにmax-age=0, s-maxage=21600を指定しています。 これはクライアントはキャッシュが最新かを必ずCDNに問い合わせ、CDNは6時間(21600秒)のキャッシュを保有するという意味になります。

ユーザーがはげったーで記事を更新した際には、記事本体と記事一覧のあるトップページそれぞれのCDNキャッシュをパージしています。

これで、ほぼ常にCDNのキャッシュにヒットする上に、記事に更新があった際にも即座に反映されるという夢のような仕組みになります。

2022/7/20追記:元々s-maxageは30日に設定していましたが、後述するようにFirebase HostingではSurrogate-ControlがきかずProxyサーバーに長期間キャッシュが残る可能性があったため6時間に変更しました。(微妙・・)

Firebase HostingのCDNキャッシュパージ

Firebase HostingではCDNとしてFastlyを利用しているらしく、Fastlyと同様の方法でキャッシュをパージすることが可能です。FastlyではキャッシュしたURLにPURGEというメソッドでリクエストを送信するとそのURLのキャッシュを削除することが可能で、Firebase Hostingでも問題なく利用できました。検索していたらGoogleのエンジニアがMLに投稿しているのをたまたまみつけました。

これをもとに試しに実装してみたらサンプルがこちらです。 上がCDNにキャッシュされた時刻、下がJavascriptで同的に取得した時刻です。リロードすると下の時間だけ動くのが分かるかと思います。

レスポンスのCache-controlヘッダはmax-age=0, s-maxage=31536000ですので、CDNには最大で365日共有キャッシュが保存されます。

ボタンを押すとAPI経由でトップページのURLにPURGEメソッドでリクエストを送信され、キャッシュがクリアされます。

ソースコードは下記にあります。 github.com

Firebase HostingとCloud Runの連携

Firebase HostingからCloud Runにリクエストを振り分ける方法は下記が参考になるかと思います。今回はFirebase Hostingのホスティング機能は利用せずに全てCloud Runから配信しています。

firebase.google.com

Firebase Hosting + Cloud Runの構成の場合、Cookie__sessionというキー以外はFirebase Hosting側で削除されてCloud Run側に到達しないので注意が必要です。また、__sessionはキャッシュのキーとして組込まれているため、適当に設定するとキャッシュがちゃんと動作しなくなる点も要注意です。ログイン情報をCookieで管理していて、サーバー側でも利用したい場合は少し扱いが難しいです。

また、CDNを経由しているためCloud RunからはHostヘッダなどから自身のWebサイトのURLを取得出来ないことに注意して下さい。ググった範囲では X-Forwarded-Host ヘッダが付与されるとも書いてある記事を見かけましたが、自分の環境では現時点では付与されていませんでした。ですのでキャッシュのパージなどで自身のURLが必要な時は環境変数などで与える必要があります。

Next.js+Firebase Hostingでの自作On-Demand ISRの実装

長い前置きでしたがここでやっと本題のキャッシュパージの実装になります。

Next.jsでのCache-Controlヘッダーの送信

Next.jsでレスポンスヘッダーを書き換えたい場合にはgetServerSidePropsの中で指定することが出来ます。

export const getServerSideProps: GetServerSideProps<PageProps> = async (
  context
) => {
    context.res.setHeader(
      'cache-control',
      'public, max-age=0, s-maxage=2592000'
    )

Next.jsとFirebase Hostingでのキャッシュパージの実装

Next.jsではSSRで生成されるコンテンツは2種類あります。 1つはコンテンツのHTMLで、ブラウザなどからURL指定でのアクセス・Aリンクからのアクセス・リロードの際に読み込まれます。 もう一つがnext/linkによるページ遷移の時に利用されるURLで、この時はコンテンツのURLにアクセスするのでなく、コンテンツに対応したjsonファイルが読み込まれてページの中身が書き変わります。

例えばNext.jsでコンテンツが提供されている https://example.com/ にアクセスがあったとすると、

のコンテンツが読み込まれます。コンテンツ更新の際にはこの2つのキャッシュを削除する必要があります。

ビルドIDはnext buildを実行した際に設定されるランダムな数値です(恐らくCache Bustingのため?)。ビルドIDは実行時に直接取得する方法が恐らくなさそうなので、next.config.jsで下記のように設定することで NEXT_BUILD_ID という環境変数経由でビルドIDを取得することが可能になります。

module.exports = {
  webpack: (config, { webpack, buildId, isServer }) => {
    config.plugins.push(
      new webpack.DefinePlugin({
        'process.env.NEXT_BUILD_ID': JSON.stringify(buildId),
      })
    )

    return config
  },
}

そして記事更新の際のキャッシュパージの実装は下記のようになります。

ここでは
https://hagetter.hansode.club/
https://hagetter.hansode.club/hi/{hid} ※hidは記事ID
の2つのURLのキャッシュを削除するものとします。

const purgeCache = async (
  req: NextApiRequest,
  hid: string,
) => {
  const baseUri = process.env.BASE_URI // フロンドエンドのホスト名を環境変数で指定
  const buildId = process.env.NEXT_BUID_ID // next.config.jsで設定

  await Promise.all([
    fetch(baseUri, { method: 'PURGE' })
    fetch(`${baseUri}/_next/data/${buildId}/index.json`,  { method: 'PURGE' })
    fetch(`${baseUri}/hi/${hid}`, { method: 'PURGE' })
    fetch(`${baseUri}/_next/data/${buildId}/hi/${hid}.json`, { method: 'PURGE' })
  ])
}

今のところはパージのみ実施していますが、fetchで再読み込みなどすれば新しいコンテンツでキャッシュをあらかじめ生成しておくことも可能かと思います。

Proxyサーバーでのキャッシュの回避(2022/7/20追記)

これでCDNについてはキャッシュを更新することが出来ましたが、途中経路に存在するプロキシサーバーなどに長期間キャッシュが残る懸念があるのではないかとのご指摘を頂きました。これについては下記2パターンを試してみたのですが、今のところうまくいかないため回避策がないようでした。

  • Surrogate-Controlヘッダを指定する … Fastlyでは問題なく利用できるのですが、Firebase Hostingではうまく動作しないようでした。Twitter上でも同様の問題にぶちあたっている人がいました
  • Firebase HostingのheadersルールでCache-Controlを書き換える … 変更後のヘッダの内容に基いてキャッシュされるようでしたので、こちらも残念ながら利用出来ませんでした。

現在はいったんキャッシュ期間を6時間ほどに設定しています。 Proxyも含めた正式対応はFirebase Hosting+FastlyがSurrogate-ControlやCDN-Cache-Controlに正式に対応するのを待つしかないようです。。

ビルドIDが変化した時のキャッシュ削除

記事更新時はこれまで説明した内容で動作しますが、ソフトウェアのアップデートなどによってビルドし直した際にはビルドIDが変化するため問題があります。

SSRしたコンテンツの中にはビルドIDが含まれているので、ビルドIDが変化した際にはコンテンツのキャッシュを一度削除した方が良さそうです。(jsonが404になるとフォールバックでURLに直接アクセスが飛ぶようなのでエラーにはならないみたいですが)

ページのソース末尾

Firebase hostingの場合は、firebase deploy --only hosting で全体のキャッシュも削除されるようなので、ソフトウェアのアップデート時にはこのコマンドも実行しておくと良いかと思います。CIで自動的に実行するようにしても良いかもしれません。

これでNext.js + Firebase HostingでOn-Demand ISRの実装が完了しました。

Firebase HostingではなくVercelの場合

ちなみにVercelの場合はとても簡単で、APIの中で下記のようにrevalidateを呼び出すだけです。面倒な部分が全部隠蔽されて楽ちんです。

res.revalidate('/')
res.revalidate(`/hi/${hid}`)

vercel.com

まとめ

今回ははげったーにおけるCDNによるレスポンス高速化について紹介しました。
次のネタは考え中です。

🌼🌼🌼 おまけ 🌼🌼🌼

趣味は酒と煙草です。

*1:handon.club版togetterなのでhagetter