💭

asset_syncを利用してS3に静的アセットをアップロード、CloudFrontで高速配信を行う

2022/06/19に公開

❓ なぜこの記事を書いたか

Railsを使用したポートフォリオを作成していた時にasset_syncを利用して静的アセットを外部に配置し、CloudFrontより高速配信する構成にしました。
S3のバケットのブロックパブリックアクセスを全てブロックにした状態で構築したところ、asset_syncの設定で詰まってしまいました。
その際の備忘録としてまとめます。

🖥️ 環境

  • Ruby 3.0.4
  • Rails 6.1.4
  • Bundler 2.2.33
  • asset_sync 2.15.2
  • fog-aws 3.14.0

👣 前提

  • Ruby on Railsで作成されたWebアプリケーションが既にある
  • AWS EC2で本番環境を構築している
  • AWS Route 53により独自ドメインを取得している

🖊️ 本題

やりたいこと

Ruby on Railsで構築されたWebアプリケーションはアセットパイプラインによりCSSやJavaScriptなどの静的ファイルを圧縮し、連結して使用しています。
そのアセットのプリコンパイルは$ RAILS_ENV**=**production bin/rails assets:precompile で提供され、/public/assets配下に出力されています。
また、Webpacker導入以降はwebpackによるビルドシステムもRailsに同梱されるようになったため、webpackによりコンパイルされたファイルは/public/packs配下に出力されています。
asset_syncはこれら/public/assetsや/public/packsに出力されたコンパイル済みのファイルをS3などのストレージサービスと共有することができます。
このS3に共有された静的アセットをCloudFrontを通じて公開し、Webアプリケーションに提供することが今回の目的です。

なお、webpackerのコンパイルタスクは assets:precompile のrakeタスクに含まれますので、Capistranoなどによる自動デプロイでも assets:precompile が走るタスクでは実行されているようです。

Webpackerはassets:precompileのrakeタスクにwebpacker:compileタスクを追加するので、assets:precompileを使う既存のデプロイパイプラインはすべて動作します。

アセットパイプライン - Railsガイド

CloudFrontをS3の全段に配置することでブラウザとCloudFront間でHTTPS通信を行うことができ、S3でホストしている静的アセットをセキュアに配信することができます。

手順

実装するために以下のような手順が必要になります。

  1. S3バケットの作成
  2. ACMによるSSLサーバ証明書の取得
  3. CloudFrontの設定
  4. Route 53でCloudFrontを指すレコードを定義
  5. asset_syncの導入

なお、1〜4までの内容は以下の記事でまとめています。
重複する内容が多いため今回は簡単にまとめています。
CloudFrontを利用してCarrierWaveでS3にアップロードした画像を表示する

S3バケットの作成

AWSのS3のコンソールより「バケットを作成」
以下のような設定を行います。

S3のバケット設定の画像1

S3のバケット設定の画像2

S3のバケット設定の画像3

ここでのポイントはACL無効にし、ブロックパブリックアクセス設定を全てブロックに設定することです。
特にブロックパブリックアクセス設定についてはチェックを外すやり方もあるようですが、後に説明する”IAMユーザーに適切なポリシーを付与する”、”CloudFrontのS3バケットアクセスでOAIを使用する”ことで補うことができました。

ACMによるSSLサーバ証明書の取得

HTTPS通信を行うため、SSLサーバ証明書を取得します。
なお、CloudFrontに設定するSSLサーバ証明書はバージニア北部で作成したものしか使用できませんでしたので注意してください。

リージョンをバージニア北部に変更 > AWS Certificate Manager > 証明書 > 証明書をリクエスト

  • 証明書タイプ→パブリック証明書をリクエスト
  • ドメイン名 → ex) rails-test-static.example.com CloudFrontに設定したいドメイン名を記述
  • DNSの検証を選択

検証を行ったのち、作成ステータスが「発行済み」変わったらOK

CloudFrontの設定

https://aws.amazon.com/jp/cloudfront/

