Cloudflare の画像キャッシュで Vary ヘッダを活用

## はじめに
2021年にローンチされた [Vary for images](https://blog.cloudflare.com/vary-for-images-serve-the-correct-images-to-the-correct-browsers/) という機能を試しました。
当時試したメモがありましたが、雑すぎて、再度…

画像ファイルのオリジンが、リクエストされた画像フォーマットに応じてデータの出し分けをしているとき、CDN キャッシュでも同様に対応させる、というのが期待です。

下図は、一つの画像 URL に対し複数のフォーマットでファイルを用意したオリジンがいて、CDN を中間におきながら、リクエストに応じて最適なファイルを返している、というイメージになります。

![Screenshot 2023-08-13 at 17.45.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/ae4fdaa7-9198-2de2-cdb6-e945a716c01e.png)




## テスト

### 初期状態

#### オリジンの設定

- 画像ファイル sample0.jpg を提供
- リクエストの Accept ヘッダを参照し、 JPEG か WebP を選んで返す
- Vary で Accept を返す

```mermaid
graph LR
  cl1(Eyeball)-- GET /sample0.jpg<br>Accept: image/jpg --> sp[(sample0.jpg<br>jpeg か webp)]
  sp[(sample0.jpg<br>jpeg か webp)] -. Content-Type: image/jpeg<br>Vary: Accept .-> cl1(Eyeball)
  style sp fill:#60AB31,color:#fff
```
```mermaid
graph LR
  cl2(Eyeball)-- GET /sample0.jpg<br>Accept: image/webp --> sp[(sample0.jpg<br>jpeg か webp)]
  sp[(sample0.jpg<br>jpeg か webp)] -. Content-Type: image/webp<br>Vary: Accept .-> cl2(Eyeball)
  style sp fill:#60AB31,color:#fff
```

#### JPEG 欲しい Eyeball

JPEG をもらえます。(curl -v の出力から一部を抜粋します。以下同じ。）

```
> GET /sample0.jpg HTTP/1.1
> Accept: image/jpg

< Content-Type: image/jpeg
< Vary: Accept
```

#### WebP 欲しい Eyeball

WebP をもらえます。

```
> GET /sample0.jpg HTTP/1.1
> Accept: image/webp

< Content-Type: image/webp
< Vary: Accept
```
### Cloudflare CDN のキャッシュを挟む

#### CDN デフォルト設定

- Vary for images はデフォルト`無効`
- 画像ファイルのキャッシュがない状態では、最初のリクエストでキャッシュが作られる
- 後続のリクエストにはキャッシュされた画像が返される
- リクエスト中の `Accept: image/` は参照されない

```mermaid
graph LR
  cl1(Eyeball)-- GET /sample0.jpg<br>Accept: image/jpg --> gw((Cloudflare CDN))
  cl2(Eyeball)-- GET /sample0.jpg<br>Accept: image/webp --> gw((Cloudflare CDN))
  gw((Cloudflare CDN))--GET /sample0.jpg<br>Accept: image/jpg-->sp[(sample0.jpg<br>jpeg か webp)]
  style gw fill:#F48120,color:#fff
  style sp fill:#60AB31,color:#fff
```

#### JPEG 欲しい Eyeball

キャッシュのない状況で、Accept: image/jpg でリクエストします。
JPEG がもらえます。
`cf-cache-status: MISS`とあるので、このリクエストで CDN にキャッシュされた可能性があります。

```
> GET /sample0.jpg HTTP/2
> Accept: image/jpg

< content-type: image/jpeg
< vary: Accept
< cf-cache-status: MISS
```

#### WebP 欲しい Eyeball

次に、WebP でリクエストします。
残念ながら WebP はもらえず、キャッシュされた JPEG が届きます。
`cf-cache-status: HIT` となっていることから、それがわかります。

```
> GET /sample0.jpg HTTP/2
> Accept: image/webp

< content-type: image/jpeg
< vary: Accept
< cf-cache-status: HIT
```


### CDN で画像フォーマットの出し分けを有効にする

JPEG と WebP の出し分けを期待し、Vary for images を有効化します。

```mermaid
graph LR
  in((API)) -. このゾーンのオリジンは <br>.jpeg, .jpg 拡張子の image/webp 版を持ってるぜ .-> gw((Cloudflare CDN))
  cl1(Eyeball)-- GET /sample0.jpg<br>Accept: image/jpg --> gw((Cloudflare CDN))
  cl2(Eyeball)-- GET /sample0.jpg<br>Accept: image/webp --> gw((Cloudflare CDN))
  gw((Cloudflare CDN))-- GET /sample0.jpg<br>Accept: image/jpg -->sp[(sample0.jpg<br>jpeg か webp)]
  gw((Cloudflare CDN))-- GET /sample0.jpg<br>Accept: image/webp -->sp[(sample0.jpg<br>jpeg か webp)]
  style gw fill:#F48120,color:#fff
  style sp fill:#60AB31,color:#fff
  style in fill:#ffff00
```

#### Vary for images を有効にする設定

[API: Cache Variants Setting](https://developers.cloudflare.com/api/operations/zone-cache-settings-change-variants-setting) で設定（PATCH）します。

```
~$ curl -s -X PATCH -H "Content-Type: application/json" -H "X-Auth-Email: $EMAIL" -H  "X-Auth-Key: $APIKEY" "https://api.cloudflare.com/client/v4/zones/$ZONEID/cache/variants" -d '{"value":{
      "jpeg": [
        "image/webp"
      ],
      "jpg": [
        "image/webp"
      ]
    }}'
```

再度リクエストを投げるまえに、キャッシュをパージします。
```
~$  curl -s -X POST -H "Content-Type: application/json" -H "X-Auth-Email: $EMAIL" -H  "X-Auth-Key: $APIKEY" "https://api.cloudflare.com/client/v4/zones/$ZONEID/purge_cache" --data '{ "files": [
 "https://$SOMEHOST/sample0.jpg"
 ]
 }'
```



#### JPEG 欲しい Eyeball

JPEG をもらえます。

```
> GET /sample0.jpg HTTP/2
> Accept: image/jpg

< content-type: image/jpeg
< content-length: 4149610
< vary: Accept
< cf-cache-status: MISS
```

#### WebP 欲しい Eyeball

無事に WebP をもらえます。

```
> GET /sample0.jpg HTTP/2
> Accept: image/webp

< content-type: image/webp
< content-length: 1297356
< vary: Accept
< cf-cache-status: MISS
```

#### キャッシュ状態

Vary for images 無効時と違い、画像フォーマットのリクエスト内容に応じたファイルをキャッシュから取得できています。

JPEG
```
> GET /sample0.jpg HTTP/2
> Accept: image/jpg

< content-type: image/jpeg
< content-length: 2480841
< vary: Accept
< cf-cache-status: HIT
```
WebP
```
> GET /sample0.jpg HTTP/2
> Accept: image/webp

< content-type: image/webp
< content-length: 1297356
< vary: Accept
< cf-cache-status: HIT
```

### 結果
ユーザのリクエストした画像フォーマットに合わせデータを返すという動作をキャッシュ経由で反映させることができました。

## メモ

### 他の画像関連機能との共存

JPEG の例を見ると cf-cache-status: MISS と HIT で、content-length が違っています。
HIT のデータサイズが小さいです(4割減）。

```
< content-length: 4149610
< cf-cache-status: MISS

⬇⬇⬇

< content-length: 2480841
< cf-cache-status: HIT
< cf-polished: degrade=85, origSize=4149610, status=vary_header_present
```

これは当該 URL に対して、画像最適化の [Polish](https://developers.cloudflare.com/images/polish/) という機能が有効になっていた影響です。
下記の処理が並行して行われていました。

- 一旦キャッシュされたオリジナル画像（JPEG）から、最適化したファイル（JPEG）が作成され、キャッシュ
- 後者を以降の応答に利用
- レスポンスに [cf-polished](https://developers.cloudflare.com/images/polish/cf-polished-statuses/) ヘッダなどが追加され、変更内容を記録


```
> GET /sample0.jpg HTTP/2
> Accept: image/jpg

< content-type: image/jpeg
< content-length: 4149610
< cache-control: public, max-age=691200
< last-modified: Mon, 16 Aug 2021 11:03:18 GMT
< etag: "3f516a-5c9ab2610c598"
< vary: Accept
< cf-cache-status: MISS

⬇⬇⬇

> GET /sample0.jpg HTTP/2
> Accept: image/jpg

< content-type: image/jpeg
< content-length: 2480841
< cache-control: public, max-age=691200
< cf-bgj: imgq:85,h2pri
< cf-polished: degrade=85, origSize=4149610, status=vary_header_present
< etag: "3f516a-5c9ab2610c598"
< last-modified: Mon, 16 Aug 2021 11:03:18 GMT
< vary: Accept
< cf-cache-status: HIT
```
#### 問題発生 - Polish の Webp 変換

Vary for images と Polish の併用が確認できましたが、Vary for images を有効化したことで、既存サービスの一つに問題が発生しました。

Polish には JPEG（GIF, PNG）を状況に応じて WebP に変換するという[オプション機能](https://developers.cloudflare.com/images/polish/compression/#webp)がありますが、それを活用していた既存の URL で WebP 変換およびキャッシュがされなくなりました。

- 対象のサービスのオリジンは JPEG 画像のみ配布（なので Vary も付けていない）
- Eyeball から Accept: image/webp を要求されたら、Polish で WebP に変換
- うまく動いていいたが、Vary for images を有効にしたことで、常時 Cache MISS
- .jpg に対する Accept: image/webp を Polish で扱ってたところに、同じ条件を扱う Vary for images が割り込んできた… 

##### Polish + WebP 変換： Vary for images 無効時の挙動
Vary for images 投入前、うまく動いていたときの状況です。

一発目（JPEG 画像が得られます）
```
> GET /images/polish/sample0.jpg HTTP/2
> Accept: image/webp

< content-type: image/jpeg
< content-length: 80629
< cache-control: public, max-age=691200
< last-modified: Mon, 14 Aug 2023 03:27:34 GMT
< cf-cache-status: MISS
```
二発目（Polish で作成された WebP 画像が得られます）
```
> GET /images/polish/sample0.jpg HTTP/2
> Accept: image/webp

< content-type: image/webp
< content-length: 28524
< cache-control: public, max-age=691200
< cf-bgj: imgq:85,h2pri
< cf-polished: qual=85, origFmt=jpeg, origSize=80629
< last-modified: Mon, 14 Aug 2023 03:27:34 GMT
< vary: Accept
< cf-cache-status: HIT
< age: 2
```
##### Polish + WebP 変換： Vary for images 有効時の挙動

Vary for images（拡張子 jpg, jpeg に対して image/webp を定義）を有効化すると、常に Cache Miss となりました。

- CDN は sample0.jpg に対して Accept: image/webp でリクエスト
- Vary for images は content-type: image/webp と vary: accept の応答を期待
- オリジンからは content-type: image/jpeg かつ vary:  accept ナシの状態で、期待外れ
- 一方、同リクエストに対しては Polish の WebP オプションも定義されている

一発目
```
> GET /images/polish/sample0.jpg HTTP/2
> Accept: image/webp

< content-type: image/jpeg
< content-length: 80629
< cache-control: public, max-age=691200
< last-modified: Mon, 14 Aug 2023 03:31:30 GMT
< cf-cache-status: MISS
```
二発目
```
> GET /images/polish/sample0.jpg HTTP/2
> Accept: image/webp

< content-type: image/jpeg
< content-length: 80629
< cache-control: public, max-age=691200
< last-modified: Mon, 14 Aug 2023 03:31:30 GMT
< cf-cache-status: MISS
```
##### Polish（WebP 変換を無効化）： Vary for images 有効時の挙動

Polish の Webp オプションを無効にすると、キャッシュされるようになり、JPEG → JPEG での最適化は実施されます。

一発目
```
> GET /images/polish/sample0.jpg HTTP/2
> Accept: image/webp

< content-type: image/jpeg
< content-length: 80629
< cache-control: public, max-age=691200
< last-modified: Mon, 14 Aug 2023 03:33:07 GMT
< cf-cache-status: MISS
```
二発目
```
> GET /images/polish/sample0.jpg HTTP/2
> Accept: image/webp

< content-type: image/jpeg
< content-length: 33470
< cache-control: public, max-age=691200
< cf-bgj: imgq:85,h2pri
< cf-polished: degrade=85, origSize=80629
< last-modified: Mon, 14 Aug 2023 03:33:07 GMT
< cf-cache-status: HIT
< age: 6
```
##### 解決策

今のところ Vary for images はゾーン全体での設定になります。

同じゾーンの配下で、Polish の WebP 変換オプションを活用しているサービスがある場合は、上記のように WebP 変換を外すか、オリジンでの WebP 対応を検討するなど、そこに頼らない設計としたほうが良さそうです。

あるいはオリジンはシンプルにして、CDN 側での変換に寄せていく方針もあるでしょう。[Image Resizing](https://developers.cloudflare.com/images/image-resizing/) や [Images](https://developers.cloudflare.com/images/cloudflare-images/) が用意されています。

![Screenshot 2023-08-14 at 11.31.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/f72e4690-915f-3e2c-c8f2-9abd7efa2e27.png)


##### 補足：オリジンが Vary をつけたとき

上記は Vary for images に指定したバリアント（accept: image/webp）でオリジンにリクエストしたにも関わらず、オリジンは期待に反して content-type: image/jpeg を返し、かつ、Vary: Accept も付けない、というケースでした。

そこで、Vary for images の要件に近づけるため、オリジンで Vary: Accept を付けてみると cf-cache-status は BYPASS になりました。
これは ["If the origin server sends Vary: Accept but does not serve the set variant, the response is not cached and displays BYPASS in the cache status in the response header. "](https://developers.cloudflare.com/cache/advanced-configuration/vary-for-images/#file-extensions) の動きです。オリジンが WebP サポートしてる想定なのに、違ったからバイパスしています。

```
> GET /images/polish/sample0.jpg HTTP/2
> Accept: image/webp

< content-type: image/jpeg
< content-length: 80629
< vary: Accept
< cache-control: public, max-age=691200
< cf-cache-status: BYPASS
```
##### Polish と Vary

一方、Polish と Vary ヘッダーについては下記の注意書きがあります。
["Polish may not be applied to origin responses that contain a Vary header. The only accepted Vary header is Vary: Accept-Encoding."](https://developers.cloudflare.com/images/polish/compression/) 
Vary for images との併用がどこまで想定されているかは未確認です。

#### パージ
キャッシュされたファイルがパージされると、バリアントも[同様にパージ](https://developers.cloudflare.com/cache/how-to/purge-cache/purge-varied-images/)されます。

#### ブラウザからのアクセス

ブラウザからもアクセスしてログを確認します。ダッシュボードの [Instant Logs](https://developers.cloudflare.com/logs/instant-logs/) から抜粋します。 

iOS Safari（Accept: \*/*;q=0.8）
 --> JPEG でキャッシュされています
```
    "ClientDeviceType": "mobile",
    "ClientRequestUserAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1",
    "RequestHeaders": {
        "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
    },
    "ResponseHeaders": {
        "content-type": "image/jpeg",
        "vary": "Accept, Accept-Encoding"
    },
```

macOS Chrome（Accept: image/avif,image/webp,image/apng,\*/*;q=0.8）
--> WebP でキャッシュされています
```
    "ClientDeviceType": "desktop",
    "ClientRequestUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
    "RequestHeaders": {
        "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
    },
    "ResponseHeaders": {
        "content-type": "image/webp",
        "vary": "Accept, Accept-Encoding"
    },
```
##### 【便利】 ログの[カスタムフィールド](https://developers.cloudflare.com/logs/reference/custom-fields/)
見たいヘッダを追加しておけば Instant Logs にもサクッと反映されます。
![Screenshot 2023-08-14 at 18.40.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/077a5573-0f50-dd0e-7f02-21009eefab9d.png)



## その他、参考情報

### API
- Vary for images を無効にする（DELETE）

```bash
~ $ curl -s -X DELETE -H "Content-Type: application/json" -H "X-Auth-Email: $EMAIL" -H  "X-Auth-Key: $APIKEY" "https://api.cloudflare.com/client/v4/zones/$ZONEID/cache/variants"
{
  "result": {
    "editable": true,
    "id": "variants",
    "modified_on": "2023-08-03T11:28:31.818839Z"
  },
  "success": true,
  "errors": [],
  "messages": []
}
```
- Vary for images の現状を得る（GET）
無効時の例

```bash
~ $ curl -s -X GET -H "Content-Type: application/json" -H "X-Auth-Email: $EMAIL" -H  "X-Auth-Key: $APIKEY" "https://api.cloudflare.com/client/v4/zones/$ZONEID/cache/variants"
{
  "result": null,
  "success": false,
  "errors": [
    {
      "code": 1142,
      "message": "Unable to retrieve variants setting value. The zone setting does not exist."
    }
  ],
  "messages": []
}
```

### リンク
[https://blog.cloudflare.com/vary-for-images-serve-the-correct-images-to-the-correct-browsers/](https://blog.cloudflare.com/vary-for-images-serve-the-correct-images-to-the-correct-browsers/)
[https://developers.cloudflare.com/api/operations/zone-cache-settings-change-variants-setting](https://developers.cloudflare.com/api/operations/zone-cache-settings-change-variants-setting)
[https://developers.cloudflare.com/cache/advanced-configuration/vary-for-images/](https://developers.cloudflare.com/cache/advanced-configuration/vary-for-images/)
[https://developers.cloudflare.com/images/polish/compression/](https://developers.cloudflare.com/images/polish/compression/)
[https://developers.cloudflare.com/images/polish/cf-polished-statuses/](https://developers.cloudflare.com/images/polish/cf-polished-statuses/)
[https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation/List_of_default_Accept_values#values_for_an_image](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation/List_of_default_Accept_values#values_for_an_image)