🌈

「CloudflareのR2って何ですか?」な私がR2(カスタムドメイン)とNext.jsを使ってファイルをアップロードする

2024/09/30に公開

こんにちは、個人開発にハマっているgontaです。
最近、個人開発でCloudflareのR2を触る機会がありましたので記事にまとめてみました。

前提

タイトルの通りでCloudflareについてあまり詳しくありません。プロダクションでR2を使う場合はより詳細にドキュメントを確認してご利用ください。

対象読者

  • 普段にawsのS3などを使っていて、他のストレージサービスを試したい人
  • CloudflareのR2を使ってファイルをアップロードしたい人

この記事で得られること

  • Cloudflareの管理画面でR2を操作することができる。
  • CloudflareのR2を使ってファイルをアップロードできるようになる。
  • CloudflareのR2を使いたくなる。(自信なし)

Cloudflare R2を求めた経緯

私は現在動画を扱うようなサービスを個人で開発しています。
そのサービスの中でCloudflare R2(以下 R2と呼ぶ)ではなく、Vercel Blob を使っていました。理由は下記です。

  • @vercel/blobというsdkも用意をされていて扱いやすかった。
  • アプリケーションをvercelにホスティングをしていて導入が簡単だった。

vercelのドキュメントもとても見やすくて個人的にはとてもいいサービスだったのですが、以下の理由でリリース前にR2に乗り換えることにしました。

動画で大きいデータを扱う

個人開発で扱っているファイルが動画を扱っていまして。
画像に比べて大きいデータを扱うことになり、Vercel BlobのHobbyプランで試したところデータ転送量ですぐに制限に引っかかってしまい、本当に Vercel Blob を扱って大丈夫なのか?と不安になりました。通常の画像であればそこまで気にせずに、Vercel Blob を使っていたと思いますが。。

Vercel BlobがGA(一般公開)となった場合の料金がどのくらいの料金になるのか不安だった

Vercel Blobの料金はこちらに記載をされています。
現在の2024年9月時点でベータ版です。
hobbyプランだと制限を超えると一定期間使えなくなるのですが、ベータ版でproプランの場合は制限を超えても料金がかからずに使えるようです。

Vercel Blob is currently available in early access. While we will start to collect usage data from the day you upload files, you will not be charged for on-demand usage over the base limits while the project is in Beta.

ただし、GA(一般公開)となった場合に制限を超えた部分に対してどのくらいの料金がかかるのかは現在公表をされておらず、料金がかかり始めた時にどのくらいの料金が請求をされるかが分かりませんでした。
なお、Vercel Blobのサービスの内部的にはR2が使われているようです。(周囲に教えていただいたり、2次情報で見たのですが1次情報がどこに書かれているかはわかりませんでした)
なので、おそらくはR2よりも金額が高くなるのは間違いないかなと考えています。

ベータ版での本番環境の利用が推奨をされない

個人開発なので正直そこまで気にしなくてもいいかな?と思ったのと、何か致命的な問題を感じていなかったのですが、Beta版に関してはこちらに記載をされていますが、まだ本番環境での使用は推奨されないようです。
これも大きな理由ではないですが一つの要因になりました。

Products in a Beta state, are not covered under the Service Level Agreement (SLA) for Enterprise plans. Vercel does not recommend using Beta products in a full production environment.

R2が良さそうだというオススメいただいた。

割と人からおすすめをされたものを使いたい気持ちがあり、早速調べながら使ってみました。
自分がざっと調べたところで、今回R2に決めた理由は下記です。
他にも色々と特徴があるようなのですが、詳しく知らない方は調べてみてください。

エグレス料金がかからない。

エグレス料金がかからないおかげでコストが削減できるようです。
エグレス料金というのがあまりわかっていなかった(今もちゃんとわかっていない。)のですが、chatgptさんに聞いたら下記とのことです。

「エグレス料金」とは、通常、クラウドサービスやデータセンターなどでデータを外部に出す際にかかる料金のことを指します。具体的には、ユーザーがクラウドプロバイダーから自分のデータをインターネット経由で外部に転送する際に発生するコストです。クラウドプロバイダーは、データの「出口(エグレス)」に対して料金を設定しており、データの転送量に応じて料金が変わることが一般的です。

https://www.cloudflare.com/ja-jp/learning/cloud/what-are-data-egress-fees/

なお、こちらを見ると、R2を使った際にどのくらいの料金がかかるか計算ができそうです。S3と比べても安価になっていてすごいなと思いました。
※他に安価に使えるStorageサービスがあったら教えてください。

