【実務・中級編】HTTP/1.1のVaryヘッダーとキャッシュの多様性 – HTTPプロトコル・通信規格実践ガイド

炎上案件の裏側に必ずいる「あいつ」:HTTP/1.1 `Vary`ヘッダーとキャッシュ汚染の防ぎ方

「おい、スマホ版の画面に、さっきのPC版の管理画面がそのまま出て全員に透けて見えるぞ!」

深夜のSlackに飛び込んできた、背筋が凍るようなアラート。インフラエンジニアなら誰もが一度は冷や汗をかいたことがあるであろう、「キャッシュ汚染(Cache Poisoning)」の瞬間です。CDNやリバースプロキシの導入によってWebサイトが爆速化する裏側で、この「キャッシュの出し分け」を誤ると、セキュリティインシデントに直結する大惨事を引き起こします。

今回は、HTTP/1.1の歴史と仕様の深い部分に立ち返り、リクエストの「多様性」をコントロールする魔術――`Vary`ヘッダーの正しい使い方と、現場で生き抜くための実務的な防衛術を紐解いていきましょう。

—

1. なぜキャッシュは「ただのURL」だけでは戦えないのか

Webの初期、HTTP/0.9や1.0の時代はシンプルでした。「URL=リソースの住所」であり、キャッシュサーバー(プロキシ)はその住所宛てのレスポンスを素直に保存し、次のリクエストに使い回していました。

しかし、現代のWebは複雑です。
同じ `/api/user/profile` というURLであっても、リクエストを送るユーザーが日本語環境なのか英語環境なのか、あるいはPCからなのかスマートフォンからなのか、さらにはダークモードを好むのかによって、返すべきレスポンス(HTMLやJSON)は完全に変わるべきです。

ここでインフラやバックエンドエンジニアが陥りがちな罠があります。
「よし、CDNのキャッシュ設定でURLごとにキャッシュを保持しよう」――これでは不十分です。CDNはデフォルトではURL(Path + Query String)しか見ていません。User-AgentやAccept-Languageといった「リクエストヘッダーの微妙な違い」を無視してキャッシュを返してしまうため、最初に英語でアクセスしたユーザーのキャッシュが、次に訪れた日本語ユーザーに配られてしまうという悲劇が起きます。

この「URLは同じなのに、リクエストの中身によって中身を変えてキャッシュしたい!」という切実な要求を解決するために、HTTP/1.1(RFC 7231 / RFC 9110)で標準化されたのが `Vary` ヘッダー です。

—

2. `Vary`ヘッダーの基本とパケットの裏側の動き

`Vary`は、オリジンサーバーがレスポンスヘッダーに付与して、キャッシュサーバー(CDNやブラウザ)にこう伝えます。

> 「おい、このレスポンスをキャッシュしていいけどよ、次に同じURLでリクエストが来たら、ここに指定したリクエストヘッダーの値が前回と一致しているか必ず確認しろよ。もし一つでも違ったら、キャッシュは使い回さずに俺(オリジン)のところに再度取りに来い」

シーケンス:Varyが守る世界線

[Client A (JA)] [CDN / Cache] [Origin Server]
| | |
|—GET /api/data———-| |
| Accept-Language: ja |—(Cache Miss)———->|
| |<--200 OK-----------------| | | Vary: Accept-Language | |<--200 OK (日本語Data)----| [Cache Store: ja] | | | | [Client B (EN)] | | |---GET /api/data----------| | | Accept-Language: en | | | |---(Header mismatch)----->| <-- Varyのおかげでミスを防げる! | |<--200 OK-----------------| |<--200 OK (英語Data)------| [Cache Store: en] | もし、オリジンサーバーが `Vary: Accept-Language` を返し忘れていた場合、Client Bが日本語のキャッシュを掴まされてしまうというわけです。 ---

3. 実務で遭遇する「Vary地獄」と罠パラメーター

`Vary` は非常に強力ですが、実務の現場では「適当に設定して大失敗する」筆頭格の機能です。特に以下のヘッダーを `Vary` に指定する際は、インフラ・アプリ両面での緻密な設計が必要です。

罠その1:`Vary: User-Agent` の呪い

「スマホとPCでHTMLを切り替えているから `Vary: User-Agent` を入れよう」――これ、現場のシニアとしては絶対にやめさせたいアンチパターンです。

世の中には何万種類ものUser-Agentが存在します。ブラウザのマイナーバージョンが変わるたび、あるいはOSがアップデートされるたびに、User-Agentの文字列は微妙に変化します。
これをそのままVaryのキーにしてしまうと、CDN上のキャッシュバリエーションが無限に増大し、キャッシュヒット率(Hit Ratio)が劇的に低下(ほぼ0%に)します。オリジンサーバーへの負荷が跳ね上がり、最悪の場合はCDNのキャッシュストレージからあふれてキャッシュが機能しなくなります。

【実務のTips】
User-AgentでVaryさせる必要がある場合でも、生の文字列をそのままキーにしてはいけません。エッジワーカー(Cloudflare WorkersやCloudFront Functionsなど)やリバースプロキシ(Nginxなど)の前段で、User-Agentをパースして「`is_mobile: true/false`」のようなシンプルなカスタムヘッダーに変換し、そのヘッダーに対してVaryさせるのがプロの技です。

