壁紙アプリの一覧を返す API が、思ったより重いことには前から気づいておりました。カテゴリごとの一覧は件数が多く、アプリを開くたびに同じ内容を毎回オリジンの PHP が組み立てて返しています。
そこで、すでに画像の配信で使っている Cloudflare をこの API の前にも置いたはずでした。ところが応答ヘッダを見ると、cf-cache-status は HIT にならず、同じ一覧を続けて取っても毎回オリジンまで届いておりました。CDN を置いただけで、一度もキャッシュとして働いていなかったのです。
原因は単純で、PHP が返していた Cache-Control: no-store でした。個人開発の API ではよくある状態だと思いますので、直し方を「端末側は変えない」という条件つきで整理した記録を残します。
素通しだった理由は、二つ重なっていました
最初に確かめたのは、そもそもなぜ HIT にならないのかです。理由は二つありました。
一つめは、Cloudflare が標準でキャッシュの対象にするのは画像や CSS などの静的ファイルの拡張子で、.php が返す JSON は対象外だという点です。二つめは、オリジンの応答が no-store を宣言していた点です。PHP はセッションを開始すると、Cache-Control: no-store, no-cache, must-revalidate を既定で付けます。セッションを使うつもりのない API でも、共通の読み込み処理の中で session_start() が呼ばれていると、同じ応答になります。
つまり、拡張子の面でもヘッダの面でも、エッジが持たない側に倒れていました。どちらか片方だけ直しても足りません。
直し方は三通りあり、「端末側に効くか」で選びました
手段は三つ考えました。
決め手は、端末側に効くかどうかでした。出荷済みのアプリは、更新を入れていない利用者の手元で動き続けます。オリジンの Cache-Control を変えると、その旧版の端末にも「しばらく取り直さない」という指示が届いてしまい、後から中身を直しても端末に残った応答は私の側から消せません。エッジの TTL だけを上書きすれば、端末には従来どおりの指示が届き、エッジで受け止める分だけを増やせます。
なお、応答の項目を位置で読む API は、項目を末尾にしか足せません。その制約を守る運用は 別の記事 に書きました。キャッシュを足す前に、その約束が崩れていないことも確かめておくと安心です。
Cache Rule で Edge TTL だけを上書きします
設定は Cache Rules の「Edge TTL」を override_origin(オリジンの指示を無視して指定した秒数にする)にし、「Browser TTL」を respect_origin(オリジンの指示をそのまま端末へ渡す)にします。対象は、重い一覧を返すパスだけに絞りました。TTL の秒数は環境に合わせて決めるものですので、ここでは例として 300 秒(5 分)にします。
# Cache Rules のエントリーポイント(http_request_cache_settings フェーズ)へ設定を入れる
# 注意: PUT はこのフェーズのルール全体を置き換える。先に現在の内容を保存してから実行する
ZONE = $CF_ZONE_ID
URL = "https://api.cloudflare.com/client/v4/zones/ $ZONE /rulesets/phases/http_request_cache_settings/entrypoint"
# 1) 変更前を必ず控える(戻し方を先に決めておく)
curl -sS " $URL " -H "Authorization: Bearer $CF_API_TOKEN " > before-cache-rules.json
# 2) 重い一覧だけを対象にしたルールを入れる
curl -sS -X PUT " $URL " -H "Authorization: Bearer $CF_API_TOKEN " \
-H "Content-Type: application/json" --data '{
"rules": [{
"description": "list API: edge ttl only",
"expression": "(http.host eq \"api.example.com\" and starts_with(http.request.uri.path, \"/v1/list\"))",
"action": "set_cache_settings",
"action_parameters": {
"cache": true,
"edge_ttl": { "mode": "override_origin", "default": 300 },
"browser_ttl": { "mode": "respect_origin" }
}
}]
}'
PUT が既存のルールをすべて置き換える点は、見落としやすいところです。すでに別の Cache Rule を入れているゾーンでは、1) の控えを開いて、残したいルールを rules に含めたうえで送ります。控えがあれば、before-cache-rules.json の rules を同じ手順で送り直すだけで元に戻せます。
対象の式にも気をつけました。利用者ごとに中身が変わる応答や、Set-Cookie を返す応答は、パスの条件から外しています。誰かの応答を別の誰かへ配ってしまう事故は、速さと引き換えにできないためです。
効いたかは、同じ URL を続けて取って確かめます
設定したあとにブラウザで画面を見ても、効いたかどうかは分かりません。見るのは応答ヘッダです。同じ URL を間隔を空けて数回取り、cf-cache-status と age を並べる小さなスクリプトを用意しました。
// edge-ttl-check.mjs 使い方: node edge-ttl-check.mjs <URL> [回数] [間隔ms]
const [ url , nArg = '4' , waitArg = '1500' ] = process.argv. slice ( 2 );
if ( ! url) { console. error ( 'URL を渡してください' ); process. exit ( 2 ); }
const n = Number (nArg), wait = Number (waitArg);
const sleep = ms => new Promise ( r => setTimeout (r, ms));
const rows = [];
for ( let i = 1 ; i <= n; i ++ ) {
const t0 = performance. now ();
const res = await fetch (url, { cache: 'no-store' , headers: { 'accept-encoding' : 'identity' } });
const body = await res. arrayBuffer (); // 本体まで読んで所要時間を測る
rows. push ({
'#' : i,
status: res.status,
'cf-cache-status' : res.headers. get ( 'cf-cache-status' ) ?? '(なし)' ,
age: res.headers. get ( 'age' ) ?? '-' ,
'cache-control' : res.headers. get ( 'cache-control' ) ?? '(なし)' ,
bytes: body.byteLength,
ms: Math. round (performance. now () - t0),
});
if (i < n) await sleep (wait);
}
console. table (rows);
const hits = rows. filter ( r => r[ 'cf-cache-status' ] === 'HIT' ). length ;
if (rows. every ( r => r[ 'cf-cache-status' ] === '(なし)' )) {
console. log ( '判定: cf-cache-status が付いていません。Cloudflare を通っていないか、別の層です。' );
} else if (hits === 0 ) {
console. log ( `判定: ${ n } 回とも HIT なし。応答が no-store 等で素通しされている可能性があります。` );
} else {
console. log ( `判定: ${ n } 回中 ${ hits } 回が HIT。エッジが応答を持っています。` );
}
ここで見る並びは三つです。設定の前は、全回が DYNAMIC や HIT なしで並び、ms もほぼ変わりません。設定の後は、1回目が MISS、2回目以降が HIT に変わり、age が秒単位で増えていきます。そして cache-control の列は、設定の前後で変わりません。この列が変わっていなければ、端末側に別の指示を渡していない証拠になります。
このスクリプトを手元で走らせたところ、cf-cache-status と age の列が期待どおり並ぶことは確かめられました。自分の API では、ms の列が MISS と HIT のあいだでどれだけ縮むかを、一度見ておくことをおすすめします。縮み方が小さければ、そのパスはキャッシュの対象にしなくてもよいという判断材料になります。
代償として、「古い応答」が TTL の間だけ生まれます
エッジが応答を持つということは、オリジンを更新した直後も、TTL が切れるまで古い一覧がエッジから返るということです。上の例なら最長で 5 分です。
私が最初に気をつけたのは、更新作業の中に「パージ」を溶かし込むことでした。壁紙を追加してオリジンへ上げたのに、アプリでは出てこない状態になり、「まだ反映されないのですが」という問い合わせを受けるのは、避けたいことです。そこで、編集とアップロードとパージを、必ず一組で動かす手順にしました。
#!/usr/bin/env bash
# publish-and-purge.sh 使い方: ./publish-and-purge.sh [--dry-run] <API のパス> ...
# アップロードが失敗したらパージへ進まない(set -e)
set -euo pipefail
DRY = 0
if [ " ${1 :- } " = "--dry-run" ]; then DRY = 1 ; shift ; fi
[ " $# " -ge 1 ] || { echo "パージ対象のパスを 1 つ以上渡してください" >&2 ; exit 2 ; }
: "${ API_ORIGIN :? 例 : https :// api . example . com }"
: "${ CF_ZONE_ID :? }" ; : "${ CF_API_TOKEN :? }"
# 1) アップロード(自分の配置コマンドに置き換える)
if [ " $DRY " -eq 1 ]; then echo "[dry-run] upload skipped" ; else ./upload.sh ; fi
# 2) パージ対象を URL にする。クエリ違いは別のキャッシュなので、アプリが叩く形をそのまま渡す
files = $( printf '"%s%s",' $( for p in " $@ " ; do echo " $API_ORIGIN " " $p " ; done))
body = "{ \" files \" :[${ files % ,}]}"
if [ " $DRY " -eq 1 ]; then echo "[dry-run] POST purge_cache $body " ; exit 0 ; fi
# 3) success が true でなければ失敗として止める
out = $( curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${ CF_ZONE_ID }/purge_cache" \
-H "Authorization: Bearer ${ CF_API_TOKEN }" -H "Content-Type: application/json" --data " $body " )
echo " $out " | grep -q '"success":true' || { echo "パージ失敗: $out " >&2 ; exit 1 ; }
echo "パージ済み: $* "
先に --dry-run で、実際に送られる JSON を確かめてください。/v1/list.php?cat=1 のようなパスを渡すと、{"files":["https://api.example.com/v1/list.php?cat=1", …]} の形になることを、私も手元で確認しました。
URL 単位のパージは、クエリ文字列まで一致した URL だけを消します。カテゴリごとにクエリが違う API なら、更新で影響を受けるカテゴリの URL をすべて渡す必要があります。ここを漏らすと、一つのカテゴリだけ古いまま残り、原因を探すのに時間を使います。エッジだけが古いときの切り分けは、以前の記事 の三者突き合わせがそのまま使えます。
今回あらためて感じたのは、キャッシュは「効かせる設定」よりも「古くなったときの出口」を先に作るほうが、あとで慌てずに済むということです。
入れる前に確かめておきたい五つのこと
設定を入れる前の確認を、順に並べます。
素通しの理由を二つとも確かめます。 対象の拡張子か、no-store か、あるいは両方か。片方しか直さないと HIT は出ません。
端末側の指示を変えるのかどうかを、先に決めます。 出荷済みのアプリがある限り、オリジンの Cache-Control を変えるのは慎重に。
対象のパスを狭く始めます。 一番重い一覧だけを対象にして、効果を見てから広げます。
利用者ごとの応答と Set-Cookie を返すパスは、式から外します。
変更前の設定を保存し、戻し方を書いてから入れます。 戻す作業は、焦っている時にいちばん間違えやすいからです。
エージェントに頼むときの指示文
この作業をエージェントに任せる場合は、設定を入れる前の控えと、パージを一組にする条件を、依頼文に書いておきます。次のような文面で渡しています。
Cloudflare の Cache Rule を 1 件だけ追加してください。
- 対象: api.example.com の /v1/list で始まるパスだけ
- Edge TTL は override_origin(300 秒)、Browser TTL は respect_origin
- 先に現在の entrypoint ruleset を before-cache-rules.json に保存すること
- PUT は既存ルールを置き換えるので、保存した rules を含めて送ること
- 実行後に edge-ttl-check.mjs を 4 回走らせ、cache-control の列が変わっていないことを報告すること
- 他のパスや他のゾーンには触れない
「他には触れない」と「変わっていないことを報告する」の二行が、あとで効きます。設定を入れる作業は、うまくいった報告だけでは不十分で、何を変えていないかまで確かめて初めて終わりになります。