タケユー・ウェブ日報

Ruby on Rails や Flutter といったWeb・モバイルアプリ技術を武器にお客様のビジネス立ち上げを支援する、タケユー・ウェブ株式会社の技術ブログです。

Railsのアセット(ActiveStorage , Webpacker, Asset Pipeline)をCloudFront経由で配信する

f:id:uzuki05:20210121025234j:plain

業務でRailsアプリを作る場合、処理負荷的にも転送量的にも重い画像等は、CDNから配信するようにすることは、最早当たり前といって良いと思います。

最近は CDK によって簡単にCloudFront Distribution設定を維持管理でき、さらに ActiveStorage 等、Rails側の機能改善もあって以前に比べて、簡単に、レールから外れない範囲で書けるようになりました。

今回は以下のような構成で、CloudFront 経由での配信によるエッジキャッシュの利用を実現する方法について紹介します。

f:id:uzuki05:20210121011247p:plain
構成イメージ

手順

  1. CDKでCloudFrontを作成する
  2. ActiveStorage の URL を CloudFront経由のものにする
  3. Webpacker, Asset Pipeline の成果物のURLを CloudFront経由のものにする

CDKでCloudFrontを作成する

前提

  • ドメイン myapp.takeyuweb.co.jp は Route 53 のPublicHostedZone 管理下
    • ステージング:
      • Railsアプリ: staging.myapp.takeyuweb.co.jp
      • CDN: staging.cdn.myapp.takeyuweb.co.jp
    • 本番:
      • Railsアプリ: myapp.takeyuweb.co.jp
      • CDN: cdn.myapp.takeyuweb.co.jp
  • ActiveStorage , Webpacker, Asset Pipeline 以外は Static Website Hosting なS3バケットへ流す
import * as cdk from "@aws-cdk/core";
import * as s3 from "@aws-cdk/aws-s3";
import * as cloudfront from "@aws-cdk/aws-cloudfront";
import * as certificatemanager from "@aws-cdk/aws-certificatemanager";
import * as route53 from "@aws-cdk/aws-route53";
import * as route53Targets from "@aws-cdk/aws-route53-targets";

