ActiveStorageでファイル重複排除システムを構築する

ユーザーが会社のロゴ、プロフィール写真、または3回もアップロードした同じPDFをアップロードするたびに、再度保存するための料金を支払っています。ActiveStorageはデフォルトで重複排除を行いません—コンテンツが同一であっても、各アップロードは新しいblobを作成します。

これを修正しましょう。同一のファイルを検出し、既存のblobを再利用する重複排除システムを構築します。これによりストレージコストを節約し、重複ファイルのアップロードを瞬時に行えます。各ユーザーに重複排除のスコープを限定するため、ユーザーは自分のアップロードに対してのみ重複排除を行い、ファイルのセキュリティを維持します。

ActiveStorageチェックサムの仕組み

すべてのActiveStorage blobにはchecksumカラムがあり、これはファイルコンテンツのMD5ハッシュです。同一のコンテンツを持つ2つのファイルは常に同じチェックサムを持ちます:

ActiveStorage::Blob.pluck(:checksum).tally
# => {"vckNNU4TN7zbzl+o3tjXPQ==" => 47, "x8K9f2mVhLpWQ..." => 12, ...}

1より大きいカウントが表示される場合、重複があります。これらを排除しましょう。

パフォーマンスのためのインデックス追加

ActiveStorageはデフォルトでchecksumカラムにインデックスを付けません。インデックスがないと、重複排除クエリはフルテーブルスキャンを行います—小規模アプリでは問題ありませんが、大規模では遅くなります。

注意: このマイグレーションはActiveStorageのスキーマを変更します。インデックスの追加は低リスク(構造的な変更なし)ですが、エンジンが所有するテーブルをカスタマイズしていることに注意してください。チームのためにこの決定を文書化してください。

class AddIndexToActiveStorageBlobsChecksum < ActiveRecord::Migration[8.0]
  disable_ddl_transaction!

  def change
    add_index :active_storage_blobs, :checksum, algorithm: :concurrently
  end
end

algorithm: :concurrentlyオプション(PostgreSQL)は書き込みをブロックせずにインデックスを構築します。これは既存のデータを持つ本番データベースには不可欠です。コンカレントインデックス作成はトランザクション内で実行できないため、disable_ddl_transaction!が必要です。

ステップ1:サーバーサイド重複排除

blobを作成する際、現在のユーザーが同じチェックサムを持つものを既に持っているか確認します。

重複排除されたダイレクトアップロードを処理するコントローラーを作成します:

