旧ブログ(ISSEN)から移行しました

【Rails × Next.js】S3 Presigned URLの仕組みと実装方法

【Rails × Next.js】S3 Presigned URLの仕組みと実装方法

塩見直樹
塩見直樹16分で読めます
はてなブックマーク

はじめに

こんにちは、株式会社TOKOSのナオキです!
本記事では、Presigned URLとは何かをわかりやすく解説し、Rails × Next.js環境でクライアントからS3へ直接ファイルをアップロードする方法を紹介します。

対象読者

  • Rails × Next.jsを使っている方
  • S3のPresigned URLの仕組みを理解したい方
  • S3へのファイルアップロード機能を実装したい方

この記事で扱う内容

  • Presigned URLの仕組みの解説
  • AWSの事前準備(IAMユーザー・S3バケットの作成)
  • Rails APIでPresigned URLを発行する実装
  • Next.jsからS3へ直接ファイルをアップロードする実装

S3のPresigned URLとは?

S3に保存されたファイル(オブジェクト)は、基本的に非公開です。
アクセス権限を持つ所有者のみが閲覧(・更新)できます。
Presigned URLは、アクセス権限を持たないユーザーに対して一定期間だけファイルのダウンロードやアップロードを許可するURLを発行する仕組みです。
このURLを知っているユーザーはAWSアカウントやIAM権限が無くても、設定された有効期限内であればS3のファイルに対して閲覧やアップロードができるようになります。

署名付き URL を使用したオブジェクトの共有 - Amazon Simple Storage Service

オブジェクトのダウンロード用に署名付き URL を作成して、オブジェクトを別のユーザーと共有できるようオブジェクトを設定する方法について説明します。

docs.aws.amazon.com

なぜPresigned URLを使うのか?

Presigned URLを使わない場合

Presigned URLを使わずにS3へファイルをアップロードする場合、クライアント → サーバー → S3という経路になります。