export class MyStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const prefix = "myapp";
    const stage: string = this.node.tryGetContext("stage") || "staging";

    // Static Website Hosting 用のバケットを作る
    // 今回は ActiveStorage , Webpacker, Asset Pipeline 以外をRailsアプリに流したくなかったので、デフォルトビヘイビアの参照先として
    const websiteBucket = new s3.Bucket(this, "website", {
      bucketName: [prefix, stage, "website"].join("-"),
      versioned: true,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
      websiteIndexDocument: "index.html",
      websiteErrorDocument: "error.html",
    });
    websiteBucket.grantPublicAccess("*", "s3:GetObject");

    // ドメインのこと
    // SSLサーバ証明書をRoute 53 Public Hosted Zone を使って取得するのと、CloudFrontに向けるレコードを作成するのに使う
    const baseDomainName = "myapp.takeyuweb.co.jp";
    const domainName =
      stage === "production" ? baseDomainName : `${stage}.${baseDomainName}`;
    const cdnDomainName =
      stage === "production"
        ? `cdn.${baseDomainName}`
        : `${stage}.cdn.${baseDomainName}`;
    const publicHostedZone = route53.PublicHostedZone.fromLookup(
      this,
      "PublicHostedZone",
      {
        domainName: baseDomainName,
      }
    );
    const cdnCert = new certificatemanager.DnsValidatedCertificate(
      this,
      "Certificate",
      {
        domainName: baseDomainName,
        subjectAlternativeNames: [cdnDomainName],
        hostedZone: publicHostedZone,
        region: "us-east-1", // CloudFrontで使う証明書は us-east-1 である必要あり
      }
    );
    const cloudFrontWebDistribution = new cloudfront.CloudFrontWebDistribution(
      this,
      "cloudFrontWebDistribution",
      {
        viewerCertificate: cloudfront.ViewerCertificate.fromAcmCertificate(
          cdnCert,
          {
            aliases: [cdnDomainName],
            securityPolicy: cloudfront.SecurityPolicyProtocol.TLS_V1,
            sslMethod: cloudfront.SSLMethod.SNI,
          }
        ),
        priceClass: cloudfront.PriceClass.PRICE_CLASS_200,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.ALLOW_ALL, // Redirect HTTP to HTTPS がデフォルトなので、それでよければ削除して良い。今回は HTTP and HTTPS
        originConfigs: [
          {
            s3OriginSource: {
              s3BucketSource: websiteBucket,
            },
            behaviors: [
              {
                isDefaultBehavior: true,
                minTtl: cdk.Duration.seconds(0),
                maxTtl: cdk.Duration.hours(1),
                defaultTtl: cdk.Duration.hours(1),
              },
            ],
          },
          {
            customOriginSource: {
              domainName: domainName,
            },
            behaviors: [
              {
                compress: false,
                minTtl: cdk.Duration.seconds(0),
                maxTtl: cdk.Duration.days(365),
                defaultTtl: cdk.Duration.days(1),
                pathPattern: "rails/active_storage/*",
              },
              {
                compress: true,
                minTtl: cdk.Duration.seconds(0),
                maxTtl: cdk.Duration.days(365),
                defaultTtl: cdk.Duration.days(1),
                pathPattern: "packs/*",
              },
              {
                compress: true,
                minTtl: cdk.Duration.seconds(0),
                maxTtl: cdk.Duration.days(365),
                defaultTtl: cdk.Duration.days(1),
                pathPattern: "assets/*",
              },
            ],
          },
        ],
        errorConfigurations: [
          {
            errorCode: 403,
            responsePagePath: "/403.html",
            responseCode: 403,
            errorCachingMinTtl: 0,
          },
          {
            errorCode: 404,
            responsePagePath: "/404.html",
            responseCode: 404,
            errorCachingMinTtl: 0,
          },
        ],
      }
    );
    // Route 53 で CDNのサブドメインをCloudFront Distributionに向ける
    const propsForRoute53Records = {
      zone: publicHostedZone,
      recordName: cdnDomainName,
      target: route53.RecordTarget.fromAlias(
        new route53Targets.CloudFrontTarget(cloudFrontWebDistribution)
      ),
    };
    new route53.ARecord(this, "ARecord", propsForRoute53Records);
    new route53.AaaaRecord(this, "AaaaRecord", propsForRoute53Records);
  }
}

ActiveStorage の URL を CloudFront経由のものにする

前提

Ruby on Rails 6.1 の新機能を使います。この新機能について詳しくは次の記事で紹介しています。

blog.takeyuweb.co.jp

コード

# config/environments/staging.rb
require_relative "./production"
# config/environments/production.rb
Rails.application.configure do

  # 省略

  Rails.application.routes.default_url_options = {
    host: Rails.env.production? ? 'myapp.takeyuweb.co.jp' : "#{Rails.env}.myapp.takeyuweb.co.jp",
    port: 443,
    protocol: 'https'
  }

  # 省略

