Kata: ファイルアップロード — 大容量データの扱い
課題の提示
「タクミさん、1GBのファイルをRailsサーバーにアップロードしたらどうなる?」
「メモリが...足りなくなりますね」
ナオミは頷いた。「そう。サーバーがメモリを食い尽くして、他のリクエストが処理できなくなる。ファイルアップロードは、アーキテクチャを根本から変える必要がある」
Kata 6: 大容量ファイルアップロード基盤
クラウドストレージサービスのアップロード機能を作りたい。
- ファイルサイズ: 最大10GB/ファイル
- 同時アップロード数: 最大500件
- ファイル種別: 動画、PDF、画像、その他
- アップロード後の処理: 画像リサイズ、動画トランスコード、PDFプレビュー生成
- ファイルの配信: 世界中のユーザーに高速配信
- セキュリティ: 認証済みユーザーのファイルのみ配信
「10GBをRailsに通すのは無理。どうする?」
タクミは考えた。「...クライアントから直接S3にアップロード?」
「正解。それが唯一の現実的な解よ」
設計判断
アップロードの流れ
なぜ Presigned URL か
Presigned URL は S3 が発行する一時的な署名付き URL だ。この URL に対して PUT リクエストを送れば、Railsサーバーを経由せずに S3 に直接ファイルをアップロードできる。
| アプローチ | メリット | デメリット |
|---|---|---|
| Rails 経由アップロード | シンプル、制御しやすい | サーバーメモリ消費、帯域制限 |
| Presigned URL + 直接S3 | サーバー負荷ゼロ、スケーラブル | フロント実装が必要 |
INFO
Presigned URL には有効期限(通常15分〜1時間)と、許可するファイルサイズの上限を設定できる。セキュリティを保ちながら直接アップロードを実現できる。
実装
Presigned URL の発行
# app/services/upload_presigner.rb
class UploadPresigner
MAX_FILE_SIZE = 10.gigabytes
URL_EXPIRY = 15.minutes
def initialize(user)
@user = user
end
def generate(filename:, content_type:, file_size:)
validate_file_size!(file_size)
validate_content_type!(content_type)
key = generate_key(filename)
url = s3_client.presign_url(
:put_object,
bucket: ENV["S3_UPLOAD_BUCKET"],
key: key,
expires_in: URL_EXPIRY.to_i,
content_type: content_type,
content_length: file_size,
metadata: {
"user-id" => @user.id.to_s,
"original-filename" => filename
}
)
# アップロード待ちのレコードを作成
upload = FileUpload.create!(
user: @user,
s3_key: key,
original_filename: filename,
content_type: content_type,
file_size: file_size,
status: :pending
)
{ presigned_url: url, upload_id: upload.id, key: key }
end
private
def validate_file_size!(file_size)
raise FileTooLargeError, "ファイルサイズは10GB以下にしてください" if file_size > MAX_FILE_SIZE
end
def validate_content_type!(content_type)
allowed_types = %w[
image/jpeg image/png image/gif image/webp
video/mp4 video/quicktime
application/pdf
application/zip
]
unless allowed_types.include?(content_type)
raise InvalidFileTypeError, "このファイル形式はサポートされていません"
end
end
def generate_key(filename)
ext = File.extname(filename)
"uploads/#{@user.id}/#{SecureRandom.uuid}#{ext}"
end
def s3_client
@s3_client ||= Aws::S3::Client.new(region: ENV["AWS_REGION"])
end
endアップロードコントローラー
# app/controllers/uploads_controller.rb
class UploadsController < ApplicationController
before_action :authenticate_user!
# Step 1: Presigned URL を発行する
def presign
presigner = UploadPresigner.new(current_user)
result = presigner.generate(
filename: params[:filename],
content_type: params[:content_type],
file_size: params[:file_size].to_i
)
render json: result
rescue FileTooLargeError, InvalidFileTypeError => e
render json: { error: e.message }, status: :unprocessable_entity
end
# Step 3: アップロード完了の通知を受け取る
def complete
upload = current_user.file_uploads.find(params[:upload_id])
# S3 にファイルが存在するか確認
unless S3ObjectChecker.exists?(upload.s3_key)
return render json: { error: "ファイルが見つかりません" }, status: :not_found
end
upload.update!(status: :uploaded)
# 後処理をキューに入れる
FileProcessingJob.perform_later(upload.id)
render json: { status: "processing", upload_id: upload.id }
end
endマルチパートアップロード(10GB以上の巨大ファイル)
# app/services/multipart_upload_service.rb
class MultipartUploadService
PART_SIZE = 100.megabytes
def initialize(s3_key, content_type)
@s3_key = s3_key
@content_type = content_type
@bucket = ENV["S3_UPLOAD_BUCKET"]
end
def initiate
response = s3_client.create_multipart_upload(
bucket: @bucket,
key: @s3_key,
content_type: @content_type
)
response.upload_id
end
def presign_parts(upload_id, total_size)
part_count = (total_size.to_f / PART_SIZE).ceil
presigner = Aws::S3::Presigner.new(client: s3_client)
(1..part_count).map do |part_number|
url = presigner.presigned_url(
:upload_part,
bucket: @bucket,
key: @s3_key,
upload_id: upload_id,
part_number: part_number
)
{ part_number: part_number, url: url }
end
end
def complete(upload_id, parts)
# parts = [{ part_number: 1, etag: "xxx" }, ...]
s3_client.complete_multipart_upload(
bucket: @bucket,
key: @s3_key,
upload_id: upload_id,
multipart_upload: {
parts: parts.map { |p| { part_number: p[:part_number], etag: p[:etag] } }
}
)
end
def abort(upload_id)
s3_client.abort_multipart_upload(
bucket: @bucket,
key: @s3_key,
upload_id: upload_id
)
end
private
def s3_client
@s3_client ||= Aws::S3::Client.new(region: ENV["AWS_REGION"])
end
endLambda によるファイル後処理
アップロード後の画像リサイズや動画トランスコードは Lambda で行う。
# app/jobs/file_processing_job.rb
class FileProcessingJob < ApplicationJob
queue_as :file_processing
def perform(upload_id)
upload = FileUpload.find(upload_id)
upload.update!(status: :processing)
case upload.content_type
when /image/
process_image(upload)
when /video/
process_video(upload)
when "application/pdf"
process_pdf(upload)
end
upload.update!(status: :completed)
rescue StandardError => e
upload.update!(status: :failed, error_message: e.message)
raise
end
private
def process_image(upload)
# Lambda を呼んで画像リサイズ
lambda_client.invoke(
function_name: "image-resizer",
payload: {
source_key: upload.s3_key,
output_prefix: "processed/images/#{upload.id}/",
sizes: [
{ width: 100, height: 100, suffix: "thumbnail" },
{ width: 800, height: 600, suffix: "medium" },
{ width: 1920, height: 1080, suffix: "large" }
]
}.to_json
)
end
def process_video(upload)
# MediaConvert でトランスコード(非同期)
mediaconvert_client.create_job(
role: ENV["MEDIACONVERT_ROLE_ARN"],
settings: build_mediaconvert_settings(upload)
)
# 完了はMediaConvertのイベントで検知
upload.update!(status: :transcoding)
end
def process_pdf(upload)
# Lambda でプレビュー画像生成
lambda_client.invoke(
function_name: "pdf-preview-generator",
payload: {
source_key: upload.s3_key,
output_key: "processed/pdf-previews/#{upload.id}/preview.jpg",
page: 1
}.to_json
)
end
endAWSインフラ構成
S3 バケットの設定
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowPresignedUpload",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::...:role/rails-app-role" },
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::upload-bucket/uploads/*",
"Condition": {
"StringEquals": {
"s3:x-amz-content-sha256": "UNSIGNED-PAYLOAD"
},
"NumericLessThanEquals": {
"s3:content-length-range": 10737418240
}
}
}
]
}CloudFront による配信制限
# 署名付き URL で認証済みユーザーのみファイルを配信
class FileUrlSigner
def self.sign(s3_key, expires_in: 1.hour)
cf_signer = Aws::CloudFront::UrlSigner.new(
key_pair_id: ENV["CF_KEY_PAIR_ID"],
private_key: ENV["CF_PRIVATE_KEY"]
)
cf_signer.signed_url(
"https://#{ENV['CF_DOMAIN']}/#{s3_key}",
expires: Time.current + expires_in
)
end
end
# コントローラーでの使用
def download
upload = current_user.file_uploads.find(params[:id])
signed_url = FileUrlSigner.sign(upload.s3_key, expires_in: 10.minutes)
redirect_to signed_url, allow_other_host: true
endWARNING
S3 バケットをパブリックにしてはいけない。CloudFront 経由の署名付き URL のみファイルにアクセスできるよう、バケットポリシーで CloudFront の OAC (Origin Access Control) のみを許可する。
進捗表示の実装
10GBのアップロードは時間がかかる。進捗表示が必須だ。
// フロントエンドのアップロード処理
async function uploadFile(file) {
// Step 1: Presigned URL を取得
const { presigned_url, upload_id } = await fetch("/uploads/presign", {
method: "POST",
body: JSON.stringify({
filename: file.name,
content_type: file.type,
file_size: file.size
})
}).then(r => r.json());
// Step 2: S3 に直接アップロード(進捗付き)
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
const percent = Math.round((event.loaded / event.total) * 100);
updateProgressBar(percent);
});
xhr.open("PUT", presigned_url);
xhr.setRequestHeader("Content-Type", file.type);
await new Promise((resolve, reject) => {
xhr.onload = () => xhr.status === 200 ? resolve() : reject();
xhr.onerror = reject;
xhr.send(file);
});
// Step 3: 完了を Rails に通知
await fetch(`/uploads/${upload_id}/complete`, { method: "POST" });
}振り返り
「大容量ファイルのアーキテクチャで、一番大事なことは?」とナオミが聞いた。
「Railsをファイルが通らないようにすること」
「なぜ?」
「Railsはリクエストを処理するためにある。ファイルの転送は、Railsの仕事じゃない。S3がそれをやる」
ナオミは満足そうに言った。「関心の分離よ。各コンポーネントが自分の仕事だけをする。Railsは認証とURL発行、S3はストレージ、CloudFrontは配信、Lambdaは変換。それぞれが得意なことをする設計が、良いアーキテクチャ」
INFO
Kata 6 の学び: ファイルアップロードはサーバーを通してはいけない。Presigned URL でクライアント→S3の直接経路を作る。後処理は Lambda などのイベント駆動で非同期に行う。
トレードオフの記録
| 決定 | メリット | デメリット |
|---|---|---|
| Presigned URL | サーバー負荷ゼロ | フロント実装複雑 |
| S3 Event → Lambda | 処理の疎結合 | Lambda の起動遅延(コールドスタート) |
| CloudFront署名付きURL | セキュア配信 | URL有効期限の管理 |