# app/controllers/deduplicated_uploads_controller.rb
class DeduplicatedUploadsController < ActiveStorage::DirectUploadsController
  before_action :authenticate_user!

  def create
    existing_blob = find_existing_blob_for_user(blob_params[:checksum])

    if existing_blob
      render json: existing_blob_json(existing_blob)
    else
      super
    end
  end

  private

  def find_existing_blob_for_user(checksum)
    return nil if checksum.blank?

    # 現在のユーザーが以前アップロードしたblobのみを検索
    # EXISTSサブクエリを使用した単一の効率的なクエリを使用
    ActiveStorage::Blob
      .joins(:attachments)
      .where(checksum: checksum)
      .where(user_owns_attachment_sql)
      .first
  end

  # current_userが添付レコードを所有しているかチェックするSQLフラグメント
  # 効率のためにEXISTSサブクエリを使用(IDをメモリにロードしない)
  def user_owns_attachment_sql
    <<~SQL.squish
      (
        (active_storage_attachments.record_type = 'Document'
         AND EXISTS (SELECT 1 FROM documents WHERE documents.id = active_storage_attachments.record_id AND documents.user_id = #{current_user.id}))
        OR
        (active_storage_attachments.record_type = 'Avatar'
         AND EXISTS (SELECT 1 FROM avatars WHERE avatars.id = active_storage_attachments.record_id AND avatars.user_id = #{current_user.id}))
      )
    SQL
  end

  def existing_blob_json(blob)
    {
      id: blob.id,
      key: blob.key,
      filename: blob.filename.to_s,
      content_type: blob.content_type,
      byte_size: blob.byte_size,
      checksum: blob.checksum,
      signed_id: blob.signed_id,
      direct_upload: nil  # アップロードをスキップするシグナル
    }
  end

  def blob_params
    params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {})
  end
end

これはactive_storage_attachmentsテーブルを通じてクエリを行い、現在のユーザーが所有するレコードに添付されたblobを見つけます。アプリケーションの所有権モデルに合わせてattachable_typesuser_record_idsを調整してください。

ルートを追加します:

# config/routes.rb
Rails.application.routes.draw do
  post '/rails/active_storage/direct_uploads',
       to: 'deduplicated_uploads#create',
       as: :deduplicated_direct_uploads
end

添付の仕組み

既存のblobが見つかると、そのsigned_idを返します。クライアントはこのsigned_idをフォームと共に送信し、ActiveStorageは既存のblobを指す新しい添付を作成します:

# app/controllers/documents_controller.rb
class DocumentsController < ApplicationController
  def create
    @document = current_user.documents.build(document_params)

    if @document.save
      redirect_to @document
    else
      render :new
    end
  end

  private

  def document_params
    params.require(:document).permit(:title, :file)
  end
end

params[:document][:file]がsigned_id文字列(アップロードされたファイルではなく)の場合、ActiveStorageはblobを見つけて添付します。1つのblobは多くの添付を持つことができます:

blob = ActiveStorage::Blob.find_by(checksum: "vckNNU4TN7zbzl+o3tjXPQ==")
blob.attachments.count
# => 5  (5つの異なるレコードがこのblobを共有)

これが重複排除の鍵です:複数のレコードが同じ保存されたファイルを参照します。

ステップ2:クライアントサイドアップロード処理

クライアントはアップロードをスキップするタイミングを知る必要があります。レスポンスでdirect_uploadnullの場合、ファイルは既に存在します:

// app/javascript/controllers/deduplicated_upload_controller.js
import { Controller } from "@hotwired/stimulus"
import { DirectUpload } from "@rails/activestorage"

export default class extends Controller {
  static targets = ["input", "progress"]
  static values = { url: String }

  upload() {
    const file = this.inputTarget.files[0]
    if (!file) return

    const upload = new DirectUpload(file, this.urlValue, this)

    upload.create((error, blob) => {
      if (error) {
        console.error(error)
      } else {
        this.handleSuccess(blob)
      }
    })
  }

  handleSuccess(blob) {
    // signed_idを持つ隠しinputを作成
    const input = document.createElement("input")
    input.type = "hidden"
    input.name = this.inputTarget.name
    input.value = blob.signed_id
    this.inputTarget.form.appendChild(input)

    if (blob.direct_upload === null) {
      this.showMessage("ファイルは既に存在します - 即時アップロード!")
    } else {
      this.showMessage("アップロード完了")
    }
  }

  // DirectUploadデリゲートメソッド
  directUploadWillStoreFileWithXHR(request) {
    request.upload.addEventListener("progress", (event) => {
      const progress = (event.loaded / event.total) * 100
      this.progressTarget.style.width = `${progress}%`
    })
  }

  showMessage(text) {
    // ステータスを表示するためにUIを更新
    this.progressTarget.textContent = text
  }
}

フォームで使用します:

<%= form_with model: @document do |f| %>
  <div data-controller="deduplicated-upload"
       data-deduplicated-upload-url-value="<%= deduplicated_direct_uploads_url %>">

    <%= f.file_field :file,
        data: {
          deduplicated_upload_target: "input",
          action: "change->deduplicated-upload#upload"
        } %>

    <div data-deduplicated-upload-target="progress"></div>
  </div>

  <%= f.submit %>
<% end %>

ステップ3:ネットワークリクエストを完全にスキップ

さらに進めることができます。クライアントサイドでチェックサムを計算し、アップロードを試みる前にblobが存在するか確認します:

コントローラー間で共有するconcernにスコーピングロジックを抽出します:

# app/controllers/concerns/blob_scoping.rb
module BlobScoping
  extend ActiveSupport::Concern

  def find_user_blob(checksum)
    return nil if checksum.blank?

    ActiveStorage::Blob
      .joins(:attachments)
      .where(checksum: checksum)
      .where(user_owns_attachment_sql)
      .first
  end

  private

  def user_owns_attachment_sql
    <<~SQL.squish
      (
        (active_storage_attachments.record_type = 'Document'
         AND EXISTS (SELECT 1 FROM documents WHERE documents.id = active_storage_attachments.record_id AND documents.user_id = #{current_user.id}))
        OR
        (active_storage_attachments.record_type = 'Avatar'
         AND EXISTS (SELECT 1 FROM avatars WHERE avatars.id = active_storage_attachments.record_id AND avatars.user_id = #{current_user.id}))
      )
    SQL
  end
end

次にルックアップコントローラーで使用します:

# app/controllers/blob_lookups_controller.rb
class BlobLookupsController < ApplicationController
  include BlobScoping
  before_action :authenticate_user!

  def show
    blob = find_user_blob(params[:checksum])

    if blob
      render json: { exists: true, signed_id: blob.signed_id }
    else
      render json: { exists: false }
    end
  end
end
# config/routes.rb
# チェックサムにはクエリパラメータを使用(base64には+、/、=が含まれ、パスではエンコードが必要)
get '/blobs/lookup', to: 'blob_lookups#show', as: :blob_lookup

クエリパラメータから読み取るようにコントローラーを更新します:

# app/controllers/blob_lookups_controller.rb
def show
  blob = find_user_blob(params[:checksum])
  # ... 残りは変更なし
end

これでJavaScriptが最初に確認できます:

// app/javascript/controllers/smart_upload_controller.js
import { Controller } from "@hotwired/stimulus"
import { DirectUpload, FileChecksum } from "@rails/activestorage"

export default class extends Controller {
  static targets = ["input", "status"]
  static values = {
    lookupUrl: String,
    uploadUrl: String
  }

  async upload() {
    const file = this.inputTarget.files[0]
    if (!file) return

    try {
      this.statusTarget.textContent = "チェックサムを計算中..."
      const checksum = await this.computeChecksum(file)

      this.statusTarget.textContent = "重複を確認中..."
      const existing = await this.lookupBlob(checksum)

      if (existing.exists) {
        this.statusTarget.textContent = "ファイルは既にアップロード済み - 即時!"
        this.attachSignedId(existing.signed_id)
        return
      }

      this.statusTarget.textContent = "アップロード中..."
      await this.performUpload(file)
    } catch (error) {
      this.statusTarget.textContent = `エラー: ${error.message}`
      console.error("アップロード失敗:", error)
    }
  }

  computeChecksum(file) {
    return new Promise((resolve, reject) => {
      FileChecksum.create(file, (error, checksum) => {
        if (error) {
          reject(new Error(`チェックサム失敗: ${error}`))
        } else {
          resolve(checksum)
        }
      })
    })
  }

  async lookupBlob(checksum) {
    const url = new URL(this.lookupUrlValue, window.location.origin)
    url.searchParams.set("checksum", checksum)

    const response = await fetch(url, {
      headers: {
        "X-CSRF-Token": this.csrfToken,
        "Accept": "application/json"
      },
      credentials: "same-origin"
    })

    if (!response.ok) {
      throw new Error(`ルックアップ失敗: ${response.status}`)
    }

    return response.json()
  }

  get csrfToken() {
    const meta = document.querySelector('meta[name="csrf-token"]')
    return meta ? meta.content : ""
  }

  attachSignedId(signedId) {
    const input = document.createElement("input")
    input.type = "hidden"
    input.name = this.inputTarget.name
    input.value = signedId
    this.inputTarget.form.appendChild(input)
  }

  performUpload(file) {
    return new Promise((resolve, reject) => {
      const upload = new DirectUpload(file, this.uploadUrlValue, this)
      upload.create((error, blob) => {
        if (error) {
          reject(new Error(`アップロード失敗: ${error}`))
        } else {
          this.statusTarget.textContent = "アップロード完了"
          this.attachSignedId(blob.signed_id)
          resolve(blob)
        }
      })
    })
  }

  directUploadWillStoreFileWithXHR(request) {
    request.upload.addEventListener("progress", (event) => {
      const percent = Math.round((event.loaded / event.total) * 100)
      this.statusTarget.textContent = `アップロード中: ${percent}%`
    })
  }
}

スコープの拡大

ユーザースコープの重複排除は最も安全なデフォルトですが、場合によってはより広い重複排除が必要かもしれません。スコープを拡大するオプションは以下の通りです:

オプションA:組織/テナントスコープ

チームメイトがファイルを共有するSaaSアプリの場合、組織内で重複排除します。これは組織のメンバーが所有するレコードをクエリする必要があります:

def find_org_blob(checksum)
  ActiveStorage::Blob
    .joins(:attachments)
    .where(checksum: checksum)
    .where(org_owns_attachment_sql)
    .first
end

def org_owns_attachment_sql
  # 組織のメンバーが所有するレコードに添付されたblobを検索
  <<~SQL.squish
    (
      (active_storage_attachments.record_type = 'Document'
       AND EXISTS (
         SELECT 1 FROM documents
         WHERE documents.id = active_storage_attachments.record_id
         AND documents.organization_id = #{current_organization.id}
       ))
      OR
      (active_storage_attachments.record_type = 'Project'
       AND EXISTS (
         SELECT 1 FROM projects
         WHERE projects.id = active_storage_attachments.record_id
         AND projects.organization_id = #{current_organization.id}
       ))
    )
  SQL
end

オプションB:パブリックファイルのみ

明示的にパブリックとしてマークされたファイルのグローバル重複排除を許可:

def find_public_blob(checksum)
  ActiveStorage::Blob
    .where(checksum: checksum)
    .where("metadata->>'public' = ?", "true")
    .first
end

def find_existing_blob(checksum)
  find_user_blob(checksum) || find_public_blob(checksum)
end

オプションC:コンテンツタイプベース(サーバーサイドのみ)

画像のような「安全な」コンテンツはグローバルに重複排除し、ドキュメントはユーザースコープを維持。これはアップロードリクエストからcontent_typeを取得できるサーバーサイド重複排除(ステップ1)でのみ機能します—アップロード前ルックアップ(ステップ3)では機能しません:

# DeduplicatedUploadsController内
def find_existing_blob_for_user(checksum)
  content_type = blob_params[:content_type]

  if content_type&.start_with?("image/")
    # 画像はグローバルに重複排除可能(低リスク)
    ActiveStorage::Blob.find_by(checksum: checksum)
  else
    # ドキュメントはユーザースコープを維持
    find_user_blob(checksum)
  end
end

エッジケースの処理

レースコンディション:同じユーザーが連続して2回ファイルをアップロード。両方のチェックサムが「見つからない」を返し、両方がアップロードされる。2つのblobができる。これは問題ありません。ユーザースコープクエリは既にこれを自然に処理し、バックグラウンドジョブでユーザーごとの重複をクリーンアップできます:

# app/jobs/deduplicate_user_blobs_job.rb
class DeduplicateUserBlobsJob < ApplicationJob
  def perform(user)
    duplicates = find_duplicate_checksums_for(user)

    duplicates.each do |checksum|
      deduplicate_blobs_with_checksum(user, checksum)
    end
  end

  private

  def find_duplicate_checksums_for(user)
    ActiveStorage::Blob
      .joins(:attachments)
      .where(user_owns_attachment_sql(user))
      .group(:checksum)
      .having("COUNT(DISTINCT active_storage_blobs.id) > 1")
      .pluck(:checksum)
  end

  def deduplicate_blobs_with_checksum(user, checksum)
    ActiveStorage::Blob.transaction do
      blobs = ActiveStorage::Blob
        .joins(:attachments)
        .where(checksum: checksum)
        .where(user_owns_attachment_sql(user))
        .order(:created_at)
        .lock("FOR UPDATE")
        .distinct

      canonical = blobs.first
      return if canonical.nil?

      # offsetの代わりにwhere.notを使用(offsetはfind_eachで機能しない)
      blobs.where.not(id: canonical.id).find_each do |duplicate|
        duplicate.attachments.update_all(blob_id: canonical.id)
        duplicate.purge
      end
    end
  end

  def user_owns_attachment_sql(user)
    <<~SQL.squish
      (
        (active_storage_attachments.record_type = 'Document'
         AND EXISTS (SELECT 1 FROM documents WHERE documents.id = active_storage_attachments.record_id AND documents.user_id = #{user.id}))
        OR
        (active_storage_attachments.record_type = 'Avatar'
         AND EXISTS (SELECT 1 FROM avatars WHERE avatars.id = active_storage_attachments.record_id AND avatars.user_id = #{user.id}))
      )
    SQL
  end
end

孤立blobfind_user_blobメソッドは既にattachmentsを通じてジョインしているため、孤立blob(添付のないもの)は自動的に除外されます。

異なるファイル名:同じコンテンツ、異なる名前。blobは元のファイル名を保存しますが、添付はそれを上書きできます。これは問題ありません—メタデータではなくコンテンツで重複排除します。

Blobの早期削除の防止

共有blobには重大な問題があります:デフォルトでは、レコードを削除するとActiveStorageはblobをパージします。ドキュメントAとドキュメントBがblobを共有している場合、ドキュメントAを削除するとblobも削除され、ドキュメントBが壊れます。

モデルで自動パージを無効にしてこれを修正します:

# app/models/document.rb
class Document < ApplicationRecord
  belongs_to :user
  has_one_attached :file, dependent: false  # 自動パージしない
end

# app/models/avatar.rb
class Avatar < ApplicationRecord
  belongs_to :user
  has_one_attached :image, dependent: false
end

これで添付が削除されてもblobは永続化されます。スケジュールされたジョブで孤立blob(添付がゼロのもの)をクリーンアップします:

# app/jobs/cleanup_orphaned_blobs_job.rb
class CleanupOrphanedBlobsJob < ApplicationJob
  def perform
    # 添付がなく、1日以上前のblobを検索(猶予期間)
    ActiveStorage::Blob
      .left_joins(:attachments)
      .where(active_storage_attachments: { id: nil })
      .where(active_storage_blobs: { created_at: ...1.day.ago })
      .find_each(&:purge)
  end
end

毎日実行するようにスケジュールします。Solid Queue(Rails 8デフォルト)の場合:

# config/recurring.yml
cleanup_orphaned_blobs:
  class: CleanupOrphanedBlobsJob
  schedule: every day at 3am

またはsidekiq-cronの場合:

# config/initializers/sidekiq.rb
Sidekiq::Cron::Job.create(
  name: "孤立blobのクリーンアップ - 毎日",
  cron: "0 3 * * *",
  class: "CleanupOrphanedBlobsJob"
)

1日の猶予期間は、blobが作成されたがまだ添付されていない場合のレースコンディションを防ぎます。

影響の測定

重複排除率を追跡します:

# コントローラー内
def create
  existing_blob = find_user_blob(blob_params[:checksum])

  if existing_blob
    Rails.logger.info "[Dedup] ユーザー#{current_user.id}のblob #{existing_blob.id}を再利用(#{existing_blob.byte_size}バイト節約)"
    StatsD.increment("uploads.deduplicated")
    StatsD.count("uploads.bytes_saved", existing_blob.byte_size)
    # ...
  end
end

ユーザーごとの重複ポテンシャルを確認:

def duplication_stats_for(user)
  # このユーザーのblobの重複チェックサムを検索
  duplicate_checksums = ActiveStorage::Blob
    .joins(:attachments)
    .where(user_owns_attachment_sql(user))
    .group(:checksum)
    .having("COUNT(DISTINCT active_storage_blobs.id) > 1")
    .pluck(:checksum)

  return { duplicates: 0, wasted_bytes: 0 } if duplicate_checksums.empty?

  # 無駄なスペースを計算
  duplicate_blobs = ActiveStorage::Blob
    .joins(:attachments)
    .where(checksum: duplicate_checksums)
    .where(user_owns_attachment_sql(user))
    .distinct

  total_bytes = duplicate_blobs.sum(:byte_size)
  unique_bytes = duplicate_blobs.select("DISTINCT ON (checksum) *").sum(&:byte_size)

  {
    duplicates: duplicate_blobs.count - duplicate_checksums.size,
    wasted_bytes: total_bytes - unique_bytes
  }
end

def user_owns_attachment_sql(user)
  <<~SQL.squish
    (
      (active_storage_attachments.record_type = 'Document'
       AND EXISTS (SELECT 1 FROM documents WHERE documents.id = active_storage_attachments.record_id AND documents.user_id = #{user.id}))
      OR
      (active_storage_attachments.record_type = 'Avatar'
       AND EXISTS (SELECT 1 FROM avatars WHERE avatars.id = active_storage_attachments.record_id AND avatars.user_id = #{user.id}))
    )
  SQL
end

まとめ

重複排除はストレージコストを節約し、繰り返しファイルのアップロードを瞬時に感じさせます。重要な洞察:ActiveStorageは既にチェックサムを計算しています—それを使用するだけです。

現在のユーザーにスコープを限定することで、セキュリティリスクなしでストレージ節約を得られます。ユーザーは自分のアップロードに対してのみ重複排除でき、アクセスすべきでないファイルへのアクセスを主張することを防ぎます。

コントローラーでサーバーサイド重複排除から始めます。完全にアップロードをスキップしたい場合はクライアントサイドルックアップを追加します。明確なユースケースがある場合にのみ、組織やパブリックファイルにスコープを拡大します。

このチュートリアルのコードはデフォルトのMD5チェックサムで動作します。FIPS環境にいる場合、Rails 8.2はSHA256をサポートしています—重複排除ロジックはまったく同じままです。