「最も速いリクエストは、オリジンサーバーに到達しないリクエストだ」と佐藤CTOは言った。「CDN を正しく設計すれば、トラフィックの80%以上をエッジで処理できる。オリジンの負荷が劇的に下がるし、ユーザー体験も向上する。」
1. CDN アーキテクチャ
CDN の基本構成
ユーザー → エッジロケーション(PoP) → オリジンシールド → オリジンサーバー
(東京/大阪) (リージョナルキャッシュ) (ap-northeast-1)
| レイヤー | 役割 | レイテンシ |
|---|---|---|
| エッジ (PoP) | ユーザーに最も近いキャッシュ | 1-20ms |
| オリジンシールド | リージョナルキャッシュ、オリジンへのリクエスト集約 | 20-50ms |
| オリジンサーバー | コンテンツの真のソース | 50-200ms |
主要 CDN サービスの比較
| 機能 | CloudFront | Fastly | Cloudflare |
|---|---|---|---|
| エッジロケーション数 | 450+ | 90+ | 310+ |
| パージ速度 | 数秒〜数分 | < 150ms | < 30ms |
| エッジコンピューティング | Lambda@Edge, CloudFront Functions | Compute@Edge (Wasm) | Workers |
| 設定方法 | AWS Console / IaC | VCL / API | Dashboard / API |
| 料金モデル | リクエスト + 転送量 | リクエスト + 転送量 | プラン制 |
2. Cache-Control ヘッダーの設計
// Cache-Control ヘッダー設計の実装
interface CacheControlConfig {
visibility: 'public' | 'private' | 'no-store';
maxAge?: number; // ブラウザキャッシュ秒数
sMaxAge?: number; // CDN キャッシュ秒数
staleWhileRevalidate?: number;
staleIfError?: number;
noCache?: boolean; // 再検証必須
immutable?: boolean;
mustRevalidate?: boolean;
}
function buildCacheControl(config: CacheControlConfig): string {
const directives: string[] = [];
directives.push(config.visibility);
if (config.noCache) directives.push('no-cache');
if (config.maxAge !== undefined) directives.push(`max-age=${config.maxAge}`);
if (config.sMaxAge !== undefined) directives.push(`s-maxage=${config.sMaxAge}`);
if (config.staleWhileRevalidate !== undefined) {
directives.push(`stale-while-revalidate=${config.staleWhileRevalidate}`);
}
if (config.staleIfError !== undefined) {
directives.push(`stale-if-error=${config.staleIfError}`);
}
if (config.immutable) directives.push('immutable');
if (config.mustRevalidate) directives.push('must-revalidate');
return directives.join(', ');
}
// リソースタイプ別の Cache-Control 設定
const cacheControlPresets: Record<string, CacheControlConfig> = {
// 静的アセット(ハッシュ付きファイル名)
'static-hashed': {
visibility: 'public',
maxAge: 31536000, // 1年
immutable: true,
},
// → "public, max-age=31536000, immutable"
// API レスポンス(頻繁に変わる)
'api-dynamic': {
visibility: 'private',
noCache: true,
maxAge: 0,
mustRevalidate: true,
},
// → "private, no-cache, max-age=0, must-revalidate"
// API レスポンス(短期キャッシュ可能)
'api-cacheable': {
visibility: 'public',
maxAge: 0,
sMaxAge: 60,
staleWhileRevalidate: 300,
staleIfError: 86400,
},
// → "public, max-age=0, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400"
// HTML ページ
'html-page': {
visibility: 'public',
maxAge: 0,
sMaxAge: 300,
staleWhileRevalidate: 600,
},
// → "public, max-age=0, s-maxage=300, stale-while-revalidate=600"
// ユーザー固有データ
'user-specific': {
visibility: 'private',
maxAge: 300,
mustRevalidate: true,
},
// → "private, max-age=300, must-revalidate"
};
3. stale-while-revalidate パターン
// stale-while-revalidate の動作フロー
// 1. キャッシュが新鮮 → そのまま返す
// 2. キャッシュが stale だが SWR 期間内 → stale を返しつつバックグラウンドで更新
// 3. SWR 期間も超過 → オリジンに取りに行く
// Express ミドルウェアでの実装
import { Request, Response, NextFunction } from 'express';
interface SWRCacheEntry {
data: string;
headers: Record<string, string>;
createdAt: number;
maxAge: number;
swrWindow: number;
}
class SWRCacheMiddleware {
private cache = new Map<string, SWRCacheEntry>();
private revalidating = new Set<string>();
middleware() {
return async (req: Request, res: Response, next: NextFunction) => {
if (req.method !== 'GET') return next();
const cacheKey = req.originalUrl;
const entry = this.cache.get(cacheKey);
if (entry) {
const age = (Date.now() - entry.createdAt) / 1000;
if (age < entry.maxAge) {
// 新鮮なキャッシュ
res.set('X-Cache', 'HIT');
res.set('Age', Math.floor(age).toString());
return res.send(entry.data);
}
if (age < entry.maxAge + entry.swrWindow) {
// Stale だが SWR 期間内 → stale を返しつつバックグラウンド更新
res.set('X-Cache', 'STALE');
res.set('Age', Math.floor(age).toString());
if (!this.revalidating.has(cacheKey)) {
this.revalidateInBackground(cacheKey, req);
}
return res.send(entry.data);
}
}
// キャッシュなし or SWR 超過 → オリジンから取得
res.set('X-Cache', 'MISS');
this.captureResponse(cacheKey, res, next);
};
}
private async revalidateInBackground(key: string, req: Request): Promise<void> {
this.revalidating.add(key);
try {
// オリジンサーバーに再取得
const response = await fetch(`http://localhost:${process.env.PORT}${req.originalUrl}`, {
headers: { 'X-Cache-Bypass': 'true' },
});
const data = await response.text();
this.cache.set(key, {
data,
headers: Object.fromEntries(response.headers.entries()),
createdAt: Date.now(),
maxAge: 60,
swrWindow: 300,
});
} finally {
this.revalidating.delete(key);
}
}
private captureResponse(key: string, res: Response, next: NextFunction): void {
const originalSend = res.send;
res.send = ((body: any) => {
this.cache.set(key, {
data: body,
headers: {},
createdAt: Date.now(),
maxAge: 60,
swrWindow: 300,
});
return originalSend.call(res, body);
}) as any;
next();
}
}
4. CDN キャッシュキーの設計
// CloudFront の Cache Policy 設計
// AWS CDK での設定例
/*
const cachePolicy = new cloudfront.CachePolicy(this, 'ApiCachePolicy', {
cachePolicyName: 'api-cache-policy',
defaultTtl: Duration.seconds(60),
maxTtl: Duration.hours(24),
minTtl: Duration.seconds(0),
// キャッシュキーに含めるヘッダー
headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
'Accept',
'Accept-Language',
),
// キャッシュキーに含めるクエリ文字列
queryStringBehavior: cloudfront.CacheQueryStringBehavior.allowList(
'page', 'limit', 'sort', 'category',
),
// キャッシュキーに含めるCookie
cookieBehavior: cloudfront.CacheCookieBehavior.none(),
// 圧縮対応
enableAcceptEncodingGzip: true,
enableAcceptEncodingBrotli: true,
});
*/
// キャッシュヒット率を上げるためのURL正規化
function normalizeUrl(url: string): string {
const parsed = new URL(url);
// クエリパラメータをソート
const params = new URLSearchParams(parsed.search);
const sortedParams = new URLSearchParams(
[...params.entries()].sort(([a], [b]) => a.localeCompare(b))
);
// 不要なパラメータを除外
const ignoredParams = ['utm_source', 'utm_medium', 'utm_campaign', 'fbclid', 'gclid'];
for (const param of ignoredParams) {
sortedParams.delete(param);
}
parsed.search = sortedParams.toString();
return parsed.toString();
}
5. キャッシュパージ戦略
// CDN キャッシュパージの実装パターン
class CdnPurgeManager {
// パターン1: パスベースのパージ
async purgeByPath(paths: string[]): Promise<void> {
// CloudFront Invalidation
/*
await cloudfront.createInvalidation({
DistributionId: DISTRIBUTION_ID,
InvalidationBatch: {
CallerReference: `purge-${Date.now()}`,
Paths: {
Quantity: paths.length,
Items: paths,
},
},
}).promise();
*/
console.log(`Purged ${paths.length} paths`);
}
// パターン2: サロゲートキーベースのパージ(Fastly/Cloudflare)
async purgeBySurrogateKey(keys: string[]): Promise<void> {
// Fastly の場合、Surrogate-Key ヘッダーでタグ付け
// レスポンス時: Surrogate-Key: product-123 category-electronics
// パージ時: そのキーに関連する全キャッシュを一括削除
console.log(`Purged surrogate keys: ${keys.join(', ')}`);
}
// パターン3: バージョニングによるパージ回避
// URL にバージョンを含めることで、パージ不要にする
generateVersionedUrl(path: string, version: string): string {
// /api/v1/products → /api/v1/products?_v=abc123
const url = new URL(path, 'https://example.com');
url.searchParams.set('_v', version);
return url.pathname + url.search;
}
}
コラム: エッジコンピューティングのユースケース
CDN のエッジで処理を実行することで、オリジンの負荷を大幅に削減できる。
CloudFront Functions(軽量、低レイテンシ):
- URL の書き換え・リダイレクト
- ヘッダーの操作
- 認証トークンの検証
- A/B テストのルーティング
Lambda@Edge(フル機能、やや高レイテンシ):
- 動的なコンテンツ生成
- 画像のリアルタイム変換
- 認証・認可の処理
- レスポンスの動的変更
Cloudflare Workers:
- APIゲートウェイ
- 地域別コンテンツ出し分け
- レート制限
まとめ
| トピック | 要点 |
|---|---|
| CDN 3層構造 | エッジ → オリジンシールド → オリジン |
| Cache-Control | s-maxage でCDN制御、stale-while-revalidate で可用性向上 |
| キャッシュキー | ヘッダー/クエリ/Cookieの最小化でヒット率向上 |
| パージ戦略 | パスベース/サロゲートキー/バージョニング |
| エッジコンピューティング | CDN 上で認証・変換・ルーティング |
チェックリスト
- CDN の3層構造を説明できる
- Cache-Control ヘッダーの主要ディレクティブを使い分けられる
- stale-while-revalidate の動作を理解した
- キャッシュキーの設計原則を知っている
- パージ戦略を適切に選択できる
次のステップへ
CDN レベルのキャッシュ戦略を学んだ。次は アプリケーションレベルのキャッシュ を学び、多層キャッシュ戦略を完成させよう。
推定読了時間: 40分