CDN と静的配信 — エッジで加速する
東京のサーバーから世界を配信する問題
EchoTaskは順調に成長していたが、CTOの田中さんからSlackが届いた。
「ハルト、海外投資家にデモを見せたんだけど、画像が全然表示されなくてね。サンフランシスコからだと30秒近くかかったよ。」
ハルトはすぐに原因を調べた。EchoTaskは東京リージョン(ap-northeast-1)にデプロイされており、静的ファイルもすべてRailsサーバーから直接配信していた。
東京 ↔ サンフランシスコ: 約8,900km
実際のRTT: 150ms〜300ms
JSバンドル: 1.2MB → 海外で8〜12秒かかる
画像: 300KB〜1MB → 20〜30秒
「静的ファイルをサーバーから配信し続けているからだ。CDNを入れれば10分の1以下になる。」
解決策が**CDN(Content Delivery Network)**だ。
CDNの仕組み — DNSからエッジまでの完全なフロー
CDNは世界各地にエッジロケーション(POP: Point of Presence)を持つ。コンテンツをエッジにキャッシュすることで、ユーザーは最寄りのサーバーから取得できる。
CDNを通じたリクエストは6ステップで進む。
- DNS解決: Route53がユーザーの地理的位置から最寄りエッジのIPを返す
- エッジ到達: ブラウザが最寄りのCloudFrontエッジにTCP接続(RTT 8ms程度)
- キャッシュ確認: エッジがURL+キャッシュキーでローカルキャッシュを検索(HIT → 即返却)
- オリジンフェッチ: キャッシュMISSの場合のみオリジン(S3/EC2)から取得(最初の1回だけ)
- エッジ保存:
Cache-Controlの指示に従ってエッジにキャッシュ - 配信: エッジからユーザーへ。以降のリクエストはすべてエッジから即座に返る
INFO
キャッシュヒット率(HIT率)が重要
CDNの効果はキャッシュHIT率で決まる。HIT率90%以上を目標にする。HIT率が低い場合はキャッシュキー(クエリパラメータ・ヘッダー)の設計を見直す。
WARNING
初回アクセスはオリジンまで到達する
「CDNを入れたのに最初のユーザーだけ遅い」という現象はこれが原因。CloudFrontはウォームアップに時間がかかる。事前にOrigin Shieldを有効にするか、デプロイ後にWarmup スクリプトで主要URLをプリフェッチすると改善できる。
CloudFront — AWSのCDN基盤
AWS CloudFrontは600以上のエッジロケーションを世界中に持つ。S3・EC2・ALBをオリジンとして使える。以下のCloudFormation定義でS3+CloudFrontを構築する。
CloudFormation設定
# cloudformation/cdn-stack.yml(抜粋)
Resources:
AssetsBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: echo-task-assets-prod
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
OriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: echo-task-oac
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
# 静的アセット用キャッシュポリシー(1年 / フィンガープリント前提)
StaticAssetsCachePolicy:
Type: AWS::CloudFront::CachePolicy
Properties:
CachePolicyConfig:
Name: echo-task-static-assets
DefaultTTL: 31536000
MaxTTL: 31536000
MinTTL: 0
ParametersInCacheKeyAndForwardedToOrigin:
EnableAcceptEncodingBrotli: true
EnableAcceptEncodingGzip: true
HeadersConfig: { HeaderBehavior: none }
CookiesConfig: { CookieBehavior: none }
QueryStringsConfig: { QueryStringBehavior: none }
# 動的API用キャッシュポリシー(60秒)
ApiCachePolicy:
Type: AWS::CloudFront::CachePolicy
Properties:
CachePolicyConfig:
Name: echo-task-api-cache
DefaultTTL: 60
MaxTTL: 300
MinTTL: 0
ParametersInCacheKeyAndForwardedToOrigin:
EnableAcceptEncodingBrotli: true
EnableAcceptEncodingGzip: true
HeadersConfig:
HeaderBehavior: whitelist
Headers: [Authorization, Accept-Language]
CookiesConfig: { CookieBehavior: none }
QueryStringsConfig:
QueryStringBehavior: allExcept
QueryStrings: [utm_source, utm_medium]
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
HttpVersion: http2and3
Aliases: [assets.echotask.example.com]
ViewerCertificate:
AcmCertificateArn: !Ref Certificate
SslSupportMethod: sni-only
MinimumProtocolVersion: TLSv1.2_2021
Origins:
- Id: S3Origin
DomainName: !GetAtt AssetsBucket.RegionalDomainName
OriginAccessControlId: !Ref OriginAccessControl
S3OriginConfig: { OriginAccessIdentity: '' }
DefaultCacheBehavior:
TargetOriginId: S3Origin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: !Ref StaticAssetsCachePolicy
Compress: true
AllowedMethods: [GET, HEAD, OPTIONS]
CacheBehaviors:
- PathPattern: /api/*
TargetOriginId: S3Origin
ViewerProtocolPolicy: https-only
CachePolicyId: !Ref ApiCachePolicy
AllowedMethods: [GET, HEAD]
PriceClass: PriceClass_200
WebACLId: !Ref WAFWebACLINFO
PriceClassの選び方: PriceClass_100(北米・欧州のみ/最安)、PriceClass_200(北米・欧州・アジア・中東・アフリカ/バランス型)、PriceClass_All(全世界/最高コスト)。日本・アジアユーザーが多いEchoTaskには PriceClass_200 が適切。
Railsのアセットパイプライン連携
ハルトはRailsとCloudFrontを以下のように接続した。
Railsの設定
# config/environments/production.rb
Rails.application.configure do
config.action_controller.asset_host = ENV.fetch('CDN_HOST')
config.public_file_server.enabled = false # CloudFrontから配信するため不要
config.assets.compile = false
config.assets.digest = true # フィンガープリント有効
end
# config/initializers/cdn.rb
# Active StorageのURLもCDN経由に
Rails.application.config.after_initialize do
ActiveStorage::Current.url_options = {
host: ENV.fetch('CDN_HOST', 'assets.echotask.example.com'),
protocol: 'https'
}
endデプロイスクリプト
#!/bin/bash
# scripts/deploy_assets.sh
set -euo pipefail
BUCKET="echo-task-assets-prod"
DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID}"
# Step 1: プリコンパイル
bundle exec rails assets:precompile RAILS_ENV=production
# Step 2: S3へ同期(フィンガープリント済みファイルは1年キャッシュ)
aws s3 sync public/assets s3://$BUCKET/assets \
--exclude "*" --include "*.css" --include "*.js" \
--include "*.png" --include "*.jpg" --include "*.woff2" \
--cache-control "public, max-age=31536000, immutable" --delete
# manifest は毎回更新(キャッシュなし)
aws s3 cp public/assets/.sprockets-manifest-*.json s3://$BUCKET/assets/ \
--cache-control "no-cache, no-store"
# Step 3: manifest のみ Invalidation(フィンガープリント済みは不要)
aws cloudfront create-invalidation \
--distribution-id $DIST_ID --paths "/assets/.sprockets-manifest-*"WARNING
--delete オプションに注意
aws s3 sync --delete はS3にあってローカルにないファイルを削除する。古いデプロイを参照しているユーザーが 404 を受けないよう、ブルーグリーンデプロイと組み合わせる場合は注意が必要。
アセットフィンガープリンティング
「アセットを1年間キャッシュしたら、更新できないのでは?」
この問題はRailsが自動解決してくれる——アセットフィンガープリンティングだ。ファイルの内容からハッシュを生成してURLに埋め込む。ファイルが変わればURLが変わるので、CDNは必ず最新版をオリジンから取得する。
# コンパイル後(ファイル内容のSHA256ハッシュ付き)
application-a1b2c3d4.css # 内容が変われば別ハッシュ → 別URL
application-9z8y7x6w.js
logo-c3d4e5f6.png# Railsが自動でCDN URLを生成(app/views/layouts/application.html.erb)
<%= stylesheet_link_tag 'application' %>
# → <link href="https://assets.echotask.example.com/assets/application-a1b2c3d4.css">
<%= javascript_include_tag 'application', defer: true %>
# → <script src="https://assets.echotask.example.com/assets/application-9z8y7x6w.js" defer>キャッシュヘッダーの設計
CDNのキャッシュ戦略はHTTPヘッダーで制御する。主要ディレクティブの早見表:
| ディレクティブ | 意味 | 使いどころ |
|---|---|---|
public | CDN・ブラウザ両方にキャッシュ可 | 静的アセット全般 |
private | ブラウザのみ(CDN不可) | ユーザー固有のレスポンス |
no-cache | キャッシュするが毎回再検証 | HTMLページ |
s-maxage=N | N秒間CDNキャッシュ(max-ageより優先) | APIレスポンス |
immutable | max-age内は再検証スキップ | フィンガープリント済みファイル |
stale-while-revalidate=N | N秒間は古いキャッシュを使いつつ裏で更新 | ほぼリアルタイムデータ |
# config/environments/production.rb
config.public_file_server.headers = {
'Cache-Control' => 'public, max-age=31536000, immutable'
}# app/controllers/api/v1/tasks_controller.rb
class Api::V1::TasksController < ApplicationController
def show
@task = Task.find(params[:id])
# ETagで条件付きリクエスト → 変更なしなら 304 Not Modified
render json: @task if stale?(@task, public: false)
end
def index
# s-maxage でCDNが60秒キャッシュ、期限後も30秒は古いキャッシュを返しつつ裏で更新
response.headers['Cache-Control'] = 'public, s-maxage=60, stale-while-revalidate=30'
render json: current_user.tasks.recent
end
endINFO
s-maxage で動的APIもCDNキャッシュ
s-maxage=60 でCDNが60秒間APIレスポンスをキャッシュ。stale-while-revalidate=30 を組み合わせると、期限後も30秒は古いキャッシュを返しつつバックグラウンドで更新するため体感速度が向上する。
Lambda@Edge — エッジでのリクエスト変換
CloudFrontのエッジでNode.js関数を実行できるLambda@Edgeを使うと、オリジンを変更せずにリクエスト変換・A/Bテスト・セキュリティヘッダー付与ができる。
セキュリティヘッダーの付与
// lambda/security-headers/index.js(Viewer Response トリガー)
exports.handler = async (event) => {
const response = event.Records[0].cf.response;
const h = response.headers;
// セキュリティヘッダーをエッジで付与(オリジン側の変更不要)
h['strict-transport-security'] = [{ key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload' }];
h['x-content-type-options'] = [{ key: 'X-Content-Type-Options', value: 'nosniff' }];
h['x-frame-options'] = [{ key: 'X-Frame-Options', value: 'DENY' }];
h['content-security-policy'] = [{ key: 'Content-Security-Policy',
value: "default-src 'self'; img-src 'self' data: https://assets.echotask.example.com; " +
"script-src 'self' https://assets.echotask.example.com" }];
return response;
};A/Bテストの実装
// lambda/ab-test/index.js(Viewer Request トリガー)
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
const cookies = parseCookies(request.headers['cookie']);
// 50:50でグループ振り分け(Cookie優先)
const group = cookies['ab-group'] ?? (Math.random() < 0.5 ? 'control' : 'treatment');
// treatment グループは /landing → /landing-v2 に書き換え
if (group === 'treatment' && request.uri.startsWith('/landing')) {
request.uri = request.uri.replace('/landing', '/landing-v2');
}
// オリジンがグループを識別できるようヘッダー付与
request.headers['x-ab-group'] = [{ key: 'X-AB-Group', value: group }];
return request;
};
function parseCookies(headers) {
if (!headers?.length) return {};
return Object.fromEntries(
headers[0].value.split(';').map(c => c.trim().split('='))
);
}WARNING
Lambda@Edgeの制約
コードサイズ: 1MB(Viewer)/ 50MB(Origin)、実行時間: 5秒(Viewer)/ 30秒(Origin)。us-east-1 にデプロイ必須(CloudFrontが自動でグローバルレプリケート)。デプロイに数分かかる。
WebP自動変換 — CloudFront Functions で画像最適化
Lambda@EdgeよりシンプルなCloudFront Functionsを使い、Accept ヘッダーに基づいてWebPを返す。
// cloudfront-functions/image-optimizer.js(Viewer Request トリガー)
function handler(event) {
var request = event.request;
var uri = request.uri;
var accept = request.headers['accept'] ? request.headers['accept'].value : '';
// 画像ファイルかつブラウザがWebP対応なら .webp に書き換え
if (/\.(jpe?g|png|gif)$/i.test(uri) && accept.includes('image/webp')) {
request.uri = uri.replace(/\.(jpe?g|png|gif)$/i, '.webp');
}
return request;
}# app/jobs/webp_conversion_job.rb
# アップロード後にS3へWebPを保存するバックグラウンドジョブ
class WebpConversionJob < ApplicationJob
queue_as :default
def perform(blob_id)
blob = ActiveStorage::Blob.find(blob_id)
return unless blob.image?
blob.open do |file|
webp_path = "#{file.path}.webp"
system("convert #{file.path} -quality 80 #{webp_path}")
Aws::S3::Client.new.put_object(
bucket: ENV['UPLOADS_BUCKET'],
key: "uploads/#{blob.key}.webp",
body: File.open(webp_path),
content_type: 'image/webp',
cache_control: 'public, max-age=31536000, immutable'
)
end
end
endActive StorageとCloudFront
ユーザーがアップロードしたファイルもCloudFront経由で配信できる。
# config/storage.yml
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
region: ap-northeast-1
bucket: echo-task-uploads-prod# app/models/concerns/cdn_attachable.rb
module CdnAttachable
extend ActiveSupport::Concern
CDN_HOST = ENV.fetch('CDN_HOST', 'assets.echotask.example.com')
S3_HOST_RE = /\Ahttps?:\/\/[^\/]*\.s3[^\/]*\.amazonaws\.com/
# Active StorageのS3 URLをCDN URLに変換して返す
def cdn_url(attachment)
return nil unless attachment.attached?
rails_blob_url(attachment, only_path: false)
.sub(S3_HOST_RE, "https://#{CDN_HOST}")
end
end
class User < ApplicationRecord
include CdnAttachable
has_one_attached :avatar
def avatar_cdn_url = cdn_url(avatar)
endINFO
Direct Upload + CloudFront: direct_upload: true でブラウザから直接S3へアップロード。書き込みはS3直接・読み取りはCloudFrontと分けることでRailsサーバーへの帯域負荷をゼロにできる。
Route53 ジオロケーションルーティング
複数リージョンにデプロイする場合、ユーザーの地理的位置に基づいてオリジンを振り分けられる。
# cloudformation/route53-geo.yml(抜粋)
Resources:
# アジアユーザー → 東京CloudFront
AsiaRecord:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostedZone
Name: api.echotask.example.com
Type: A
SetIdentifier: asia
GeoLocation:
ContinentCode: AS
AliasTarget:
DNSName: !GetAtt TokyoCloudFront.DomainName
HostedZoneId: Z2FDTNDATAQYW2 # CloudFront固定値
# 北米ユーザー → バージニアCloudFront
NorthAmericaRecord:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostedZone
Name: api.echotask.example.com
Type: A
SetIdentifier: north-america
GeoLocation:
ContinentCode: NA
AliasTarget:
DNSName: !GetAtt VirginiaCloudFront.DomainName
HostedZoneId: Z2FDTNDATAQYW2
# その他 → 東京(デフォルト)
DefaultRecord:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostedZone
Name: api.echotask.example.com
Type: A
SetIdentifier: default
GeoLocation: { CountryCode: '*' }
AliasTarget:
DNSName: !GetAtt TokyoCloudFront.DomainName
HostedZoneId: Z2FDTNDATAQYW2
TokyoHealthCheck:
Type: AWS::Route53::HealthCheck
Properties:
HealthCheckConfig:
Type: HTTPS
FullyQualifiedDomainName: api-tokyo.echotask.example.com
ResourcePath: /health
RequestInterval: 30
FailureThreshold: 3 # 3回失敗でフェイルオーバーWARNING
ジオロケーションルーティングの落とし穴
ジオロケーション判定はIPアドレスベースのため、VPNや企業プロキシを使うユーザーは意図しないリージョンにルーティングされる。デバッグ時は dig api.echotask.example.com でどのIPが返るか確認する。
WAF統合 — CloudFrontへのセキュリティレイヤー
CloudFrontにAWS WAFを統合することで、DDoS・SQLインジェクション・不正ボットをエッジで遮断できる。
# cloudformation/waf-stack.yml
# ※ CloudFront用WAF Web ACLは us-east-1 リージョンで作成必須
WAFWebACL:
Type: AWS::WAFv2::WebACL
Properties:
Name: echo-task-waf
Scope: CLOUDFRONT
DefaultAction:
Allow: {}
Rules:
# AWSマネージドルール(SQLi / XSSなど一般的な脅威対策)
- Name: AWSManagedRulesCommonRuleSet
Priority: 1
OverrideAction: { None: {} }
Statement:
ManagedRuleGroupStatement:
VendorName: AWS
Name: AWSManagedRulesCommonRuleSet
VisibilityConfig:
SampledRequestsEnabled: true
CloudWatchMetricsEnabled: true
MetricName: CommonRuleSet
# レートリミット(IP単位で5分間1,000リクエストまで)
- Name: RateLimitRule
Priority: 2
Action: { Block: {} }
Statement:
RateBasedStatement:
Limit: 1000
AggregateKeyType: IP
VisibilityConfig:
SampledRequestsEnabled: true
CloudWatchMetricsEnabled: true
MetricName: RateLimit# WAFのブロックログをRailsから参照
# app/services/waf_log_analyzer.rb
class WafLogAnalyzer
def self.blocked_last_hour
Aws::CloudWatchLogs::Client.new(region: 'us-east-1')
.filter_log_events(
log_group_name: '/aws/waf/echo-task',
start_time: 1.hour.ago.to_i * 1000,
filter_pattern: '{ $.action = "BLOCK" }'
).events.map { |e| JSON.parse(e.message) }
end
endINFO
WAFはus-east-1で作成する
CloudFront用WAF Web ACLは必ず us-east-1 リージョンで作成する必要がある(CloudFrontがグローバルサービスのため)。ALBやAPI GatewayにアタッチするWAFは各リージョンで作成できる。
CloudFrontのキャッシュ無効化(Invalidation)
フィンガープリントなしのファイルや緊急時の更新には、Invalidationでキャッシュを即座に削除する。
# 緊急時は全体を、通常は特定パスのみ
aws cloudfront create-invalidation \
--distribution-id E1ABCDEFGHIJKL \
--paths "/assets/*" "/uploads/*" "/api/v1/tasks"# app/services/cdn_invalidation_service.rb
class CdnInvalidationService
include Singleton
def initialize
@client = Aws::CloudFront::Client.new(region: 'us-east-1')
@dist_id = ENV.fetch('CLOUDFRONT_DISTRIBUTION_ID')
end
def invalidate(paths)
@client.create_invalidation(
distribution_id: @dist_id,
invalidation_batch: {
paths: { quantity: paths.length, items: paths },
caller_reference: "#{Time.current.to_i}-#{SecureRandom.hex(4)}"
}
)
end
end
# app/models/task.rb
class Task < ApplicationRecord
after_commit on: [:update, :destroy] do
CdnInvalidationService.instance.invalidate(
["/api/v1/tasks", "/api/v1/tasks/#{id}"]
)
end
endWARNING
Invalidationは月1,000パスまで無料(超過は1,000パスあたり$0.005)。ワイルドカード /assets/* は1パスカウント。フィンガープリンティングを正しく使えば、デプロイ時のInvalidationはほぼ不要になる。
セキュリティ: Origin Access Control
S3バケットへの直接アクセスをブロックし、CloudFront経由でのみアクセスできるようにする。OAC(OAIの後継)を使うことで、SigV4署名でS3との通信を保護できる。
# OACはCloudFormation/CDNスタック内で定義済み(前掲のcdn-stack.yml参照)
# S3バケットポリシー — CloudFrontサービスプリンシパルのみ許可
BucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref AssetsBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: cloudfront.amazonaws.com }
Action: s3:GetObject
Resource: !Sub "${AssetsBucket.Arn}/*"
Condition:
StringEquals:
AWS:SourceArn: !Sub
"arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}"# spec/services/cdn_security_spec.rb
RSpec.describe 'CDN Security' do
it 'S3直接アクセスが403を返す' do
url = "https://echo-task-assets-prod.s3.ap-northeast-1.amazonaws.com/assets/application.css"
expect(Net::HTTP.get_response(URI(url)).code).to eq('403')
end
it 'CloudFront経由では200でx-cacheヘッダーが付く' do
res = Net::HTTP.get_response(URI("https://assets.echotask.example.com/assets/application.css"))
expect(res.code).to eq('200')
expect(res['x-cache']).to match(/cloudfront/i)
end
end改善結果
ハルトがCDNを導入して1週間後、田中CTOからSlackが届いた。「サンフランシスコのデモが1秒で読み込まれるようになった!」
JS読み込み: 日本 850ms→45ms (-94%)、米国 5.2s→55ms (-99%)
LCP: 欧州 11.2s→0.9s
帯域幅コスト: ¥180,000→¥12,000/月 (-93%)。CloudFront+S3で +¥28,000。合計 -¥140,000/月
HIT率: 静的アセット 98.3% / Active Storage 87.4% / API 61.2%
CDNはインフラ改善の中でもコストパフォーマンスが群を抜いて高い。設定数時間で効果は永続的だ。
しかし静的配信を最適化したことで、今度は「重い処理」がボトルネックとして浮き上がってきた。
「メール送信・画像処理・レポート生成——これらをAPIのレスポンス内でやるのはおかしい。ユーザーを待たせている。」
次の章では、非同期処理とメッセージキューを学ぶ。
INFO
この章のキーポイント
- CDNはDNS解決→エッジ到達→キャッシュ確認→オリジンフェッチ→エッジ保存→配信の6ステップで動く
- フィンガープリンティングで「1年間キャッシュ + 即座の更新」を両立する
s-maxageを使えば動的APIレスポンスもCDNでキャッシュできる- Lambda@Edgeでセキュリティヘッダー付与やA/Bテストをエッジで実行できる
- CloudFront FunctionsでWebP自動変換など軽量処理をエッジにオフロードできる
- Route53のジオロケーションルーティングで複数リージョン対応が可能
- WAFをCloudFrontに統合してDDoS・SQLインジェクションを防御する
- Origin Access ControlでS3への直接アクセスを完全にブロックする