【実務・中級編】 Varyヘッダーによるキャッシュのキー制御 – Web APIアーキテクチャ・データ連携実践ガイド

こんにちは。ネットワークの深淵とHTTPパケットの気まぐれを愛してやまない、インフラアーキテクトの私です。

Web APIの設計において、リソース指向(RESTful)な美しいURL設計やステータスコードの選択にこだわりを持つエンジニアは多いです。しかし、いざ「CDNやリバースプロキシを挟んで高負荷に耐えるAPIを作ろう」となった途端、キャッシュの罠に足元をすくわれる現場を数多く見てきました。

「管理画面では最新データが見えているのに、一般ユーザー向けAPIには古いキャッシュが返る」
「スマホアプリからのリクエストだけgzip圧縮されずにレスポンスが肥大化している」

こうしたトラブルの多くは、HTTPキャッシュの挙動を司る隠れた主役、Varyヘッダーの理解不足に起因します。今回は、RFC 9110(HTTP Semantics)が定義するVaryヘッダーの本質に迫り、実務で迷わないキャッシュキー制御の極意を解説しましょう。

—

なぜ Vary ヘッダーが必要なのか?(RFC 9110の背景)

WebブラウザやCDNなどの共有キャッシュ(Shared Cache)は、基本的にリクエストの URI をキーにしてレスポンスを保存・返却します。しかし、現代のWeb APIは多様性に満ちています。

同じ /api/v1/users/42 というURLであっても、リクエストを送るクライアントの状況によって返すべきデータが異なる場合があります。

  • クライアントが Accept-Encoding: gzip をサポートしているなら圧縮データを返したい。
  • Authorization ヘッダーに含まれるトークンによって、認可されたユーザー固有のデータを返したい。
  • Accept-Language: ja であれば日本語のメッセージを返したい。

もし、CDNが単にURLだけでキャッシュを判断してしまうとどうなるでしょうか?最初に英語圏のユーザーがアクセスしてキャッシュされた英語のレスポンスが、後から来た日本語ユーザーに返されてしまう「言語混同事故」が起きます。

ここで登場するのが Vary ヘッダーです。オリジンサーバーがレスポンスに Vary: Accept-Language を付与することで、CDNなどのキャッシュサーバーに対してこう伝えます。

「おい、このキャッシュを保存するときは、URLだけでなく Accept-Language ヘッダーの値もキャッシュのキー(識別子)に含めてくれよな」

これが、Vary ヘッダーの存在意義であり、キャッシュの制御を司るメカニズムの正体です。

—

実際の通信フローとキャッシュヒットの裏側

パケットがネットワーク上をどのように流れ、キャッシュサーバー(VarnishやCloudflare、CloudFrontなど)がどのように振る舞うのか、シーケンスを見てみましょう。

Client                  CDN / Proxy                  Origin Server
  |                          |                             |
  |--- GET /api/items ------>|                             |
  |    (Accept-Encoding: gzip)|                             |
  |                          |--- GET /api/items --------->|
  |                          |    (Accept-Encoding: gzip)  |
  |                          |                             |
  |                          |<-- 200 OK ------------------|
  |                          |    (Content-Encoding: gzip) |
  |                          |    (Vary: Accept-Encoding)  |
  |                          |                             |
  |                          | [Cache Store]               |
  |                          | Key: URL + Accept-Encoding  |
  |<-- 200 OK ---------------|                             |
  |    (Cached Response)     |                             |
  |                          |                             |
  |--- GET /api/items ------>|                             |
  |    (Accept-Encoding: br) |                             |
  |                          |                             |
  |                          | [Cache Lookup]              |
  |                          | Key mismatch! (br != gzip)  |
  |                          |--- GET /api/items --------->|
  |                          |    (Accept-Encoding: br)    |

CDNは、オリジンサーバーから返された Vary ヘッダーに記載されているフィールド名を読み取り、それらのリクエストヘッダーの「値の組み合わせ」をキャッシュの副次的なキーとしてハッシュ化します。そのため、Accept-Encoding が異なれば、別個のキャッシュエントリとして安全に保持されるわけです。

—

実務で直面する「Varyの罠」と正しい設定指針

理論はシンプルですが、実務の現場では Vary の指定を誤ることで、「キャッシュが全く効かなくなる(キャッシュヒット率の急降下)」という重大なインシデントを引き起こします。

1. Vary: * の絶対的な悪