このとき、クライアントからサーバーへ画像などのファイルを送る方法としては、一般的に以下の2つがあります。

  • バイナリ送信(multipart/form-data
  • Base64エンコード

しかし、これらの方法にはそれぞれ以下のようなデメリットがあります。

バイナリ送信(multipart/form-data

  • JSONデータとファイルを同時に送る場合、multipart/form-dataの構築が複雑になりがち

Base64エンコード

  • バイナリデータをBase64に変換する際、CPUへ負荷がかかる
  • Base64に変換することでデータサイズが約33%増加する

また、いずれの方法でもサーバーがファイルデータを中継するため、ファイルサイズが大きいほどサーバーへの負荷も増大します。

バイナリ送信・Base64エンコードについて調べた際に以下の記事が参考になったので、気になった方はご覧ください。

JPG画像をBase64で送るとサイズが33%増えるが、Gzip圧縮すれば「ほぼ元通り」になるという話 - Qiita

当たり前なのではというはてぶが多かったので、画像のファイルフォーマット毎の差異もまとめてみました。 Web APIでJPG画像を送信する際、「バイナリ送信(multipart/form-data)で送るか」「Base64エンコードしてJSONに埋め込むか」で迷うこと...

qiita.com

Presigned URLを使う場合

一方、Presigned URLを使うとクライアントからS3へ直接ファイルをアップロードでき、サーバーはPresigned URLの発行のみを行ないます。

これによりサーバーを経由する必要がなくなり、上記のようなデメリットが解消されます。

シーケンス図を見比べると、Presigned URLを使うほうがリクエストの流れが複雑に見えるかもしれません。
しかし、サーバーの負荷においてもっともネックになるのはファイルデータの受け取り・送信処理です。
Presigned URLを使えばその処理がサーバーから不要になるため、サーバーの負荷を軽減できます。

Presigned URLを使うメリット

  • シンプルなファイル送信:画像などのバイナリデータをそのまま送信できるため、multipart/form-dataの構築やBase64エンコードが不要になる
  • サーバー負荷の軽減:ファイル送信処理にてサーバーを経由する必要がなくなるため、サーバーの負荷を軽減できる
  • セキュリティの確保:許可されたユーザーにのみアクセス権限をあたえ、一定時間後にURLが期限切れになる
  • スケーラビリティ: サーバーがファイルデータを処理しないため、同時アップロード数が増えてもサーバーへの負荷を最小限に抑えることができる

Presigned URLの生成に必要な情報

Presigned URLを生成するには、以下の4つの情報が必要です。

項目説明
バケット名どのS3バケットに対してアクセスを許可するか
オブジェクトキー対象のファイルパス(例:products/images/sample.jpg
HTTPメソッド操作の種類を指定する(下記参照)
有効期限URLがいつまで有効かを指定する

HTTPメソッドによって、Presigned URLで行える操作が異なります。

HTTPメソッド操作用途
GETダウンロードS3上のファイルを取得する
PUTアップロードS3にファイルを保存する
HEADメタデータの確認ファイル情報を確認する

オブジェクトキーの指定方法はHTTPメソッドによって少し異なります。

  • GET/HEAD:S3上に存在する既存ファイルのパスを指定
  • PUT:S3のどこにどんな名前で保存するかを指定(ファイルが存在しなくてもOK)

Presigned URLを作成できるユーザー

Presigned URLは誰でも作れるわけではありません。有効なセキュリティ認証情報を持つユーザーである必要があります。

認証情報の種類によって、生成できるPresigned URLの有効期限の上限が異なります。

認証情報の種類有効期限の上限
IAMユーザー最大7日間
IAMロールの認証情報ロールセッションの有効期限まで
EC2インスタンスのIAMロール認証情報ロール認証情報の期間(通常6時間)
AWS Security Token Service認証情報一時的な認証情報の期間中のみ

有効期限の上限(ツール別)

Presigned URLの有効期限は、使用するツールによっても上限が異なります。

ツール説明有効期限の上限
AWSコンソールブラウザの管理画面(GUI)1分〜12時間
AWS CLIターミナルでコマンド入力最大7日間
AWS SDKコードに組み込んで使用最大7日間

今回の実装ではRailsからAWS SDK(aws-sdk-s3 gem) を使用してPresigned URLを生成するため、最大7日間の有効期限を設定できます。

オススメの有効期限

AWSの推奨する有効期限はありませんが、用途に応じて以下のようなポイントを踏まえて決めるのがいいと思われます。

GET:メール等で相手に渡すことが多く、開くタイミングをこちらでは決めにくいのでやや長めに取りがちです。Presigned URLが漏洩した場合にファイルの読み取りができてしまいます。そのファイルの機密性によって有効期限を決めるのがいいと思われます。機密性が低いファイルであれば長めに設定しても問題ありません。

PUT:Presigned URLを発行してすぐ使用する場面が一般的なので短めにしがちです。Presigned URLが漏洩した場合に第三者が意図しないファイルをS3バケットに書き込めてしまうため、セキュリティ観点からも短めにしたほうがいいです。

目安はGETなら15分〜1時間、PUTなら5〜15分程度です。

AWSの事前準備

1. S3バケットの作成

S3コンソールから「バケットを作成」ボタンをクリックします。
今回はバケット名を「test-bucket-20260322」とし、デフォルトの設定で作成しました。

2. CORSの設定

S3コンソールから対象のS3バケットを選択し、「アクセス許可」タブをクリックします。
下部に「Cross-Origin Resource Sharing (CORS)」項目があるので、「編集」ボタンをクリックし、下記のJSON形式で設定を追加します。

Cross-Origin Resource Sharing (CORS)
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT"],
    "AllowedOrigins": ["http://localhost:3000"]
  }
]

  • AllowedHeaders:リクエストに含めることを許可するHTTPヘッダー(例:Content-Type, Authorizationなど)
  • AllowedMethods:許可するHTTPメソッド(例:GET, PUTなど)
  • AllowedOrigins:リクエスト元として許可するオリジン
  • ExposeHeaders:参照を許可するレスポンスヘッダー(例:ETag, x-amz-meta-custom-headerなど)

3. IAMポリシーの作成

IAMコンソールから「ポリシー」を選択し、「ポリシーを作成」ボタンをクリックします。
JSONタブを選択し、下記のJSON形式でポリシーを作成します。

ポリシーエディタ
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject"],
      "Resource": "arn:aws:s3:::test-bucket-20260322/*"
    }
  ]
}

  • Version:ポリシーのバージョン、最新が2012-10-17で推奨されているバージョン
  • Statement:ポリシーのステートメント、複数のステートメントを記述できる
  • Effect:ポリシーの効果(AllowまたはDeny)、Allowは許可、Denyは拒否
  • Action:許可するアクション(例:s3:PutObject, s3:GetObjectなど)
  • Resource:許可するリソース(例:arn:aws:s3:::test-bucket-20260322/\*など)

このポリシーは、test-bucket-20260322バケットに対して、PUTメソッドとGETメソッドを許可するポリシーになります。

4. IAMユーザーの作成

