CDNアーキテクチャ
注: この文書は英語版からの翻訳です。コードブロックおよびMermaidダイアグラムは原文のまま保持しています。
TL;DR
CDN(Content Delivery Network)は、地理的に分散したエッジサーバーにコンテンツを配信し、静的および動的コンテンツをユーザーの近くにキャッシュします。これにより、レイテンシの削減、オリジンサーバーの負荷軽減、可用性の向上を実現します。現代のCDNはエッジコンピューティング、セキュリティ機能、リアルタイム最適化も提供しています。
なぜCDNが必要なのか?
CDNなしの場合:
CDNありの場合:
CDNアーキテクチャ概要
PoP = Point of Presence(接続拠点)
CDNリクエストフロー
# Edge server configuration with caching and origin fetch
# Defines a shared memory cache zone: 10MB key space, 10GB storage, inactive eviction at 60m
proxy_cache_path /var/cache/nginx/cdn
levels=1:2 # Two-level directory hash for cache files
keys_zone=cdn_cache:10m # 10MB shared memory for cache keys
max_size=10g # Maximum disk space for cached responses
inactive=60m # Evict entries not accessed in 60 minutes
use_temp_path=off; # Write directly to cache dir (avoid extra copy)
server {
listen 443 ssl http2;
server_name cdn.example.com;
# Activate the cache zone defined above
proxy_cache cdn_cache;
# Cache key: scheme + host + URI covers most variations
proxy_cache_key "$scheme$host$request_uri";
# Forward requests to the origin server on cache MISS or STALE
location / {
proxy_pass https://origin.example.com;
# TTL rules by upstream status code
proxy_cache_valid 200 302 10m; # Cache successful responses for 10 minutes
proxy_cache_valid 301 1h; # Permanent redirects cached longer
proxy_cache_valid 404 1m; # Cache 404s briefly to protect origin
# Bypass cache when client sends Cache-Control: no-cache
proxy_cache_bypass $http_cache_control;
# Serve stale content while revalidating in the background
proxy_cache_use_stale error timeout updating
http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
# Collapse concurrent requests for the same uncached resource into one origin fetch
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
# Expose cache status to the client via response header
add_header X-Cache-Status $upstream_cache_status always;
add_header X-Edge-Location "Tokyo" always;
}
}$upstream_cache_status が返すキャッシュステータス値:
| ヘッダー値 | 意味 |
|---|---|
HIT | キャッシュから提供 |
MISS | キャッシュになし、オリジンから取得 |
STALE | 期限切れエントリを再検証中に提供 |
BYPASS | キャッシュをスキップ(例: Cache-Control: no-cache) |
REVALIDATED | 条件付きリクエストでオリジンとの鮮度を確認済み |
# Cache HIT — asset served from the edge, 120 seconds old
$ curl -I https://cdn.example.com/images/hero.jpg
HTTP/2 200
content-type: image/jpeg
cache-control: public, max-age=600
age: 120
x-cache-status: HIT
x-edge-location: Tokyo
# Cache MISS — first request, fetched from origin
$ curl -I https://cdn.example.com/images/new-banner.jpg
HTTP/2 200
content-type: image/jpeg
cache-control: public, max-age=600
age: 0
x-cache-status: MISS
x-edge-location: Tokyo
# Cache BYPASS — client explicitly skipped cache
$ curl -I -H "Cache-Control: no-cache" https://cdn.example.com/images/hero.jpg
HTTP/2 200
content-type: image/jpeg
cache-control: public, max-age=600
age: 0
x-cache-status: BYPASS
x-edge-location: Tokyoキャッシング戦略
Cache-Controlヘッダー
server {
listen 443 ssl http2;
server_name cdn.example.com;
# ── Static assets: immutable, cache for 1 year ─────────────────────
# Versioned filenames (app.a1b2c3.js) make long TTLs safe.
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
proxy_pass https://origin.example.com;
proxy_cache cdn_cache;
proxy_cache_valid 200 365d;
# public → allow CDN and browser caching
# max-age=31536000 → 1 year in seconds
# immutable → tell browsers: never revalidate
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# ── API responses: short CDN TTL + stale fallbacks ─────────────────
# s-maxage overrides max-age for shared caches (CDN) only.
# stale-while-revalidate lets the CDN serve stale while refreshing.
# stale-if-error serves stale if origin returns 5xx.
location /api/ {
proxy_pass https://origin.example.com;
proxy_cache cdn_cache;
proxy_cache_valid 200 60s;
add_header Cache-Control "public, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400" always;
# nginx equivalent of stale-if-error: serve stale on upstream failures
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
proxy_cache_background_update on; # revalidate in background (stale-while-revalidate)
}
# ── User-specific data: never cache on CDN ─────────────────────────
# private → only the end-user's browser may cache
# no-store → CDN must not store a copy at all
location /api/me {
proxy_pass https://origin.example.com;
proxy_no_cache 1; # do not write to cache
proxy_cache_bypass 1; # do not read from cache
add_header Cache-Control "private, no-store, max-age=0" always;
}
}# Verify Cache-Control headers for each content type
# Static asset — long-lived, immutable
$ curl -I https://cdn.example.com/static/app.a1b2c3.js
HTTP/2 200
content-type: application/javascript
cache-control: public, max-age=31536000, immutable
x-cache-status: HIT
age: 8640
# API response — short CDN TTL with stale fallbacks
$ curl -I https://cdn.example.com/api/products
HTTP/2 200
content-type: application/json
cache-control: public, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400
x-cache-status: HIT
age: 45
# User-specific data — private, never cached by CDN
$ curl -I -H "Authorization: Bearer tok_xxx" https://cdn.example.com/api/me
HTTP/2 200
content-type: application/json
cache-control: private, no-store, max-age=0
x-cache-status: BYPASSキャッシュキー設計
# Cache key design — include request variations so different
# representations get their own cache entry.
# Default cache key uses scheme + host + URI
proxy_cache_key "$scheme$host$request_uri";
location ~* \.(jpg|png|webp)$ {
proxy_pass https://origin.example.com;
proxy_cache cdn_cache;
proxy_cache_valid 200 1h;
# ── Vary by Accept header (WebP vs JPEG) ──────────────────────
# Origin sends `Vary: Accept` so the CDN stores one entry per
# Accept value. Extend the cache key to match.
proxy_cache_key "$scheme$host$request_uri$http_accept";
# ── Vary by device pixel ratio and width query param ──────────
# For responsive images, include DPR header and width param
# so each device resolution gets its own cached variant.
proxy_cache_key "$scheme$host$request_uri|Accept=$http_accept|DPR=$http_dpr|width=$arg_width";
# Pass variation headers upstream so origin can respond correctly
proxy_set_header Accept $http_accept;
proxy_set_header DPR $http_dpr;
}# Two requests for the same image produce different cache entries:
# WebP-capable browser at 2x resolution
$ curl -I -H "Accept: image/webp" -H "DPR: 2" "https://cdn.example.com/images/hero.jpg?width=800"
HTTP/2 200
content-type: image/webp
vary: Accept, DPR
x-cache-status: MISS
# Cache key: "https|cdn.example.com|/images/hero.jpg?width=800|Accept=image/webp|DPR=2|width=800"
# JPEG-only browser at 1x resolution
$ curl -I -H "Accept: image/jpeg" -H "DPR: 1" "https://cdn.example.com/images/hero.jpg?width=400"
HTTP/2 200
content-type: image/jpeg
vary: Accept, DPR
x-cache-status: MISS
# Cache key: "https|cdn.example.com|/images/hero.jpg?width=400|Accept=image/jpeg|DPR=1|width=400"オリジンシールド
シールドなしの場合(オリジンはキャッシュミスごとにN回のリクエストを受信):
シールドありの場合(オリジンはキャッシュミスごとに1回のリクエストのみ受信):
# Origin Shield configuration
# The shield is a mid-tier nginx proxy that sits between edge PoPs and the origin.
# It coalesces concurrent requests for the same resource so the origin sees only one.
proxy_cache_path /var/cache/nginx/shield
levels=1:2
keys_zone=shield_cache:20m # Larger key zone — aggregates all edge traffic
max_size=50g
inactive=24h # Keep entries longer than edge caches
use_temp_path=off;
server {
listen 443 ssl http2;
server_name shield.internal.example.com;
proxy_cache shield_cache;
proxy_cache_key "$scheme$host$request_uri";
location / {
proxy_pass https://origin.example.com;
# Cache durations mirror edge, but shield holds entries longer
proxy_cache_valid 200 302 30m;
proxy_cache_valid 301 6h;
proxy_cache_valid 404 5m;
# ── Request coalescing ────────────────────────────────────
# proxy_cache_lock ensures only ONE request per cache key
# reaches the origin. All other concurrent requests wait for
# the first to complete, then share its cached response.
proxy_cache_lock on;
proxy_cache_lock_age 10s; # Max time to wait before sending another request
proxy_cache_lock_timeout 15s; # Max time a waiting request will block
# Serve stale on origin failure — shield is the last line of defense
proxy_cache_use_stale error timeout updating
http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
add_header X-Shield-Cache $upstream_cache_status always;
}
}# Edge servers point to the shield instead of directly to origin:
# (in edge server config)
# proxy_pass https://shield.internal.example.com;
# First edge request — shield fetches from origin (MISS)
$ curl -sI https://cdn.example.com/images/hero.jpg | grep -i x-
x-cache-status: MISS
x-shield-cache: MISS
# Second edge request from a different PoP — shield serves cached (HIT)
$ curl -sI https://cdn.example.com/images/hero.jpg | grep -i x-
x-cache-status: MISS
x-shield-cache: HIT
# Origin received only 1 request even though 2 PoPs askedキャッシュ無効化
URLによるパージ
# ── Purge specific URLs ───────────────────────────────────────────────
$ curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"files": [
"https://cdn.example.com/images/hero.jpg",
"https://cdn.example.com/css/main.css"
]}'
# {"success": true, "result": {"id": "abc123..."}}
# ── Purge by URL prefix ──────────────────────────────────────────────
$ curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"prefixes": ["https://cdn.example.com/images/"]}'
# ── Purge by cache tag (most efficient — surgical invalidation) ──────
# Origin sets: Cache-Tag: product-123, category-electronics
$ curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"tags": ["product-123"]}'
# Invalidates all pages tagged with product-123, leaves everything else intact
# ── Nuclear option — purge everything ────────────────────────────────
$ curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"purge_everything": true}'# Origin server: attach cache tags to responses so purges can target them
location /products/ {
proxy_pass http://backend;
# Tag every product page with its product ID and category.
# When product-123 changes, purge the "product-123" tag
# instead of listing every URL that includes it.
add_header Cache-Tag "product-$arg_id, category-$arg_cat" always;
add_header Cache-Control "public, s-maxage=3600" always;
}キャッシュバージョニング(URLベースの無効化)
# Cache versioning via content-hashed filenames
# Build tools (webpack, vite) produce filenames like app.a1b2c3d4.js.
# When the file changes, the hash changes → new URL → automatic cache bust.
# Match versioned static assets (contain a hash segment in the filename)
location ~* \.(js|css)$ {
proxy_pass https://origin.example.com;
proxy_cache cdn_cache;
# Safe to cache for 1 year because the filename itself changes on update
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# Strip query-string version params so old ?v= patterns still get cached
# consistently — the filename hash is the canonical version key.
location ~* ^/static/ {
proxy_pass https://origin.example.com;
proxy_cache cdn_cache;
proxy_cache_valid 200 365d;
# Ignore ?v= query strings in cache key — filename hash is sufficient
proxy_cache_key "$scheme$host$uri";
add_header Cache-Control "public, max-age=31536000, immutable" always;
}# In HTML templates:
# <script src="/static/app.a1b2c3d4.js"></script>
#
# When app.js changes, the build produces app.ef56gh78.js — a brand-new URL.
# The old cached entry expires naturally; no purge needed.
$ curl -I https://cdn.example.com/static/app.a1b2c3d4.js
HTTP/2 200
content-type: application/javascript
cache-control: public, max-age=31536000, immutable
x-cache-status: HIT
age: 259200エッジコンピューティング
# ── A/B testing at the edge ───────────────────────────────────────────
# Route users to variant a or b based on a cookie.
# Sticky: once assigned, the cookie keeps the user on the same variant.
map $cookie_variant $ab_variant {
"b" "b"; # Existing cookie → honour it
default "a"; # No cookie → default to variant a
}
server {
listen 443 ssl http2;
server_name cdn.example.com;
location / {
# Rewrite request path to /<variant>/original/path before forwarding
rewrite ^(.*)$ /$ab_variant$1 break;
proxy_pass https://origin.example.com;
# Tag the response so downstream can see which variant was served
add_header X-Variant $ab_variant always;
# Set the variant cookie if the client doesn't already have one
# (nginx evaluates $cookie_variant from the request)
if ($cookie_variant = "") {
add_header Set-Cookie "variant=$ab_variant; Max-Age=86400; Path=/" always;
}
}
}
# ── Geolocation-based origin routing ──────────────────────────────────
# Use the GeoIP2 module to select the closest origin.
map $geoip2_data_country_code $geo_origin {
CN "apac-origin.example.com";
HK "apac-origin.example.com";
TW "apac-origin.example.com";
DE "eu-origin.example.com";
FR "eu-origin.example.com";
GB "eu-origin.example.com";
default "us-origin.example.com";
}
server {
listen 443 ssl http2;
server_name cdn-geo.example.com;
# ── Bot detection — return lightweight response for crawlers ──────
location / {
if ($http_user_agent ~* "(Googlebot|Bingbot|curl|wget)") {
# Serve a pre-rendered or stripped-down page for bots
rewrite ^ /bot-render$request_uri break;
}
proxy_pass https://$geo_origin;
proxy_set_header Host $host;
}
# ── Image optimization — serve correct format & size at the edge ──
location ~* \.(jpg|png|webp)$ {
proxy_pass https://$geo_origin;
proxy_cache cdn_cache;
proxy_cache_valid 200 1h;
# Vary cache by Accept (WebP support) and DPR (pixel density)
proxy_cache_key "$scheme$host$request_uri|$http_accept|$http_dpr";
# Pass client hints to the image-resizing origin
proxy_set_header Accept $http_accept;
proxy_set_header DPR $http_dpr;
proxy_set_header Width $arg_width;
}
}# Verify A/B variant assignment
$ curl -I https://cdn.example.com/landing
HTTP/2 200
x-variant: a
set-cookie: variant=a; Max-Age=86400; Path=/
# Subsequent request with cookie — same variant, no new cookie
$ curl -I -b "variant=a" https://cdn.example.com/landing
HTTP/2 200
x-variant: a
# Geo-routed request — origin selected by country
$ curl -I https://cdn-geo.example.com/products
HTTP/2 200
x-cache-status: MISS
# Request forwarded to eu-origin.example.com (based on client IP in GB)マルチCDNアーキテクチャ
# Multi-CDN routing via nginx as a traffic-splitting load balancer.
# Weighted upstreams distribute traffic across CDN providers.
# Health checks automatically remove unhealthy providers.
upstream cdn_backends {
# ── Weighted traffic split ────────────────────────────────────
# CloudFlare: 60%, Fastly: 25%, Akamai: 15%
server cf.example.com weight=60;
server fastly.example.com weight=25;
server akamai.example.com weight=15;
# ── Health checks (nginx Plus / OpenResty) ────────────────────
# Probe each backend every 10s; mark as down after 3 failures;
# re-enable after 2 consecutive successes.
# Traffic auto-redistributes among healthy backends.
health_check interval=10 fails=3 passes=2 uri=/health;
# ── Failover ──────────────────────────────────────────────────
# If the selected backend fails, retry on the next one
# (up to 2 retries before returning an error to the client).
}
server {
listen 443 ssl http2;
server_name www.example.com;
location / {
proxy_pass https://cdn_backends;
proxy_set_header Host $host;
# Retry on the next upstream if the chosen one fails
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2; # Max 2 failover attempts
proxy_next_upstream_timeout 5s; # Time budget for retries
# Pass the selected backend name for observability
add_header X-CDN-Backend $upstream_addr always;
}
}# Verify weighted routing — repeated requests hit different backends
$ for i in $(seq 1 5); do
curl -sI https://www.example.com/ | grep x-cdn-backend
done
x-cdn-backend: 104.16.132.229:443 # CloudFlare
x-cdn-backend: 104.16.132.229:443 # CloudFlare
x-cdn-backend: 151.101.1.57:443 # Fastly
x-cdn-backend: 104.16.132.229:443 # CloudFlare
x-cdn-backend: 23.215.0.136:443 # Akamai
# When a backend goes down, health checks remove it automatically.
# All traffic redistributes among remaining healthy providers.パフォーマンスメトリクス
# ── nginx logging for CDN metrics ─────────────────────────────────────
# Custom log format capturing cache status, latency, and upstream timing.
# Feed these logs into Prometheus/Grafana or any log aggregator.
log_format cdn_metrics
'$remote_addr - [$time_local] '
'"$request" $status $body_bytes_sent '
'cache=$upstream_cache_status ' # HIT, MISS, BYPASS, etc.
'edge_time=${request_time}s ' # Total time at edge
'origin_time=${upstream_response_time}s ' # Time waiting on origin
'bytes_sent=$bytes_sent';
access_log /var/log/nginx/cdn_access.log cdn_metrics;# ── Collect metrics via Cloudflare API ────────────────────────────────
# Cache hit ratio, bandwidth, error rates (last 1 hour)
$ curl -s "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/analytics/dashboard?since=-60" \
-H "Authorization: Bearer ${CF_API_TOKEN}" | jq '.result.totals'
{
"requests": {
"all": 1250000,
"cached": 1137500, # 91% cache hit ratio (target: >90%)
"uncached": 112500
},
"bandwidth": {
"all": 52428800000, # ~52 GB total
"cached": 47185920000, # ~47 GB served from cache
"uncached": 5242880000 # ~5 GB pulled from origin
},
"threats": { "all": 342 },
"pageViews": { "all": 890000 },
"uniques": { "all": 245000 }
}
# ── Latency percentiles via curl timing ───────────────────────────────
# Measure real edge latency from the client's perspective.
$ curl -o /dev/null -s -w "\
dns: %{time_namelookup}s\n\
connect: %{time_connect}s\n\
ttfb: %{time_starttransfer}s\n\
total: %{time_total}s\n\
http_code: %{http_code}\n" \
https://cdn.example.com/images/hero.jpg
dns: 0.012s
connect: 0.025s
ttfb: 0.038s # Time to first byte — edge latency (target: <50ms)
total: 0.052s
http_code: 200
# ── Cost savings calculation ──────────────────────────────────────────
# With 91% cache hit ratio and $0.09/GB origin egress:
# Bandwidth saved: 47 GB × $0.09 = $4.23/hour saved
# Origin load: reduced to 9% of total traffic
# Effective origin capacity: 1 / (1 - 0.91) ≈ 11× multiplier主要なメトリクスと目標値:
| メトリクス | 目標値 | 計測方法 |
|---|---|---|
| キャッシュヒット率 | > 90% | ログの $upstream_cache_status またはCDN分析API |
| エッジレイテンシ p50 | < 50ms | curl -w '%{time_starttransfer}' またはRUM |
| エッジレイテンシ p99 | < 200ms | $request_time のログパーセンタイル |
| エラー率 (4xx) | < 1% | nginx ログの $status |
| エラー率 (5xx) | < 0.1% | nginx ログの $status + CDNダッシュボード |
CDNプロバイダー比較
| 機能 | CloudFlare | Fastly | Akamai | AWS CloudFront |
|---|---|---|---|---|
| グローバルPoP | 285+ | 80+ | 4000+ | 450+ |
| エッジコンピュート | Workers | Compute@Edge | EdgeWorkers | Lambda@Edge |
| 即時パージ | 対応 | 対応 (<150ms) | 非対応 (~5s) | 対応 (~1min) |
| 無料枠 | 充実 | 制限付き | なし | 制限付き |
| WebSocket | 対応 | 対応 | 対応 | 対応 |
| リアルタイムログ | 対応 | 対応 | 対応 | 対応 |
重要なポイント
可能な限りキャッシュする: 静的アセット、APIレスポンス、HTMLページなど、キャッシュを増やすほどパフォーマンスが向上しコストが下がります
適切なTTLを使用する: バージョン付き静的アセットには長いTTL(1年)、動的コンテンツにはstale-while-revalidate付きの短いTTLを使用してください
キャッシュタグを実装する: 無関係なコンテンツをパージすることなく、外科的な無効化を可能にします
オリジンシールドを導入する: 特に複数のPoPでのキャッシュミス時に、オリジンの負荷を劇的に削減します
マルチCDNを検討する: 高可用性に不可欠です。アクティブ-アクティブまたはフェイルオーバー構成を使用してください
エッジコンピューティングを活用する: 認証、A/Bテスト、パーソナライゼーションのロジックをユーザーの近くに配置してください
キャッシュヒット率を監視する: 90%以上を目指し、キャッシュミスの原因となるパターンを調査してください