Fastly で Varyヘッダーを活用する

```
この記事は以下の記事を参考にして書かれています。英文のブログですが内容をより詳しく理解したい場合はこちらのブログもご参照下さい。
https://www.fastly.com/blog/best-practices-using-vary-header
https://www.fastly.com/blog/getting-most-out-vary-fastly
```

```
特に記載がない限り本記事の記載内容は Fastly のデフォルト設定での挙動となります。
Fastly の正式なサポート内容ついては以下のドキュメントをご参照下さい。
サポートページ: https://docs.fastly.com/
日本語(一部のみ): https://docs.fastly.com/ja/
```


# この記事の内容について

HTTP の Vary ヘッダーは正しく利用すると非常に便利な HTTP ヘッダーですが、間違った使い方をするとサーバーに無駄な負荷を発生させてしまったり、場合によっては正しくないコンテンツを配信してしまう結果にもなりえます。この記事ではFastlyを利用して Vary ヘッダーを効果的に利用する手順についてまとめてみたいと思います。

## Varyヘッダーについて

そもそも Vary ヘッダーとはどういったものでしょうか？ 

Vary ヘッダーとは簡単にいうと Fastly サーバーなどのキャッシュサーバーに「同じ URL でも　Vary ヘッダーに指定されているヘッダーの内容が異なれば別オブジェクトとして取り扱いなさい」というオリジンサーバーからの命令となります。

### Vary ヘッダーが原因で生じる問題

Vary ヘッダーが正しく設定されないことが理由で起きうる代表的な問題について考えてみたいと思います。

#### Accept-Encoding
例えば Chrome などのブラウザから Web ページをリクエストする HTTP リクエストには `accept-encoding: gzip` といったようなリクエストヘッダーがついています。

簡単に言うとこれはブラウザからサーバーへ「コンテンツを gzip で圧縮して送ってくれてもいいよ」というメッセージになります。
このヘッダーがリクエストについていてサーバー側が gzip 圧縮に対応している場合、 通信の効率をあげるため、例えば html などテキストベースのコンテンツを gzip 圧縮してブラウザに返却します。
gzip 圧縮に対応していないブラウザがあった場合、そのブラウザは同じオブジェクトをリクエストした場合でも `accept-encoding` ヘッダーはリクエストに付与しません。
この場合、サーバーとしては「このブラウザは gzip したら解凍できないのか。であれば圧縮せずにそのままでコンテンツを送ってあげよう」という挙動となります。

ブラウザが直接 Web サーバーと通信している場合、これは特に問題にはなりません。ですが次のように中間に Fastly などのキャッシュするレイヤーがある場合に問題となる場合があります。Fastly を利用しているサイトに２つのクライアントがリクエストを送るケースを考えてみます。

1. クライアントA が `accept-encoding: gzip` ヘッダーを付けてコンテンツ(index.html)をリクエスト
2. Fastly サーバーはリクエストをそのままオリジンサーバーに転送
3. オリジンサーバーは index.html を gzip 圧縮して返却
4. Fastly サーバーは返却されたレスポンスを index.html として（圧縮されたまま）キャッシュしてクライアントAに配信
5. gzip 圧縮に対応していないクライアントB が index.html をリクエスト
6. Fastly サーバーは index.html のキャッシュを持っているので、4のステップでキャッシュした(圧縮された)オブジェクトをクライアントBに返却
7. クライアントB は圧縮されたレスポンスを解凍することが出来ないので正常に表示することが出来ない。。

といったように Vary が正しく設定されていないとクライアントが理解できないコンテンツを配信してしまう問題が発生してしまう可能性があります。

この問題を避けるために Vary を利用します。サーバーからのレスポンスに `Vary: accept-encoding` というヘッダーがついていることで、Fastly サーバーは同じ index.html でもリクエストの `accept-encoding` ヘッダーが異なる（もしくは存在しない）場合、別のオブジェクトとしてキャッシュします。
それでは Vary ヘッダーが存在する場合のフローを考えてみます。

1. クライアントA が `accept-encoding: gzip` ヘッダーを付けてコンテンツ(index.html)をリクエスト
2. Fastly サーバーはリクエストをそのままオリジンサーバーに転送
3. オリジンサーバーは`Vary: accept-encoding`ヘッダーを付与して index.html を gzip 圧縮して返却。
4. Fastly サーバーは返却されたレスポンスを `accept-encoding` の値が `gzip` の場合の index.html として（圧縮されたまま）キャッシュしてクライアントAに配信
5. gzip 圧縮に対応していないクライアントB が index.html をリクエスト
6. Fastly サーバーは index.html のキャッシュを持っているが、クライアントBのリクエストには `accept-encoding` ヘッダーがついていないのでキャッシュを利用できないと判断
7. Fastly サーバーが `accept-encoding` ヘッダーがついていないリクエストをそのままオリジンサーバーに転送
8. オリジンサーバーは index.html を gzip 圧縮せずに返却
9. Fastly サーバー返却されたレスポンスを`accept-encoding` がついていない場合の index.html としてキャッシュしてクライアントBに配信

つまり、同じURLの index.html でも 複数のバリエーション、つまり gzip 圧縮されているものと、そうでないもの２種類を正しく使い分けることが出来ます。

であれば異なるコンテンツを返却する可能性がある場合、関連するヘッダーをすべて Vary にいれてしまえばいいのではないか、と思いたいところなのですがそうでもありません。なぜなのかを次のケースで考えてみたいと思います。


#### User-Agent