AWSのS3と互換がある

自分はawsのS3のsdkを扱ったことがあったので、その知識をそのまま使えるのは嬉しかったです。

https://www.cloudflare.com/ja-jp/developer-platform/solutions/s3-compatible-object-storage/

R2を使ってみる

下記のように進めていきます。

cloudflareの画面で設定

  1. アカウントの登録
  2. R2でバケットを登録する
  3. 画像を画面アップロードしてみる
  4. バケット配下のオブジェクト(ファイル)を公開する

コードを書く

  1. 細かい設定
  2. サーバーからファイルをアップロードしてみる。
  3. 署名付きURLを作成してファイルをアップロード

R2のドキュメントはこちらです。

https://www.cloudflare.com/ja-jp/developer-platform/r2/

こちらのGet startedを見てやると進めそうです。
wranglerというnpm packageを使うとcloudflare 用のcliツールを使えるようなのですが今回こちらは使用をしていません。

アカウントの登録

こちらから

  • アカウントの登録
  • クレジットカードの登録

と進めてください。

https://dash.cloudflare.com/login

R2でバケットを登録する

https://developers.cloudflare.com/r2/get-started/

バケットに関するドキュメントはこちらになります。

https://developers.cloudflare.com/r2/

自分は先にいくつか作っているのですが、「バケットを作成する」に進んでください。

バケットに関しては自動で近い位置に表示をされるようで日本だとアジア太平洋で表示をされました。

https://developers.cloudflare.com/r2/reference/data-location/#automatic-recommended

ここまでやるとバケットの登録は完了です。

画像をアップロードしてみる

対象のバケットのページに入ると下記のような画面になります。

早速ファイルをアップロードしてみると下記のように表示して画像を表示するところまでできました。

バケット配下のオブジェクト(ファイル)を公開する

このままでは画像のURLで必要になるURL(カスタム ドメイン)が表示されていません。
Public bucketsのドキュメントをみるとデフォルトでバケット配下のオブジェクト(ファイル)は非公開となっているようです。

Public Bucket is a feature that allows users to expose the contents of their R2 buckets directly to the Internet. By default, buckets are never publicly accessible and will always require explicit user permission to enable.

https://developers.cloudflare.com/r2/buckets/public-buckets/

こちらを公開するには2つの選択肢があるようです。

Expose your bucket as a custom domain under your control.
Expose your bucket as a Cloudflare-managed subdomain under https://r2.dev.

では、対象バケットのパブリックアクセスのところを確認します。
こちらカスタムドメインを登録するか、r2.dev サブドメインのどちらかで公開ができるようです。

r2.dev サブドメインを使えばドメインを新たに紐づけることもなく簡単に使用ができそうですが、レート制限があったりと本番環境での利用を推奨されていないため今回はカスタムドメインを使って対応をしたいと思います。

詳細なドキュメントはこちらになります。

https://developers.cloudflare.com/r2/buckets/public-buckets/#connect-a-bucket-to-a-custom-domain

私の場合はcloudflareでドメインを新たに購入します。

早速バケットの設定画面に行き、カスタムドメインの設定をします。
バケットごとに別のドメインを登録しておかないとエラーが出るのでサブドメインを指定をして別のドメインで登録をする。
バケット名をサブドメインに指定をするとよさそうです。

数分経つと対象ドメインのステータスがアクティブになります。

これで対象バケットのオブジェクト(ファイル)のURL(カスタム ドメイン)が表示をされて対象のファイルにアクセスができるようになります。

Next.jsを使ってR2へファイルをアップロードする

ここまではCloudflareの管理画面を触ってR2のバケットやオブジェクトを触ってみました。
ここからは実際にR2のバケットにNext.jsを使ってファイルをアップロードしてみたいと思います。
今回アップロードにサーバーが必要になるのでNext.jsを利用していきます。

前提

下記の前提で進めていきたいと思います。

  • next.js 14.2.5(app router)
  • next.js自体の環境構築は完了している
  • 前段で説明をしたバケットの登録やカスタムURLの公開がされている。

APIトークンの発行

R2 API トークンの管理に進みます。

API トークンを作成するを押下して進みます。

特段問題がなければ下記のような設定で良いと思います。
バケットごとにapi tokenを分ける場合はバケットの指定をした方がいいかもしれませんが、今回そこまではしません。

登録をすると下記のような画面が表示をされます。
こちらのapi tokenはこの画面を離れるともう確認をすることができませんので必ずメモしておいてください。

