APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務
API キャッシュは Cache-Control で「どこに・どれくらい保存するか」、ETag + If-None-Match で「中身が変わったか」 の二段構えが基本。304 Not Modified でボディ転送を省略し、stale-while-revalidate で体感速度を上げる。CDN 利用時は キャッシュキー設計 と Vary を誤るとキャッシュポイズニングのリスクがある。まず curl でヘッダーを検証し、エンドポイント単位の鮮度要件表を作る。
なぜ API キャッシュを設計段階で決めるのか
フロントエンドの最適化だけでは、同じ JSON を何度も取得するコストは消えない。React Query や SWR ライブラリはクライアント側のキャッシュを賢くするが、HTTP レイヤー を設計していなければ、毎回オリジンまで往復する。ブラウザ、CDN、リバースプロキシは HTTP ヘッダー一行 で挙動が変わる。
設計を後回しにすると、次のようなトラブルが起きやすい。
- 本番で古い在庫数が表示される(CDN が昨日の JSON を返し続ける)
- デプロイ後もユーザーが旧版 UI を見る(
max-age=86400の API がパージされていない) - ユーザー A の注文履歴がユーザー B に表示される(CDN キャッシュキーに Authorization が含まれていない、または Vary 欠落)
- 304 が一度も返らず帯域が削減されない(ETag がリクエストごとにランダム生成されている)
キャッシュ設計のゴールは次の 2 点だ。
- 鮮度(Freshness) — ユーザーが見るデータが許容範囲内で新しいこと
- 効率(Efficiency) — 帯域・サーバー負荷・TTFB を減らすこと
この 2 つはトレードオフになる。在庫 API は秒単位の鮮度が必要だが、ブログ一覧は 5 分古くても問題ない。だから「何を」「どの層で」「どれくらい」キャッシュするかを、エンドポイントごと に決めておく。
HTTP キャッシュの主要プレイヤーは次の 3 層だ。
- ブラウザ(プライベートキャッシュ) —
private/max-ageに従いローカル保持 - CDN・リバースプロキシ(共有キャッシュ) —
public/s-maxageを参照。privateなら通常保存しない - オリジンサーバー — キャッシュミス時に生成。ETag 一致なら 304 でボディ省略
設計段階でこの 3 層を意識しておけば、後から「CDN を挟んだら動かない」という手戻りを減らせる。
HTTP キャッシュの基本用語
実装に入る前に、RFC 7234(Caching)で使われる用語を整理しておく。
| 用語 | 意味 |
|---|---|
| Fresh(新鮮) | max-age 内。再検証なしでキャッシュを返してよい |
| Stale(古い) | max-age 経過後。再検証が必要、または SWR 等の例外適用 |
| Revalidation(再検証) | オリジンへ条件付きリクエスト(If-None-Match 等)を送り、304 か 200 を受け取る |
| Shared cache | CDN など複数ユーザーで共有するキャッシュ |
| Private cache | ブラウザなど単一ユーザー向けキャッシュ |
Age ヘッダーは、共有キャッシュがそのレスポンスを保持してきた秒数を示す。CDN 経由で Age: 580 が付いていれば、エッジで約 10 分経過している。curl で確認できる。
curl -sSI 'https://api.example.com/v1/public/categories' \
| grep -iE '^(age|cache-control|etag|x-cache):'
Cache-Control:ディレクティブ一覧と設計の考え方
Cache-Control はレスポンス(ときにリクエスト)に付与し、キャッシュの 保存可否・期間・再検証の仕方 を指示する。API 設計で触れるディレクティブを表にまとめる。
| ディレクティブ | 対象 | 意味 |
|---|---|---|
max-age=秒 | すべて | この秒数までは「新鮮(fresh)」とみなし、再検証なしで返せる |
s-maxage=秒 | 共有キャッシュ(CDN) | CDN 向けの max-age。ブラウザは無視する |
no-store | すべて | 一切保存しない。機密・決済向け |
no-cache | すべて | 保存はするが、使う前に 必ずオリジンへ再検証 |
must-revalidate | すべて | 期限切れ(stale)後は再検証なしに返してはいけない |
private | 共有キャッシュ | ブラウザのみキャッシュ可。CDN は保存しない |
public | 共有キャッシュ | CDN も保存可 |
immutable | ブラウザ | 内容が変わらない前提。再検証リクエスト自体を抑止 |
stale-while-revalidate=秒 | ブラウザ・一部 CDN | 期限切れ後もこの秒数は古いレスポンスを返しつつ裏で更新 |
stale-if-error=秒 | ブラウザ・一部 CDN | オリジンエラー時、期限切れキャッシュをこの秒数まで返してよい |
エンドポイント別のヘッダー例
# ビルド成果物(ハッシュ付きファイル名)
Cache-Control: public, max-age=31536000, immutable
# 更新頻度が低い公開 API(CDN + ブラウザ + SWR)
Cache-Control: public, max-age=300, s-maxage=600, stale-while-revalidate=60
# 認証付き・個人データ(304 で帯域節約、CDN には載せない)
Cache-Control: private, no-cache, must-revalidate
# 決済結果・個人情報
Cache-Control: private, no-store
no-store は「保存禁止」、no-cache は「保存はするが使う前に検証必須」。名前と直感が逆なので、設計レビューでは必ず curl で実例を確認する。
max-age=0 と no-cache は似ているが、no-cache の方が「再検証必須」の意図が明確だ。認証付き API で ETag による 304 を使うなら private, no-cache が定番になる。
リクエスト側 Cache-Control
クライアントも Cache-Control ヘッダーを送れる。Cache-Control: no-cache をリクエストに付けると、「キャッシュがあっても再検証せよ」という指示になる。ブラウザの「ハードリロード」に近い挙動だ。API クライアントが常に最新を欲しい場合は、サーバー側の no-store と組み合わせて設計する。
ETag の生成 — Strong と Weak の完全理解
ETag(Entity Tag)はリソースの バージョン識別子。内容やメタデータからサーバーが生成し、レスポンスヘッダーに付ける。クライアントは次回 If-None-Match にこの値を載せ、サーバーは一致すれば 304 を返す。
RFC 7232 では ETag を Strong と Weak に分類する。選び方を誤ると、304 が返らない、Range リクエストが壊れる、圧縮方式の違いで毎回 200 になる、といった問題が起きる。
Strong ETag(強い ETag)
バイト単位で同一性を保証する。形式は "abc123" のようにダブルクォートで囲む。W/ プレフィックスは付けない。
ETag: "8f14e45fceea167a5a36dedd4bea2543"
向いているケース
- JSON レスポンス全体の SHA-256(キーをソートして正規化したうえで)
- ファイルのバイナリハッシュ
id+updated_at+revisionを連結してハッシュ化
// Node.js — 決定的な Strong ETag 生成例
import { createHash } from 'node:crypto';
function strongEtag(payload) {
const body = typeof payload === 'string' ? payload : JSON.stringify(payload);
return `"${createHash('sha256').update(body).digest('hex').slice(0, 16)}"`;
}
// レコード更新で ETag が変わる例
function etagFromRecord(record) {
const seed = `${record.id}:${record.updatedAt}:${record.revision}`;
return `"${createHash('sha256').update(seed).digest('hex').slice(0, 16)}"`;
}
Strong ETag は Range リクエスト(Range: bytes=0-1023)と組み合わせて使える。大きなファイルの部分取得では、Weak ETag では 206 Partial Content の整合性が保証されない。
Weak ETag(弱い ETag)
意味的に同等なら一致とみなす。形式は W/"abc123" のように W/ プレフィックスを付ける。
ETag: W/"20260621-42"
向いているケース
- 圧縮方式(gzip / br)でボディのバイト列が変わるが意味は同じ
- フィールド順序が不定な JSON(正規化前)
- 大きなペイロードで毎回フルハッシュが高コスト
- 秒精度の
updated_atだけで「更新があったか」を判定したい
# Python — Weak ETag(updated_at ベース)
from datetime import datetime
def weak_etag(record_id: int, updated_at: datetime) -> str:
ts = updated_at.strftime("%Y%m%dT%H%M%SZ")
return f'W/"{record_id}-{ts}"'
// Go — Weak ETag(リビジョン番号ベース)
func weakETag(id int64, revision int) string {
return fmt.Sprintf(`W/"%d-r%d"`, id, revision)
}
Strong と Weak の比較表
| 観点 | Strong | Weak |
|---|---|---|
| 形式 | "hash" | W/"hash" |
| 一致条件 | バイト列が完全一致 | 意味的に同等 |
| Range リクエスト | サポート可能 | 不可(RFC 7232) |
| 生成コスト | フルボディハッシュは重い | メタデータベースで軽い |
| API JSON | 正規化した JSON なら Strong 推奨 | 非正規化・圧縮差分なら Weak |
| If-None-Match 照合 | 同一 Strong 値のみ 304 | Weak 同士、または Strong と Weak のルールに従う |
Weak ETag の照合ルール(実務でハマる点)
If-None-Match: W/"abc" を送ったとき、サーバー側の ETag が W/"abc" なら 304。Strong "abc" と Weak W/"abc" の照合は RFC 7232 のルールに従う。クライアントが送る ETag 文字列は、前回レスポンスの ETag をそのまま使う のが鉄則だ。引用符や W/ を手で削ると 304 にならない。
# Weak ETag の 304 検証
curl -sS -D - -o /dev/null \
'https://api.example.com/v1/products/99' \
-H 'Accept: application/json'
# レスポンス: ETag: W/"99-r7"
curl -sS -D - -o /dev/null \
'https://api.example.com/v1/products/99' \
-H 'Accept: application/json' \
-H 'If-None-Match: W/"99-r7"'
# 期待: HTTP/2 304
決定的生成の重要性
複数サーバー(オリジン + ワーカー)で同じ ETag を出す必要があるなら、決定的なアルゴリズム に統一する。ランダム UUID を毎レスポンス生成すると 304 が機能しない。ロードバランサ配下では、サーバー A が "aaa"、サーバー B が "bbb" を返すと、クライアントは常に 200 になる。
Last-Modified との併用
Last-Modified はリソースの最終更新日時。If-Modified-Since と組み合わせて 304 を返せる。静的ファイルでは ETag + Last-Modified の両方を付けることが多い。
JSON API では ETag を優先 する理由は次のとおり。
- 秒精度のため、同一秒内の連続更新を区別できない
- DB の
updated_atがアプリケーションロジックとズレることがある - 分散環境でサーバー間の時刻ずれが 304 判定を壊す
# Last-Modified による条件付き GET
curl -sSI \
-H 'If-Modified-Since: Sat, 21 Jun 2026 10:00:00 GMT' \
'https://api.example.com/v1/articles/42'
ETag と Last-Modified を両方返す場合、クライアントは通常 If-None-Match を優先 する。サーバー実装では両方の条件を評価し、どちらかが一致すれば 304 を返す実装が一般的だ。
If-None-Match と 304 Not Modified の実際のフロー
304 フローは「キャッシュに載っている ETag が、サーバー上の現行版と一致するか」を確認する仕組みだ。以下は 実際の curl 手順 で再現できる。
ステップ 1:初回 GET で ETag を取得
curl -sS -D - -o /tmp/body.json \
'https://api.example.com/v1/articles/42' \
-H 'Accept: application/json'
期待するレスポンスヘッダー(抜粋):
HTTP/2 200
content-type: application/json
etag: "a1b2c3d4e5f67890"
cache-control: private, max-age=0, must-revalidate
content-length: 2847
ボディは /tmp/body.json に保存される。-D - でレスポンスヘッダーを標準出力に出し、-o でボディをファイルへ分離する。
PowerShell では次のように ETag を変数に取り込める。
$headers = curl.exe -sSI 'https://api.example.com/v1/articles/42'
$etag = ($headers | Select-String '^etag:').Line.Split(':', 2)[1].Trim()
Write-Host "ETag=$etag"
ステップ 2:If-None-Match 付きで再リクエスト
前回の ETag を変数に入れて送る。引用符を含めた 完全一致 が重要だ。
ETAG='"a1b2c3d4e5f67890"'
curl -sS -D - -o /tmp/body2.json \
'https://api.example.com/v1/articles/42' \
-H 'Accept: application/json' \
-H "If-None-Match: ${ETAG}"
内容が変わっていなければ 304:
HTTP/2 304
etag: "a1b2c3d4e5f67890"
cache-control: private, max-age=0, must-revalidate
304 では ボディが空。/tmp/body2.json のサイズは 0 バイトになる。クライアント(ブラウザや fetch ラッパー)はローカルキャッシュのボディを使う。
内容が更新されていれば 200:
HTTP/2 200
etag: "f9e8d7c6b5a43210"
content-type: application/json
content-length: 2901
{"id":42,"title":"更新後のタイトル",...}
新しい ETag とボディが返る。以降は新 ETag で 304 判定を続ける。
ステップ 3:転送量の比較
# 200 のとき(初回)
curl -sS -w 'http_code=%{http_code} size_download=%{size_download}\n' \
-o /dev/null 'https://api.example.com/v1/articles/42'
# 304 のとき
curl -sS -w 'http_code=%{http_code} size_download=%{size_download}\n' \
-o /dev/null \
-H 'If-None-Match: "a1b2c3d4e5f67890"' \
'https://api.example.com/v1/articles/42'
304 では size_download がヘッダー分のみ(数十〜数百バイト)になり、JSON 本体(数 KB〜数 MB)の転送を省略できる。モバイル回線や高頻度ポーリング API では効果が大きい。
ステップ 4:複数 ETag の If-None-Match
If-None-Match はカンマ区切りで複数 ETag を指定できる。キャッシュに複数バージョンがある場合や、リスト取得で部分一致を試す高度な用途向けだ。
curl -sS -I \
-H 'If-None-Match: "old-etag", "a1b2c3d4e5f67890", W/"weak-1"' \
'https://api.example.com/v1/articles/42'
通常の単一リソース GET では、直前に受け取った ETag 1 つを送れば十分だ。
ステップ 5:CDN 経由での 304 確認
CDN が public レスポンスをエッジに載せている場合、304 は エッジから返る こともある。Age ヘッダーと X-Cache(CDN によって名称は異なる)で確認する。
# 初回 — オリジンまたは MISS
curl -sSI 'https://cdn.example.com/v1/public/categories'
# 2 回目 — HIT(Age が増えている)
curl -sSI 'https://cdn.example.com/v1/public/categories'
# 3 回目 — If-None-Match で再検証(304 または 200)
curl -sSI \
-H 'If-None-Match: "categories-v20260621"' \
'https://cdn.example.com/v1/public/categories'
エッジがオリジンへ再検証し、変更がなければ 304 を返す。変更があれば 200 と新 ETag でエッジキャッシュが更新される。
サーバー側の判定ロジック(概念)
// Express 風の疑似コード
app.get('/v1/articles/:id', async (req, res) => {
const article = await db.getArticle(req.params.id);
if (!article) return res.status(404).json({ error: 'not found' });
const etag = strongEtagFromRecord(article);
res.set('ETag', etag);
res.set('Cache-Control', 'private, no-cache');
const clientEtag = req.get('If-None-Match');
if (clientEtag === etag) {
return res.status(304).end(); // ボディなし
}
res.json(article);
});
If-None-Match: * は「その URL に何かキャッシュがあれば 304」という特殊形式。DELETE 前の存在確認など限定的な用途向けで、通常の GET キャッシュでは使わない。
304 を返す前に 認可チェック を省略しない。トークン失効後も古い ETag で 304 が返ると、キャッシュ済みボディが漏れる。認証失敗時は 401/403 を返し、キャッシュヘッダーを no-store にする。
stale-while-revalidate:古いキャッシュを活かす戦略
stale-while-revalidate(SWR)は、期限切れ(stale)のキャッシュを一時的に返しながら、バックグラウンドで最新版を取得する ディレクティブだ。Web の Cache-Control 拡張として RFC 5861 で定義されている。
Cache-Control: public, max-age=60, stale-while-revalidate=300
この例のタイムライン:
| 経過時間 | 状態 | 挙動 |
|---|---|---|
| 0〜60 秒 | fresh | キャッシュを即返す。オリジンへ行かない |
| 60〜360 秒 | stale(SWR 内) | 古いキャッシュを即返す。裏でオリジンへ再検証 |
| 360 秒以降 | stale(SWR 外) | 通常の再検証フロー。待ちが発生しうる |
SWR が向く API・向かない API
向く
- ニュース一覧、ブログカテゴリ、商品カタログ
- 設定 JSON(機能フラグ)で数秒〜数分の遅延が許容されるもの
- ダッシュボードの参考指標(リアルタイム性が低い KPI)
向かない
- 在庫数、座席残数、オークション現在価格
- 決済ステータス、二要素認証のチャレンジ状態
- 医療・金融など規制上「常に最新」が求められるデータ
stale-if-error との組み合わせ
stale-if-error=86400 を併用すると、オリジンが 500/503 を返したとき 最大 24 時間 期限切れキャッシュを配信してよい、という指示になる。公開 read-only API の可用性向上に有効だが、意図せず古い在庫データを配る リスクもある。プロダクトオーナーと許容範囲を合意してから付ける。
Cache-Control: public, max-age=120, s-maxage=300, stale-while-revalidate=60, stale-if-error=3600
フロントエンドでの利用例
async function fetchWithSWR(url, { onRevalidate } = {}) {
const res = await fetch(url, { cache: 'default' }); // ブラウザキャッシュを利用
const cacheControl = res.headers.get('Cache-Control') || '';
const swrMatch = cacheControl.match(/stale-while-revalidate=(\d+)/);
const swr = swrMatch ? Number(swrMatch[1]) : 0;
if (res.ok && swr > 0 && onRevalidate) {
// バックグラウンド再取得(UI は古いデータのまま表示)
fetch(url, { cache: 'reload' }).then(onRevalidate).catch(() => {});
}
return res.json();
}
React Query の staleTime / gcTime は アプリ層 のキャッシュ設定であり、HTTP の stale-while-revalidate とは別物だ。両方を使う場合、どちらが先に stale 判定するかを整理しないと、想定より古いデータが表示される。
CDN での SWR 設定
Cloudflare では Cache-Control の stale-while-revalidate を解釈し、エッジでバックグラウンド再検証を行う。Fastly では stale-while-revalidate ディレクティブと VCL の stale_if_error を組み合わせる。いずれも オリジンが返すヘッダー とダッシュボードの Edge TTL が矛盾していないか確認する。
# SWR 付きレスポンスのヘッダー確認
curl -sSI 'https://api.example.com/v1/public/feed' \
| grep -i cache-control
# cache-control: public, max-age=60, s-maxage=120, stale-while-revalidate=300
オリジンが s-maxage=600 を返しているのに CDN ダッシュボードで 86400 に固定していると、意図とズレた配信になる。Infrastructure as Code で CDN ルールをコード管理し、オリジンヘッダーをソースオブトゥルースにするのが望ましい。
CDN キャッシュキー設計
CDN は URL をキーにレスポンスをエッジに保存するが、キーの構成を誤ると別ユーザーのレスポンスが返る ことになる。API で CDN キャッシュを使うときは、次の要素を設計する。
キャッシュキーの構成要素
| 要素 | 含める? | 備考 |
|---|---|---|
パス(/v1/items) | 必須 | 基本キー |
クエリ(?page=2) | エンドポイント依存 | ページネーションは通常含める |
Accept / Accept-Language | 内容が変わるなら | Vary とセットで |
Authorization / Cookie | 原則含めない | ユーザー固有 API は CDN キャッシュしない |
| カスタムヘッダー | 必要時のみ | X-Api-Version 等 |
| HTTP メソッド | GET のみ対象 | POST レスポンスはキャッシュしない |
公開 GET API のキー例
# 良い例 — 全ユーザー同一レスポンス
GET /v1/public/categories
Cache-Control: public, max-age=300, s-maxage=600
CDN Cache Key: https://api.example.com/v1/public/categories
(Authorization をキーに含めない)
Cloudflare の Cache Key カスタマイズでは、クエリ文字列の 特定パラメータのみ をキーに含める設定ができる。?page=1&_t=1234567890 の _t を除外しないと、毎リクエストキャッシュミスになる。
ユーザー固有 API は CDN に載せない
# 悪い例 — Authorization をキーに含めて CDN キャッシュ
Cache-Control: public, max-age=60
Vary: Authorization
# 良い例 — オリジン手前で private に
Cache-Control: private, no-cache
Vary: Authorization でキーを分岐させる方法もあるが、キーの組み合わせが爆発し、設定ミスで ユーザー A の JSON がユーザー B に返る リスクが高い。個人データは private でブラウザキャッシュのみに留めるのが安全だ。
Vary ヘッダーとキー拡張
Vary は「このリクエストヘッダーの値によってレスポンスが変わる」と CDN・ブラウザに伝える。
HTTP/2 200
content-type: application/json
vary: Accept-Encoding, Accept-Language
cache-control: public, max-age=600
etag: "feed-ja-v3"
上記の場合、CDN は実質的に次のようなキー空間を持つ。
Key = URL + Accept-Encoding + Accept-Language
Vary を付け忘れると、gzip 版が plain リクエストに返る、英語版 JSON が日本語クライアントに返る、といった キャッシュポイズニング に近い事故が起きる。
クエリ文字列と正規化
?sort=name と ?sort=name& は CDN によって別キーになることがある。オリジンまたは CDN ルールで クエリのソート・正規化 を行う。
| クエリ | 正規化後 | 備考 |
|---|---|---|
?b=2&a=1 | ?a=1&b=2 | ソートでキー統一 |
?page=1&utm_source=twitter | ?page=1 | トラッキングパラメータ除外 |
?page=1&page=2 | 先勝ち or 400 | 曖昧な重複は API 側で拒否 |
Cloudflare の「Query String Sort」、Fastly の req.url.qs.sort() など、CDN 機能で正規化する。
Cache-Tag によるパージ
大量 URL を個別パージするのは運用負荷が高い。レスポンスに Cache-Tag(Cloudflare)や Surrogate-Key(Fastly)を付け、タグ単位でパージする。
HTTP/2 200
cache-control: public, s-maxage=3600
cache-tag: articles, article-42
etag: "42-v12"
記事 42 を更新したら article-42 タグだけパージすれば、関連エンドポイント(詳細・一覧・RSS)をまとめて無効化できる。
パージ戦略
CDN キャッシュを使う以上、コンテンツ更新時のパージ をデプロイフローに含める。
- 単一リソース更新 → その URL をパージ
- 一括更新 → タグベースパージ(
Cache-Tagヘッダー + CDN のタグパージ) - 緊急時 → プレフィックスパージ(影響範囲に注意)
- デプロイパイプライン →
/*ではなく影響パスのみパージ(オリジン負荷スパイクを防ぐ)
# curl で CDN 経由のキャッシュ状態を確認(Age で鮮度推定)
curl -sSI 'https://cdn.example.com/v1/public/categories' \
| grep -iE '^(age|cf-cache-status|x-cache|cache-control):'
キャッシュポイズニングと安全な設計
キャッシュポイズニング(Cache Poisoning)は、攻撃者またはバグが原因で、有害なレスポンスがキャッシュに載り、他ユーザーに配信される 攻撃・事故の総称だ。API 設計では次の落とし穴に注意する。
1. Vary ヘッダーの欠落
Accept-Encoding や Accept-Language で内容が変わるのに Vary がないと、gzip 版と plain 版、日本語版と英語版が混在する。
# 正しい例
Content-Type: application/json
Content-Encoding: gzip
Vary: Accept-Encoding, Accept-Language
Cache-Control: public, max-age=300
2. 未使用の Host / X-Forwarded-* をキーに含めない
攻撃者が X-Forwarded-Host: evil.com を送り、レスポンスに反射されると、その Host がキーに入っている CDN が 悪意ある URL 付き JSON をキャッシュしうる。オリジンは信頼できないリクエストヘッダーをレスポンスに反映しない。CDN 側で許可ヘッダーをホワイトリスト化する。
3. エラーレスポンスのキャッシュ
500 や空ボディが public, max-age=3600 で返ると、CDN がエラーを 1 時間配信し続ける。エラー時は Cache-Control: no-store または短い max-age にする。
// エラー時はキャッシュさせない
res.status(500).set('Cache-Control', 'no-store').json({ error: 'internal' });
4. Set-Cookie 付きレスポンスの public キャッシュ
Set-Cookie があるレスポンスを public でキャッシュすると、セッション Cookie 付きボディが他ユーザーに渡る恐れがある。Cookie を Set するレスポンスは private, no-store が原則。
5. Web Cache Deception
/api/user/123/profile.css のように、静的拡張子付き URL で API JSON を返すと、CDN が「CSS として」長期キャッシュする設定と衝突しうる。API は /api/ プレフィックスでルーティングを分け、静的と混在させない。
6. キャッシュキーに含めるべきでないヘッダー
| ヘッダー | リスク |
|---|---|
X-Forwarded-Host | ホストヘッダーインジェクション経由のポイズニング |
任意の X-* | 攻撃者が自由にキーを増やし、キャッシュを汚染 |
Cookie 全体 | セッションごとにキーが分裂し、ヒット率ゼロ + 漏洩リスク |
「このレスポンスは 誰にとって同じ内容 か?」をエンドポイントごとに書き出す。答えが「ユーザーごとに違う」なら CDN の public は禁止。curl で Vary と Cache-Control を本番・ステージング両方で突き合わせる。
エンドポイント別のキャッシュ戦略
| リソース種別 | 推奨ヘッダー・備考 |
|---|---|
| ハッシュ付き静的資産 | public, max-age=31536000, immutable + CDN。URL 自体がバージョン |
| 公開・更新少ない API | public, max-age=300, s-maxage=600, stale-while-revalidate=60 + Strong ETag |
| 認証付き・個人データ | private, no-cache + ETag(304 で帯域節約)。CDN 非キャッシュ |
| 在庫・決済・機密 | private, no-store。ETag も付けないか、付けても no-store 優先 |
| POST/PUT/PATCH/DELETE | 原則キャッシュ不可。GET のみ Cache-Control 設計対象 |
Cache Busting(静的資産)
長期キャッシュしたファイルを更新するとき、同じ URL だと古い版が返り続ける。ファイル名にコンテンツハッシュ(app.a1b2c3.js)を付けるのが確実だ。API の JSON に ?v=20260621 を付ける方式はキャッシュキーが増えパージが複雑になる。静的はファイル名ハッシュ、API は max-age + ETag で制御する。
条件付き GET の API 設計パターン
| パターン | Cache-Control | ETag | CDN |
|---|---|---|---|
| 公開マスタデータ | public, max-age=3600, s-maxage=7200 | Strong(内容ハッシュ) | 積極キャッシュ |
| 認証付きプロフィール | private, no-cache | Strong(id+revision) | 載せない |
| リアルタイム在庫 | private, no-store | なし | 載せない |
| 検索結果(公開) | public, max-age=60, stale-while-revalidate=120 | Weak(クエリ+時刻) | クエリ正規化必須 |
実装・検証チェックリスト
エンドポイントごとに鮮度要件を分類する(静的 / 準静的 / 個人 / 機密)
Cache-Control を設計し、認証レスポンスに誤って public を付けていないか確認する
Strong または Weak ETag を決定的に生成し、更新時に値が変わるユニットテストを書く
curl -D - で ETag・Cache-Control を確認し、If-None-Match で 304・転送量ゼロを検証する
CDN 経由の場合、s-maxage・キャッシュキー・Vary・パージ手順をドキュメント化する
stale-while-revalidate の許容「古さ」をプロダクトオーナーと合意し、在庫等クリティカル API には適用しない
エラーレスポンスに no-store が付いているか、Set-Cookie + public の組み合わせがないか確認する
curl ワンライナー集
# ヘッダーのみ確認
curl -sS -I 'https://api.example.com/v1/articles/42'
# 初回 GET — ETag をヘッダーから目視確認
curl -sS -D - -o /dev/null 'https://api.example.com/v1/articles/42'
# 304 確認(ステータス行のみ)
curl -sS -I -H 'If-None-Match: "a1b2c3d4e5f67890"' \
'https://api.example.com/v1/articles/42' | head -n 1
# Weak ETag で 304 確認
curl -sS -I -H 'If-None-Match: W/"99-r7"' \
'https://api.example.com/v1/products/99' | head -n 1
# Cache-Control / Vary / Age の一括確認
curl -sSI 'https://api.example.com/v1/public/categories' \
| grep -iE '^(cache-control|etag|vary|age):'
# 転送バイト比較(200 vs 304)
curl -sS -w 'code=%{http_code} bytes=%{size_download}\n' -o /dev/null \
'https://api.example.com/v1/articles/42'
curl -sS -w 'code=%{http_code} bytes=%{size_download}\n' -o /dev/null \
-H 'If-None-Match: "a1b2c3d4e5f67890"' \
'https://api.example.com/v1/articles/42'
# CDN キャッシュヒット確認
curl -sSI 'https://cdn.example.com/v1/public/categories' \
| grep -iE '^(age|cf-cache-status|x-cache):'
DevTools の Network タブでは、304 レスポンスは Size 列が (disk cache) や (memory cache) と表示され、Transferred がヘッダー分のみになる。本番とステージングでヘッダーが一致しているかもデプロイ前に確認する。
ユニットテストで押さえる ETag テスト
import { describe, it, expect } from 'vitest';
import { etagFromRecord } from './etag';
describe('etagFromRecord', () => {
it('同じレコードなら同じ ETag', () => {
const r = { id: 1, updatedAt: '2026-06-21T10:00:00Z', revision: 3 };
expect(etagFromRecord(r)).toBe(etagFromRecord({ ...r }));
});
it('revision が変われば ETag も変わる', () => {
const base = { id: 1, updatedAt: '2026-06-21T10:00:00Z', revision: 3 };
const updated = { ...base, revision: 4 };
expect(etagFromRecord(base)).not.toBe(etagFromRecord(updated));
});
});
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| Web APIのエラーハンドリング設計 | Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け |
| Web APIのべき等性(Idempotency)設計 | Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド |
| API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 | API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 |
| APIのレート制限(Rate Limiting)実装ガイド | APIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング |
| APIレート制限と429対応 | APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装 |
| WebHook設計のベストプラクティス | WebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する |
| ZodでAPI境界のランタイム検証を設計する | ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026) |
| OpenAPI 3.1仕様のCIバリデーション完全ガイド | OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions |
| GraphQL N+1 問題の解決 | GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
| Event SourcingとCQRSの基礎 | Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド |
まとめ
API キャッシュの設計は、Cache-Control で層と寿命を決め、ETag + If-None-Match + 304 で無駄な転送を削り、stale-while-revalidate で体感速度を上げ、CDN キャッシュキーと Vary でポイズニングを防ぐ —— この 4 点をセットで考える。
Strong ETag は正規化 JSON や Range 向け、Weak ETag は圧縮差分やメタデータベース更新向け。curl で If-None-Match を送り 304 と転送量ゼロを確認する習慣が、設計と実装のズレを早期に見つける。
最初の一歩は、curl で現状の ETag / Cache-Control / Vary を洗い出し、エンドポイント単位の 鮮度要件表 を作ることだ。そこから Strong ETag の生成方式と CDN の s-maxage を決めれば、帯域・TTFB・オリジン負荷を安全に下げられる。