IAMコンソールのサイドバーから「ユーザー」を選択し、「ユーザーを追加」ボタンをクリックします。
今回は名前を「test-user」とし、デフォルトの設定で作成しました。

5. IAMユーザーにポリシーの追加

IAMユーザーの作成が完了したら、3で作成したポリシーを追加します。
対象のIAMユーザーを選択し、「許可ポリシー」項目の「許可を追加」ボタンをクリックし、先ほど作成したポリシーを選択します。

6. IAMユーザーのアクセスキー・シークレットキーの取得

最後にアクセスキー・シークレットキーを取得します。
対象のIAMユーザーを選択し、「セキュリティ認証情報」タブをクリックし、「アクセスキーを作成」ボタンをクリックします。
以下の項目では「AWSの外部で実行されるアプリケーション」を選択します。

後はデフォルトの設定で作成します。
作成後は以下のようにアクセスキーとシークレットキーが表示されます。

これでAWSの事前準備は完了です。

RailsでPresigned URLを発行する

1. aws-sdk-s3 gemの追加

RailsにはAWSのサービスを操作する機能が標準では備わっていません。
そのため、AWSが提供しているRuby向けのSDKであるaws-sdk-s3 gemを追加する必要があります。
このgemを追加することでAws::S3::ClientAws::S3::Presignerなどのクラスが使えるようになり、RailsからS3の操作やPresigned URLの生成が可能になります。

Gemfile
gem 'aws-sdk-s3'

2. 環境変数の設定

AWSの事前準備で取得したアクセスキーとシークレットキー、リージョン、バケット名を環境変数に設定します。

.env
AWS_ACCESS_KEY=取得したアクセスキー
AWS_SECRET_ACCESS_KEY=取得したシークレットキー
AWS_REGION=ap-northeast-1 # 東京リージョン
AWS_S3_BUCKET=test-bucket-20260322

3. S3クライアントの初期化

config/initializers/配下にファイルを作成すると、Railsアプリケーションの起動時に自動で読み込まれます。
ここでAWSの認証情報を設定しておくことで、アプリケーション全体でAws::S3::Clientなどのクラスを使用する際に、毎回認証情報を渡す必要がなくなります。

config/initializers/aws.rb
Aws.config.update(
  region: ENV.fetch("AWS_REGION"),
  credentials: Aws::Credentials.new(
    ENV.fetch("AWS_ACCESS_KEY"),
    ENV.fetch("AWS_SECRET_ACCESS_KEY")
  )
)
  • Aws.config.update:AWS SDK全体の設定を更新するメソッド
  • region:AWSリソースが存在するリージョンを指定(ap-northeast-1は東京リージョン)
  • credentialsAws::Credentialsにアクセスキーとシークレットキーを渡してAWSへの認証情報を設定

4. Presigned URLを発行するAPIの作成

Presigned URLを生成するサービスクラスとコントローラーを作成します。

app/services/presigned_url_service.rb
class PresignedUrlService
  def initialize
    @client = Aws::S3::Client.new
    @signer = Aws::S3::Presigner.new(client: @client)
    @bucket = ENV.fetch("AWS_S3_BUCKET")
  end
 
  def generate_presigned_url(filename, content_type)
    object_key = "uploads/#{SecureRandom.uuid}/#{filename}"
 
    url = @signer.presigned_url(
      :put_object,
      bucket: @bucket,
      key: object_key,
      content_type:,
      expires_in: 600 # 有効期限:10分
    )
 
    { url:, object_key: }
  end
end

SecureRandom.uuidでファイル名にUUIDを付与することで、同じファイル名によるS3上のファイルの上書きを防いでいます。

app/controllers/api/presigned_urls_controller.rb
module Api
  class PresignedUrlsController < ApplicationController
    def create
      service = PresignedUrlService.new
      result = service.generate_presigned_url(
        params[:filename],
        params[:content_type]
      )
 
      render json: result
    end
  end
end
config/routes.rb
namespace :api do
  resources :presigned_urls, only: [:create]
end

5. 動作確認

Railsサーバーをポート3001で起動し、curlでAPIを叩いてPresigned URLが返ってくることを確認します。

ターミナル
rails server -p 3001
ターミナル
curl -X POST http://localhost:3001/api/presigned_urls \
  -H "Content-Type: application/json" \
  -d '{"filename": "test.jpg", "content_type": "image/jpeg"}'

以下のようなレスポンスが返ってくれば成功です。

