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_typesとuser_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_uploadがnullの場合、ファイルは既に存在します:
// 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
孤立blob:find_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をサポートしています—重複排除ロジックはまったく同じままです。