もっともやりがちなミスが、あらゆる変動要因を恐れるあまり Vary: * を指定してしまうことです。
RFC 9110において、Vary: * は「このレスポンスはリクエストヘッダーのあらゆる要素に依存するため、いかなる共有キャッシュも絶対に再利用してはならない(事実上のキャッシュ無効化)」を意味します。
APIのパフォーマンスを限界まで高めたいインフラエンジニアにとって、これは禁忌です。本当に必要なヘッダーだけを個別指定してください。

2. Authorization ヘッダーとVaryの爆弾

「ユーザーごとのパーソナライズされたAPIだから」と、レスポンスに Vary: Authorization を指定していませんか?
これを行うと、リクエストごとに Authorization トークンの文字列が異なるため、ユーザーの数だけCDN上に全く同じ中身のキャッシュが乱立することになります(キャッシュのフラグメンテーション)。
プライベートなデータであれば、共有キャッシュではなく Cache-Control: private を使うべきです。逆に、パブリックなAPIであれば Authorization をVaryのキーにするのは避け、URL側でリソースを分離する設計(例: /api/v1/users/{id}/profile)に落とし込むのが王道です。

—

実装サンプル:言語と圧縮に応じたVaryの制御

では、実際にPython(FastAPIやFlaskなど)やNginx、curlを用いた具体的な設定・検証方法を見ていきましょう。

Python(HTTPヘッダーの制御例)

以下のコードは、リクエストの Accept-Language に応じて返す言語を切り替えつつ、適切な Vary ヘッダーを付与するハンドラーのイメージです。

from fastapi import FastAPI, Header, Response

app = FastAPI()

@app.get("/api/v1/greeting")
def get_greeting(response: Response, accept_language: str = Header(default="en")):
    # キャッシュサーバーに向けて、Accept-Languageに応じてキャッシュを出し分けるよう指示
    response.headers["Vary"] = "Accept-Language"
    
    # CDNやブラウザ向けのキャッシュ有効期限(例: 1時間)
    response.headers["Cache-Control"] = "public, max-age=3600"

    if accept_language.startswith("ja"):
        return {"message": "こんにちは、世界!"}
    
    return {"message": "Hello, World!"}

curlによるVary挙動のデバッグ手順

インフラエンジニアが現場で真っ先に叩くべきは curl です。キャッシュが意図通りにヒットしているか、Vary が正しく機能しているかを検証するコマンド例を載せておきます。

# 1回目のリクエスト(日本語を要求)
curl -i -H "Accept-Language: ja" https://api.example.com/api/v1/greeting

# 2回目のリクエスト(英語を要求 - キャッシュキーが異なるためオリジンにヒットするはず)
curl -i -H "Accept-Language: en" https://api.example.com/api/v1/greeting

レスポンスヘッダーに含まれる Age ヘッダーの有無や、CDN固有のキャッシュヒット判定ヘッダー(例: X-Cache: HIT / MISS)を観察することで、Varyによるキー制御が期待通りに動作しているかをリアルタイムに暴くことができます。

Nginx側でのVary設定(補足)

オリジンアプリケーション側で Vary を付与し忘れた場合でも、Nginxなどのリバースプロキシ側で補正することが可能です。

location /api/ {
    # 圧縮関連のVaryヘッダーを確実に出力させる
    gzip on;
    gzip_vary on;
    
    # 必要に応じて独自のVaryを追加
    # proxy_hide_header X-Powered-By;
    # add_header Vary "Accept-Encoding, Accept-Language" always;
    
    proxy_pass http://backend_cluster;
}

—

まとめ:美しいAPI設計と堅牢なインフラは両立する

REST APIの設計において、美しいエンドポイントURLや適切なメソッド選択は開発者のための美学です。しかし、それを支えるHTTPヘッダー、特に Vary のようなプロトコルの深淵を理解しているか否かが、数万人・数百万人のトラフィックを受け止めるプロダクトの生死を分けます。

「とりあえずキャッシュを効かせたい」「とりあえず Vary: * にしておこう」という安易なアプローチは、やがてスケール時の巨大な技術的負債へと変わります。
RFCの仕様に立ち返り、パケットの往来に思いを馳せながら、最小限かつ最適な Vary キーを設計する――これこそが、一流のインフラエンジニア、そしてWeb APIアーキテクトの仕事です。

現場のトラブルシューティングに行き詰まったときは、ブラウザの向こう側やCDNの裏側で、プロキシがどのヘッダーをキーにキャッシュを迷子にさせているのか、パケットとヘッダーをじっくり観察してみてください。答えは常に、仕様書とログの中にあります。

コメント

タイトルとURLをコピーしました