レスポンス
{
  "url": "https://test-bucket-20260322.s3.ap-northeast-1.amazonaws.com/uploads/550e8400-e29b.../test.jpg?X-Amz-Algorithm=...",
  "object_key": "uploads/550e8400-e29b.../test.jpg"
}

返却されたurlは、S3オブジェクトのURL末尾に?X-Amz-Algorithm=...のような署名用のクエリパラメータが付与されたものです。
署名を除いた、オブジェクト自体のURLの形は次のようになります。

https://{バケット名}.s3.{リージョン}.amazonaws.com/{オブジェクトキー}

本記事の例では、次のようになります。

https://test-bucket-20260322.s3.ap-northeast-1.amazonaws.com/uploads/.../test.jpg

S3バケットはデフォルトで非公開のため、署名のないこのURLをブラウザで開いてもファイルは取得できません。

Next.jsからS3へ直接ファイルをアップロードする

1. 環境変数の設定

Rails APIのURLを環境変数に設定します。

.env.local
API_BASE_URL=http://localhost:3001

2. アップロード処理の作成

Rails APIからPresigned URLを取得し、そのURLを使ってS3に直接ファイルをアップロードする関数を作成します。

lib/uploadToS3.ts
const API_BASE_URL = process.env.API_BASE_URL
 
export const uploadToS3 = async (file: File) => {
  try {
    // 1. Rails APIからPresigned URLを取得
    const presignedResponse = await fetch(`${API_BASE_URL}/api/presigned_urls`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        filename: file.name,
        content_type: file.type,
      }),
    })
 
    if (!presignedResponse.ok) {
      throw new Error("Presigned URLの取得に失敗しました")
    }
 
    const { url, object_key } = await presignedResponse.json()
 
    // 2. Presigned URLを使ってS3に直接アップロード
    const uploadResponse = await fetch(url, {
      method: "PUT",
      headers: {
        "Content-Type": file.type,
      },
      body: file,
    })
 
    if (!uploadResponse.ok) {
      throw new Error("S3へのアップロードに失敗しました")
    }
 
    return object_key
  } catch (error) {
    console.error(error)
    throw new Error("予期しないエラーが発生しました")
  }
}

ポイントは2つのリクエストに分かれている点です。

  1. Rails APIへのリクエスト:Presigned URLを取得する
  2. S3へのリクエスト:取得したPresigned URLに対してファイルを直接アップロードする

3. アップロードフォームの作成

ファイル選択とアップロードを行うコンポーネントを作成します。

app/components/FileUploadForm.tsx
"use client"
 
import { useState } from "react"
import { uploadToS3 } from "@/lib/uploadToS3"
 
export function FileUploadForm() {
  const [file, setFile] = useState<File | null>(null)
  const [uploading, setUploading] = useState(false)
  const [message, setMessage] = useState("")
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!file) return
 
    setUploading(true)
    setMessage("")
 
    try {
      const objectKey = await uploadToS3(file)
      setMessage(`アップロード成功!(${objectKey})`)
    } catch (error) {
      setMessage(error instanceof Error ? error.message : "エラーが発生しました")
    } finally {
      setUploading(false)
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="file"
        accept="image/jpeg, image/png, image/webp"
        onChange={(e) => setFile(e.target.files?.[0] ?? null)}
      />
      <button type="submit" disabled={!file || uploading}>
        {uploading ? "アップロード中..." : "アップロード"}
      </button>
      {message && <p>{message}</p>}
    </form>
  )
}

4. 動作確認

Railsサーバー(ポート3001)とNext.jsの開発サーバー(ポート3000)をそれぞれ起動し、ファイルを選択してアップロードボタンをクリックします。

ターミナル
# Rails(ポート3001)
rails server -p 3001
 
# Next.js(ポート3000)
npm run dev

「アップロード成功!」と表示されれば、クライアントからS3へ直接ファイルがアップロードされています。
S3コンソールから対象のバケットを確認し、uploads/ディレクトリ内にファイルが保存されていることを確認してみましょう。

まとめ

本記事では、Presigned URLの仕組みからRails × Next.jsでの実装方法まで解説しました。
Presigned URLを活用することで、サーバーを経由せずにクライアントからS3へ直接ファイルをアップロードでき、サーバーの負荷軽減が期待できます。
今回はアップロードを実装しましたが、同様の仕組みでダウンロード用のPresigned URLも発行できます。ぜひ活用してみてください。

この記事を書いた人

塩見直樹
塩見直樹

TOKOSのバックエンドエンジニア。パフォーマンス最適化と可読性の向上を目指しています。アニメ好きで、特にHUNTER×HUNTERが好きです。