KAKESU~/@dotbind-kondo/cloudflare-images-transformations

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=9422Free プランの 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=jsonwidth を無視して常にソースのメタデータを返す。
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実際に返る画素数
400400×400
720720×720
14401440×1440
1920 / 2160 / 40001800×1800(元のまま)

結論としては「fit=scale-down なら拡大されない」で合っていた。
合っていたが、根拠にしていた証拠は無効だった。検証手段そのものを検証しないと、
正しい結論に間違った理由でたどり着く。


width を投げないと、ほぼ意味がない

format=auto だけでも変換は動く。が、効果がまるで違う。

対象指定結果削減
ポートレート写真<br>(元 631 KB PNG)format=auto のみWebP 555 KB▲12%
width=400,format=autoAVIF 7.4 KB▲98.8%
ヒーロー背景<br>(元 4.04 MB PNG)format=auto のみPNG 2.12 MB▲47%
width=1920,format=autoWebP 316 KB▲92.4%
width=800,format=autoWebP 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想定表示サイズ用途
heroBg1920全幅全幅の装飾背景(PC)
heroBgSp900全幅全幅の装飾背景(SP)
feature21601080px主役画像、ギャラリー拡大表示
content1440約 720px2 カラム内の写真
card720360pxカード・プロフィール写真
thumb400200pxサムネイル

画像 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 つも触っていない。やったことは、

  1. cdnImageUrl() という URL ビルダーを 1 本追加
  2. mediaUrl() に第 3 引数(プリセット)を追加。未指定なら従来どおり素の URL(後方互換)
  3. 表示サイズを知っている 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 400400×400AVIF 7.6 KB
card 720720×720AVIF 17.7 KB
content 14401440×1440AVIF 53.7 KB
heroBg 19201800×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/... を前置するだけで入る。
そちらは次回。