九月のなかば、浮世絵壁紙の新しい素材を配信ツリーへ重ねた翌朝のことです。実機でアプリを開き、新しい ID の一覧をなぞっていくと、絵は確かに増えておりました。ところが、同じ ID のまま差し替えたはずの数枚が、前の版のままで並んでおります。紙の縁を削り直し、クロップの位置を直した、あの手直しが一枚も届いておりませんでした。
上書きは済んでおりました。エージェントの報告も「上げました」で終わっておりました。それでも古いのです。私は Cloudflare のダッシュボードを開き、該当する URL をパージしました。ここまでは、いつもの手順です。
手が止まったのは、パージのあとも同じ絵が出続けたときでした。エッジを空にしたのに古い——その時点で、私は配信経路に自分が数えていなかった層があることを認めるしかなくなりました。
配信経路には、数えていなかった層がありました
個人開発で運用している壁紙アプリの画像は、Xserver 上のストレージ用ドメインをオリジンにして、Cloudflare のエッジを通してアプリへ届けております。画像は一度公開したら変わらない前提で、Cache-Control: public, max-age=31536000, immutable を返しています。一年、書き換えない約束です。
その約束を、私自身が破りました。同じ ID の画像を、同じ URL に上書きしたのです。ID を変えれば済む話ではありましたが、アプリ側のお気に入りや通知の参照が ID に紐づいているため、差し替えは同一 URL で行う方針にしております。
ここで私が頭に描いていた経路は「ローカルのファイル → オリジン → エッジ → アプリ」の三段でした。実際には、オリジンの中にもう一段ありました。Xserver の XPageSpeed が、静的な JPEG を最適化版に書き換え、元の URL のまま その最適化版を返していたのです。この段は、Cloudflare からは見えません。
層 誰が持っているか 古くなる条件 見分ける手がかり
ローカル 手元のステージング 上げ漏れ・merge 失敗 ファイルが無い/サイズが違う
オリジンの最適化層 Xserver の XPageSpeed 上書き後も最適化版を返す etag: W/"PSA-…"・Last-Modified が消える
オリジンの素の応答 Apache の静的配信 本来は上書き直後に新しくなる ?cb= 付きで取ると新しい
エッジ Cloudflare immutable のままパージ前 cf-cache-status: HIT と age
表にすると当たり前に見えますが、私はこの二行目を数えておりませんでした。
「上げました」を、三者の突き合わせに置き換えました
最初に見直したのは、確認の仕方そのものです。それまでの私は、アップロード後にブラウザで数枚を開いて目で確かめておりました。目で見る確認は、エッジが返した一枚を見ているだけで、オリジンが何を持っているかは分かりません。
そこで、同じ画像を三つの経路で取り、突き合わせる小さなスクリプトを置きました。要は、?cb=$RANDOM を付けてオリジンの真の姿を見る ことです。素の URL と食い違えば、そのあいだのどこかが古い、と判定できます。
#!/usr/bin/env bash
# 同じ画像を「素の URL」と「cb 付き」で取り、ヘッダとサイズを並べる
# 使い方: ./probe.sh 1353 (壁紙 ID)
set -eu
ID = " $1 "
BASE = "https://storage.example.com/ios/wallpaper_apps/ukiyo-e/6_5inch/wallpaper"
URL = "${ BASE }/wallpaper${ ID }.jpg"
LOCAL = "./_out_ukiyo-e/ios/6_5inch/wallpaper/wallpaper${ ID }.jpg"
probe () {
# -D で応答ヘッダを stderr 側に逃がし、本文はサイズだけ数える
local u = " $1 "
curl -sS -D /tmp/h. $$ -o /tmp/b. $$ " $u "
local size; size = $( wc -c < /tmp/b. $$ | tr -d ' ' )
printf '%-8s size=%-8s cf=%-8s age=%-6s etag=%s lm=%s\n' \
" $2 " " $size " \
"$( grep -i '^cf-cache-status:' /tmp/h. $$ | awk '{print $2}' | tr -d '\r')" \
"$( grep -i '^age:' /tmp/h. $$ | awk '{print $2}' | tr -d '\r')" \
"$( grep -i '^etag:' /tmp/h. $$ | cut -d ' ' -f2- | tr -d '\r')" \
"$( grep -i '^last-modified:' /tmp/h. $$ | cut -d ' ' -f2- | tr -d '\r')"
rm -f /tmp/h. $$ /tmp/b. $$
}
printf '%-8s size=%s\n' local "$( wc -c < " $LOCAL " | tr -d ' ')"
probe " $URL " edge
probe "${ URL }?cb= $RANDOM " origin
このスクリプトが返す三行を並べると、事故の種類が読めます。ローカルとオリジン(cb 付き)のサイズが違えば上げ漏れです。オリジンは新しいのにエッジだけ古く cf=HIT と age が出ていれば、パージで直ります。そして、cb を付けてもサイズが古いまま、etag が W/"PSA- で始まっている なら、オリジン側の最適化層が握っております。九月の朝に私が見たのは、この三つ目でした。
エージェントの完了報告を信じない、という話は エージェントの定期実行で、失敗より先に「走らなかった」を疑うようになりました にも書き残しましたが、今回は少し向きが違います。報告は正しかったのです。上書きは本当に済んでいました。正しい報告のあとに、誰にも見えない層が古い版を返していただけでした。
エッジだけが古いときは、消し方を件数で選びます
順番として、まず Cloudflare 側の話を片づけておきます。上書きした画像が数枚なら URL 単位のパージで足ります。ところが、素材を一括で差し替えた回は、対象が 536 本ありました。
Cloudflare の API でファイル単位のパージは 1 回あたり 30 本までです。536 本なら 18 回に分けて送ることになります。ダッシュボードのカスタムパージには「プレフィックス」があり、こちらは 1 回に 100 件まで、しかも storage.example.com/ios/wallpaper_apps/ukiyo-e/6_5inch/wallpaper のようにスキーム無しのパスを 1 行書けば配下がまとめて対象になります。ディレクトリ単位で差し替える運用なら——私の場合はまさにそうでした——この場合はプレフィックスのほうが速くて確実です。
一方で「すべてをパージ」は選びません。全壁紙がエッジから消え、翌朝までにオリジンへ大量に取りに戻ります。共有サーバーのオリジンにとって、これはいちばん避けたい戻り方です。
URL 単位で送る場合は、一覧を 30 本ずつに刻む小さな道具を置いております。ステージングの merge 結果から、上書きになった ID だけを拾って cloudflare_purge_urls.txt に吐き、それをこのスクリプトが読みます。
"""cloudflare_purge_urls.txt を 30 本ずつ Cloudflare の purge_cache へ送る。
トークンは環境変数から読む(記事中に実値を書かない)。
"""
import json
import os
import sys
import time
import urllib.request
ZONE_ID = os.environ[ "CF_ZONE_ID" ]
TOKEN = os.environ[ "CF_API_TOKEN" ] # Zone > Cache Purge 権限だけを持つトークン
ENDPOINT = f "https://api.cloudflare.com/client/v4/zones/ { ZONE_ID } /purge_cache"
CHUNK = 30 # ファイル単位パージの上限(1 リクエストあたり)
def purge (files):
body = json.dumps({ "files" : files}).encode()
req = urllib.request.Request(
ENDPOINT , data = body, method = "POST" ,
headers = { "Authorization" : f "Bearer { TOKEN } " , "Content-Type" : "application/json" },
)
with urllib.request.urlopen(req, timeout = 30 ) as res:
data = json.load(res)
if not data.get( "success" ):
raise RuntimeError (data.get( "errors" ))
return data[ "result" ][ "id" ]
def main (path):
with open (path, encoding = "utf-8" ) as f:
urls = [line.strip() for line in f if line.strip()]
# 同じ URL を二度送らない。順序は保つ
seen, ordered = set (), []
for u in urls:
if u not in seen:
seen.add(u)
ordered.append(u)
total = len (ordered)
for i in range ( 0 , total, CHUNK ):
chunk = ordered[i:i + CHUNK ]
rid = purge(chunk)
print ( f "[ { i + len (chunk) :>4 } / { total } ] purged { len (chunk) } files (request { rid } )" )
time.sleep( 0.5 ) # レート制限に当てない程度の間隔
if __name__ == "__main__" :
main(sys.argv[ 1 ] if len (sys.argv) > 1 else "cloudflare_purge_urls.txt" )
このスクリプトは、投げたら終わりではありません。送ったあとに先ほどの probe.sh で数本を取り直し、cf=MISS か EXPIRED に変わったことを見てから閉じるようにしています。パージが効いたかどうかも、報告ではなく応答で確かめる——ここは同じ線引きです。
パージしても直らなかった朝に、ヘッダで見えたもの
さて、九月の朝に戻ります。URL 単位でパージし、cf=MISS になったのを確かめ、それでも画像は古いままでした。probe.sh の origin 行を見直すと、cb 付きで取ったはずの応答に etag: W/"PSA-…" が付き、Last-Modified が消えておりました。
PSA- は PageSpeed 系の最適化が付ける弱い ETag です。Xserver では XPageSpeed という名前で提供されていて、JPEG を再圧縮した版を、元の URL のまま返します。上書きしたファイルが手元にあっても、最適化層が前の版から作った成果物を握っていれば、オリジンは古い絵を返し続けます。Cloudflare がオリジンへ取りに戻っても、受け取るのはその古い絵です。
ここに、私にとって直感に反する発見がもう一つありました。最適化層の応答には s-maxage=10 が付いておりました。こちらが一年の immutable を返しているつもりでも、エッジの保持は 10 秒に縮められていた のです。古い絵を握ったまま、エッジの効きだけを落とす——壁紙のストレージにとって、これほど割に合わない組み合わせはありません。壁紙は配信時点で q85 に揃えてあり、再圧縮は劣化にしかならないのですから。
s-maxage が効くのは、Cloudflare が共有キャッシュとして max-age より s-maxage を優先して自分の保持時間を決めるからです。ブラウザには一年の約束が届き、エッジには 10 秒の約束が届く——同じ一つのヘッダ行が、受け取る側によって別の意味になっておりました。ダッシュボードで Edge Cache TTL を上書きしていなかった私の設定では、オリジンの言い分がそのまま通っていたのです。
観察したヘッダ 最適化層が有効なとき 切ったあと
etagW/"PSA-…"(弱い ETag)Apache の "inode-size-mtime" 形式
Last-Modified消える ファイルの mtime が戻る
Cache-Controls-maxage=10 が付くmax-age=31536000, immutable のみ
本文サイズ ローカルと一致しない ローカルと一致する
.htaccess で切るときは、書き方を一つ間違えると 500 になります
対処は二つあります。サーバーパネルの「XPageSpeed 設定」を OFF にするか、.htaccess で該当ディレクトリだけ切るかです。私はストレージ用のディレクトリ 5 つすべてで OFF にしました。画像を最適化されて助かる場面が、壁紙の配信には一つもないからです。
.htaccess で切る場合に、一つ落とし穴があります。mod_pagespeed の一般的な書き方 ModPagespeed off をそのまま置くと、Xserver では 500 のエラーが返ります。ディレクティブ名が違うのです。Xserver 独自の XPagespeed off を使い、さらにモジュールが無い環境でも壊れないよう、制御ファイルの存在で囲みます。
# storage.example.com の配信ルート直下 .htaccess
# 壁紙は配信時に最終画質へ揃えてあるので、オリジン側の再圧縮は切る
<IfFile /var/xpagespeed/xpagespeed_ctl>
XPagespeed off
</IfFile>
# 参考: 静的画像の約束はこちら。上書きするときはパージまでが 1 セット
< FilesMatch "\.(jpg|jpeg|png|webp)$" >
Header set Cache-Control "public, max-age= 31536000 , immutable"
</ FilesMatch >
<IfFile> で囲む理由は二つです。一つは、サーバー移行で XPageSpeed の無い環境に .htaccess を持ち越したときに、未知のディレクティブで 500 を出さないためです。もう一つは、.htaccess をリポジトリで管理しているので、5 台で同じファイルを使い回せるためです。
そして、順番があります。**OFF にしたあと、もう一度 Cloudflare をパージします。**最適化層を切っただけでは、エッジは s-maxage=10 のあいだに拾った古い版を持っているかもしれません。私は一度、OFF にして安心し、翌朝また古い絵を見ました。
判定をスクリプトに持たせ、名指しで出すようにしました
三度目の朝を迎えないために、確認を checkserver というサブコマンドにまとめました。やっていることは probe.sh の三者比較を ID の範囲でサンプリングし、事故を種類ごとに名指しすることです。カテゴリの上げ漏れ——wallpaper_num だけ上がって categories/*.txt が古い——も同じ場所で数えます。
"""checkserver: ローカル/オリジン(cb 付き)/エッジの三者を突き合わせ、
事故を種類ごとに名指しする。サンプル数は --sample、開始 ID は --id-from。
"""
import argparse
import os
import random
import urllib.request
from dataclasses import dataclass
BASE = "https://storage.example.com/ios/wallpaper_apps/ukiyo-e/6_5inch/wallpaper"
LOCAL_DIR = "./_out_ukiyo-e/ios/6_5inch/wallpaper"
@dataclass
class Probe :
size: int
cf_status: str
age: str
etag: str
last_modified: str
def fetch (url):
req = urllib.request.Request(url, headers = { "User-Agent" : "checkserver/1.0" })
with urllib.request.urlopen(req, timeout = 20 ) as res:
body = res.read()
h = {k.lower(): v for k, v in res.headers.items()}
return Probe(
size = len (body),
cf_status = h.get( "cf-cache-status" , "-" ),
age = h.get( "age" , "-" ),
etag = h.get( "etag" , "-" ),
last_modified = h.get( "last-modified" , "-" ),
)
def classify (local_size, edge, origin):
"""判定の順番が大事。上流(ローカル)から順に疑う。"""
if local_size is None :
return "MISSING_LOCAL"
if origin.etag.startswith( 'W/"PSA-' ) or origin.last_modified == "-" :
return "ORIGIN_OPTIMIZER" # cb 付きでも古い=オリジン側の最適化層が握っている
if origin.size != local_size:
return "ORIGIN_STALE" # 上げ漏れ・merge 失敗
if edge.size != origin.size and edge.cf_status == "HIT" :
return "EDGE_STALE" # パージで直る
return "OK"
def main ():
ap = argparse.ArgumentParser()
ap.add_argument( "--sample" , type = int , default = 20 )
ap.add_argument( "--id-from" , type = int , default = 1 )
ap.add_argument( "--id-to" , type = int , required = True )
args = ap.parse_args()
ids = random.sample( range (args.id_from, args.id_to + 1 ),
k = min (args.sample, args.id_to - args.id_from + 1 ))
tally = {}
for wid in sorted (ids):
local = os.path.join( LOCAL_DIR , f "wallpaper { wid } .jpg" )
local_size = os.path.getsize(local) if os.path.exists(local) else None
edge = fetch( f " { BASE } /wallpaper { wid } .jpg" )
origin = fetch( f " { BASE } /wallpaper { wid } .jpg?cb= { random.randint( 1 , 10 ** 9 ) } " )
verdict = classify(local_size, edge, origin)
tally[verdict] = tally.get(verdict, 0 ) + 1
if verdict != "OK" :
print ( f "wallpaper { wid } : { verdict } local= { local_size } origin= { origin.size } "
f "edge= { edge.size } cf= { edge.cf_status } age= { edge.age } etag= { origin.etag } " )
print ( "---" )
for k, v in sorted (tally.items()):
print ( f " { k :<18 } { v } " )
if tally.get( "ORIGIN_OPTIMIZER" ):
print ( "→ XPageSpeed が旧版を保持しています。OFF にしてから、もう一度パージしてください" )
if tally.get( "EDGE_STALE" ):
print ( "→ エッジが古い版を保持しています。プレフィックスか URL 単位でパージしてください" )
if __name__ == "__main__" :
main()
classify の順番には理由があります。上流から順に疑わないと、エッジが古いのかオリジンが古いのか、同じ「サイズが違う」で混ざってしまうからです。最適化層の判定を最初に置いたのは、これが cb 付きの応答にまで及ぶ唯一の層だからです。
この検査を一度走らせただけで、九月の朝には三種類の事故が同時に見つかりました。カテゴリの上げ漏れ、エッジの古い版、そして最適化層の握り込みです。三つが同じ「古い絵」という症状で現れていたので、目で見ていた頃の私には一つの事故に見えておりました。
Antigravity のエージェントには、この checkserver を merge のあとに必ず走らせ、名指しの行をそのまま報告に含めるよう頼んでおります。パージの実行とサーバーパネルの操作は、私が手で行います。消す操作をエージェントに渡さない線引きは、消えたのは中身ではなく作業ディレクトリでした のときに引いたものをそのまま使っています。
上書き配信は、パージと最適化層の確認までが 1 セット
いま思えば、事故の種は「一年変わらない」と約束した画像を同じ URL で上書きした時点で蒔かれておりました。ID を変える設計にしていれば、この記事の大半は要らなかったのだと思います。それでも参照の都合で同一 URL を選ぶなら、その代償として確認の工程を増やすほかありません。
私が引いた線引きは一文です。上書き配信したら、Cloudflare のパージと XPageSpeed の確認までが 1 セット。 上げただけでは、何も終わっておりません。
Cloudflare の側だけを見ていた頃の私は、パージが効かない理由をエッジの中に探し続けておりました。探す場所が違ったのです。エッジと手元のあいだには、契約したサーバーが黙って挟んでいる層がある——そう数え直してから、翌朝に古い絵を見ることはなくなりました。Cloudflare 側の設計を一から組む方は、Antigravity × Cloudflare エッジSaaS設計ガイド の構成と併せて、オリジンに何が挟まっているかを先に確かめていただければと思います。
まずは、手元で配信している画像を 1 本だけ選び、素の URL と ?cb= 付きの二通りで curl -sD - を叩いて、etag と Last-Modified を並べてみてください。二つが同じ顔をしていなければ、あなたのオリジンにも、数えていなかった層があります。