同じ URL でのコンテンツの出し分ける場合、一番よくあるケースが `User-Agent` によるコンテンツの出しわけです。例えばデスクトップ向けのサイトとモバイル向けのサイトを同じドメインで運用したいといったケースはよくあると思います。この場合オリジンからのレスポンスに `Vary: User-Agent` を設定すれば良いでしょうか？こうすることで確かにデスクトップ向けのコンテンツがモバイルで表示されることはなくなります。

ただし、ここで注意が必要なのは Fastly を含む Vary に対応している CDN やキャッシュサーバーは User-Agent の文字列が一文字でも異なれば別のオブジェクトとしてキャッシュを別に保持するということです。

Chrome, Firefox, Safari, Edge などはそれぞれ User-Agent が異なります。さらに同じブラウザであってもバージョンや適用されているパッチ、OSのバージョンが違えば User-Agent の値は異なります。一例として Fastly がサンプルした 100,000 リクエストでは 8,000 種類の異なる User-Agent が確認されました。 

つまり本来はデスクトップとスマホ向けの２種類のキャッシュを保持すればいいだけなのに、不用意に Vary を利用すると大量の種類のキャッシュが発生しキャッシュヒット率の低下、オリジンオフロード率の低下と CDN サービスを適用するメリットが低下してしまいます。

Vary を利用する場合は、最小限のパターンを持つリクエストヘッダーを Vary に含めることがポイントとなります。


### Vary の正規化

繰り返しになりますが Vary を効果的に使うためには、コンテンツのバリエーションの数と同じだけの種類を持つヘッダーを Vary に設定することが必要です。Fastly では VCL を利用して コンテンツの種類の数だけのバリエーションを持つヘッダーを作成し、最低限必要な数だけをキャッシュさせることが可能です。

例えば上記のデスクトップ向けとモバイル向けでコンテンツを出し分けたい場合は mobile/desktop という２種類の値を含むヘッダーを Vary に設定すればいいわけです。これは以下のようなシンプルなコードで実現出来ます。

まず、リクエストがモバイルかデスクトップなのかを User-Agent から判断し、`X-device-type` というヘッダーにそれぞれ mobileまたはdesktop という値を設定します。

```vcl:vcl_recv
if(req.http.User-Agent ~ "(?i)(Mobile|Android|iPhone|Phone)") {
  set req.http.X-device-type = "mobile";
} else {
  set req.http.X-device-type = "desktop";
}
```

続いてオリジンから返却された Vary ヘッダーに先程設定した `X-device-type` を追加します。

```vcl:vcl_fetch
  if (beresp.http.Vary) {
    set beresp.http.Vary = beresp.http.Vary ", X-device-type";
  } else {
    set beresp.http.Vary = "X-device-type";
  }
``` 
もしくは以下の1行のコードでも上記と同じ動作になります。

```vcl:vcl_fetch
set beresp.http.Vary:X-device-type = "";
```


こうすることで、一つの URL に対して `X-device-type` の種類だけ（つまりここではmobileとdesktopの２種類）をキャッシュとして保持します。

上記の例ではサンプルですので User-Agent からデバイス種別を判断するのにかなりシンプルなロジックを利用していますが、Fastly では `client.platform.hwtype` という変数を利用することでデバイスタイプを取得することも可能です。

https://developer.fastly.com/reference/vcl/variables/client-request/client-platform-hwtype/

その他、以下のコードなどを参照して独自のデバイス判別ロジックを実装することも可能です。

https://github.com/willemk/varnish-mobiletranslate/blob/master/mobile_detect.vcl


#### 注意事項

上記のようなコンテンツの出し分け時の注意事項としては、Fastly 側で設定している `X-device-type` の判断と、オリジン側でモバイル向け、デスクトップ向けのコンテンツを生成する際の判断を完全に一致させる必要があります。
この判断ロジックがずれてしまっていると、オリジン側がモバイル用に作成したコンテンツを Fastly サーバー側がデスクトップ用コンテンツとしてキャッシュし、デスクトップに配信してしまう、もしくはその逆のパターンが発生してしまう恐れがあります。

オリジン側でのコンテンツ出し分けのためのデバイス判断ロジックを `User-Agent` の値から行うのではなく、Fastly がリクエストに付与した `x-device-type` ヘッダーの値を利用して行うで、この判断ロジックの不一致の問題は避けることが出来ます。

### Vary コンテンツの Purge

Vary ヘッダーを利用して同一 URL に対して複数のバリエーションのコンテンツがある場合の Purge (キャッシュ削除）ですが、Vary によって異なるバージョンのコンテンツでも Fastly 内でキャッシュを識別するための Cache Key は同じになります。

例えば、index.html にモバイル向け、デスクトップ向けのバリエーションが存在したとしても、index.html が対象となるPurge リクエストを送るとすべてのバリエーションが一度に削除されます。


### 余談
上でも説明したように、Vary に設定しているヘッダーの値が一文字でも異なると Fastly サーバーは異なるバージョンのキャッシュを保持します。Vary に Accept-Encoding
 が設定されている(`Vary: Accept-Encoding`)場合、以下の3種類はすべて gzip 圧縮された同じコンテンツにも関わらずそれぞれ別のキャッシュオブジェクトとして保持されてしまうことになります。

```
Accept-Encoding: gzip
Accept-Encoding: gzip, br, deflate
Accept-Encoding: gzip,br,deflate
```
実は Fastly では無駄なキャッシュのバリエーションを増やさないように、上記の３種類を同じ Accept-encoding として正規化する処理がデフォルトで実装されています。これは通常の設定画面からは見ることが出来ない Fastly の Master VCL との中で実装されています。どのようなコードで実装されているか興味がある人は以下のブログをご参照下さい。
https://www.fastly.com/blog/best-practices-using-vary-header