# Cloudflare Images でページの画像を 89MB から 5.7MB にした話

> @dotbind-kondo · published 2026-09-10T03:02:50Z · #astro #cloudflare #images #パフォーマンス #画像最適化

# 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` レスポンスヘッダーで判定する。

```bash
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` を使った。

```bash
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 が返る。この値では何も検証できていない。

実ファイルをダウンロードして画素数を直接測り直すのが正しい。

```bash
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 つ持たせた。

```ts
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 つだけ。プリセットの割り当てを「表示サイズを知っているレイヤー」に置くのがポイントで、
ヘルパーは何ピクセルで表示されるかを知らないし、知るべきでもない。

```ts
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/...` を前置するだけで入る。
そちらは次回。

