LESSON 40分

「最も速いリクエストは、オリジンサーバーに到達しないリクエストだ」と佐藤CTOは言った。「CDN を正しく設計すれば、トラフィックの80%以上をエッジで処理できる。オリジンの負荷が劇的に下がるし、ユーザー体験も向上する。」

1. CDN アーキテクチャ

CDN の基本構成

ユーザー → エッジロケーション(PoP) → オリジンシールド → オリジンサーバー
           (東京/大阪)              (リージョナルキャッシュ)   (ap-northeast-1)
レイヤー役割レイテンシ
エッジ (PoP)ユーザーに最も近いキャッシュ1-20ms
オリジンシールドリージョナルキャッシュ、オリジンへのリクエスト集約20-50ms
オリジンサーバーコンテンツの真のソース50-200ms

主要 CDN サービスの比較

機能CloudFrontFastlyCloudflare
エッジロケーション数450+90+310+
パージ速度数秒〜数分< 150ms< 30ms
エッジコンピューティングLambda@Edge, CloudFront FunctionsCompute@Edge (Wasm)Workers
設定方法AWS Console / IaCVCL / APIDashboard / 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-Controls-maxage でCDN制御、stale-while-revalidate で可用性向上
キャッシュキーヘッダー/クエリ/Cookieの最小化でヒット率向上
パージ戦略パスベース/サロゲートキー/バージョニング
エッジコンピューティングCDN 上で認証・変換・ルーティング

チェックリスト

  • CDN の3層構造を説明できる
  • Cache-Control ヘッダーの主要ディレクティブを使い分けられる
  • stale-while-revalidate の動作を理解した
  • キャッシュキーの設計原則を知っている
  • パージ戦略を適切に選択できる

次のステップへ

CDN レベルのキャッシュ戦略を学んだ。次は アプリケーションレベルのキャッシュ を学び、多層キャッシュ戦略を完成させよう。

推定読了時間: 40分