2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

予期せぬS3アクセスを防止!CarrierWave 3.1系のパフォーマンス問題とその対処法を考える

2
Last updated at Posted at 2026-08-30

TL; DR(最初に結論)

  • CarrierWave を 3.0 系から 3.1 系に上げると、present? / image? / valid? / save を呼ぶだけで S3 への HEAD リクエストが発生し、一覧画面などでパフォーマンス問題を引き起こす恐れがある
  • CarrierWaveのmasterブランチでは present?(添付ファイルの有無)と exists?(ストレージ実在確認)を分離する形で解消済み。ただし本記事の執筆時点では未リリース
  • 3.0 系に留まる回避策は、既知の脆弱性と警告発生(Rails 8.1環境)のため推奨できない。当面は CW 3.1.3以上 + モンキーパッチが現実的(コード例は本記事で紹介)

はじめに

CarrierWave (以下CW)を 3.0 系から 3.1 系にアップグレードすると、post.image.present?post.image?を呼ぶだけでS3 への HEAD リクエストが発生するようになります。
レコードを一覧表示するページなら、レコード件数ぶんの HEAD リクエストが S3 に飛びます。

また、モデルの valid? / save を呼んだ場合も同様にS3へのHEADリクエストが飛びます。

そのため、CW を 3.0 系から 3.1 系にアップグレードすると、Railsアプリケーションのパフォーマンスが悪化する恐れがあります。

この記事ではその原因と回避策をまとめます。

参考:この記事で登場するモデルとUploaderについて

この記事では以下のような2種類のモデルとUploaderが登場します。
その前提で記事を読み進めていってください。

# 記事と、その記事に添付される画像
class Post < ApplicationRecord
  mount_uploader :image, ImageUploader
end

class ImageUploader < CarrierWave::Uploader::Base
  storage :fog

  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end
end
# 任意のファイルを添付できるモデル
class Attachment < ApplicationRecord
  mount_uploader :file, FileUploader
end

class FileUploader < CarrierWave::Uploader::Base
  include CarrierWave::Vips

  storage :fog

  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

  # 画像ファイルのときだけサムネイルを生成
  version :thumb, if: :image_file? do
    process resize_to_fit: [ 200, 200 ]
  end

  private

  def image_file?(new_file)
    new_file.content_type&.start_with?("image/")
  end
end

なぜ3.1ではS3へHEADリクエストを投げるようになったのか?