手元に.envファイルを作成して先ほど確認した値を設定してください。

.env
R2_ENDPOINT=エンドポイント
R2_ACCESS_KEY=アクセスキー
R2_SECRET_KEY=シークレットキー

## 本来一つでいいのですが、今回サーバー側とブラウザ側の2つでアップロードして使いたいため2つ登録しておきます。
R2_CUSTOM_DOMAIN_URL=カスタムドメインのurl
## NEXT_PUBLIC_ をつけるのはNext.jsでブラウザ側で環境変数を使うためのルールになります。
NEXT_PUBLIC_R2_CUSTOM_DOMAIN_URL=カスタムドメインのurl

これでcloudflareのR2のapiにアクセスすることが可能になります。

サーバーからファイルをアップロードしてみる。

下記の実装はバイナリーデータをサーバーに送ってから対応をしていますが、fileをbase64に変換をして送るやり方でも良いかと思います。

実装をする

実装の流れは下記です。

  1. ブラウザから画像ファイルをアップロード
  2. formDataにfileをappendして api にリクエストをする
  3. api側でformDataを受け取り対象ファイルをアップロードする

下記をinstallします。npm, pnpmなどお好きなパッケージマネージャーを利用してください。

yarn add @aws-sdk/client-s3

まずはクライアント側から実装をします。

src/app/server-upload/page.tsx
'use client'
import { useState } from 'react'

export default function Home() {
  const [isLoading, setIsLoading] = useState(false)
  const [url, setUrl] = useState('')
  // 必要な部分だけが見えるようにエラー処理は行っていません。
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return
    setIsLoading(true)
    const formData = new FormData()
    formData.append('file', file)
    const res = await fetch('/api/server-upload', {
      method: 'POST',
      body: formData,
    })

    if (res.ok) {
      alert('成功しました。')
      const data = await res.json()
      console.log({ data })
      setUrl(data.url)
    } else {
      alert('失敗しました。')
    }
    setIsLoading(false)
  }

  return (
    <div>
      <div>
        <h2>サーバー側で画像アップロード</h2>
        urlは{url}
        {url && !isLoading ? (
          <>
            <h2>画像</h2>
            <div>
              <img src={url} alt="upload" width={200} />
            </div>
          </>
        ) : (
          <>
            <input type="file" onChange={handleFileChange} className="hidden" id="upload" />
            <label htmlFor="upload">upload</label>
          </>
        )}
      </div>
    </div>
  )
}

次にapi側も実装をします。

src/app/api/server-upload/route.tsx
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { format } from 'date-fns'
import { type NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  const formData = await req.formData()
  const imageFileData = formData.get('file') as File
  const imageFileDataArrayBuffer = await imageFileData.arrayBuffer()
  const imageFileDataBuffer = Buffer.from(imageFileDataArrayBuffer)
  const s3 = new S3Client({
    region: 'auto',
    endpoint: process.env.R2_ENDPOINT as string,
    credentials: {
      accessKeyId: process.env.R2_ACCESS_KEY as string,
      secretAccessKey: process.env.R2_SECRET_KEY as string,
    },
  })
  // keyの部分がファイル名になります。
  const key = `${format(new Date(), 'yyyyMMddHHmmssSSS')}_${imageFileData.name}`
  const command = new PutObjectCommand({
    // バケット名はどこか別の箇所で管理した方がよさそうです。環境ごとに変えるのであれば環境変数など
    Bucket: 'sample',
    Key: key,
    ContentType: imageFileData.type,
    Body: imageFileDataBuffer,
    ACL: 'public-read',
  })
  await s3.send(command)
  // サーバー側でurlのパスを組み立てたので、サーバー側の環境変数を使いましたが、クライアントで組み立てる場合はNEXT_PUBLIC_のprefixをつけて対応をしてもいいと思います。
  const uploadedUrl = `${process.env.R2_CUSTOM_DOMAIN_URL}/${key}`
  return NextResponse.json({
    url: uploadedUrl,
  })
}

ファイルアップロードを行います。このよう画像がアップロードできたら成功です。

署名付きURLを作成してファイルをアップロード

今回、私が作ろうとしているサービスは動画サービスになります。
一つの動画で10~20MBくらいの重い動画を扱う予定があり、このような大きなデータを都度 api routes(サーバレス関数)に送って負荷をかける、またサーバーへの余分なデータ転送コストをかけたくありませんでした。
なので署名付きURLを使ってブラウザ側からアップロードをする方法について調べて実装をしてみました。
実装方法自体はこちらのSDK for JavaScript (v3) や cloudflareのドキュメントを参考にさせていただきました。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/example_s3_Scenario_PresignedUrl_section.html