end
# config/initializers/active_storage.rb
Rails.application.config.active_storage.resolve_model_to_route = :cdn_proxy
# config/routes.rb
Rails.application.routes.draw do

  # (省略)

  # config/initializers/active_storage.rb の 
  # Rails.application.config.active_storage.resolve_model_to_route = :cdn_proxy
  # と組み合わせることで
  # url_for(user.photo) や url_for(user.photo.variant(resize: "500x500")) を CDN URL にすることが可能
  direct :cdn_proxy do |model, options|
    # staging / production では CDN(CloudFront)経由で画像URLにアクセスさせ、Rails サーバーへの負荷を抑える
    # development "http://localhost:3000/rails/active_storage/representations/proxy/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZVE9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--89fdfc021778670d3f0837c2160091fa8e007280/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDRG9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2QzNKbGMybDZaVWtpRGpFd01qUjRNVEF5TkFZN0JsUTZDbk4wY21sd1ZBPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--49c3b7c91f1ebccd4c293844babeb2568eda4f93/bb215fd6-4dc4-45c8-9570-596089ef51454119549275407786334.jpg"
    # staging "http://staging.cdn.myapp.takeyuweb.co.jp/rails/active_storage/representations/proxy/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZVE9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--89fdfc021778670d3f0837c2160091fa8e007280/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDRG9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2QzNKbGMybDZaVWtpRGpFd01qUjRNVEF5TkFZN0JsUTZDbk4wY21sd1ZBPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--49c3b7c91f1ebccd4c293844babeb2568eda4f93/bb215fd6-4dc4-45c8-9570-596089ef51454119549275407786334.jpg"
    # production "http://cdn.myapp.takeyuweb.co.jp/rails/active_storage/representations/proxy/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZVE9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--89fdfc021778670d3f0837c2160091fa8e007280/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDRG9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2QzNKbGMybDZaVWtpRGpFd01qUjRNVEF5TkFZN0JsUTZDbk4wY21sd1ZBPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--49c3b7c91f1ebccd4c293844babeb2568eda4f93/bb215fd6-4dc4-45c8-9570-596089ef51454119549275407786334.jpg"
    cdn_options = if Rails.env.development?
        Rails.application.routes.default_url_options
      else
        {
          protocol: 'https',
          port: 443,
          host: Rails.env.production? ? "cdn.myapp.takeyuweb.co.jp" : "#{Rails.env}.cdn.myapp.takeyuweb.co.jp"
        }
      end

    if model.respond_to?(:signed_id)
      route_for(
        :rails_service_blob_proxy,
        model.signed_id,
        model.filename,
        options.merge(cdn_options)
      )
    else
      signed_blob_id = model.blob.signed_id
      variation_key  = model.variation.key
      filename       = model.blob.filename

      route_for(
        :rails_blob_representation_proxy,
        signed_blob_id,
        variation_key,
        filename,
        options.merge(cdn_options)
      )
    end
  end
end

利用例

Rails.application.routes.url_helpers.url_for(user.photo) # => "https://staging.cdn.myapp.takeyuweb.co.jp/rails/active_storage/blobs/proxy/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZDg9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--d248c202e2e205d7cff8385cceaa598ea0fe244f/photo.jpg"

Rails.application.routes.url_helpers.url_for(user.photo.variant(resize: "180x180", strip: true)) # => "https://staging.cdn.myapp.takeyuweb.co.jp/rails/active_storage/representations/proxy/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZDg9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--d248c202e2e205d7cff8385cceaa598ea0fe244f/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDRG9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2QzNKbGMybDZaVWtpRERFNE1IZ3hPREFHT3daVU9ncHpkSEpwY0ZRPSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--3d33580e2f4e2740d69cef7539a66efa8cbc7dee/photo.jpg"
<%= image_tag user.photo %>

<%= image_tag user.photo.variant(resize: "180x180", strip: true) %>
<img src="https://staging.cdn.myapp.takeyuweb.co.jp/rails/active_storage/blobs/proxy/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZDg9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--d248c202e2e205d7cff8385cceaa598ea0fe244f/photo.jpg">

<img src="https://staging.cdn.myapp.takeyuweb.co.jp/rails/active_storage/representations/proxy/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZDg9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--d248c202e2e205d7cff8385cceaa598ea0fe244f/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDRG9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2QzNKbGMybDZaVWtpRERFNE1IZ3hPREFHT3daVU9ncHpkSEpwY0ZRPSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--3d33580e2f4e2740d69cef7539a66efa8cbc7dee/photo.jpg">

参考

discuss.rubyonrails.org

Webpacker, Asset Pipeline の成果物のURLを CloudFront経由のものにする

# config/environments/staging.rb
require_relative "./production"
# config/environments/production.rb
Rails.application.configure do

  # 省略

  # Enable serving of images, stylesheets, and JavaScripts from an asset server.
  config.action_controller.asset_host = "https://#{Rails.env.production? ? 'cdn.myapp.takeyuweb.co.jp' : "#{Rails.env}.cdn.myapp.takeyuweb.co.jp"}"

  # 省略

end