CloudflareワーカーのキャッシュAPIと無効化戦略
Workers の Cache API を使用すると、TTL をきめ細かく制御できます。 stale-while-revalidate とキーによる無効化: ビルド方法を学ぶ 高性能のグローバル分散キャッシュ層。
エッジでのキャッシュ: 従来の CDN を超えて
従来の CDN は、標準の HTTP ヘッダーに従って静的アセットをキャッシュします。 Cloudflare ワーカー コンセプトをさらに進化させたものです。 キャッシュAPI、あなたのコード TypeScript は、何をどのくらいの期間キャッシュするかを非常に正確に制御します。 キャッシュが無効になる方法と、キャッシュが古いときに適用するフォールバック ロジック。
結果は1つです プログラム可能な CDN: ルールを設定する代わりに ダッシュボードに静的なものがある場合は、その場で判断するビジネス ロジックを作成します。 応答は、どの TTL およびどのキャッシュ キーを使用してキャッシュ可能です。この柔軟性 これは、REST API、カスタム ページ、および半動的コンテンツにとって特に価値があります。
何を学ぶか
- Cloudflare Workers Cache APIの仕組みとBrowser Cache APIとの違い
- キャッシュ戦略: キャッシュ優先、ネットワーク優先、再検証中に失効する
- カスタム キャッシュ キー: ユーザー、言語、バージョンごとにキャッシュを分離します。
- Cloudflare Zone Purge APIを使用したURL、タグ、プレフィックスの無効化
- さまざまなヘッダー: Accept-Encoding、Accept-Language の差別化されたキャッシュ
- 高度なパターン: キャッシュ ウォーミング、猶予期間、KV によるサーキット ブレーカー
- よくある間違いと本番環境でのそれを回避する方法
キャッシュ API: 基本とブラウザとの違い
Workers で公開される Cache API は、Workers と同じインターフェイスに従います。 Service Worker キャッシュ API ブラウザと同じですが、重要な違いがあります。 Workers では、キャッシュは次のとおりです。 配布された すべてのCloudflare PoPで: フランクフルトのワーカーが応答をキャッシュすると、 ロンドンやアムステルダムでは、その答えは自動的には得られません。すべての PoP には、 独自のローカルキャッシュ。
// Accesso alla cache nel Worker
// In Workers esiste un unico "default" cache namespace
const cache = caches.default;
// Oppure cache named (isolata per nome, utile per namespace logici)
const apiCache = await caches.open('api-v2');
// Le operazioni fondamentali:
// cache.match(request) -> Response | undefined
// cache.put(request, response) -> void
// cache.delete(request) -> boolean
キャッシュ API: 重要な制約
- HTTP リクエストのみ (ブラウザのような任意の URL ではありません)
- 応答をキャッシュすることはできません
Vary: * - キャッシュは PoP ごとに行われます。データセンター間の自動無効化はありません。
cache.delete() - ステータス 206 (部分コンテンツ) の応答はキャッシュできません
- 尊重される最大 TTL は 31 日です
戦略 1: フォールバック ネットワークを使用したキャッシュ ファースト
データがほとんど変更されない API の最も一般的な戦略: キャッシュから提供する 利用可能な場合は、ソースに移動してキャッシュにデータを追加します。
// worker.ts - Cache-First Strategy
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Solo richieste GET sono cacheable
if (request.method !== 'GET') {
return fetch(request);
}
const cache = caches.default;
// 1. Controlla la cache
let response = await cache.match(request);
if (response) {
// Cache HIT: aggiungi header diagnostico e restituisci
const headers = new Headers(response.headers);
headers.set('X-Cache-Status', 'HIT');
return new Response(response.body, {
status: response.status,
headers,
});
}
// 2. Cache MISS: fetch dall'origin
response = await fetch(request);
// 3. Clona la risposta (il body e un ReadableStream, consumabile una sola volta)
const responseToCache = response.clone();
// 4. Metti in cache con ctx.waitUntil() per non bloccare la risposta al client
ctx.waitUntil(
cache.put(request, responseToCache)
);
// 5. Restituisci la risposta originale con header diagnostico
const headers = new Headers(response.headers);
headers.set('X-Cache-Status', 'MISS');
return new Response(response.body, {
status: response.status,
headers,
});
},
};
interface Env {}
戦略 2: 再検証中に失効させる
戦略 再検証中に失効する それは最高のものを与えるものです データの鮮度と体感速度の間の妥協点: クライアントは いつも バックグラウンドで実行されている場合でも、キャッシュからの即時応答 ワーカーは次のリクエストに備えてキャッシュを更新します。
// Stale-While-Revalidate implementato manualmente
// (Cloudflare supporta anche il header standard, ma questa versione offre più controllo)
const CACHE_TTL = 60; // Secondi prima che la cache sia "fresh"
const STALE_TTL = 300; // Secondi aggiuntivi in cui la cache e "stale but usable"
interface CacheMetadata {
cachedAt: number;
ttl: number;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (request.method !== 'GET') return fetch(request);
const cache = caches.default;
// Crea una Request con una custom cache key che include i metadata
const cacheKey = new Request(request.url, {
headers: request.headers,
});
const cached = await cache.match(cacheKey);
if (cached) {
const cachedAt = parseInt(cached.headers.get('X-Cached-At') ?? '0');
const age = (Date.now() - cachedAt) / 1000;
if (age < CACHE_TTL) {
// FRESH: servi dalla cache senza revalidazione
return addCacheHeaders(cached, 'FRESH', age);
}
if (age < CACHE_TTL + STALE_TTL) {
// STALE: servi dalla cache ma revalida in background
ctx.waitUntil(revalidate(cacheKey, cache));
return addCacheHeaders(cached, 'STALE', age);
}
}
// MISS o troppo vecchio: fetch sincrono
return fetchAndCache(cacheKey, cache, ctx);
},
};
async function revalidate(cacheKey: Request, cache: Cache): Promise<void> {
const fresh = await fetch(cacheKey.url);
if (fresh.ok) {
const toCache = addTimestamp(fresh);
await cache.put(cacheKey, toCache);
}
}
async function fetchAndCache(
cacheKey: Request,
cache: Cache,
ctx: ExecutionContext
): Promise<Response> {
const response = await fetch(cacheKey.url);
if (response.ok) {
const toCache = addTimestamp(response.clone());
ctx.waitUntil(cache.put(cacheKey, toCache));
}
const headers = new Headers(response.headers);
headers.set('X-Cache-Status', 'MISS');
return new Response(response.body, { status: response.status, headers });
}
function addTimestamp(response: Response): Response {
const headers = new Headers(response.headers);
headers.set('X-Cached-At', String(Date.now()));
// Cache-Control: max-age elevato per far "sopravvivere" la risposta in cache
headers.set('Cache-Control', 'public, max-age=86400');
return new Response(response.body, { status: response.status, headers });
}
function addCacheHeaders(response: Response, status: string, age: number): Response {
const headers = new Headers(response.headers);
headers.set('X-Cache-Status', status);
headers.set('Age', String(Math.floor(age)));
return new Response(response.body, { status: response.status, headers });
}
interface Env {}
カスタム キャッシュ キー: コンテキストによるキャッシュの分離
デフォルトでは、キャッシュ キーはリクエストの完全な URL です。しかし、多くの場合必要になるのは、
言語、API バージョン、ユーザーまたはデバイス層ごとに分けられたキャッシュ。解決策は
一つ作る カスタムキャッシュキー オブジェクトとして Request
短縮URL付き。
// Cache differenziata per lingua e versione API
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const cache = caches.default;
const url = new URL(request.url);
// Estrai parametri rilevanti per la cache key
const lang = request.headers.get('Accept-Language')?.split(',')[0]?.split('-')[0] ?? 'en';
const apiVersion = url.searchParams.get('v') ?? 'v1';
const tier = request.headers.get('X-User-Tier') ?? 'free';
// Costruisci una URL sintetica come cache key
// Non deve essere una URL reale, solo identificativa
const cacheKeyUrl = new URL(request.url);
cacheKeyUrl.searchParams.set('_ck_lang', lang);
cacheKeyUrl.searchParams.set('_ck_v', apiVersion);
// Non includiamo 'tier' nella key se vuoi condividere la cache tra tier
const cacheKey = new Request(cacheKeyUrl.toString(), {
method: 'GET',
// Importante: non copiare headers di autenticazione nella cache key
// altrimenti ogni utente avrebbe la propria cache entry
});
// Cerca nella cache con la custom key
let response = await cache.match(cacheKey);
if (response) {
return response;
}
// Fetch dall'origin passando la richiesta originale (con auth headers)
const originResponse = await fetch(request);
if (originResponse.ok && isCacheable(originResponse)) {
const toCache = setCacheHeaders(originResponse.clone(), 300);
ctx.waitUntil(cache.put(cacheKey, toCache));
}
return originResponse;
},
};
function isCacheable(response: Response): boolean {
// Non mettere in cache risposte con dati personali o Set-Cookie
if (response.headers.has('Set-Cookie')) return false;
if (response.headers.get('Cache-Control')?.includes('private')) return false;
if (response.headers.get('Cache-Control')?.includes('no-store')) return false;
return true;
}
function setCacheHeaders(response: Response, maxAge: number): Response {
const headers = new Headers(response.headers);
headers.set('Cache-Control', `public, max-age=${maxAge}, s-maxage=${maxAge}`);
// Rimuovi header che potrebbero impedire il caching
headers.delete('Set-Cookie');
return new Response(response.body, { status: response.status, headers });
}
interface Env {}
キャッシュヘッダー: s-maxage、stale-while-revalidate、stale-if-error
Cloudflare は、 標準のキャッシュ制御ヘッダー そしてそれを拡張します 意味。これらのガイドラインを理解することが不可欠です。
| 指令 | 意味 | Esempio |
|---|---|---|
max-age=N |
ブラウザと CDN の TTL (N 秒) | max-age=300 |
s-maxage=N |
CDN/プロキシのみの TTL (Cloudflare の max-age をオーバーライドします) | s-maxage=3600, max-age=60 |
stale-while-revalidate=N |
再検証中に古いものを提供する追加の秒数 | s-maxage=60, stale-while-revalidate=300 |
stale-if-error=N |
オリジンがエラーを返した場合に古いものを提供する秒数 | stale-if-error=86400 |
no-store |
いかなる状況でもキャッシュしないでください | 機密データの場合 |
private |
ブラウザのみをキャッシュし、CDN はキャッシュしません | 認証された応答の場合 |
// Esempio: API con s-maxage e stale-while-revalidate via header
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Routing con TTL differenziati per tipo di risorsa
if (url.pathname.startsWith('/api/products')) {
return fetchWithCacheHeaders(request, {
sMaxAge: 300, // 5 minuti fresh in CDN
staleWhileRevalidate: 3600, // 1 ora stale accettabile
staleIfError: 86400, // 1 giorno stale in caso di errore origin
});
}
if (url.pathname.startsWith('/api/user')) {
// Dati utente: non cacheare in CDN
return fetchWithCacheHeaders(request, {
sMaxAge: 0,
private: true,
});
}
if (url.pathname.startsWith('/static')) {
// Asset statici: cache aggressiva
return fetchWithCacheHeaders(request, {
sMaxAge: 31536000, // 1 anno
immutable: true,
});
}
return fetch(request);
},
};
interface CacheOptions {
sMaxAge?: number;
staleWhileRevalidate?: number;
staleIfError?: number;
private?: boolean;
immutable?: boolean;
}
async function fetchWithCacheHeaders(
request: Request,
options: CacheOptions
): Promise<Response> {
const response = await fetch(request);
const headers = new Headers(response.headers);
let cacheControl = '';
if (options.private) {
cacheControl = 'private, no-store';
} else {
const parts: string[] = ['public'];
if (options.sMaxAge !== undefined) parts.push(`s-maxage=${options.sMaxAge}`);
if (options.staleWhileRevalidate) parts.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
if (options.staleIfError) parts.push(`stale-if-error=${options.staleIfError}`);
if (options.immutable) parts.push('immutable');
cacheControl = parts.join(', ');
}
headers.set('Cache-Control', cacheControl);
return new Response(response.body, { status: response.status, headers });
}
interface Env {}
無効化: URL、タグ、およびプレフィックスのパージ
cache.delete(request) ワーカーが実行されているローカル PoP でのみキャッシュを削除します。
キャッシュを無効にするには 世界中のすべてのPoPにわたって、APIを使用する必要があります
CloudflareゾーンのパージREST。これはコンテンツ管理の正しいメカニズムです
そして展開します。
// Invalidation globale tramite Cloudflare API
// Da usare tipicamente da un webhook CMS o da un Worker admin
interface PurgeOptions {
files?: string[]; // URL specifici
tags?: string[]; // Cache-Tag headers
prefixes?: string[]; // URL prefix
hosts?: string[]; // Tutti gli URL di un host
}
async function purgeCloudflareCache(
zoneId: string,
apiToken: string,
options: PurgeOptions
): Promise<void> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(options),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Purge failed: ${JSON.stringify(error)}`);
}
}
// Worker che funge da webhook per invalidazione CMS
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
// Verifica il secret del webhook
const secret = request.headers.get('X-Webhook-Secret');
if (secret !== env.WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
const body = await request.json() as WebhookPayload;
// Invalida le URL specifiche aggiornate dal CMS
if (body.type === 'post.updated') {
await purgeCloudflareCache(env.ZONE_ID, env.CF_API_TOKEN, {
files: [
`https://example.com/blog/${body.slug}`,
`https://example.com/api/posts/${body.id}`,
`https://example.com/sitemap.xml`,
],
});
}
// Invalida per tag (richiede Cache-Tag header sulle risposte origin)
if (body.type === 'category.updated') {
await purgeCloudflareCache(env.ZONE_ID, env.CF_API_TOKEN, {
tags: [`category-${body.categorySlug}`],
});
}
return new Response(JSON.stringify({ purged: true }), {
headers: { 'Content-Type': 'application/json' },
});
},
};
interface WebhookPayload {
type: string;
id?: string;
slug?: string;
categorySlug?: string;
}
interface Env {
ZONE_ID: string;
CF_API_TOKEN: string;
WEBHOOK_SECRET: string;
}
キャッシュタグ: セマンティック無効化
I キャッシュタグ これらは無効化のための最も強力なメカニズムです
選択的。ヘッダーを追加することで機能します Cache-Tag 答えへ:
各応答には複数のタグを含めることができ、すべての URL を無効にすることができます
単一の API 呼び出しでタグに関連付けられます。
// Origin server o Worker che aggiunge Cache-Tag alle risposte
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const response = await fetch(request);
const headers = new Headers(response.headers);
// Aggiungi tag semantici basati sul contenuto
const tags: string[] = [];
// Tag per tipo di risorsa
if (url.pathname.startsWith('/api/products')) {
const productId = url.pathname.split('/')[3];
tags.push('products'); // Invalida tutti i prodotti
if (productId) tags.push(`product-${productId}`); // Invalida questo prodotto specifico
}
if (url.pathname.startsWith('/api/categories')) {
const catId = url.pathname.split('/')[3];
tags.push('categories');
if (catId) tags.push(`category-${catId}`);
}
// Tag per versione dell'API
const apiVersion = url.pathname.split('/')[2];
if (apiVersion?.startsWith('v')) {
tags.push(`api-${apiVersion}`);
}
if (tags.length > 0) {
// Cache-Tag: lista separata da virgole, max 16KB
headers.set('Cache-Tag', tags.join(','));
}
return new Response(response.body, { status: response.status, headers });
},
};
// Esempio di invalidazione per tag dopo un aggiornamento:
// POST /api/admin/purge
// { "tags": ["product-123", "categories"] }
// Invalida tutte le URL che hanno Cache-Tag: product-123 o categories
interface Env {}
高度なパターン: KV を L2 としてキャッシュする
キャッシュ API には、プログラムからアクセスできないという重要な制限があります。 任意の読み取り/書き込み、HTTP リクエストのみ。他のパターンについては 複雑 (調整された無効化、サーキット ブレーカー、オブジェクト キャッシュなど) 非HTTP)、使用します L2 キャッシュとしてのワーカー KV.
// Cache a due livelli: Cache API (L1, HTTP) + KV (L2, programmabile)
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const cacheKey = buildCacheKey(url);
// L1: Cache API (piu veloce, locale al PoP)
const l1Cache = caches.default;
const l1Hit = await l1Cache.match(request);
if (l1Hit) {
return addHeader(l1Hit, 'X-Cache', 'L1-HIT');
}
// L2: KV Store (globale, programmabile, con TTL gestito da KV)
const kvCached = await env.API_CACHE.getWithMetadata<CacheMetadata>(cacheKey);
if (kvCached.value !== null) {
const { value, metadata } = kvCached;
// Ricostruisci una Response dalla stringa KV
const cachedResponse = new Response(value, {
headers: {
'Content-Type': metadata?.contentType ?? 'application/json',
'Cache-Control': 'public, max-age=60',
'X-Cache': 'L2-HIT',
'X-Cached-At': String(metadata?.cachedAt ?? 0),
},
});
// Popola anche L1 per richieste successive nello stesso PoP
ctx.waitUntil(l1Cache.put(request, cachedResponse.clone()));
return cachedResponse;
}
// MISS su entrambi i livelli: fetch dall'origin
const originResponse = await fetch(request);
if (originResponse.ok) {
const body = await originResponse.text();
const contentType = originResponse.headers.get('Content-Type') ?? 'application/json';
const metadata: CacheMetadata = {
cachedAt: Date.now(),
contentType,
url: url.toString(),
};
// Salva in KV con TTL di 5 minuti
ctx.waitUntil(
env.API_CACHE.put(cacheKey, body, {
expirationTtl: 300,
metadata,
})
);
// Salva anche in L1
const toL1 = new Response(body, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=60',
'X-Cache': 'MISS',
},
});
ctx.waitUntil(l1Cache.put(request, toL1));
return new Response(body, {
headers: {
'Content-Type': contentType,
'X-Cache': 'MISS',
},
});
}
return addHeader(originResponse, 'X-Cache', 'BYPASS-ERROR');
},
};
function buildCacheKey(url: URL): string {
// Normalizza l'URL per la cache key (rimuovi query params non semantici)
const params = new URLSearchParams(url.searchParams);
params.delete('utm_source');
params.delete('utm_medium');
params.delete('_t'); // timestamp di cache-busting
params.sort(); // ordine deterministico
return `${url.pathname}?${params.toString()}`;
}
function addHeader(response: Response, key: string, value: string): Response {
const headers = new Headers(response.headers);
headers.set(key, value);
return new Response(response.body, { status: response.status, headers });
}
interface CacheMetadata {
cachedAt: number;
contentType: string;
url: string;
}
interface Env {
API_CACHE: KVNamespace;
}
キャッシュのウォーミング: キャッシュを事前に設定する
Il キャッシュウォーミング 最初にキャッシュを事前に設定する方法です。 実際のリクエストが到着するため、デプロイメント後のコールド キャッシュの問題が解消されます。 大規模な無効化。これは、Cron トリガーを介してスケジュールされたワーカーで実装されます。
// wrangler.toml - Cron Trigger per cache warming
// [triggers]
// crons = ["*/15 * * * *"] # Ogni 15 minuti
// worker.ts - Cache Warming Worker
const URLS_TO_WARM = [
'https://api.example.com/products?featured=true',
'https://api.example.com/categories',
'https://api.example.com/homepage',
'https://api.example.com/navigation',
];
export default {
// Scheduled handler per Cron Trigger
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
console.log(`Cache warming started at ${new Date(event.scheduledTime).toISOString()}`);
const results = await Promise.allSettled(
URLS_TO_WARM.map(url => warmUrl(url))
);
const succeeded = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
console.log(`Cache warming complete: ${succeeded} success, ${failed} failed`);
},
async fetch(request: Request): Promise<Response> {
return new Response('Cache Warmer Worker', { status: 200 });
},
};
async function warmUrl(url: string): Promise<void> {
// Forza bypass della cache aggiungendo header speciale
// (da gestire lato Worker principale con whitelist IP o secret)
const response = await fetch(url, {
headers: {
'Cache-Control': 'no-cache', // Forza revalidazione
'X-Cache-Warm': 'true',
},
});
if (!response.ok) {
throw new Error(`Failed to warm ${url}: ${response.status}`);
}
}
interface Env {}
interface ScheduledEvent {
scheduledTime: number;
cron: string;
}
キャッシュのデバッグとモニタリング
Cloudflareは、キャッシュのステータスを理解するために、応答で診断ヘッダーを公開します。
最も重要なことは CF-Cache-Status:
| CFキャッシュステータス | 意味 |
|---|---|
HIT |
Cloudflareキャッシュから提供される |
MISS |
キャッシュされず、オリジンでリクエストされました |
EXPIRED |
キャッシュされているが TTL 期限切れ、オリジンへのリクエスト |
STALE |
古い状態で提供される (再検証中に失効する) |
BYPASS |
キャッシュがバイパスされました (Cookie、Auth ヘッダーなど) |
DYNAMIC |
キャッシュ不可 (動的応答) |
REVALIDATED |
キャッシュはオリジンで検証されました (304 未変更) |
// Worker che logga le metriche di cache su KV Analytics
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const response = await fetch(request);
const cacheStatus = response.headers.get('CF-Cache-Status') ?? 'UNKNOWN';
// Logga la metrica in background
ctx.waitUntil(
logCacheMetric(env, {
url: request.url,
status: cacheStatus,
timestamp: Date.now(),
country: request.cf?.country ?? 'unknown',
datacenter: request.cf?.colo ?? 'unknown',
})
);
return response;
},
};
async function logCacheMetric(env: Env, metric: CacheMetric): Promise<void> {
// Aggrega per finestre di 1 minuto
const minuteKey = `metrics:${Math.floor(metric.timestamp / 60000)}:${metric.status}`;
const current = parseInt(await env.METRICS_KV.get(minuteKey) ?? '0');
await env.METRICS_KV.put(minuteKey, String(current + 1), { expirationTtl: 3600 });
}
interface CacheMetric {
url: string;
status: string;
timestamp: number;
country: string;
datacenter: string;
}
interface Env {
METRICS_KV: KVNamespace;
}
結論と次のステップ
Cloudflare Workers Cache API は、CDN をパッシブツールからコンポーネントに変換します 建築分野で活躍中。この記事で説明されている戦略を使用すると、次のことが可能になります。 負荷を軽減する、粒度が高く、セマンティックな、無効化可能なキャッシュ層 一般的なパブリック API ワークロードの場合、オリジンで 70 ~ 90%。
覚えておくべき重要なポイント: ctx.waitUntil() 応答を妨げないように
クライアントに対して、カスタム キャッシュ キーを構築してさまざまなコンテキストを分離し、キャッシュ タグを使用します
セマンティック無効化の場合は、キャッシュ API と KV を組み合わせて、より複雑なパターンを実現します。
シリーズの次の記事
- 第9条: 地元の労働者の検査 — Miniflare、Vitest e Wrangler 開発: デプロイを行わずにワーカーの単体テストと統合テストを作成する方法。
- 第10条: エッジのフルスタック アーキテクチャ — ケーススタディ ゼロから本番環境へ: GitHub Actions 上の Workers + D1 + R2 および CI/CD を備えた完全な REST API。







