CDNと静的アセット — エッジで配信する
画像が遅い理由
「東京のサーバーから大阪のユーザーへ画像を送ると何ミリ秒かかる?」アキラが問いかけた。
物理法則に勝てない。光の速度は有限で、東京→大阪の往復は約20ms。これが日本国内なら問題ないが、海外ユーザーになると話が変わる。
東京から各地域までの往復遅延(RTT):
大阪: 約 20ms
ソウル: 約 30ms
シンガポール: 約 80ms
バンコク: 約 90ms
ロンドン: 約 240ms
ニューヨーク: 約 280ms
サンパウロ: 約 330ms
Buzzのユーザー分布(海外展開後):
日本: 45%
東南アジア: 30%
北米: 15%
欧州: 10%
シンガポールのユーザーが東京のサーバーから5MBの動画を取得するとしよう——往復遅延80msに加えて転送時間も加わり、体験は最悪だ。しかも画像や動画は繰り返しアクセスされる。同じコンテンツを何度も東京から配信するのは無駄だ。
INFO
CDN(Content Delivery Network)はコンテンツをユーザーの近くにあるエッジサーバーにキャッシュすることで、オリジンサーバーへの距離を大幅に縮める。CloudFrontは世界400以上のエッジロケーションを持ち、ユーザーに最も近いエッジから配信する。
S3 + CloudFront の構成
Loading diagram...
S3 バケット設定(Terraform)
# terraform/s3.tf
resource "aws_s3_bucket" "buzz_media" {
bucket = "buzz-media-${var.environment}"
}
# バージョニング(誤削除対策)
resource "aws_s3_bucket_versioning" "buzz_media" {
bucket = aws_s3_bucket.buzz_media.id
versioning_configuration {
status = "Enabled"
}
}
# ライフサイクルポリシー
resource "aws_s3_bucket_lifecycle_configuration" "buzz_media" {
bucket = aws_s3_bucket.buzz_media.id
rule {
id = "media-lifecycle"
status = "Enabled"
# 古いバージョンを30日後に削除
noncurrent_version_expiration {
noncurrent_days = 30
}
# 90日後に低頻度アクセスストレージに移動(コスト削減)
transition {
days = 90
storage_class = "STANDARD_IA"
}
# 1年後にGlacierに移動(アーカイブ)
transition {
days = 365
storage_class = "GLACIER"
}
}
}
# 暗号化
resource "aws_s3_bucket_server_side_encryption_configuration" "buzz_media" {
bucket = aws_s3_bucket.buzz_media.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.buzz_s3.arn
}
}
}
# パブリックアクセスブロック(CloudFront OAC 経由のみ許可)
resource "aws_s3_bucket_public_access_block" "buzz_media" {
bucket = aws_s3_bucket.buzz_media.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}CloudFront ディストリビューション
# terraform/cloudfront.tf
# Origin Access Control(OACでS3を保護)
resource "aws_cloudfront_origin_access_control" "buzz_s3" {
name = "buzz-s3-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "buzz" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
price_class = "PriceClass_200" # アジア・北米・欧州
http_version = "http2and3" # HTTP/3 対応
# オリジン1: S3(メディアファイル)
origin {
domain_name = aws_s3_bucket.buzz_media.bucket_regional_domain_name
origin_id = "S3MediaOrigin"
origin_access_control_id = aws_cloudfront_origin_access_control.buzz_s3.id
}
# オリジン2: ALB(Rails API)
origin {
domain_name = aws_alb.buzz.dns_name
origin_id = "ALBOrigin"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
# オリジンシールド(キャッシュヒット率を上げる)
origin_shield {
enabled = true
origin_shield_region = "ap-northeast-1"
}
}
# デフォルト動作(Rails API)
default_cache_behavior {
target_origin_id = "ALBOrigin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
compress = true
forwarded_values {
query_string = true
cookies {
forward = "all" # セッションCookieをオリジンに転送
}
headers = ["Authorization", "Origin", "Accept"]
}
min_ttl = 0
default_ttl = 0 # APIレスポンスはキャッシュしない
max_ttl = 0
}
# メディアファイル(S3)のキャッシュ設定
ordered_cache_behavior {
path_pattern = "/uploads/*"
target_origin_id = "S3MediaOrigin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
forwarded_values {
query_string = false # クエリパラメータはキャッシュキーに含めない
cookies { forward = "none" }
}
min_ttl = 86400 # 1日
default_ttl = 2592000 # 30日
max_ttl = 31536000 # 1年(フィンガープリント付きURL)
}
# JS/CSS(フィンガープリント付き)の永続キャッシュ
ordered_cache_behavior {
path_pattern = "/assets/*"
target_origin_id = "ALBOrigin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
forwarded_values {
query_string = false
cookies { forward = "none" }
}
min_ttl = 31536000 # 1年(内容が変わったらURLが変わる)
default_ttl = 31536000
max_ttl = 31536000
}
# WAF連携(DDoS・不正アクセス対策)
web_acl_id = aws_wafv2_web_acl.buzz_cloudfront.arn
# カスタムエラーページ
custom_error_response {
error_code = 404
response_code = 404
response_page_path = "/errors/404.html"
error_caching_min_ttl = 300
}
custom_error_response {
error_code = 503
response_code = 503
response_page_path = "/errors/503.html"
error_caching_min_ttl = 30 # サーバーエラーは短めにキャッシュ
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate.buzz.arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
tags = { Name = "buzz-cloudfront" }
}Railsからの Active Storage 設定
# config/storage.yml
amazon:
service: S3
region: ap-northeast-1
bucket: buzz-media-production
# サーバーサイド暗号化
upload:
server_side_encryption: "aws:kms"
storage_class: "STANDARD"
metadata:
cache-control: "public, max-age=31536000, immutable"# config/environments/production.rb
config.active_storage.service = :amazon# config/initializers/active_storage_cdn.rb
# Active Storage の URL を CloudFront 経由に変換
Rails.application.config.after_initialize do
# ActiveStorage::Blob の url メソッドをオーバーライド
ActiveStorage::Current.url_options = { host: ENV['APP_HOST'] }
end
module ActiveStorageCdnHelper
def cdn_url(blob_or_attachment, **options)
return nil unless blob_or_attachment.attached?
# S3のURLをCloudFrontのURLに変換
original_url = if blob_or_attachment.is_a?(ActiveStorage::Attachment)
blob_or_attachment.blob.url(**options)
else
blob_or_attachment.url(**options)
end
original_url.gsub(
"#{ENV['S3_BUCKET']}.s3.ap-northeast-1.amazonaws.com",
ENV['CLOUDFRONT_DOMAIN']
)
end
end
ActionView::Base.include(ActiveStorageCdnHelper)Railsアセットパイプラインの最適化
# config/environments/production.rb
# フィンガープリント(内容が変わったらURLが変わる → 永久キャッシュ可能)
config.assets.digest = true
config.assets.compile = false # 本番でのオンデマンドコンパイル禁止
# 圧縮設定(Gzip + Brotli)
config.assets.css_compressor = :sass
config.assets.js_compressor = :terser
config.middleware.insert_before ActionDispatch::Static, Rack::Deflater
# 静的ファイルのキャッシュヘッダー
config.public_file_server.headers = {
'Cache-Control' => 'public, max-age=31536000, immutable',
'X-Content-Type-Options' => 'nosniff'
}キャッシュコントロールヘッダーの設計
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_cache_headers
private
def set_cache_headers
if request.get?
# ページの内容によってキャッシュ戦略を変える
case
when devise_controller?
# 認証ページはキャッシュしない
response.headers['Cache-Control'] = 'no-store'
when params[:controller] == 'timeline'
# タイムラインはリアルタイムなのでキャッシュしない
response.headers['Cache-Control'] = 'no-cache, must-revalidate'
when params[:controller] == 'users' && params[:action] == 'show'
# ユーザープロフィールは60秒キャッシュ
response.headers['Cache-Control'] = 'public, max-age=60, stale-while-revalidate=600'
when params[:controller] == 'posts' && params[:action] == 'show'
# 投稿詳細は5分キャッシュ(いいね数の更新は遅延してOK)
response.headers['Cache-Control'] = 'public, max-age=300, stale-while-revalidate=3600'
end
else
# 書き込みリクエストはキャッシュしない
response.headers['Cache-Control'] = 'no-store'
end
end
end画像最適化
# Gemfile
gem 'image_processing' # libvips or ImageMagick
gem 'active_storage_validations'# app/models/post.rb
class Post < ApplicationRecord
has_one_attached :image do |attachable|
# 用途別にバリアントを定義
attachable.variant :thumb,
resize_to_limit: [150, 150],
format: :webp,
quality: 80,
strip: true # EXIFデータを除去(プライバシー保護)
attachable.variant :medium,
resize_to_limit: [600, 600],
format: :webp,
quality: 85,
strip: true
attachable.variant :large,
resize_to_limit: [1200, 1200],
format: :webp,
quality: 90,
strip: true
# OGP用(SNSシェア時のサムネイル)
attachable.variant :ogp,
resize_to_fill: [1200, 630], # OGP推奨サイズ
format: :jpeg,
quality: 90
end
validates :image,
content_type: %w[image/png image/jpeg image/webp image/gif],
size: { less_than: 10.megabytes, message: '10MB以下の画像を選択してください' },
dimension: { width: { min: 100, max: 8000 }, height: { min: 100, max: 8000 } }
after_commit :preprocess_image_variants, on: :create
private
def preprocess_image_variants
return unless image.attached?
# 非同期でバリアントを事前生成(初回アクセスを速くする)
PreprocessImageJob.perform_later(id)
end
end# app/jobs/preprocess_image_job.rb
class PreprocessImageJob < ApplicationJob
queue_as :default
def perform(post_id)
post = Post.find(post_id)
return unless post.image.attached?
# バリアントを事前生成してS3にキャッシュ
[:thumb, :medium, :large, :ogp].each do |variant|
post.image.variant(variant).processed
end
post.update_columns(image_processed: true)
end
end<%# app/views/posts/_post.html.erb %>
<%# WebP形式 + レスポンシブ画像(srcset)+ 遅延読み込み %>
<% if post.image.attached? && post.image_processed? %>
<picture>
<source
type="image/webp"
srcset="
<%= cdn_url(post.image.variant(:large)) %> 1200w,
<%= cdn_url(post.image.variant(:medium)) %> 600w,
<%= cdn_url(post.image.variant(:thumb)) %> 150w
"
sizes="(max-width: 480px) 150px, (max-width: 900px) 600px, 1200px"
>
<img
src="<%= cdn_url(post.image.variant(:medium)) %>"
alt="<%= post.alt_text.presence || "#{post.user.username}の投稿画像" %>"
loading="lazy"
decoding="async"
width="600"
height="600"
class="post-image"
>
</picture>
<% else %>
<%# 画像処理中はスケルトンを表示 %>
<div class="image-skeleton" aria-label="画像を処理中..."></div>
<% end %>署名付きURLで非公開コンテンツを保護
プライベートな画像(ダイレクトメッセージの添付など)はS3を直接公開せず、署名付きURLを使う。
# app/models/direct_message.rb
class DirectMessage < ApplicationRecord
has_one_attached :attachment
# 有効期限付きの署名URLを生成
def attachment_url(expires_in: 1.hour)
return nil unless attachment.attached?
# Active Storage の署名URLを生成(デフォルトはS3の署名URL)
attachment.url(
expires_in: expires_in,
disposition: 'inline'
)
end
end# CloudFront 署名付きURL(より高パフォーマンス)
# S3の署名URLだとCloudFrontキャッシュをバイパスしてしまう
class CloudFrontUrlSigner
CLOUDFRONT_DOMAIN = ENV['CLOUDFRONT_DOMAIN']
CLOUDFRONT_KEY_PAIR_ID = ENV['CLOUDFRONT_KEY_PAIR_ID']
# 環境変数から秘密鍵を読み込む
PRIVATE_KEY = OpenSSL::PKey::RSA.new(
ENV['CLOUDFRONT_PRIVATE_KEY'].gsub('\\n', "\n")
)
def self.signed_url(path, expires_in: 1.hour)
expiry = Time.current.to_i + expires_in.to_i
url = "https://#{CLOUDFRONT_DOMAIN}#{path}"
signer = Aws::CloudFront::UrlSigner.new(
key_pair_id: CLOUDFRONT_KEY_PAIR_ID,
private_key: PRIVATE_KEY
)
signer.signed_url(url, expires: expiry)
end
def self.signed_cookie(path_pattern, expires_in: 1.hour)
expiry = Time.current.to_i + expires_in.to_i
signer = Aws::CloudFront::CookieSigner.new(
key_pair_id: CLOUDFRONT_KEY_PAIR_ID,
private_key: PRIVATE_KEY
)
# Cookie型の署名(複数ファイルへのアクセスを一度に許可)
signer.signed_cookie("https://#{CLOUDFRONT_DOMAIN}#{path_pattern}",
expires: expiry)
end
end
# 使い方
CloudFrontUrlSigner.signed_url('/private/image.jpg', expires_in: 30.minutes)
# => "https://xxx.cloudfront.net/private/image.jpg?X-Amz-Security-Token=..."
# ダイレクトメッセージのスレッド全体に一括でアクセス許可
cookies_hash = CloudFrontUrlSigner.signed_cookie("/messages/thread_123/*")
cookies_hash.each { |name, value| cookies[name] = value }WAF(Web Application Firewall)の設定
CloudFrontにWAFを付けてセキュリティを強化する。
# terraform/waf.tf
resource "aws_wafv2_web_acl" "buzz_cloudfront" {
name = "buzz-cloudfront-waf"
scope = "CLOUDFRONT" # CloudFront用はus-east-1に作成する必要あり
provider = aws.us_east_1
default_action {
allow {}
}
# AWSマネージドルール(一般的な攻撃パターンをブロック)
rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 1
override_action { none {} }
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesCommonRuleSet"
sampled_requests_enabled = true
}
}
# レートリミット(1IPから5分間に1000リクエストまで)
rule {
name = "RateLimit"
priority = 2
action { block {} }
statement {
rate_based_statement {
limit = 1000
aggregate_key_type = "IP"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "RateLimit"
sampled_requests_enabled = true
}
}
# ボット対策
rule {
name = "AWSManagedRulesBotControlRuleSet"
priority = 3
override_action { none {} }
statement {
managed_rule_group_statement {
name = "AWSManagedRulesBotControlRuleSet"
vendor_name = "AWS"
managed_rule_group_configs {
aws_managed_rules_bot_control_rule_set {
inspection_level = "COMMON"
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "BotControl"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "BuzzWAF"
sampled_requests_enabled = true
}
}CDN導入の効果
CloudFront 導入後(2週間後の計測):
キャッシュヒット率: 89.3%
オリジンへのトラフィック: 10.7%(89.3%削減)
レスポンスタイム改善:
日本ユーザー: 20ms → 8ms
東南アジアユーザー: 80ms → 12ms
北米ユーザー: 280ms → 25ms
欧州ユーザー: 240ms → 20ms
月次コスト変化:
S3 転送料金: $1,200/月 → $130/月(89%削減!)
ALB 負荷: 40% 削減(CloudFrontがキャッシュするため)
CloudFront料金: $180/月(追加)
WAF料金: $60/月(追加)
純コスト削減: $1,200 - ($130 + $180 + $60) = $830/月削減
Loading diagram...
INFO
CDNのキャッシュヒット率を上げるコツ: URLにクエリパラメータを含めない(?v=1.0ではなくフィンガープリントをパスに含める)、Vary ヘッダーを最小限にする、適切なTTLを設定する。Vary: Cookie はキャッシュヒット率を激減させるので使わない。
「コストが劇的に下がった」アキラは笑顔で言った。「でも次のフェーズでは、RDBだけでは限界が来る領域がある。検索、セッション、アクティビティフィード——これらはRDB向きじゃない」
次章では、DynamoDB、ElastiCache、OpenSearchなど、用途に特化したデータベースを組み合わせるポリグロット・パーシステンスを学ぶ。
付録: CDN最適化チェックリスト
配信設定:
□ HTTP/2 または HTTP/3 を有効化しているか
□ gzip/Brotli 圧縮を有効化しているか
□ WebP画像フォーマットを使っているか
□ 画像の遅延読み込み (loading="lazy") を設定しているか
□ srcset でレスポンシブ画像を配信しているか
キャッシュ:
□ 静的アセットにフィンガープリントを付けているか
□ メディアファイルのTTLは適切か(最低30日)
□ APIレスポンスはキャッシュしないよう設定しているか
□ キャッシュヒット率を監視しているか(目標85%以上)
セキュリティ:
□ S3はパブリックアクセスをブロックしているか
□ CloudFront Origin Access Control (OAC) を設定しているか
□ WAFでレートリミットを設定しているか
□ HTTPSへのリダイレクトを設定しているか
□ HSTS (Strict-Transport-Security) ヘッダーを設定しているか
コスト:
□ 未使用のCloudFrontディストリビューションを削除したか
□ S3のライフサイクルポリシーを設定したか
□ 月次のデータ転送量を監視しているか