Cloudflare Images でページの画像を 89MB から 5.7MB にした話
Cloudflare Images でページの画像を 89MB から 5.7MB にした話
あるコーポレートサイトの特設セクションで、画像がまったく最適化されないまま配信されていた。
Cloudflare Images の変換機能(Transformations)を入れて、1 ページあたりの画像総量を
89.65 MB → 5.69 MB(▲93.7%) まで落とした。
固定費 $0、想定ランニングコスト月 $0〜0.50。作業はヘルパー 1 本と設定 16 箇所。
その過程で踏んだ勘違いと、「ぼやけさせない」ための設計判断を残しておく。
前提:何が重かったか
サイトは Astro + Cloudflare Workers 配信。画像は 2 系統あった。
| 系統 | 置き場所 | 状態 |
|---|---|---|
| 静的画像 | public/ | 1,477 枚 / 241 MB、4 MB 級の PNG が複数 |
| CMS 画像 | ヘッドレス CMS(オブジェクトストレージ保存) | 原寸のまま配信 |
そして決定的だったのが、astro:assets の <Image> を 1 箇所も使っていなかったこと。
すべて生の <img> で、リサイズもフォーマット変換も一切かかっていなかった。
astro.config.ts には imageService: 'compile'(ビルド時 sharp 最適化)が設定されていたが、astro:assets を使っていない以上これは何も仕事をしていない。設定はあるのに効いていない、
という状態だった。
なぜ Cloudflare Images を選んだか
正攻法は <img> を astro:assets の <Image> に置き換えることだ。だがこれには前提があって、
対象画像を public/ から src/assets/ に移動する必要がある(public/ 配下はastro:assets の対象外)。1,477 枚の移動と全参照パスの書き換えは、それだけで一仕事になる。
対して Cloudflare Images の変換は、URL の前に /cdn-cgi/image/... を挟むだけ。
ファイルは 1 枚も動かさない。CMS 由来の動的画像はそもそもビルド時最適化ができないので、
どのみち実行時変換が要る。両方を 1 つの仕組みで賄えるなら、そちらが早い。
課金モデルが効いた
Cloudflare Images の料金は用途で分かれている。
| 項目 | 料金 |
|---|---|
| Images Transformed | 最初の 5,000 unique/月は込み、超過分 $0.50 / 1,000 |
| Images Stored | $5 / 10 万枚 / 月 |
| Images Delivered | $1 / 10 万配信 / 月 |
今回は画像を Cloudflare に保存せず変換だけ使うので、Stored / Delivered は発生しない。
しかも保存しない構成なら zero-storage プランが選べて、前払い・固定費が $0 になる。
そして課金単位の "unique transformation" は
「元画像 × 変換パラメータの組み合わせ」を、カレンダー月ごとに初回リクエストのみ課金する。
つまり PV に比例しない。同じ画像が何万回配信されようと、その月の課金は 1 回だ。
画像点数から逆算すると月 5,000 unique に収まるか、超えても数百件。月 $0〜0.50 に落ち着く。
帯域とコアウェブバイタルの改善幅を考えれば、コストは論点にならなかった。
⚠️ ただし Free プランは「5,000 unique を超えると新規変換が
9422エラーを返す」挙動なので、
本番運用するなら Paid にしておくこと。固定費は増えない。
踏んだ勘違い 3 つ
1. ゾーンで有効化しても、何も起きない
ダッシュボードで Images > Transformations をゾーンに対して有効化した。
……が、サイトは 1 バイトも軽くならない。
当然で、Cloudflare Images は画像に対して有効化するものではなく、
変換専用のエンドポイントを通したときだけ働くものだからだ。
https://www.example.org/cdn-cgi/image/width=720,format=auto/https://cms.example.org/media/photo.png
└──────── 有効化したゾーン ────────┘└─ 変換指示 ─┘└──────── ソース画像 ────────┘
- ゾーン部分が変換を実行する場所。ここが有効化済みである必要がある
- ソース画像はただ読み込まれる材料。元ファイルは一切変更されない
「有効化」はスイッチではなく、エンドポイントを使えるようにする操作でしかない。
アプリ側が URL を組み立て直すまで、実ページは何も変わらない。
2. cf-resized はソース URL には付かない
変換が効いているかは cf-resized レスポンスヘッダーで判定する。
curl -sS -o /dev/null -D- \
-H "Accept: image/avif,image/webp,image/*,*/*" \
"https://www.example.org/cdn-cgi/image/width=720,fit=scale-down,format=auto,quality=85/https://cms.example.org/media/photo.png" \
| grep -iE "^HTTP|content-type|content-length|cf-resized"
ここでソース画像の URL(https://cms.example.org/media/photo.png)を叩いて
「cf-resized が無い」と悩んだ。当たり前で、そちらは変換前の元ファイルそのものだ。/cdn-cgi/image/... の URL を叩かないと出てこない。
cf-resized | 意味 |
|---|---|
internal=ok/... | 変換成功 |
err=9404 | ソース画像が見つからない |
err=9422 | Free プランの 5,000 unique 超過 |
err=9401 / 9403 | ゾーンで未有効、または許可されていないソース |
| ヘッダーなし | Transformations が有効になっていない(素通し) |
3. format=json は変換後ではなく「元画像」の解像度を返す
これが一番危なかった。
拡大(アップスケール)でぼやけないか検証するのに、format=json を使った。
curl "https://www.example.org/cdn-cgi/image/width=4000,format=json/<source>"
# => {"width":1800,"height":1800,"original":{...,"width":1800,"height":1800,...}}
元画像 1800×1800 に対して width=4000 を要求したのに 1800 が返ってきた。
「なるほど拡大されないのか」と結論しかけた。
が、これは間違い。 format=json は width を無視して常にソースのメタデータを返す。width=400 でも同じく 1800 が返る。この値では何も検証できていない。
実ファイルをダウンロードして画素数を直接測り直すのが正しい。
curl -sS -o out.img ".../cdn-cgi/image/width=400,fit=scale-down/<source>"
file -b out.img # => PNG image data, 400 x 400, ...
| 指定 width | 実際に返る画素数 |
|---|---|
| 400 | 400×400 |
| 720 | 720×720 |
| 1440 | 1440×1440 |
| 1920 / 2160 / 4000 | 1800×1800(元のまま) |
結論としては「fit=scale-down なら拡大されない」で合っていた。
合っていたが、根拠にしていた証拠は無効だった。検証手段そのものを検証しないと、
正しい結論に間違った理由でたどり着く。
width を投げないと、ほぼ意味がない
format=auto だけでも変換は動く。が、効果がまるで違う。
| 対象 | 指定 | 結果 | 削減 |
|---|---|---|---|
| ポートレート写真<br>(元 631 KB PNG) | format=auto のみ | WebP 555 KB | ▲12% |
width=400,format=auto | AVIF 7.4 KB | ▲98.8% | |
| ヒーロー背景<br>(元 4.04 MB PNG) | format=auto のみ | PNG 2.12 MB | ▲47% |
width=1920,format=auto | WebP 316 KB | ▲92.4% | |
width=800,format=auto | WebP 67.7 KB | ▲98.4% |
ヒーロー背景に至っては、format=auto 単体だと Cloudflare が変換を見送って PNG のまま返してくる。
理由は単純で、**画像の重さの主因は「フォーマットが古いこと」ではなく
「表示サイズに対して解像度が過剰なこと」**だから。200px で表示する写真に
1800×1800 の原寸を配っていれば、フォーマットを変えたところで焼け石に水になる。
「ぼやけさせない」ための設計
ここが今回の肝だった。リサイズは諸刃の剣で、小さくしすぎると当然ぼやける。
対策 1:プリセットの width を「物理ピクセル」で定義する
ぼやけの主犯は DPR(Retina)だ。CSS 上 360px の枠でも、DPR 2 の端末では 720 物理ピクセル要る。
なのでプリセットの値を CSS 表示サイズではなく物理ピクセル(CSS × 2)で定義した。
| プリセット | width | 想定表示サイズ | 用途 |
|---|---|---|---|
heroBg | 1920 | 全幅 | 全幅の装飾背景(PC) |
heroBgSp | 900 | 全幅 | 全幅の装飾背景(SP) |
feature | 2160 | 1080px | 主役画像、ギャラリー拡大表示 |
content | 1440 | 約 720px | 2 カラム内の写真 |
card | 720 | 360px | カード・プロフィール写真 |
thumb | 400 | 200px | サムネイル |
画像 1 枚ずつ最適値をチューニングする必要はない。用途を数種類に束ねれば十分効く。
対策 2:fit=scale-down を常に付ける
元画像より大きい width を指定しても引き伸ばさせない。前掲の実測どおり、width=4000 を要求しても 1800×1800 で止まる。
指定が元画像を超えた場合もフォーマット変換自体は効くので、
「大きめのプリセットを当てても無駄にはならない」(1800×1800 PNG 645 KB → WebP 122 KB)。
対策 3:srcset を使わない、という判断
正攻法は srcset で 1x/2x を出し分けることだ。今回はあえて単一 URL に揃えた。
- AVIF/WebP なら 2 倍解像度でも元画像より 1〜2 桁小さい(実測 631 KB → 17.7 KB)
- モデルの型と 13 個の
<img>を触らずに済む - 1 画像あたりの unique transformation を 1 つに抑えられる
3 番目が地味に効く。課金単位が「元画像 × パラメータの組み合わせ」なので、srcset で細かい幅を並べるとその分だけ課金対象が増える。同じ画像に width=380 とwidth=400 を投げれば 2 課金だ。プリセットを固定しておくと消化数が読める。
1x 端末が 2 倍の画素を受け取る無駄は残るが、絶対量が数十 KB なので割に合う。
例外:サムネイルと拡大表示で URL を分ける
ギャラリーだけはこの方針が破綻した。サムネイルと拡大モーダルが同じ URL を共有していて、
サムネイル用に縮小した画像をモーダルで拡大すると、当然ぼやける。
ここは素直に解像度を 2 つ持たせた。
gallery: images.map((m) => ({
src: mediaUrl(m, cmsUrl, 'card'), // サムネイル (720)
zoomSrc: mediaUrl(m, cmsUrl, 'feature'), // 拡大表示 (2160)
alt: ...,
})),
GalleryImage.zoomSrc は省略可能にして、未指定なら src にフォールバックさせている。
実装:チョークポイントを 1 つ見つける
コード側の作業は思ったより小さかった。対象セクションの <img> は 13 個あったが、src を遡ると全部が 1 つのヘルパー関数に行き着いた。
各セクションの <img> → model/fromPayload.ts → mediaUrl()
(3 ファイル・16 箇所) ↑ ここだけ変える
なので <img> は 1 つも触っていない。やったことは、
cdnImageUrl()という URL ビルダーを 1 本追加mediaUrl()に第 3 引数(プリセット)を追加。未指定なら従来どおり素の URL(後方互換)- 表示サイズを知っている
fromPayload.ts側で用途別プリセットを指定
の 3 つだけ。プリセットの割り当てを「表示サイズを知っているレイヤー」に置くのがポイントで、
ヘルパーは何ピクセルで表示されるかを知らないし、知るべきでもない。
export function cdnImageUrl(src: string, preset: ImagePreset): string {
if (!src || !isTransformable(src)) return src;
const origin = transformOrigin();
if (origin === null) return src; // dev などでは変換しない
const options = `width=${PRESET_WIDTH[preset]},fit=scale-down,format=auto,quality=${QUALITY}`;
// 絶対 URL・ルート相対パスのどちらも、先頭 '/' 区切りで options の後ろに繋ぐ
const source = src.startsWith('/') ? src : `/${src}`;
return `${origin}/cdn-cgi/image/${options}${source}`;
}
isTransformable() で SVG・data:/blob:・既に変換済みの URL を弾いている。
二重変換の防止は地味に大事で、これが無いと /cdn-cgi/image/.../cdn-cgi/image/... が生まれる。
環境ごとの有効・無効
変換は Cloudflare ゾーン上でしか動かない。**.pages.dev や .workers.dev では動かず、
画像が壊れる**。ローカル開発も同様。
なので環境変数 1 つで切り替えられるようにした。
IMAGE_CDN_ORIGIN | 挙動 |
|---|---|
| 未設定 | 本番ビルドのみ同一オリジンで有効。dev では無効(元 URL を返す) |
off | 全環境で無効。*.pages.dev へデプロイする場合に指定 |
https://www.example.org | 指定ゾーン経由で変換 |
本番ビルドではこの関数がまるごと return ""; に定数畳み込みされるので、実行時コストもゼロだ。
そして「絶対オリジンを指定できる」ようにしておくと、**ローカル開発でも本番ゾーン経由で
変換を確認できる**。これが確認作業で効いた。dev サーバーは localhost だが、
画像だけ本番ゾーンに変換させれば実物が見える。
結果
対象ページの全変換 URL(102 件)について、変換後と元画像を突き合わせた。
| 合計 | |
|---|---|
| 変換前 | 89.65 MB |
| 変換後 | 5.69 MB |
| 削減 | 83.97 MB(▲93.7% / 約 1/15.8) |
プリセット別の実測(元 1800×1800 PNG 645 KB)はこう。
| プリセット | 実際に返る画素数 | 配信量 |
|---|---|---|
thumb 400 | 400×400 | AVIF 7.6 KB |
card 720 | 720×720 | AVIF 17.7 KB |
content 1440 | 1440×1440 | AVIF 53.7 KB |
heroBg 1920 | 1800×1800(元のまま) | WebP 121.9 KB |
format=auto はブラウザの Accept を見て出し分けるので、AVIF 対応端末は AVIF、
WebP のみなら WebP、どちらも非対応なら元フォーマットのリサイズ版が返る。
まとめ
- Cloudflare Images の変換はエンドポイントを通したときだけ働く。ゾーンの有効化だけでは何も起きない
widthを投げないと効果は 1〜4 割程度。重さの主因は解像度過剰であってフォーマットではない- ぼやけ対策は ①プリセットを物理ピクセル(CSS × 2)で定義 ②
fit=scale-downを常用 の 2 段構え srcsetを捨てて単一 URL に揃えると、実装も課金も単純になる。絶対量が小さいので割に合う- サムネイルと拡大表示が URL を共有している場所だけは例外。解像度を分ける
- 検証は
cf-resizedヘッダーで。ただしソース URL ではなく変換 URL を叩く format=jsonは変換後の解像度を返さない。拡大の検証には使えない
public/ から src/assets/ へ 1,477 枚を移動する大工事と比べると、
ヘルパー 1 本と設定 16 箇所で済んだのは悪くない取引だったと思う。
残りの静的画像側も、同一ゾーンなので相対パスに /cdn-cgi/image/... を前置するだけで入る。
そちらは次回。