mybook

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)を持つ。コンテンツをエッジにキャッシュすることで、ユーザーは最寄りのサーバーから取得できる。

Loading diagram...

CDNを通じたリクエストは6ステップで進む。

  1. DNS解決: Route53がユーザーの地理的位置から最寄りエッジのIPを返す
  2. エッジ到達: ブラウザが最寄りのCloudFrontエッジにTCP接続(RTT 8ms程度)
  3. キャッシュ確認: エッジがURL+キャッシュキーでローカルキャッシュを検索(HIT → 即返却)
  4. オリジンフェッチ: キャッシュMISSの場合のみオリジン(S3/EC2)から取得(最初の1回だけ)
  5. エッジ保存: Cache-Control の指示に従ってエッジにキャッシュ
  6. 配信: エッジからユーザーへ。以降のリクエストはすべてエッジから即座に返る

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 WAFWebACL

INFO

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ヘッダーで制御する。主要ディレクティブの早見表:

ディレクティブ意味使いどころ
publicCDN・ブラウザ両方にキャッシュ可静的アセット全般
privateブラウザのみ(CDN不可)ユーザー固有のレスポンス
no-cacheキャッシュするが毎回再検証HTMLページ
s-maxage=NN秒間CDNキャッシュ(max-ageより優先)APIレスポンス
immutablemax-age内は再検証スキップフィンガープリント済みファイル
stale-while-revalidate=NN秒間は古いキャッシュを使いつつ裏で更新ほぼリアルタイムデータ
# 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
end

INFO

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
end

Active 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)
end

INFO

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
end

INFO

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
end

WARNING

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への直接アクセスを完全にブロックする