業務でRailsアプリを作る場合、処理負荷的にも転送量的にも重い画像等は、CDNから配信するようにすることは、最早当たり前といって良いと思います。
最近は CDK によって簡単にCloudFront Distribution設定を維持管理でき、さらに ActiveStorage 等、Rails側の機能改善もあって以前に比べて、簡単に、レールから外れない範囲で書けるようになりました。
今回は以下のような構成で、CloudFront 経由での配信によるエッジキャッシュの利用を実現する方法について紹介します。
- ActiveStorageで添付したファイルやオンザフライ変換( variant ) 結果
- Webpacker, Asset Pipeline の成果物
手順
- CDKでCloudFrontを作成する
- ActiveStorage の URL を CloudFront経由のものにする
- Webpacker, Asset Pipeline の成果物のURLを CloudFront経由のものにする
CDKでCloudFrontを作成する
前提
- ドメイン
myapp.takeyuweb.co.jp
は Route 53 のPublicHostedZone 管理下 - 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 の新機能を使います。この新機能について詳しくは次の記事で紹介しています。
コード
# 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">
参考
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