Rails+CloudFrontでパフォーマンス改善を試みた話(署名付きURL編)

本記事について

本記事は、2024年1月に社内で実施した勉強会の内容をもとに、外部向けに加筆・再構成したものです。
記載の内容は執筆当時の情報であり、現在の仕様やベストプラクティスと異なる可能性があります。
実装にあたっては、必ず最新の公式ドキュメントをご確認いただくようお願いいたします。


はじめに

Ajaxで20件〜数千件の画像を読み込むページに対して、パフォーマンス改善に取り組んだ事例をご紹介します。

前提としての条件

  • 画像はAmazon S3に格納
  • URLは署名付きで、有効期限が設定されている
  • RailsのActiveStorageによって生成されるS3リダイレクトURLも併用可能であること

以上の条件を満たした上で、CloudFront側で有効期間内のキャッシュを保持することで、キャッシュ削除やハードリロード時のパフォーマンス改善を目指しました。

本記事では、CloudFrontの設定が完了している前提で、Rails側でどのような修正を行えばCloudFront経由の画像URLを取得できるのかという点にフォーカスしています。

参考サイト

Amazon CloudFront公式ガイド(2024年1月閲覧)https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/Introduction.html

Qiita, @may-solty,【AWS】CloudFrontで署名付きURLの設定方法(プライベートコンテンツの配信)(2024年1月閲覧)
https://qiita.com/may-solty/items/a1ac7d64f9a24efad4ab

Qiita, @may-solty,【Rails】CloudFrontの署名付きURLの作成方法(2024年1月閲覧)
https://qiita.com/may-solty/items/1bfa6a9e9d50e8b615e1

上記はCarrierWaveを前提とした内容のため、適宜読み替えて実装を修正しています。

Amazon S3の直接リンクをCloudFrontのリンクへ修正

修正前の実装例

# config/storage.yml
amazon:
  service: S3
  access_key_id: ..
  secret_access_key: ..
  region: ..
  bucket: ..

# models/cat.rb

has_one_attached :main_picture

# view(slim)

= image_tag @cat.main_picture #<----ここを修正していく

1. 複数環境へのデプロイを想定し、署名用のKey Pair IDおよび秘密鍵、CloudFrontホスト名を環境変数として設定

署名付きURLの生成
$ aws cloudfront sign \\
--url <https://xxxx.cloudfront.net/index.html> \\
--key-pair-id K3XXXXXXXXXXXX \\
--private-key file://PATH/TO/YOUR/private_key.pem \\
--date-less-than 2020-11-18T19:30:00+09:00

参考:
Qiita, @may-solty,【AWS】CloudFrontで署名付きURLの設定方法(プライベートコンテンツの配信) – 署名付きurlの生成(2024年1月閲覧)
https://qiita.com/may-solty/items/a1ac7d64f9a24efad4ab#署名付きurlの生成
で生成できる場合の設定例

下記の環境変数をセット

  • CLOUD_FRONT_HOST_NAME:xxxx.cloudfront.net
  • CLOUD_FRONT_KEY_PAIR_ID:key-pair-id
  • CLOUD_FRONT_PRIVATE_KEY:file://PATH/TO/YOUR/private_key.pem

2. CloudFrontのURLを取得するヘルパーメソッドを用意、使用

# helper
	# CloudFrontの署名付きURLの有効期間
  CDN_EXPIRE_TIME = 5.minutes.freeze

	def cdn_url_for(blob)
    url = "https://#{ENV['CLOUD_FRONT_HOST_NAME']}/#{blob.key}"
    @signer ||= Aws::CloudFront::UrlSigner.new(
										key_pair_id: ENV['CLOUD_FRONT_KEY_PAIR_ID'],
										private_key: ENV['CLOUD_FRONT_PRIVATE_KEY']
									)
    @signer.signed_url(url, expires: Time.current + CDN_EXPIRE_TIME)
  end

# view
= image_tag cdn_url_for(@cat.main_picture)

3. CSPにCloudFrontのホストを設定

# ApplicationControllerのbefore_filterでcspをセットしていたので、追加して指定
class ApplicationController < ActionController::Base
  after_action :allow_iframe
:
  def allow_iframe
      :
			@csp = {
        'default-src' => ["'self'", "'unsafe-inline'", "'unsafe-eval'", ...],
        'img-src' => ["'self'", 'data:', 'blob:', ...,
          "https://#{ENV['AWS_BUCKET']}.s3.#{ENV['AWS_REGION']}.amazonaws.com"],
        'object-src' => ["'none'"],
        'script-src' => ["'self'", "'unsafe-inline'", "'unsafe-eval'", 'blob:',
          '<https://www.googletagmanager.com>', '<https://www.recaptcha.net>', ...],
        'style-src' => ["'self'", ...],
        'form-action' => ["'self'", ...],
        'frame-ancestors' => ["'self'", ...]
      }
			
			# CloudFrontのホストを追加
			@csp.merge!({'img-src' => @csp['img-src'] | ["https://#{ENV['CLOUD_FRONT_HOST_NAME']}"]})

      response.headers['Content-Security-Policy'] = @csp.map { |k, v| "#{k} #{v.join(' ')}; " }.join('')
:

※当初、この設定を行っておらず画像が表示されない事象が発生しました(参考スクリーンショット)。

画像のURLが以下のような形式になって、画像が取得出来ていれば変更完了です。
https://xxxx.cloudfront.net/?Expires=1702356799&Signature=xxxxxx&Key-Pair-Id=xxxxx

パフォーマンス検証

Chromeのデベロッパーツールから、描画が完了した際に表示される「終了」時間を確認しました。

