Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

【保存版】Active Storageの内部詳細と、5年以上productionで運用して得た知見(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

参考: Active Storage の概要 - Railsガイド

元記事はRuby on Rails Discussionsへの書き込みですが、「公式ドキュメントにしたい」との声もあったほど大きな反響を呼びました。

2023年の情報につき、本記事のクラウドなどの情報はその後状況が変わっている可能性もありますのでご了承ください。

【保存版】Active Storageの内部詳細と、5年以上productionで運用して得た知見(翻訳)

🔗 1: 概要

私の会社では、ユーザーアップロードの実装が必要になる直前に、幸運にもRails 5.2(とActive Storage)がリリースされました。そして、Heroku、AWS、GCPという3つのホスティングサービスと、S3、GCS、R2という3つの異なるストレージプロバイダにわたって、Active Storageを5年以上production環境で使ってきました。

私たちの主なユースケースは、私たちが販売している製品の画像や、ユーザーがWebサイトにアップロードした結婚式/誕生日/出産祝いの画像のギャラリーです。つまり、私達の場合は画像変換処理に大きく依存しており、Active Storageを使うと、画像生成が簡単になります。

私たちはActive Storageを気に入っていますが、その代わり、知っておく必要のある設計上の決定事項がいくつもあります。これらは、Active Storageを使っているページのみならず、アプリケーション全体のパフォーマンスにも影響を及ぼします。

🔗 2: Active Storageの基本を理解する

🔗 2.1: 基本的なユースケースの例

この後で説明する内容を理解するには、まずActive Storageの内部では何が行われているかを押さえておく必要があります。

ここでは、以下のCompanyモデルがあり、企業のロゴ画像をActive Storageに保存したいとします。

class Company < ApplicationRecord
  has_one_attached :logo
end

これで、以下を実行するとファイルを添付できるようになります。

@company.logo.attach(io: File.open(Rails.root.join("public/favicon-192.png")), filename: 'logo.png')

さらに、以下のように書くことで、添付ファイルを最適化(=サイズの大きい画像を小さくする)したものを表示できるようになります。Active Storageでは、このような最適化した画像を「バリアント(variant)」と呼んでいます。

<%= image_tag @company.logo.variant(resize_to_limit: [200, 200], saver: { strip: true, compression: 9 }) %>

このページを開くと、企業ロゴのサイズが幅200px高さ200pxにリサイズされ、容量削減のためデータ圧縮も行われます。この処理のために、Comppanyモデルのテーブルにカラムを追加する必要はありません。バリアントが欲しいと指定すればバリアントが手に入ります。

🔗 2.2: Active Storageの3つのテーブルの内容

Active Storageをインストールすると、以下の3つのテーブルが作成されます。

  • active_storage_blobs
  • active_storage_attachments
  • active_storage_variant_records
🔗 2.2.1: active_storage_blobs

このテーブルには、添付したファイルに関する情報が保存されます。

key
ストレージサービス内で使われるファイル名を指定します。これはActiveStorage::Blobgenerate_unique_secure_tokenメソッドで生成されます。生成される名前には数値と英小文字のみが含まれます。
filename
添付したときの元のファイル名です。
content_type
marcel gemによって切り出され、バイナリデータに基づいて宣言されるContent-Typeとファイル名です。
metadata
ActiveStorage::Blobanalyzeによって切り出されます。高さ/幅/回転など、ファイルに関するさまざまなメタ情報を含められます。
service_name
ファイルアップロード先のストレージサービス名です。これは初期実装にはありませんでしたが、添付ファイルごとにサービスを使い分けるために後から追加されました。
byte_size
ファイルサイズです(単位はバイト)。
checksum
これについて気にする必要はありません。Active Storageでは、ファイルが壊れていないことを確認するのに使われます。
🔗 2.2.2: active_storage_attachments

このテーブルは、blob1(ファイル)をモデルに接続するのに使うjoinテーブルです。

blob_id
active_storage_blobsテーブルを参照します。
record_typeおよびrecord_id
自分のモデルへのポリモーフィック参照です。本記事の例ではrecord_type = 'Company'となります。
name
has_one_attached属性です。本記事の例ではname = 'logo'となります。
🔗 2.2.3 active_storage_variant_records

このテーブルは、バリアントが既に生成済みかどうかをトラッキングします。これは初期の実装にはありませんでしたが、ストレージサービスにバリアントファイルが既に存在するかどうかを確認しなくて済むようにするため、最適化として後から追加されました。

blob_id
active_storage_blobsテーブルを参照します。これは元のblobであり、バリアントblobではありません
variation_digest
作成したレコードで.variantメソッドにオプションを渡すことで生成されます。本記事の例では、{ resize_to_limit: [200, 200], saver: { strip: true, compression: 9 } }というハッシュのダイジェストになります。

🔗 2.3: Active Storageの内部のしくみ

🔗 2.3.1: 画像を添付するときのフロー
  1. Active Storageは、active_storage_blobsテーブルに新規レコードを1件作成する(B1
  2. Active Storageは、active_storage_attachmentsテーブルに新規レコード(A1)を作成し、上で当該companyに対して新規作成されたblob(B1)に接続する。
  3. Active Storageは、ファイルをストレージサービスにアップロードする。このとき、blob(B1)で生成された名前をファイル名として使う。
  4. Active Storageは、blob(B1)を処理してそこからメタデータを抽出するためのActiveStorage::AnalyzeJobをエンキューする。
🔗 2.3.2: 画像を表示するときのフロー

image_tagで(バリアントではなく)元のファイルを表示する場合は、以下のようになります。

  1. Active Storageは、name = "logo"を持つcompanyのactive_storage_attachmentテーブルのデータ(A1)を探索します。
  2. Active Storageは、添付ファイルの参照であるactive_storage_blobテーブルのデータ(B1)を探索します。
  3. Active Storageは、(ストレージではなく)自分のコントローラのいずれかを指すURL(/rails/active_storage/blobs/:signed_id/*filename)を生成します。

image_tagでバリアントを表示する場合は、以下のようになります。

  1. Active Storageは、name = "logo"を持つcompanyのactive_storage_attachmentテーブルのデータ(A1)を探索します。
  2. Active Storageは、添付ファイルの参照であるactive_storage_blobテーブルのデータ(B1)を探索します。
  3. Active Storageは、(ストレージではなく)自分のコントローラのいずれかを指すURL( /rails/active_storage/blobs/representations/:signed_id/:variation_key/*filename)を生成します。

ブラウザから元の画像のURLをリクエストされると、以下のようになります。

  1. リクエストがActiveStorage::BlobsController#showにルーティングされます(Rails 6.1より前の場合)。
    現在は2つのコントローラが利用可能(2.2.3を参照)。
  2. Active Storageは、署名済みidにあるactive_storage_blobテーブルのデータ(B1)を探索します。
  3. Active Storageは、そのリクエストを、ストレージサービス内で生成されたファイルのURLにリダイレクトします。

ブラウザから、バリアント画像のURLを初めてリクエストされると、以下のようになります。

  1. リクエストがActiveStorage::BlobsController#showにルーティングされます(Rails 6.1より前の場合)。
    現在は2つのコントローラが利用可能(2.2.3を参照)。
  2. Active Storageは、署名済みidにあるactive_storage_blobテーブルのデータ(B1)を探索します。
  3. Active Storageは、variation_keyをオプションのハッシュにデコードします。
    (本記事の例では { resize_to_limit: [200, 200], saver: { strip: true, compression: 9 } }のようになります)
  4. Active Storageは、active_storage_variant_recordsが存在するかどうかをチェックしますが、何も見つかりません。つまり、このバリアントはまだ生成されていないことになります。
  5. Active Storageは、ストレージからファイルをダウンロードします。
  6. Active Storageは、active_storage_variant_recordsテーブルに新規レコードを1件作成します(V1)(これは元のblobを参照します)。
  7. Active Storageは、これから生成するバリアント用の新規レコードをactive_storage_blobsテーブルに作成します(B2)。
  8. Active Storageは、作成した新規blobレコード(B2)と新規バリアントレコード(V1)をそれぞれ参照する新規レコードをactive_storage_attachmentsテーブルに作成します(A2)。
  9. Active Storageは、ステップ2で得たオプションをimage_processing gemに渡してファイルをダウンロードすると、新しいファイルが生成される。
  10. Active Storageは、新規作成されたファイルをストレージサービスにアップロードする。このとき、blob(B2)で指定されたkeyをファイル名として使う。
  11. Active Storageは、このリクエストを、ストレージサービス内の生成ファイルを指すURLにリダイレクトする。

ブラウザから、バリアント画像のURLを再度リクエストされると、以下のようになります。

  1. リクエストがActiveStorage::BlobsController#showにルーティングされます。
  2. Active Storageは、署名済みidにあるactive_storage_blobテーブルのデータ(B1)を探索します。
  3. Active Storageは、variation_keyをオプションのハッシュにデコードします。
    (本記事の例では { resize_to_limit: [200, 200], saver: { strip: true, compression: 9 } }のようになります)
  4. Active Storageは、active_storage_variant_recordsテーブルで(V1)を探索して、V1を見つけます。
  5. Active Storageは、バリアントレコード(V1)を参照するレコード(A2)をactive_storage_attachmentテーブルで探索します。
  6. Active Storageは、この添付ファイルを参照するレコード(B2)をactive_storage_blobテーブルで探索します。
  7. Active Storageは、このリクエストを、ストレージサービス内の生成ファイルを指すURLにリダイレクトする。
🔗 2.3.3: ファイルを「リダイレクトモード」「プロキシモード」「publicモード」で配信する

Rails 6.1までのActive Storageでは、画像配信に使えるのはリダイレクトモードだけでした。リダイレクトモードでは、Active Storageのいずれかのコントローラ(元のファイルのblobか、バリアントの表現)へのURLを生成し、そのコントローラが、そのリクエストを「ストレージに実際にあるファイルを指すURL(署名済み、期限付き)」へリダイレクトします。
リダイレクトモードによる配信は、privateファイルの場合は問題なく機能します。つまり、public画像を配信しようとすると、CDNでキャッシュされなくなる可能性があったのです。

ありがたいことに、Railsコントリビュータによって以下の2つのモードが追加されました。

  1. プロキシモード#34477
    リクエストをストレージにリダイレクトするのではなく、リクエストされたファイルをActive Storageがストリーミングします。これにより、CDNで画像をキャッシュできるようになります。その代わり、Pumaのワーカーの1つがストリーミングのためにビジー状態になります。

  2. publicモードfeab703
    publicモードでは、生成されるURLが変更されて、Active Storageのコントローラを指すのではなく、ストレージ内のファイルを直接指すようになります。つまり、画像ファイルがリクエストされたときにアプリに余分な負荷が発生しなくなります。その代わり、アプリがビューを生成するときにバリアントのURLを生成するための負荷が(クエリの形で)発生します。

Active Storageで利用されるコントローラと、生成されるURLについて以下の表に示します。

モード 使われる画像 使われるコントローラ 生成されるURL
リダイレクト 元の画像 ActiveStorage::Blobs::RedirectController /rails/active_storage/blobs/redirect/:signed_id/*filename
リダイレクト バリアント画像 ActiveStorage::Representations::RedirectController /rails/active_storage/representations/redirect/:signed_blob_id/:variantion_key/*filename
プロキシ 元の画像 ActiveStorage::Blobs::ProxyController /rails/active_storage/blobs/proxy/:signed_id/*filename
プロキシ バリアント画像 ActiveStorage::Representations::ProxyController /rails/active_storage/representations/proxy/:signed_blob_id/:variantion_key/*filename
public 元の画像 (該当なし) ストレージサービスごとに異なる(S3、Google Storageなど)
public バリアント画像 (該当なし) ストレージサービスごとに異なる(S3、Google Storageなど)
🔗 2.3.4: ダイレクトアップロードのフロー

ユーザーに写真のアップロードを許可する場合、デフォルトのビルダーの組み込みメソッドを以下の2通りの方法で利用できます。

<%= form.file_field :attachments %>
<%= form.file_field :attachments, direct_upload: true %>

1つ目の方が使いやすいと言えます。ファイルをアプリにアップロードすると、アプリがストレージサービスにファイルをアップロードします。

2番目の方法であるダイレクトアップロードは、ストレージでCORSを設定する必要があるのと、SPA(Single Page Application)を構築する場合はおそらくアップロードを処理するためのカスタムJavaScriptを書く必要が生じるため、その分複雑になります。
その代わり、ファイルのアップロードをアプリが処理する必要がないというメリットがあります(これが重要な理由については2.4で後述します)。

ダイレクトアップロードのフローは以下のようになります。

  1. フォームを送信すると、Turboがdirect_uploadを認識して送信を停止します。
  2. TurboがPOSTリクエストを送信します。
  3. Active Storageは、そのファイルの新規レコードをactive_storage_blobsテーブルに作成します(B1)。
  4. Active Storageは、Turboがファイルをアップロードするときに必要となる「blob」と「署名済みセキュアURL」に関する情報を含むJSONを返します。
  5. Turboは、ファイルをストレージにアップロードして、Active Storageから取得したsigned_idを、ファイルの当該フィールド値に挿入します。
  6. Turboは、フォーム送信を再開します。
  7. アプリのコントローラがパラメータを受け取って、それを用いてモデルの保存や作成を行うと、Active Storageは、ファイルの当該フィールドから取得した値を用いてblob(B1)を探索します。
  8. Active Storageは、active_storage_attachmentsテーブルで新規レコードを1件作成します(A1)。これは、今のblob(B1)とモデルを参照します。
  9. Active Storageは、そのblob(B1)を処理してメタデータを抽出するためにActiveStorage::AnalyzeJobをエンキューします。
🔗 2.3.5: PNGのフォールバック

Active Storageにはさまざまなオプションがありますが、画像を表示するうえで重要なコンフィグオプションが2つあります。

web_image_content_types
ユーザーに配信できる画像のフォーマットを指定します。デフォルトはpngjpeggifだけです。
variable_content_types
サーバーにインストールされているバージョンのlibvipsやimage_magickで処理できる画像フォーマットを指定します。

この2つのコンフィグオプションによって、(バリアントでない)元の画像を表示するときのActive Storageの振る舞いを制御できます。

画像がweb_image_content_typesオプションで指定されているフォーマットを使っている場合、Active Storageはそのファイルをそのまま配信します。

それ以外の場合は、variable_content_typesオプションで指定されているフォーマットのリストに該当するかどうかをチェックします。
該当する場合は、PNGに変換します(この変換は、アプリで使っている画像ライブラリでサポートされていることを暗黙の前提としています)。
どちらのリストにも該当がない場合は、画像をバイナリファイルとして配信します。

おそらく実用上は、これらのリストに2箇所変更を加える必要があるでしょう。

  • webp画像を配信する場合は、web_image_content_typeswebpを追加する必要があります。

  • 現在利用しているディストリビューションリポジトリ環境にあるimage_magickやlibvipsを使う場合は、variable_content_typesのリストにあるフォーマットの一部がサポートされていない可能性が高いので、そうしたフォーマットを削除しておく必要があります。さもないと、エラー処理サービス(SentryやHoneybadgerなど)がエラーで溢れてしまうでしょう。

🔗 2.3.6: 分析ジョブ

Active Storageは、ファイルがモデルに添付されるたびに、ActiveStorage::AnalyzeJobのインスタンスをエンキューし、続いてblobのanalyzeメソッドを呼び出します。このanalyzeメソッドは、ファイルからメタデータを抽出するために使われます。この処理は、デフォルトのActive Storageで利用できるさまざまなアナライザのいずれかを指定することで行われます。

ActiveStorage::Analyzer::ImageAnalyzer
画像の幅(width)や高さ(height)を抽出できます。
ActiveStorage::Analyzer::VideoAnalyzer
ファイルに音声チャンネルや動画チャンネルが含まれている場合は、画像の幅(width)や高さ(height)の他に、duration、angle、aspect ratioも抽出できます。
ActiveStorage::Analyzer::AudioAnalyzer
音声データのduration、bit rate、sample rateを抽出できます。

通常はこれで十分ですが、独自のアナライザを書くことで、他の情報もファイルから手軽に抽出できるようになります。
たとえば、PNG画像を安全にJPG画像に変換してファイル容量を削減するために、ファイルに透明度(transparency)が設定されているかどうかをチェックしたい場合は、libvipsを用いて以下のようなカスタムアナライザを書けます。

class MyAnalyzer < Analyzer::ImageAnalyzer::Vips
  def metadata
    read_image do |image|
      if rotated_image?(image)
        { width: image.height, height: image.width, opaque: opaque?(image) }.compact
      else
        { width: image.width, height: image.height, opaque: opaque?(image) }.compact
      end
    end
  end

  private
    def opaque?(image)
      return true unless image.has_alpha?
      image[image.bands - 1].min == 255
    rescue ::Vips::Error
      false
    end
end

続いて、これをアナライザのコンフィグ配列に追加します。

Rails.application.config.to_prepare do
  Rails.application.config.active_storage.analyzers.prepend MyAnalyzer
end

🔗 2.4: これらがアプリにどんな影響を及ぼすか

ここまでで、Active Storageの背後で動いている特有のマジック(画像を添付する方法、カラムやテーブルを追加せずにバリアントを作成する方法)の仕組みを理解できました。
ここからは、Active Storageに関するいくつかの経験則と注意点を紹介します。以下の説明は、処理する対象が画像であるか、他のファイルタイプであるかにかかわらず重要です。

🔗 2.4.1: 添付ファイルを含むレコードを複数表示するときは、N+1クエリが発生しないよう十分注意すること

既に見てきたように、companyのロゴを表示するには、Active Storageが別途クエリを発行する必要があります。最初に添付ファイルを(joinテーブルで)検索するためのクエリ、そして実際のファイルのblobを検索するためのクエリです。
こうした余分なクエリが発行されないようにするには、以下のようにスコープを利用するか、手動で対応します。

@companies = Company.all.with_attached_logo
@companies = Company.all.includes(:logo_attachment, :logo_blob)

補足しておくと、以下の2つのパターンを使っています。

  • with_attached_ATTRIBUTE
  • includes(:ATTRIBUTE_attachment, :ATTRIBUTE_blob)

ただし、ATTRIBUTEは、has_one_attachedに渡した名前です。

🔗 2.4.2: アプリを低速なクライアントから保護するには、常にダイレクトアップロードでファイルをアップロードすること

ユーザーがファイルを(ストレージに直接ではなく)サーバーにアップロードすると、Pumaワーカーの1つがビジー状態になって、他のリクエストを処理できなくなってしまいます。ユーザーの接続が低速な場合や、ユーザーが巨大なファイルをアップロードしている場合は、処理に長時間かかってしまう可能性があります。

コンカレンシー設定の低いWebサーバー数台だけで実行している場合(Herokuの例で言うと、標準の2x web dynoを2つ実行している場合)、ワーカーはわずか4つになる可能性があります。この状態で「接続の遅いユーザー」が2人いると、2人のユーザーがアップロードしている間はサーバーの処理能力が半減してしまいます。つまり、他のユーザーのページ遷移が遅くなり、不満と悪印象を与えてしまうことになります。

🔗 2.4.3: プロキシモードを使う場合は、アプリを低速なクライアントから保護するために、サーバーとユーザーの間にnginxかCloudflareを配置すること

これも上と同じ理由です。プロキシモードのActive Storageでは、ファイルがユーザーにストリーミングされている間はPumaのワーカーがビジー状態になります。ユーザーの接続速度が遅いと、ワーカーのビジー状態が長時間に渡る可能性があります。

アプリの手前にnginxやCloudflare(のようなリバースプロキシ)を配置することで、ストリーミングをそこでバッファリングできるよう設定可能になり、クライアントが低速な場合でも、ワーカーがファイル全体の送信を迅速に完了して他のリクエストに対応できるようになります。これは、Pumaで行うよりもずっと優れています。

🔗 2.4.4: オンデマンドのバリアント生成はよい機能だが、気をつけないとアプリ全体が止まってしまう可能性がある

小規模なアプリを使っている、または水平スケーリングしたいという理由で、メモリ節約(1GBを超えないなど)のためにvCPUを1〜2個しかサーバーに割り当てていない状況を考えてみましょう。このとき、Pumaに設定されるワーカー数は2〜3で、メモリの80〜90%をアプリで消費しているとします。

このアプリには、ユーザーが複数の写真をアップロードできるページがあります。アップロードが完了すると、ユーザーのギャラリーページにリダイレクトされます。ギャラリーページには、サイズの小さい圧縮済みの写真が表示されます。

さて、あるユーザーが写真を10枚いっぺんにアップロードしたとします。ユーザーがリダイレクトされると、ブラウザが10枚の写真(サイズの小さい圧縮済みバージョン)を取得するためのリクエストを送信します。
この時点では、10枚の写真のバリアントはまったく生成されていないので、サーバーは10枚の写真をダウンロードして同時に処理することになります。
つまり、多数のバリアント生成処理によって、貴重なCPU時間が奪い合いになり、残り少ないメモリを食い尽くしてスワップが発生します。

こうなると、画像生成リクエスト以外の処理も目に見えて遅くなってしまいます。それ以外のリクエストを処理するためのCPU時間を確保するだけでも苦労し、オブジェクトをアロケーションするにはメモリをスワップしなければならなくなります。
私は、問題のページをAPMで表示したときに、通常のレスポンスタイムは100ms以下のはずが、200〜300msになってしまったのを見たことがあります。原因は、imagemagickでPNGファイルを処理している間は、シンプルな.findメソッドでも50msかかるためです。

さらに、ワーカーの合計数が10を下回っている場合、画像リクエストの一部がロードバランサーのキュー内で画像の生成完了を待っている状態になります。そうなると、以後他のユーザーからのページ移動リクエストもすべて待ち状態になってしまいます。

これを軽減する方法はいくつかあります。

  • imagemagickをlibvipsに置き換える
    libvipsの方が高速かつメモリ使用量も押さえられます。

  • サーバーを水平スケーリングするのではなく、垂直スケーリングする
    垂直スケーリングすることで、1個のサーバー内にあるすべてのワーカーが占有される可能性が減り、空いたvCPUによって画像が高速に処理されるようになります。

  • メモリの空き容量に余裕があまりない場合は、サーバーにメモリを追加してスワップを回避すること
    AWSでは、ファミリーを切り替える(c6iからm6iなど)ことを意味します。
    CGPでは、カスタムマシンを使って1GB RAMを追加します。

  • バリアントをバックグラウンドジョブで事前生成する#47473

🔗 3: production環境でActive Storageから画像配信するときに知っておくべきこと

ここまでで、Active Storageの仕組みに加えて、Active Storageの設計上の決定事項がアプリに与える影響についても理解できました。

次は、私たちがproduction環境でActive Storageを運用し続けて得た教訓についてお話ししたいと思います。

警告: 以下の文章は少々長くなっています。また、Active Storage固有の問題ではない、一般的な画像処理についての話も含まれています。

🔗「ImageMagickが遅くてメモリ食いでセキュリティ問題だらけで困ってます...」

Rails 7でlibvipsがデフォルトになった理由がまさにこれです。
詳しい説明については、私がdiscussionに投稿したMake Vips the recommended/default variant processor for Active Storageのスレッドや、ImageMagickの既知のCVE情報お読みください。

🔗「今のディストロのリポジトリにある画像処理ライブラリのバージョンが古くて、サポートされているフォーマットが少なくて困ってます...」

私たちがHerokuを使わなくなった主な理由の1つがこれです2
私たちは、18.04 LTSで足止めされてしまい、もっと新しいlibvipsが使えるバージョンを使う方法がありませんでした。幸い、2023年の現在は、どのLTSディストロでも、十分新しいlibvipsをリポジトリで利用できるようになっているので、以前ほど問題ではなくなりました。

しかし、どのファイルフォーマットがサポートされているかは今も問題です。
すべてのディストロでサポートされているのは「JPEG、PNG、BMP、TIFF、GIF」、これで全部です(適切なパッケージをインストールしていれば、WEBPもサポートされますが)。
しかしユーザーはどんなフォーマットのファイルを投げてくるかわかったものではありません。ユーザーは、HEICやらAVIFやらJPEG2000やらJPEGXLやら、目に付くものなら何でもアップロードしようとするものです。

では、他のフォーマットのサポートはどうすればよいのでしょうか?
必要なファイルフォーマットごとにdevパッケージをインストールして、画像ライブラリ(ImageMagickかlibvips)を自分でソースコードからコンパイルし、そのパッケージに正しくリンクされるようにすることになるでしょう。

はい、ソースコードからコンパイルしてください。

既にそうしているのであれば、この機会にlibpngとlibjpeg-turboはシステムから削除して、もっと高速なlibspngと、さらに優秀なmozjpegに置き換えることをおすすめします(後述しますが、最後のmozjpegは重要です)。

🔗「せっかくaccept='image/jpeg,image/png'で画像フォーマットを制限しているのに、ユーザーはお構いなしに他の画像フォーマットでもアップロードするので困ってます...」

詳しい状況はわかりませんが、どうやらAndroidブラウザの1つがaccept属性(または指定のファイル形式)を完全に無視しているせいで、ユーザーがどんなフォーマットでも好きにアップロードできてしまっているようですね。私たちが画像ライブラリで多数のファイルフォーマットをサポートしなければならなかった理由のひとつが、これです。

🔗「ついでですけど、動画ファイルだからといって、音声ストリームや動画ストリームとは限らないですよね...」

おっしゃるとおりです。Androidのボイスレコーダーで使われているファイルフォーマットは、動画として認識されてしまいます(#42431)。

つまり、Webアプリ内で動画を扱うときは、blobのメタデータをチェックしておくこと。さもないと、動画だと思っていたものが実は動画チャンネルを持っていないということになりかねません。

🔗「CDNは画像をキャッシュできると言ってるのに、できなくて困っています...」

Cloudflareのドキュメントを呼んでいくつかテストを実行してみた所、最初の2回の画像リクエストはキャッシュMISSになって、リクエストがアプリに到達します。しかし3回目の画像リクエストはキャッシュHITになり、リクエストはアプリに届かなくなりました。これは正常なんでしょうか?

違います。

これは、配信されたリクエストを処理するデータセンター(PoP: Point of Presence)に、画像のキャッシュが保存されているということです。しかしCDNにはデータセンターが1つしか存在していないわけではありません。CDNは、世界中に何十ものデータセンターを配置しており、Cloudflareは米国内だけでも46箇所にのぼります。しかも、キャッシュがPoP間で自動的に伝搬されるプレミアムプランに課金する(またはArgoなどのアドオンを使う)のでない限り、各PoPは画像1個につきリクエストを2つずつ送信することになります。

つまり、画像が完全にキャッシュされるまでは、米国内だけも92件ものリクエストがPumaサーバーに到達することになります。これは正常なんでしょうか?

これも違います。

御存知の通り、画像がキャッシュされているからといって、そこにとどまっているとは限りません。TTLを1か月に設定したとしても、キャッシュが維持されているかフレッシュかはあくまで別の話です。特定のPoPで画像のリクエスト数が不十分な場合、そのキャッシュは削除されます。つまり、そのPoPではサーバーへのリクエスト送信の回数が2倍になるわけです。

もちろん、ロゴ以外のJSファイルやCSSファイル、ホームページ上の画像などについては、キャッシュに残るのに十分なリクエスト数があります。しかし大量の画像を扱うWebアプリを実行すると、大変なことになります。
動かしているサーバーが数台しかなければ、リクエストのキューイングに時間がかかっていることがはっきり感じられるでしょう(ちゃんと監視してますか?)。ユーザーが商品リストの中から6個ほど気に入った商品をそれぞれ別タブで開いただけで、Active Storageのプロキシコントローラにいきなり何10件ものリクエストが押し寄せることになります(バリアントが処理済みであることを心よりお祈りいたします)。

🔗「nginxは問題解決に役立つけど、気をつけないと自分の足を撃ち抜いてしまうんですよね...」

nginxを設定することで、アプリが配信するすべてのファイル(プロキシコントローラがストリーミングするものも含めて)をディスクに保存できるようになり、同じリクエストがもう一度来たらキャッシュから直接配信できるようになります。こうすることで、Pumaワーカーが他のリクエストを処理する余裕が生まれます(さらに遅いクライアントからも保護できます)。

ただし、このような設定を行うときは、Set-Cookieヘッダーに proxy_hide_headerproxy_ignore_headerを使うことを絶対に忘れてはいけません。さもないと、nginxがセッションcookie(アプリがセキュリティ関連の情報(現在ログイン中のユーザーなど)を保存している可能性があります)をキャッシュしてしまいます。

これがどういうことかおわかりですか?
つまり、ユーザーAがアプリにログインして、普通にページを移動しているときに、突然無関係なユーザーBのcookieが設定されて、ユーザーBのキャッシュ済み画像が表示されてしまうということです。これがどれほど深刻であるかは言うまでもありませんよね?特にユーザーBが管理者だったとしたらなお深刻です。

🔗「配信する画像を適切に最適化するのが難しくて困ってます...」

私からのおすすめ情報として、画像の縦横サイズを2xにすることをおすすめします(つまり、100x100の画像を表示するなら、200x200にリサイズしたものを使うということです)。理由は、それ以上サイズを大きくしても意味がないためです↓。

参考: Capping image fidelity on ultra-high resolution devices

そのうえで、以下のいずれかの変換方法を適用しましょう。

  • Vipsライブラリを使っている場合:
# JPEG
user.avatar.variant({ saver: { strip: true, quality: 80, interlace: true, optimize_coding: true, trellis_quant: true, quant_table: 3 }, format: "jpg" })

# PNG
user.avatar.variant({ saver: { strip: true, compression: 9 }, format: "png" })

# WEBP
user.avatar.variant({ saver: { strip: true, quality: 75, lossless: false, alpha_q: 85, reduction_effort: 6, smart_subsample: true }, format: "webp" })
  • ImageMagickライブラリを使っている場合:
# JPEG
user.avatar.variant({ saver: { strip: true, quality: 80, interlace: "JPEG", sampling_factor: "4:2:0", colorspace: "sRGB", background: :white, flatten: true, alpha: :off }, format: "jpg" })

# PNG
user.avatar.variant({ saver: { strip: true, quality: 75 }, format: "png" })

# WEBP
user.avatar.variant({ saver: { strip: true, quality: 75, define: { webp: { lossless: false, alpha_quality: 85, thread_level: 1 } } }, format: "webp" })

上のオプションについていくつか注意事項があります。

  • formatキーワードを必ず追加すること。
    最近のAndroid携帯の中には、拡張子が.jfifの画像ファイルを送信するものがあります。これによって、正常なJPEGであってもlibvipsで処理できなくなります。
    format: :jpgを明示的に追加することで、このファイルをJPEGとして扱えるようになります。

  • optimize_codingtrellis_quantquant_tableオプションは、画像ライブラリとしてmozjpegをコンパイルしておかないと動きません。
    デフォルトのlibjegを使っている場合、これらのオプションは無視されます。なお経験上、mozjpegは品質の目立った低下もなく、20〜35%余分にサイズを削減できます。これは、多くの画像でJPEGをWEBP並に処理するのに十分な能力があります。

🔗「LighthouseやPageSpeedの推奨事項がどうもよいと思えないんですよね...」

LighthouseやPageSpeedでは、ページ上の画像について「WEBPを使うこと」「画像を表示と同じサイズに変更すること」を推奨しています。

これらは、常によいアドバイスとは限りません。
WEBPはインターレースをサポートしていないので、画像が完全にダウンロードされるまで表示できません。
JPEGではインターレースがサポートされているので、ファイルサイズが大きくても(mojzpegを使っていればもっと小さくなる可能性もあります)、低解像度バージョンは完全なWEBPバージョンよりも表示が高速になります。

また、画像のリサイズはやりすぎない方がよいでしょう。
たとえば、アプリに商品一覧ページがあり、拡大版の写真リストと、ユーザーがクリックすると別の写真が表示されるサムネイルのリストが表示されるとします。
この場合は、拡大版の写真リストと同じバリアントを使う方がよいでしょう。それによって、ユーザーがサムネイルをクリックしたときに、ブラウザのキャッシュで確実にファイルが見つかるので、すぐに表示されるようになります。

🔗「ストレージサービスはどこも一長一短ですよね...」

S3 gemは、失敗時に自動的にリトライするように設定されており、デフォルトのタイムアウトは60秒です。つまり1回のアップロードが完了するまでに数分かかり、その間ずっとワーカーがビジーになります。

Google Cloud Storageは、2件のリクエストが同じメタデータを同時に更新しようとするとエラーになります。あるblobのActiveStorage::AnalyzeJobを同時に.analyzeで実行しようとすると、コードが動かなくなります。

R2はまだ若いサービスであり、使い始めて数か月もすると、早くも一時的なコネクション問題などがいくつか発生しています。

🔗「ストレージサービスを乗り換えたいんだけど、乗り換え先を決めると会計からにらまれそう...」

R2を使っている場合を除いて、料金は転送されたGB数ごとに発生します。
また、Active Storageはファイルをフォルダに分割することを許していないので、元のファイル(再取得できない可能性あり)とバリアントファイル(再生成可能)を区別する方法がないということになります。すなわち、すべてを転送するには料金を支払う必要があります。

🔗 ボーナス1:「has_many_attachedは、"Active Storageの優秀な機能リスト"にはまず入らないでしょうね...」

has_many_attachedは、使うたびに後悔してしまいます。ただしその理由は、has_many_attachedが悪い機能だからではなく、これを使っているうちに、いつの日か、ファイルがblobの場合には追加できない情報を追加する羽目になるからです。

たとえば、Productに多数のimagesがあり、以下のように書いたとします。

class Product < ApplicationRecord
  has_many_attached :images
end

それらしく見えますね。product.images.firstと書けばカバー画像も表示できるでしょう。

しかし、今から1か月後に、販売可能なすべてのProductをリスト表示するページを見てみると、衣料品のページの1つに、服のサイズ表の画像がカバー画像として表示されていることに気づきました。そこで、別の画像をカバー画像にしようとしたのですが、画像がblobなので、これはできません。さらに、画像に属性を追加することもできません(し、してはいけません)。

つまり、以下のようにモデルを作り直すしかありません。

class Product < ApplicationRecord
  has_many :photos
end

class Photo < ApplicationRecord
  has_one_attached :file
  validates :position, presence: true
end

それが終わったら、数百万個にのぼる可能性のある画像を、シンプルなblobから完全なモデルに移行しなければならないことに気づく羽目になります。

そういうわけで、皆さんも使うならぜひhas_one_attachedにしておきましょう!

🔗 ボーナス2:「コネクションプールは、思ったより大きなものが必要そうなんですよね...」

最近、SlackのCGRPグループで見かけた議論に、コネクションプール数をPumaのスレッド設定と同じに設定したにもかかわらず、ActiveRecord::ConnectionTimeoutErrorがスローされるというものがありました。
@tekin.co.ukが掘り下げたところ、Active Storageのプロキシモードが原因だったことがわかりました。詳しくは以下のブログをどうぞ。

参考: Why the advice to have a connection pool the same size as your Puma threads is (probably) wrong for you | tekin.co.uk

関連記事

Rails: Active Storageで知っておくべきアドバイス集(翻訳)

Rails 6.1: Active Storageのファイルをプロキシ経由で配信する(翻訳)

Rails: ActiveStorageでファイルを削除するときは、単にnilで更新するだけでいい

ActiveStorageでアップロードしたファイルとプレビュー画像に認証をかける


  1. 訳注: blobは「binary large object」の略です。参考: バイナリ・ラージ・オブジェクト - Wikipedia 
  2. 訳注: 現在はheroku-buildpack-vipsが利用できるようです。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。