mybook

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のライフサイクルポリシーを設定したか
  □ 月次のデータ転送量を監視しているか