測定対象(読み込んだ画像)

  • 20枚 × 3回(計60枚)
  • 画像サイズの内訳
     - 1〜2MB:3枚
     - 200〜800KB:57枚

測定パターン

  • 表示環境
    • S3
    • CloudFront
  • ブラウザのリロード方法
    • 通常リロード
    • キャッシュ削除+ハードリロード
  • 描画パターン
    • 初期表示
      • Ajaxリクエストで画像20枚を読み込み、完了までの時間を測定
    • ページング読み込み
      • 1回あたり20枚×2回読み込み(スクロールにより 20枚/ページを2回読込)

測定結果

(※単位:秒)

ブラウザにキャッシュがない状態での読み込み速度については、わずかではありますが改善が見られました。

目次

社内勉強会時の質疑応答メモ

  • CloudFrontの利用がS3より低コストとなるケースもあるとの指摘がありました
    • 参考:
      Qiita, @yamamoto_y(yamayama), S3ウェブホスティングとS3 + CloudFront構成の料金比較(2024年1月閲覧)
      https://qiita.com/yamamoto_y/items/c58ae2083a792d8b7b0f
      月間で「1万リクエスト・5PB程度のデータ転送」が発生する規模のサイトでは、料金が多少安くなるケースもあるよう

  • 署名付きURLの発行には約500msの処理時間がかかるため、そもそも署名付きURLを使用しない方がパフォーマンス面では有利との意見がありました
    • 画像URLはS3のURLではなくRails側のURLを使用する
    • アクセス制御はRails側でログインを必須にするなどの制御を行う
    • 参考:
      Qiita, @oieioi, S3 + CloudFront で配信してる画像をRailsアプリの認証がないとみれなくする(2024年1月閲覧)
      https://qiita.com/oieioi/items/1813b208fc36fbec7d32
    • 仮に画像URLが外部に流出した場合でも、Rails 側のアクセス制御が有効となるため、一定のセキュリティは確保することができる
    • 1画像あたりの読み込み時間が約1/10程度まで短縮される可能性がある

おまけ①:署名付きURLではなくRails側アプリのURLで試してみた結果

質疑応答時の意見を踏まえ、署名付きURLではなくRails 側アプリのURLを使用する方法を試してみました。

パフォーマンスを確認したところ、約1000枚の画像を読み込むページでは、従来の方法の方が高速であることが分かりました。
これは、画像1枚ごとにユーザーのサインイン状態を確認する処理が入っているためで、サーバー側のレスポンス待機時間が長くなっていたことが原因と考えられます。

なお、before_filterでのチェックを1回で済ませる仕組みがあれば、多少高速化できる可能性があります。

おまけ②:パフォーマンス改善について

参考:
Qiita, @nuko-suke, Webフロントエンドパフォーマンスチューニング85選(2024年1月閲覧)
https://qiita.com/nuko-suke/items/50ba4e35289e98d95753

以下の CSS や JavaScript の読み込みタイミングは、
Rails では /app/views/layouts/application.html.slim に記述されていることが多いため、比較的早い段階で対応可能かと考えられます。

不要なCSSを削除する
使っていないCSSは削除しましょう。
Chromeのデベロッパーツールを使えば不要なCSSを洗い出すことができます。

引用URL:Qiita, @nuko-suke, Webフロントエンドパフォーマンスチューニング85選
https://qiita.com/nuko-suke/items/50ba4e35289e98d95753

不要なJavaScriptを削除する
使っていないJavaScriptは削除しましょう。
例えば、console.logは基本的にプロダクションのコードでは不要なので、eslintで検出するなりbabelで削除するなりします。

引用URL:Qiita, @nuko-suke, Webフロントエンドパフォーマンスチューニング85選
https://qiita.com/nuko-suke/items/50ba4e35289e98d95753

ファーストビューに影響のあるCSSはheadタグの先頭で読み込む
JavaScriptと違い、ブラウザのCSSの解析はHTMLの解析をブロックしません。
ファーストビューで読み込ませたいCSSはできるだけheadタグの先頭に読み込ませて、速くスタイリングされたファーストビューをユーザーに見せるようにしましょう。

引用URL:Qiita, @nuko-suke, Webフロントエンドパフォーマンスチューニング85選
https://qiita.com/nuko-suke/items/50ba4e35289e98d95753

ファーストビューに影響のないCSSはbodyタグの末尾で読み込む
逆にファーストビューに影響のないCSSはbodyタグの末尾で読み込ませることで、ブラウザにCSSの読み込みを遅延させます。

引用URL:Qiita, @nuko-suke, Webフロントエンドパフォーマンスチューニング85選
https://qiita.com/nuko-suke/items/50ba4e35289e98d95753

JavaScriptはbodyタグの末尾で読み込む
ブラウザはJavaScriptの解析を始めるとHTMLやCSSの解析をストップします。
なので、JavaScriptはbodyタグの末尾で読み込み、HTMLやCSSの解析が終わった後のJavaScriptを解析するようにしましょう。
ただし、Google Analyticsなどの解析用のJavaScript等は除きます。

引用URL:Qiita, @nuko-suke, Webフロントエンドパフォーマンスチューニング85選
https://qiita.com/nuko-suke/items/50ba4e35289e98d95753

パフォーマンス測定関連の参考

Zenn, BellStone, ブラウザの仕組みとパフォーマンス最適化(2024年1月閲覧)https://zenn.dev/bellstone/articles/6617bb4d403afc

PageSpeed Insights
https://pagespeed.web.dev

役に立ったらシェアしていただけると嬉しいです
  • URLをコピーしました!
  • URLをコピーしました!
目次