この変更は無意味に入ったわけではなく、古くからあるバグ報告(#1926)への対応でした。

CW には、条件付きで versionを生成する機能があります。たとえば「画像ファイルのときだけサムネイル用の version を作る」といった使い方です。

class FileUploader < CarrierWave::Uploader::Base
  # ...

  version :thumb, if: :image_file? do
    process resize_to_fit: [ 200, 200 ]
  end

  # ...

このとき「version が実際に生成されたかどうか」を attachment.file.thumb.present? で判定しようとすると、ストレージによって結果が変わるという問題がありました。

  • ローカルの file ストレージ: SanitizedFile#empty? がファイルシステムを実際に見るため、実体のない version は present?false
  • fog(S3)ストレージ: empty? が実装されておらず、ストレージに問い合わせないため、実体のない version でも present?true
# CW 3.0以前:
# attachment.file.thumb が実際に生成されていない場合、
# 以下のコードはローカルでfalse、本番でtrueになる
attachment.file.thumb.present?

「そんな不整合は滅多に起きないのでは?」と思うかもしれませんが、以下のような運用をすると発生します。

  • version を後からアップローダーに追加した場合、既存レコードには version の実体がないため、recreate_versions! を全レコードに流すまでの間(あるいは流し忘れたレコードで恒常的に)この状態になる
  • version の条件(if:)や名前を後から変更した場合、以前の定義では生成されなかったレコードが、新しい定義では「あるはず」と判定されるようになる

CW 3.1で上記の問題は解決された、が……。

CW 3.1 はこの問題を解消するために、fog ストレージにも empty? を実装し、「present? はストレージに実在するかを返す」方向で両者の意味を揃えました。

# CW 3.1:
# attachment.file.thumb が実際に生成されていない場合、
# 以下のコードはローカルでも本番でもfalseになる
attachment.file.thumb.present?

empty?メソッドについて

ここでいう empty?CarrierWave::Storage::Fog::File という内部クラスに追加されたメソッドで、アプリケーションコードから直接呼び出すものではありません。
attachment.file.empty? のように呼んでも NoMethodError になります。
present? / blank? を呼んだときに、アップローダーが内部で保持しているファイルオブジェクトに対して empty? が呼び出されます。

ただし、CW 3.1ではその代償として present? を呼ぶたびに S3 への問い合わせが発生するようになりました。

その結果、たとえば以下のようなケースでCW 3.0では発生していなかったS3への問い合わせが、3.1で発生するようになります(いずれも画像が添付されている場合。添付されていなければ3.1でもリクエストは発生しない)。

# 3.0 では S3 アクセスなし
# → 3.1 では HEAD リクエストが発生
post.image.present?
post.image.blank?
post.image.presence
post.image?
post.valid?
post.save

なお、valid? / save の HEAD リクエストは、画像のバリデーションを何も定義していなくても発生します。
CW が mount_uploader 時にいくつかのバリデーションを自動追加しており、その内部で blank?(= S3 への問い合わせ)が呼ばれるためです。

上に挙げたようなメソッドは Rails アプリの至るところで呼ばれるため、これがパフォーマンス問題として跳ね返ってくることになります(以下のissueを参照)。

参考情報
URLの生成やファイルサイズの取得など、3.0でも3.1でもS3アクセスの有無が変わらないものもあります。

# 3.0 でも 3.1 でも S3 アクセスなし
post.image.url
post.image_url
post[:image].present?
post.image_identifier

# 3.0 でも 3.1 でも HEAD リクエストが発生
post.image.size
post.image.file.exists?

masterブランチでこの問題が解消された :tada:

2024年12月のCW 3.1.0のリリース以来、この問題はずっと解消されないままでしたが、2026年8月28日の以下のコミットでようやく解消されました。

この変更により、CW 3.1でHEADリクエストが発生していた以下のようなコードは、再びリクエストが発生しなくなります(CW 3.0の挙動に戻る)。

# 3.0 では S3 アクセスなし
# → 3.1 では HEAD リクエストが発生
# → 最新のmasterでは再び S3 アクセスなしに
post.image.present?
post.image.blank?
post.image.presence
post.image?
post.valid?
post.save

また、ストレージに実際にファイルが存在するかどうかを確認したい場合は、新しく追加されたexists?メソッドを使用します。

# 最新のmasterではexists?メソッドが使える
# attachment.file.thumb が実際に生成されていればtrue、されていなければfalse
# なお、ファイルの実在確認を行うので、S3へのHEADリクエストは発生する
attachment.file.thumb.exists?

参考までに、コミットメッセージの日本語訳を以下に載せておきます(ChatGPTによる自動翻訳)。

【翻訳】`#blank?` でストレージにファイルの存在確認を問い合わせるのをやめる

#blank? でストレージにファイルの存在確認を問い合わせるのをやめる

Uploader#blank? はファイルに処理を委譲していましたが、リモートストレージではファイルの #empty? が HEAD リクエストを発生させます。

ActiveSupportでは #present?!#blank? として実装されているため、Railsで一般的な書き方をすると、ネットワークアクセスが発生していました。

たとえば、

  • user.avatar?
  • user.avatar.present?
  • validates :avatar, presence: true
  • シリアライザ
  • ビューでの存在チェック

などです。しかも、一覧画面で多数のレコードを表示する場合、レコードごとにこのリクエストが発生する可能性があります(#2802、#2776、#2784)。

CarrierWave::Storage::Fog::File#empty? は、両方のストレージで「ファイルが存在するか」という意味を揃えるために、3.1.0で追加されました(#1926)。ローカルファイルを扱う SanitizedFile#empty? がファイルシステムを確認するため、それに合わせたものです。

しかし、この変更によって、「ファイルが添付されているか」と「ファイルが実際にストレージに存在するか」という2つの異なる問題が混同されていました。

そこで、この2つを分離します。

  • #blank? / #present?ファイルが添付されているかを答える
  • 新しく追加する #exists?ファイルが実際にストレージに存在するかを答える

キャッシュされたファイルについては、キャッシュ名がすでに削除されたキャッシュを指している可能性があるため、引き続き存在確認を行います。ただしキャッシュはローカルにあるため、この確認はリモートストレージへの問い合わせとは違って低コストです。

また、versionについては、親ファイルが存在する場合には常に「present」と判断するようにしました。これは #1926 が、conditional version が作成されたかどうかを判定するために利用していた挙動です。実際にversionがストレージに存在するかどうかを確認したい場合は、明示的に #exists? を使います。

さらに、リモートストレージへの不要な問い合わせが発生していた2つのケースも修正しました。

  1. directory.files.head||= でメモ化されていましたが、ファイルが存在しない場合にはその結果が nil になるため、アクセスするたびに再度問い合わせが発生していました(#2698、#2793)。
  2. #size は、ストレージがcontent lengthを返さない場合に #zero? を呼び出してしまう問題がありました(#2787)。

現時点ではどう対処すべきか?

ただし、この記事の執筆時点(2026年8月31日)ではこの修正はまだ公式リリースされていません。

パフォーマンス問題を避けるなら、この修正がリリースされるまで、CW 3.0.xを使い続ければいいのでは?と思う人もいるかもしれません。
ですが、以下のような理由からそれはあまり推奨されません。

  • 既知の脆弱性 CVE-2026-44587 が残っており、修正版は 3.1.3 以降(3.0 系向けの修正リリースはない)
  • Rails 8.2 で削除予定の String#mb_chars を使っているため、Rails 8.1 では deprecation 警告が大量に出る

そのため、現実的にはセキュリティ修正済みの 3.1.3 以上を使ったうえで、パフォーマンス問題には別の方法で対処することになります。

たとえば、以下のようなモンキーパッチを適用すると、CW 3.1系を使いつつ、前述のパフォーマンス問題を避けることができます(Claude Codeで作成、筆者のローカル環境で動作確認済み)。

config/initializers/carrierwave_patch_2776.rb
# CarrierWave 3.1 系で present? / blank? / valid? などが S3 への HEAD リクエストを
# 発生させる問題(issue #2776)に対する master の修正コミットを 3.1 系に移植する
# モンキーパッチ(3.1.3 で動作確認済み)。
# https://github.com/carrierwaveuploader/carrierwave/commit/f635d88b9debeda27b25148856ca5e0faa186d17
#
# 修正込みのバージョン(4.0 予定)がリリースされたら、このファイルごと削除して
# bundle update carrierwave すること。

unless CarrierWave::VERSION.start_with?("3.1.")
  raise "carrierwave が 3.1 系以外(#{CarrierWave::VERSION})になっています。" \
        "このモンキーパッチがまだ必要か確認し、不要なら " \
        "config/initializers/carrierwave_patch_2776.rb を削除してください。"
end

module CarrierWavePatch2776
  module UploaderProxyPatch
    # blank? / present? は「ファイルが添付されているか」だけを返し、
    # ストレージには問い合わせない
    def blank?
      return true unless file
      # キャッシュ名が指す先のキャッシュは消えていることがあるため検証する。
      # キャッシュはローカルなので安価(リモートストレージへの問い合わせは発生しない)
      return file.empty? if cached?

      false
    end

    # ストレージ上の実在確認(リモートストレージでは HEAD リクエストが発生する)
    def exists?
      !!file&.exists?
    end
  end

  module FogFilePatch
    def read
      file_body = file&.body

      return if file_body.nil?
      return file_body unless file_body.is_a?(::File)

      # Fog::Storage::XXX::File#body could return the source file which was uploaded to the remote server.
      return read_source_file if ::File.exist?(file_body.path)

      # If the source file doesn't exist, the remote content is read
      # file のメモ化を defined? 方式に変えたことに合わせ、本家コミットと同じく
      # @file = nil ではなく remove_instance_variable でメモ化を解除する
      # (nil を代入すると「nil がメモ化された状態」になるため)。
      # なお fog-aws では body が String で返るためこの分岐には到達しないことを
      # 実測で確認済み(body が ::File を返しうる他の fog プロバイダ向けの移植)
      remove_instance_variable(:@file)
      file.body
    end

    def size
      file&.content_length || 0
    end

    private

    # 404(不在)もメモ化し、アクセスのたびに HEAD が再発行されるのを防ぐ
    def file
      return @file if defined?(@file)

      @file = directory.files.head(path)
    end
  end
end

CarrierWave::Uploader::Base.prepend CarrierWavePatch2776::UploaderProxyPatch
CarrierWave::Storage::Fog::File.prepend CarrierWavePatch2776::FogFilePatch

参考:サンプルアプリで実際の挙動を確認する

本記事で説明した内容はすべてサンプルアプリ上で動作確認済みです。
サンプルアプリは以下のリポジトリで公開しています。

各ブランチはそれぞれ以下のような目的で用意しています。

ブランチ CarrierWave 目的
main 3.1.3 3.1 系で present? / valid? などが発生させる HEAD リクエストをテストコードで検証している
verify-carrierwave-3-0-downgrade 3.0.7 問題が発生する前の 3.0 系にダウングレードし、3.1 系との挙動差を検証
verify-carrierwave-master-fix master(f635d88) CWのmasterに入った修正コミット取り込み後の挙動を検証
verify-monkey-patch-2776 3.1.3 + モンキーパッチ 上記の修正コミットを 3.1.3 にモンキーパッチとして移植し、master 取り込み時と同じ挙動になることを検証

まとめ

というわけで、この記事ではCarrierWave 3.1系で発生するパフォーマンス問題と、その対処方法についてまとめました。

各バージョンの挙動の違いをまとめると以下のようになります。

CW 3.0 系 CW 3.1 系 master(未リリース)
present? / blank? の意味 添付ファイルの有無 ストレージ実在確認(HEAD 発生) 添付ファイルの有無
valid? / save の S3 アクセス なし あり なし
ストレージ実在確認の手段 file.exists? present? / blank?(または file.exists?) exists?(または file.exists?)

masterブランチのコードが公式リリースされると嬉しいのですが、まだリリースされていないため、本記事では代わりにモンキーパッチを当ててパフォーマンス問題を解消する方法を紹介しました。

この記事がCarrierWaveユーザーのみなさんの参考になれば幸いです!

おまけ

この記事の別バージョンもあります。
みなさんはどっちの記事が好きですか?

なぜ同じ記事を2つも書いたのか、その理由は以下のブログ記事に書いてあります。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?