mybook

Kata: ファイルアップロード — 大容量データの扱い

課題の提示

「タクミさん、1GBのファイルをRailsサーバーにアップロードしたらどうなる?」

「メモリが...足りなくなりますね」

ナオミは頷いた。「そう。サーバーがメモリを食い尽くして、他のリクエストが処理できなくなる。ファイルアップロードは、アーキテクチャを根本から変える必要がある」


Kata 6: 大容量ファイルアップロード基盤

クラウドストレージサービスのアップロード機能を作りたい。

  • ファイルサイズ: 最大10GB/ファイル
  • 同時アップロード数: 最大500件
  • ファイル種別: 動画、PDF、画像、その他
  • アップロード後の処理: 画像リサイズ、動画トランスコード、PDFプレビュー生成
  • ファイルの配信: 世界中のユーザーに高速配信
  • セキュリティ: 認証済みユーザーのファイルのみ配信

「10GBをRailsに通すのは無理。どうする?」

タクミは考えた。「...クライアントから直接S3にアップロード?」

「正解。それが唯一の現実的な解よ」


設計判断

アップロードの流れ

Loading diagram...

なぜ 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
end

Lambda によるファイル後処理

アップロード後の画像リサイズや動画トランスコードは 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
end

AWSインフラ構成

Loading diagram...

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
end

WARNING

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有効期限の管理