チラシの裏

AppEngineでキャッシュの動作がおかしい問題

AppEngine上にデプロイしたNext.jsのアプリで、キャッシュがうまく動作しない事象が発生しました。
結論として、この問題はAppEngine上の問題で解決は不可能で回避するしかありませんでした。

本記事ではこの問題について記載していきます。

環境と現象

今回は下記の環境によって作成しています。

  • Next.js v13 (13以下であればおそらく全て同じ)
  • AppEngine
    • standard環境 (flexibleでは確認していない)
    • nodejs18(16でも同じ、nodejs以外でも同様の問題が発生する)

再現用に下記のリポジトリを用意しました。

appengine-cache-test

今回問題が発生するのは puclicディレクトリのファイルです。
今回は、下記のようなファイルを作成しました。

public/dummy.html
<p>Hello?????</p>

next.jsではpublic/以下のファイルは静的ファイルとして公開できます。実際に/dummy.htmlにアクセスしてみるとちゃんと表示されています。

dummy.htmlの初回アクセス結果

アクセスして気づきました「!じゃなくて?になっている!」と。
そして、下記のようにファイルを修正してみました。

public/dummy.html
<p>Hello!!!!!</p>

そして再デプロイして……デプロイ完了。見てみましょう。

あれ、変わらないぞ…?

キャッシュを無効化してリロード(Ctrl-Shift-RとかCmd-Shift-R)を行うとうまく表示されます。つまりキャッシュが効いているような気がするのですが…一体なんででしょうか。

理由と解決方法

この現象の理由ですが、Next.jsの静的ファイルのETagの生成アルゴリズムと、AppEngineの特性によって引き起こされています。

理由

上記の現象、ぱっと見るとクライアントキャッシュが利用されていることが原因に見えます。しかし、レスポンスをよく見ると、キャッシュコントロールは実質利用できない状態になっています。

Cache-Control: public, max-age=0

max-ageが0というのは、0秒間キャッシュを使ってもいいよという意味であり、キャッシュは無条件で使えません。
ただ、レスポンスステータスコードを見ると304です。これは「変更はありませんのでキャッシュを使ってください」という意味です。
これによりブラウザはローカルキャッシュを使います。

つまり問題はサーバー側が304を返すこととなります。

次に考えるのは、何故304を返すのでしょうか。いくつか理由はありますが、今回はETagが原因です。ETagヘッダーは、リソースのバージョンを表すものです。AppEngineではETagが同一なものに関して自動的に304を付与するようです。正確にはEdge Cacheという機能がGCPにはあるらしいのですが、公式の説明が全く見当たらないため挙動から想像しています。
今回はレスポンスを見る限り古い値と新しい値とでETagの値が同一であるため、それが原因と推測できました。

Etag: W/"16-49773873e8"

つまり、このETagが生成されるのが問題ということですね。ETagを生成するのはAppEngine(EdgeCache)でしょうか?Next.jsでしょうか?どんなに調べてもEdgeCacheについて情報がなかったので、とりあえず今回はNext.jsの生成に問題があると考えました。

コードを読んでみるとNext.jsには2通りのETagの生成があります。
1つ目は、pages(app directoryも同じかも)に記載している部分に関するETagの生成。もう1つが静的ファイル、すなわちpublic/以下に対するものです。

詳しくは記載しませんが、ソースコードを追うとpublic/以下の静的ファイルはsendというパッケージを利用して公開していることがわかります(正確には、next.js内部にコンパイル済みのsendがある。そのためpackage.jsonの依存にはない)。このsendパッケージは内部でetagパッケージを利用しています。

このetagは2つのETagの生成方法がありますが、利用されているのはファイルのstatを利用した方法です。
ETagの生成は簡単に言えば下記のようなものになっています。

W/"<16進数のファイルサイズ>-<16進数の作成時刻(ミリ秒付きのUnix時間)>"

ETagの後半部分である49773873e8を10進数になおして見ると315,532,801,000。ミリ秒付きなので315,532,801。これをUnix時間として変換すると1980-01-01 00:00:01という謎の時刻になります。しかし、なんとなく見えてきましたね。この作成時刻がAppEngineの環境では一定なのでETagが同一になると。

結論として、AppEngineの仕様として、ファイルの作成時刻が 1980-01-01 00:00:01 になるということらしいです(https://issuetracker.google.com/issues/168399701#comment11[1]

すなわち、AppEngine上では作成日をもとにしたETagは同一になる可能性があるということです。今回の場合はファイルサイズが変わっていないため、同一と判定されたんですね。

回避策

いくつかありますが、最も簡単で最も網羅的なのは、キャッシュを無効化することです。

例えばNext.jsの場合はnext.config.jsheaders()関数を定義すれば、ヘッダーを修正することが出来ます。

next.config.js
async headers() { return [ { source: "/dummy.html", headers: [ { key: "Cache-Control", value: "no-cache", }, ], }, ]; },

s-maxageなども動作すると思うので、設定値はお好みで。

他にも、ファイルサイズを常に変えるようにするなどもあります。


  1. 何故1970年じゃないのか、何故01秒なのか…。 ↩︎