CloudFront > ディストリビューションを作成

  • オリジンドメインでS3で作成したバケットのドメイン名を指定

  • S3バケットアクセスS3側でブロックパブリックアクセスをすべてブロックに設定しているため、こちらでOAIを作成することでアクセスできるように設定

    • 新しいOAIを作成→初回はS3のバケットポリシーにOAIを自動追加するため、「はい、バケットポリシーを自動で更新します」を選択
      CloudFrontの設定の画像1
  • ビュワープロトコルポリシー→Redirect HTTP to HTTPS
    ブラウザからCloudFrontへHTTPでアクセスされた時、HTTPSにリダイレクトするように設定
    CloudFrontの設定の画像2

  • 代替ドメイン名→CloudFrontに設定したいドメイン名を設定します。

  • カスタムSSL証明書→ACMで取得した証明書を選択
    CloudFrontの設定の画像3

Route 53でCloudFrontを指すレコードを定義

CloudFrontのディストリビューションが作成できたら、DNSにレコードを登録します。
Route 53 > ホストゾーン > ドメイン名 > レコードを作成
シンプルルーティング > シンプルなレコードを定義

  • 値/トラフィックのルーティング先で「CloudFrontディストリビューションへのエイリアス」> 作成したディストリビューションを選択
    Route53でレコード定義の画像

asset_syncの導入

IAMユーザーの作成