—

4. 実装・設定例:現場で使えるコードスニペット

では、実際にWeb APIやインフラ設定でどのように `Vary` を扱うのか、具体的なコードを見ていきましょう。

① バックエンドAPIの例 (Node.js / Express)

多言語対応のAPIエンドポイントで、リクエストの言語に応じてレスポンスを変え、CDNに正しくキャッシュさせる例です。

const express = require(‘express’);
const app = express();

app.get(‘/api/greeting’, (req, res) => {
// リクエストヘッダーから言語を取得(デフォルトは英語)
const lang = req.headers[‘accept-language’] || ‘en’;

let message = “Hello!”;
if (lang.startsWith(‘ja’)) {
message = “こんにちは!”;
} else if (lang.startsWith(‘es’)) {
message = “¡Hola!”;
}

// 【重要】Accept-Languageに応じてキャッシュを出し分けることをCDNに通知
// さらに、共有キャッシュ(CDN)でのキャッシュを許可し、ブラウザでは毎回検証させる
res.setHeader(‘Vary’, ‘Accept-Language’);
res.setHeader(‘Cache-Control’, ‘public, max-age=3600, s-maxage=86400’);

res.json({
language: lang,
greeting: message
});
});

app.listen(3000, () => {
console.log(‘API Server running on port 3000’);
});

② リバースプロキシの例 (Nginx)

バックエンドから送られてきた `Vary` ヘッダーを適切に処理しつつ、Nginx自身でもプロキシキャッシュを制御する設定例です。

http {
# プロキシキャッシュの保存先と容量を定義
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=60m use_temp_path=off;

server {
listen 80;
server_name api.example.com;

location /api/ {
proxy_pass http://backend_upstream;

# バックエンドからの Vary ヘッダーを尊重してキャッシュキーを自動生成させる
proxy_cache api_cache;
proxy_cache_key $scheme$request_method$host$request_uri$http_accept_language;

# 200 OKのレスポンスを1時間キャッシュ
proxy_cache_valid 200 1h;

# クライアントへのレスポンスにキャッシュステータスを目視できるように付与
add_header X-Cache-Status $upstream_cache_status;
}
}
}

③ 動作確認:curlを使ったデバッグ手順

インフラのデバッグにおいて、`curl` は最高の相棒です。異なる `Accept-Language` を送ったときに、本当にキャッシュが機能しているか(あるいはVaryが正しく効いているか)を確かめるコマンドがこちらです。

1. 日本語環境でリクエストを投げる
curl -i -H “Accept-Language: ja-JP,ja;q=0.9” https://api.example.com/api/greeting

2. 英語環境でリクエストを投げる(CDNでキャッシュが別々に保持されるか確認)
curl -i -H “Accept-Language: en-US,en;q=0.9” https://api.example.com/api/greeting

レスポンスヘッダーに含まれる `Vary: Accept-Language` や、CDN固有のキャッシュヒットヘッダー(例: `CF-Cache-Status: HIT` や `X-Cache: HIT`)を注意深く観察してください。ここが意図通りに動いていれば、あなたの設計は正解です。

—

5. シニアからの教訓:キャッシュ汚染を防ぐための黄金律

最後に、実務で数々の修羅場をくぐってきた私から、Varyヘッダーとキャッシュを扱う上での黄金律を授けます。

1. 「Vary: 」は核兵器。原則使用禁止。
`Vary: ` は「すべてのリクエストヘッダーが一致しないとキャッシュを使わない」という意味ですが、これは事実上「キャッシュを完全無効化する」と同義です。どうしてもキャッシュさせたくないなら、`Vary: ` ではなく `Cache-Control: no-store` を使いましょう。
2. CDNの仕様を必ずマニュアルで確認する
Cloudflare、CloudFront、Fastly、Akamaiなど、CDNベンダーによって `Vary` ヘッダーの解釈や、キャッシュキーへの組み込み方には微妙な方言や制限(例: Varyに指定できるヘッダーの数に上限がある等)が存在します。「動くはずだ」と思い込まず、必ず検証環境でパケットとキャッシュの振る舞いをテストしてください。
3. 認証トークン(Cookie / Authorization)をVaryに入れてはいけない
「ユーザーごとに中身を変えたいから `Vary: Cookie` にしよう」――これは最悪のセキュリティホールを生みます。CookieにはセッションIDが含まれるため、これをVaryのキーにすると、ユーザーの数だけキャッシュが生成されてCDNの容量が爆発するか、あるいは共有プロキシ経由で別人のセッションキャッシュが漏洩する致命的なインシデント(キャッシュ汚染)に繋がります。ユーザーごとの出し分けには、キャッシュをさせない(`private` を使う)、あるいはエッジ側でトークンを検証してURLを書き換えるなどのアプローチを取りましょう。

HTTPの歴史のなかで地味ながらも極めて重要な役割を持つ `Vary` ヘッダー。その挙動を正しく理解し、コントロールできるようになれば、あなたも立派な「インフラ・プロトコルスペシャリスト」です。

セキュアで爆速なWebシステムを、その手で構築し続けてください。健闘を祈ります!

コメント

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