https://developers.cloudflare.com/r2/examples/aws/aws-sdk-js-v3/#generate-presigned-urls

実装をする

実装の流れは下記です。

  1. ブラウザから画像ファイルをアップロード
  2. 画像ファイルからファイル名を組み立て、getSignedUrlを使って署名付きURLを発行する
  3. 署名付きURLを使って対象ファイルをアップロードする
  4. カスタムドメインをベースにしてブラウザ側で表示をするURLを組み立てる

下記を追加でinstallしておきます。

@aws-sdk/s3-request-presigner

こちらもまずはクライアント側から実装をします。

src/app/presigned-url/page.tsx
'use client'

import { format } from 'date-fns'
import { useState } from 'react'

export default function Home() {
  const [isLoading, setIsLoading] = useState(false)
  const [url, setUrl] = useState('')

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return
    setIsLoading(true)
    const fileName = `${format(new Date(), 'yyyyMMddHHmmssSSS')}_${file.name}`

    const res = await fetch('/api/presigned-url', {
      method: 'POST',
      body: JSON.stringify({ fileName }),
      headers: {
        'Content-Type': 'application/json',
      },
    })
    if (res.ok) {
      const { presignedUrl } = await res.json()
      const result = await fetch(presignedUrl, {
        method: 'PUT',
        headers: {
          'Content-Type': file.type,
        },
        body: file,
      })
      if (result.ok) {
        alert('成功しました。')
        setUrl(`${process.env.NEXT_PUBLIC_R2_CUSTOM_DOMAIN_URL}/${fileName}`)
      }
    } else {
      setUrl('')
      alert('失敗しました。')
    }
    setIsLoading(false)
  }

  return (
    <div>
      <div>
        <h2>ブラウザ側で画像アップロード</h2>
        urlは{url}
        {url && !isLoading ? (
          <>
            <h2>画像</h2>
            <div>
              <img src={url} alt="upload" width={200} />
            </div>
          </>
        ) : (
          <>
            <input type="file" onChange={handleFileChange} className="hidden" id="upload" />
            <label htmlFor="upload">upload</label>
          </>
        )}
      </div>
    </div>
  )
}

引き続きapi側を実装します。

src/app/api/presigned-url/route.tsx
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { type NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  const reqData = await req.json()
  const fileName = reqData.fileName
  const presignedUrl = await createPresignedUrlWithClient({
    bucket: 'sample', // cloudflareで事前に登録をしているバケット名
    key: fileName,
  })
  return NextResponse.json({
    presignedUrl,
  })
}

const createPresignedUrlWithClient = ({ bucket, key }: { bucket: string; key: string }) => {
  const client = new S3Client({
    region: 'auto',
    endpoint: process.env.R2_ENDPOINT as string,
    credentials: {
      accessKeyId: process.env.R2_ACCESS_KEY as string,
      secretAccessKey: process.env.R2_SECRET_KEY as string,
    },
  })
  const command = new PutObjectCommand({ Bucket: bucket, Key: key })
  return getSignedUrl(client, command, { expiresIn: 3600 })
}

ただ、おそらくはこのままだとCORSのエラーが発生すると思います。

もう一度管理画面に行き、対象バケットのCORSの設定を行っていきます。

デフォルトの表示はこのようになっています。

自分の場合はportで他のサービスで衝突するのが面倒で3005を使っていたりするので下記のようにします。ファイルContent-Typeも指定をする必要があるのでAllowedHeadersで許可します。

なお、本番やdev環境等を使う場合は対象のドメインを別で指定してください。
本番で試していないのですが、追加をすることでおそらくは成功をすると思います。
余談ですが、画面上で設定をするときに候補を出してくれていて、設定がしやすくてとても体験が良かったです。

再度アップロードをしてみるとCORSのエラーがなくなり、成功をしました。

これでアップロードも完了です。

cloudflareのR2を使ってみての感想

cloudflareのR2の画面等を使ってみてUIもわかりやすくて対応がしやすかったです。
私は今までcloudflareについてあまり調べてこなかったのですが、今回色々と調査する中で、2次情報についても割と充実しているような印象を受けました。
これから個人サービスで実際に使ってみて、実際にどのくらいの料金になるのかは今後検証してみたいと思います。
また、私のように実装までのイメージをつかめていない方の少しでも参考になれば嬉しいです。
ここまでお読みいただきありがとうございました。

immedioテックブログ

Discussion