RailsアプリケーションからS3にアクセスするために新たにAWS IAMにてポリシーの作成を行います。

  • README.mdを参考にasset_sync用のポリシーを作成
    IAM > ポリシー > ポリシーを作成
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "s3:ListBucket",
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::rails-test-static"
        },
        {
            "Action": "s3:PutObject*",
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::rails-test-static/*"
        }
    ]
}
  • IAM ユーザーの作成
    IAM > ユーザー > ユーザーを作成
    AWS認証タイプをアクセスキーとし、新たにIAMユーザーを作成します。
    IAMユーザーの作成の画像1

既存のポリシーを直接アタッチにて先ほど作成したポリシーを選択します。
IAMユーザーの作成の画像2
作成されたアクセスキーIDとシークレットアクセスキーを使用しますので、csvファイルを忘れずにダウンロードしておいてください。

  • credentials.yml.encで環境変数として設定
    作成したIAMユーザーのアクセスキーIDとシークレットアクセスキーを登録します。
EDITOR='vi' bin/rails credentials:edit

aws:
  # S3のバケットの設定
  s3_region: ap-northeast-1 # バケットを作成したリージョン名
  s3_bucket: rails-test-static # バケット名
  s3_host: https://rails-test-static.example.com # CloudFrontに設定したドメイン名

  # IAMアクセス設定
  access_key_id: xxx
  secret_access_key: xxx

gemのインストール

Gemfile
gem "asset_sync"
gem "fog-aws"
$ bundle install

asset_syncの設定

  • 設定ファイルを作成する
    Rails のジェネレーターを利用してasset_sync用の設定ファイルを作成します。
    S3を利用する場合は --provider=AWS を指定してください。
$ bin/rails g asset_sync:install --provider=AWS

# asset_syncの設定ファイルが作成される
Running via Spring preloader in process 6259
      create  config/initializers/asset_sync.rb
  • 設定を記述
    こちらもREADME.mdを参考に設定していきます。
    Webpackerを使用する場合もREADME.mdに設定方法が明示してありますので、それをもとに作成しました。
config/initializers/asset_sync.rb
if defined?(AssetSync)
  AssetSync.configure do |config|
    config.enabled = false if Rails.env.development?
    config.fog_public = false # S3バケットはブロックパブリックアクセスを全てブロックに設定しているため、こちらでも非公開を設定

    # AWSの設定
    config.fog_provider = 'AWS'
    config.fog_region = Rails.application.credentials.dig(:aws, :s3_region)
    config.fog_directory = Rails.application.credentials.dig(:aws, :s3_bucket)
    config.aws_access_key_id = Rails.application.credentials.dig(:aws, :access_key_id)
    config.aws_secret_access_key = Rails.application.credentials.dig(:aws, :secret_access_key)

    # S3にある元のアセットファイルを保持する
    config.existing_remote_files = 'keep'

    config.aws_session_token = ENV['AWS_SESSION_TOKEN'] if ENV.key?('AWS_SESSION_TOKEN')

    # webpackerに対応
    config.run_on_precompile = false

    config.add_local_file_paths do
      public_root = Rails.root.join('public')
      Dir.chdir(public_root) do
        packs_dir = Webpacker.config.public_output_path.relative_path_from(public_root)
        Dir[File.join(packs_dir, '/**/**')]
      end
    end
  end
end

ここでの注意点はS3のブロックパブリックアクセスの設定に応じて fog_public = false を設定することです。
この値はデフォルトで true ですので、ここを false にしないとコンパイル時に以下のようなエラーが発生します。

error log
$ bin/rails assets:precompile RAILS_ENV=production

yarn install v1.22.19
[1/4] Resolving packages...
success Already up-to-date.
Done in 0.67s.
Everything's up-to-date. Nothing to do
rails aborted!
Excon::Error::BadRequest: Expected(200) <=> Actual(400 Bad Request)
excon.error.response
  :body          => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Error><Code>AccessControlListNotSupported</Code><Message>The bucket does not allow ACLs</Message><RequestId>xxx</RequestId><HostId>xxx=</HostId></Error>"
  :cookies       => [
  ]
  :headers       => {
    "Connection"       => "close"
    "Content-Type"     => "application/xml"
    "Date"             => "Sun, 19 Jun 2022 02:19:22 GMT"
    "Server"           => "AmazonS3"
    "x-amz-id-2"       => "xxx"
    "x-amz-request-id" => "xxx"
  }
  :host          => "rails-test-static.s3.ap-northeast-1.amazonaws.com"
  :local_address => "xx.xx.xx.xx"
  :local_port    => 49618
  :path          => "/packs/css/application-38b8d4a2.css"
  :port          => 443
  :reason_phrase => "Bad Request"
  :remote_ip     => "xx.xx.xx.xx"
  :status        => 400
  :status_line   => "HTTP/1.1 400 Bad Request\r\n"
/app_name/lib/tasks/asset_sync.rake:3:in `block in <main>'
/app_name/bin/rails:5:in `<top (required)>'
/app_name/bin/spring:31:in `block in <top (required)>'
/app_name/bin/spring:28:in `<top (required)>'
  • lib/tasks/asset_sync.rake
    こちらもREADME.mdに従います。
    webpacker:compile の実行後に assets:symc を実行させるようにtaskを追加しています。
    Rake::Task#enhanceの理解についてはこちらを参考にさせていただきました。
lib/tasks/asset_sync.rake
if defined?(AssetSync)
  Rake::Task['webpacker:compile'].enhance do
    Rake::Task['assets:sync'].invoke
  end
end

production環境のasset_hostにCloudFrontを設定する

config/environments/production.rb
 config.asset_host = Rails.application.credentials.dig(:aws, :s3_host)

プリコンパイルを実行してみる

  • production環境でプリコンパイルを実行
$ bin/rails assets:precompile RAILS_ENV=production

Capistranoによる自動デプロイを行なっている場合はこちらでもasset_syncは実行されます

$ bundle exec cap production deploy
  • S3のバケットにassets/とpacks/が作成され、コンパイル後のファイルが格納されていることが確認できます。
    S3バケットの画像

本番環境でアクセスし、headタグ内のstylesheetやscriptタグのsrcがCloudFrontに指定したURLになっていればOKです。
同時にhttpsでアセットを取得できていることも確認できます。
ブラウザでアッセトのURLを確認画像

📓 まとめ

Railsアプリケーションにおいてasset_syncを使用し、静的アセットをS3にアップロードすることができました。
また、S3の全段にCloudFrontを配置することでセキュア且つ高速に静的アセットをブラウザに届けることができます。
今回は特にasset_syncの fog_public = false の設定で詰まってしまいました。
同様の構成で設定されている方の一助になれば幸いです。

☕ 参考にさせていただきました

先人の方々の知見に沢山助けられました。
本当にありがとうございました。

https://qiita.com/uochann/items/cb6a099c7069f37773fe

https://qiita.com/take18k_tech/items/2efd778c8dd9aae9496e

Discussion