🖼️

Cloudflare画像配信パターン

2025/01/31に公開
52

Cloudflareを使った画像の配信パターンを紹介します。

Cloudflareで画像配信をする方法はたくさんあります。例えば、Cloudflare Imagesというプロダクトがありますが「それだけ」を使うのではなく、Cloudflare Workersをプロキシのように使ってR2をバックエンドにするといった画像配信の方法もあります。たくさんあるがゆえ、アプリケーションに最適な方法とその実装が分からないことがあるので、少しでも分かるようにしたいです。

Cloudflare Imagesについて

名称からして、画像配信というとこのプロダクトに辿り着きます。

https://developers.cloudflare.com/images/

ただ、Cloudflare Imagesは機能の集合です。これから紹介するようにCloudflare Imagesだけで配信ができるパターンもありますし、他のパターンでは、Cloudflare Imagesの画像変換や最適化の機能を使うことがあります。

Cloudflare Workersをプロキシにする

画像配信に限らずCloudflare Workersをオリジンの前段に置いていわゆるリバースプロキシとして使う手法は広く使われています。Cloudflareでは、管理画面上で多くのことができますが、その機能をCloudflare Workersを使って実装することができます。例えば、レスポンスにヘッダを追加する例は以下のようなコードになります。

import { Hono } from 'hono'

const app = new Hono()

app.all('*', async (c) => {
  const res = await fetch(c.req.raw)
  const newResponse = new Response(res.body, res)
  newResponse.headers.set('X-Custom', 'Foo')
  return newResponse
})

export default app

その他の例はプロキシパターンとして以下でピックアップしているので参考にしてください。

https://zenn.dev/yusukebe/articles/647aa9ba8c1550

6つのパターン

画像配信にはざっと6つのパターンがあります。

  1. Cloudflare Imagesのストレージを使う
  2. R2 + Custom Domain
  3. R2 + Workers
  4. 外部ストレージ + Workers
  5. KV + Workers
  6. Workers + Assets

それではそれぞれの特徴と実装方法をみていきましょう。

1. Cloudflare Imagesのストレージを使う

Cloudflare Imagesというサービスを「Cloudflare Imagesが提供する」ストレージを使って利用するパターンです。

料金

分かりにくいのですが、R2やKVを使う場合とは違い「Cloudflare Imagesが提供する」ストレージを使う場合は料金が発生します。Cloudflare Imagesを有効にしようとするとこのような画面になります。自分のストレージを使うと「No Cost」ですが、他の選択肢は料金が記載されています。

料金

使い方

画像のアップロードの方法はいくつか種類がありますが、基本的に以下です。

  • ダッシュボードから
  • APIを叩く

アップロードした画像は以下のURLからアクセス可能になります。

https://imagedelivery.net/<アカウント固有のID>/<画像のID>/<画像の変換ルール>

またAPIやWorkersから画像を取得し、配信することができます。

2. R2 + カスタムドメイン

オブジェクトストレージのR2をストレージにして配信するパターンです。後述するようにR2の配信にはWorkersを使うことができますが、このパターンは「使わない」方法です。

R2 + Custom Domain

管理画面で設定をするので、ヘッダの追加なども含め、コードを書くことなく、全て管理画面で完結させることができます。

一方で署名付きURLの検証など複雑なことはできません。その場合はWorkersを使いましょう。

料金

R2の場合ダウンロード、いわゆるイグレス料金が無料なので、このパターンは無料で始めることができます。

実装

R2ではバケットを作ってそこにオブジェクトを入れて操作することになります。方法は以下の3つです。

  • 管理画面を使う
  • APIを使う
  • Workersを使う

管理画面ではフォームからファイルをアップロードできます。また、APIはAmazon S3互換なので、それに従うか、それ用のライブラリを使えます。Workersを使う方法は「R2 + Workers」のパターンを参照してください。

アップロードしたファイルをHTTPでアクセスできるように公開するには、r2.devもしくはカスタムドメインを使う方法と、Workersを使う方法があります。

r2.devとカスタムドメインは管理画面上でR2バケットを指定するだけで設定は済みます。r2.devは誰も使えますが、レート制限があったりやキャッシュが使えなかったりするので、本番環境には向きません。カスタムドメインを使う場合は、対象のドメインがCloudflareに登録されていることが前提です。トップレベルもしくはサブドメインを割り当てることができます。

カスタムドメインを割り当てた配信では標準でキャッシュが効きます。一度キャッシュされた画像は速く配信されます。

3. R2 + Workers

R2とWorkersを組み合わせると署名付きURLの検証など複雑な処理を含んだ配信をすることができます。

R2 + Workers

画像のアップロード、配信から必要な機能の全てを実装することができます。また、パフォーマンスを発揮するために必須な、キャッシュを効かせることもできます。

料金

Workersはリクエストの数によって課金が発生する可能性があります。

料金

とはいえフリーで10万リクエスト/日、$5以上の課金で1,000万リクエスト/月、100万ごとに$0.30と安価です。詳しくは以下ドキュメントをみましょう。

https://developers.cloudflare.com/workers/platform/pricing/

実装

Workersの実装について解説します。さほど難しくはありません。

コードのみを紹介します。設定はドキュメントを参考にしてください。

https://developers.cloudflare.com/r2/api/workers/workers-api-usage/

画像をアップロードするためのエンドポイントまでをHonoを使って書くと以下のようになります。

import { Hono } from 'hono'

// TypeScriptの型を指定
type Bindings = {
  BUCKET: R2Bucket
}

const app = new Hono<{
  Bindings: Bindings
}>()

app.put('/upload', async (c) => {
  // フォームリクエストから画像と名前を取得
  const { file, name } = await c.req.parseBody<{ file: File; name: string }>()
  // R2バケットに置く
  const result = await c.env.BUCKET.put(name, file, {
    httpMetadata: {
      // メタデータでContent Typeを指定
      contentType: file.type
    }
  })
  return c.json(result)
})

https://<ドメイン名>/<画像の名前>といったアドレスで画像を配信するには以下のようなエンドポイントを書けばいいでしょう。

app.get('/:id', async (c) => {
  // バケットから名前を指定してオブジェクトを取得
  const object = await c.env.BUCKET.get(c.req.param('id'))
  if (!object) {
    return c.notFound()
  }
  // ArrayBufferが画像のデータになる
  const body = await object.arrayBuffer()
  return c.body(body, 200, {
    // メタデータにはContent Typeが入っている
    'Content-Type': object.httpMetadata?.contentType ?? 'image/jpeg'
  })
})

Cache API

Workers内ではサービスワーカーにあるCache相当のAPIが実装されており、それを使うことでレスポンスにキャッシュを効かせることができます。

Cache APIを使ったキャッシュの基本的な実装をみていきます。

キーにはURLを使うことが多いです。以下はURLの文字列をキーにcacheオブジェクトからキャッシュを取得し、キャッシュが存在すればそのままレスポンスとして返えしています。また、キャッシュが存在しない場合、レスポンスにCache-Controlヘッダを設定しています。この値がキャッシュする時間になります。次にキャッシュをセットするのですが、waitUntilというメソッドを使うことで、非同期で行うことができます。

app.get('*', async (c, next) => {
  // URLをキーにする
  const cacheKey = c.req.url
  // キャッシュオブジェクト
  const cache = caches.default
  // キャッシュされたレスポンスを取得
  const cachedResponse = await cache.match(cacheKey)
  // もしキャッシュが存在したら返却
  if (cachedResponse) {
    return cachedResponse
  }

  await next()

  if (!c.res.ok) {
    return
  }

  // Cache-Controlヘッダの値によってキャッシュする時間を設定
  c.header('Cache-Control', 's-maxage=60')
  // waitUntilを使って非同期でキャッシュをセット
  const res = c.res.clone()
  c.executionCtx.waitUntil(cache.put(cacheKey, res))
})

4. 外部ストレージ + Workers

Workersから外部のストレージの画像を配信できます。

External Storages + Workers

R2以外のAmazon S3やGoogleのCloud Storageなどのストレージに画像が置いてある場合に使えます。HTTPでアクセスできる必要はありますが、何かしらのアクセス制限をそれらにかければ、元画像が不本意に配信されることを防げます。また、R2 + Workersの場合と同じくWorkersを使っているので、複雑な処理を書くことができます。

実装

仕組みは簡単でfetchでとってきたリソースのレスポンスを返します。プロキシの典型的な使い方です。

import { Hono } from 'hono'

const app = new Hono()

app.get('/:external_object_id', async (c) => {
  const id = c.req.param('external_object_id')
  const response = await fetch(`https://ss.yusukebe.com/${id}`)
  return response
})

export default app

fetchによるキャッシュ

fetchのオプションにCloudflare固有のキャッシュに関するオプションを付加することで、ストレージからのレスポンスをキャッシュすることができます。以下の例は60秒間、コンテンツをキャッシュする例でえす。

const response = await fetch(`https://ss.yusukebe.com/${id}`, {
  // Cloudflare固有のオプション
  cf: {
    // キャッシュの設定
    cacheEverything: true,
    // TTLを60秒に
    cacheTtl: 60,
  },
})
return response

ストレージ先の振り分け

Workersを使っているので、例えば、パスによって画像のストレージを振り分けることができます。アセットはhost-Aから、ブログ用の画像はhost-Bから取得するといった処理はこのように書けます。

app.get('/assets/:assetName', (c) => {
  return fetch(`http://host-A/${c.req.param('assetName')}`)
})

app.get('/blog-images/:blogImageName', (c) => {
  return fetch(`http://host-B/${c.req.param('blogImageName')}`)
})

5. KV + Workers

CloudflareにはシンプルなKey-Value StoreのKVというプロダクトがあります。KVは更新が即座に反映されず、60秒以内のラグが発生する可能性があるのですが、一度入れてしまえばリードは高速です。

KVに画像を置いてWorkersを経由して配信するのがこのパターンです。Cache APIなどを使わずとも、速くコンテンツを返すことができます。

KV + Workers

嬉しいのは、より細やかなキャッシュ制御ができる点です。例えば、Cache APIやfetchを使ったキャッシュの場合、意図的なキャッシュのパージは(エンタープライズプランでなければ)ドメイン全体のキャッシュを消すしか方法がありません。TTLは効かせることができます。KVの場合は、更新のラグを我慢できれば、意図的なキャッシュの削除をすることができます。プログラマブルなキャッシュと考えることができます。

料金

Workersとは別にKVの利用には料金がかかります。ここがR2と比べると辛いところですが、フリープランで1日10万リクエストのリードがあります。

料金

実装

KVをWorkersから使うための設定は以下を参考にしてください。

https://developers.cloudflare.com/kv/concepts/kv-bindings/

KVを使った画像配信の簡単な例を紹介します。以下では、/uploadPUTでフォームリクエストを飛ばすと指定したキーとTTLでKVに画像が保存されます。また、/<キー名>にGETをすると対象の画像を取得できます。

ポイントはKVに入れるコンテンツをArrayBufferとして扱っていることと、KVはオブジェクトにメタデータを指定できるのでファイルのContent Typeを挿入時に入れています。

import { Hono } from 'hono'

type Bindings = {
  KV: KVNamespace
}

const app = new Hono<{
  Bindings: Bindings
}>()

app.put('/upload', async (c) => {
  // 画像のファイル、名前、キャッシュの有効時間
  const { file, name, ttl } = await c.req.parseBody<{
    file: File
    name: string
    ttl: string
  }>()
  // KVにArrayBufferの値をセット
  await c.env.KV.put(name, await file.arrayBuffer(), {
    expirationTtl: ttl ? Number(ttl) : undefined,
    metadata: {
      // contentTypeというメタデータにファイルのタイプを指定
      contentType: file.type,
    },
  })
  return c.text('Uploaded!')
})

app.get('/:kv_id', async (c) => {
  // KVから取得
  const result = await c.env.KV.getWithMetadata<{
    contentType: string
  }>(c.req.param('kv_id'), {
    // コンテンツをArrayBufferで取得する
    type: 'arrayBuffer',
  })
  if (!result.value) {
    return c.notFound()
  }
  return c.body(result.value, 200, {
    // メタデータに入れておいたタイプをContent-Typeヘッダにセット
    'Content-Type': result.metadata?.contentType ?? 'image/png',
  })
})

export default app

意図的な削除の例はこのようなコードで表せます。記事が削除されたらその記事と組み付く画像を削除します。

app.delete('/posts/:post_id', async (c) => {
  const postId = c.req.param('post_id')
  // ここで記事の削除の処理
  // 対象の画像を削除
  await c.env.KV.delete(`${postId}.png`)
  return c.redirect('/')
})

6. Workers Static Assets

Workersには静的ファイルをサーブするためのStatic Assetsという機能があります。まだベータですが、今後必ずベータが取れる機能です。

https://developers.cloudflare.com/workers/static-assets/

これを使うとWorkersのプロジェクト内で指定したディレクトリ、例えばpublicに入れた画像がサーブされます。

Workers Static Assets

デプロイ環境へはWorkerをWranglerでデプロイすると勝手にpublic内の画像をアップロードしてくれます。APIを使って直接アップロードする方法もありますが、Wranglerを使うことを推奨しています。

Static Assetsには以下のような制限があります。

  • Workerにつき20,000ファイルまで
  • 1ファイル25MiBまで

Webサイトのロゴやパーツの画像など、数量の予測が付く画像には最適だと思ます。

その他のパターン

以上6つのパターンを紹介しました。

他にもBundlingという方法を使ってWorkerの中に画像を埋め込んでしまうこともできます。

https://developers.cloudflare.com/workers/wrangler/configuration/#bundling

コード例は以下のようになります。

import photo from '../images/photo.jpg'

app.get('/photo', (c) => {
  return c.body(photo)
})

ただ、これだとWorkerの容量がデカくなってしまいパフォーマンスが出ないですし、ファイル容量にも限界があるので使うことはないでしょう。

Imagesの機能を使った画像の変換と最適化

Cloudflare ImagesのTransformationsという機能を有効にすると画像の変換をすることができます。

https://developers.cloudflare.com/images/transform-images/

変換には2つの方法があります。

  1. URLから
  2. Workersを使う(fetchのみ)

2番目の「Workersを使う」は「外部ストレージ + Workers」で紹介したfetchによる画像の取得の場合のみ使えます。

URLから

Transformationsを有効にすると以下のURLで変換後の画像がサーブされます。

https://<ドメイン名>/cdn-cgi/image/<オプション>/<ソース画像のパス>

Cloudflareに登録しているドメイン(ゾーン)がyusukebe.comだった場合、yusukebe.comもしくはそのサブドメインを対象にできます。例えば「<ドメイン名>」にはimage-distribution.yusukebe.comを入れることができます。オプションというのは画像の変換ルールです。

以下のようなURLの画像があった場合。

https://image-distribution.yusukebe.com/r2/ship.jpg

このアドレスで変換された画像へアクセスできます。オプションで幅を400pxにして、品質を75にする変換ルールを指定しています。

https://image-distribution.yusukebe.com/cdn-cgi/image/width=400,quality=75/r2/ship.jpg

Workersを使う

配信用の画像をfetchで取得する場合、Cloudflare Images特有のオプションを渡すことで画像の変換をすることができます。

以下は幅を400pxに、品質を75にする例です。

app.get('/:external_object_id', async (c) => {
  const id = c.req.param('external_object_id')
  const response = await fetch(`https://ss.yusukebe.com/${id}`, {
    cf: {
      // Transformations用のオプション
      image: {
        // 幅を400pxに
        width: 400,
        // 品質を75に
        quality: 75,
      },
    },
  })
  return response
})

他のオプションについては以下のページに載っています。

https://developers.cloudflare.com/images/transform-images/transform-via-workers/

パフォーマンスについて

パフォーマンスの違いについて考えてみます。以下の5つパターンを比べてみます。

  • R2 + Custom Domain + CDN Cache
  • R2 + Workers
  • R2 + Workers + Cache API
  • KV + Workers
  • Workers + Assets

何を指標に比べるかは難しいのですが、今回はTTFB(Time to first byte)を使いました。ツールは以下のスクリプトを用います。

https://github.com/jaygooby/ttfb.sh

結果は以下の画像の通りです。

ttfb

「R2 + Custom Domain」が速いのはある程度予想が付きました。ただ、Workersを使うとより複雑なことができるので、用途によって使い分けるとよいでしょう。想定よりKVが速いですね。KVも選択肢に入れるといいでしょう。

例: r2-image-worker

「R2 + Workers」パターンで昔作って、今でも重宝しているアプリケーション「r2-image-worker」を紹介します。

https://github.com/yusukebe/r2-image-worker

めちゃくちゃ単純な仕組みです。

  • PUT /uploadにファイルを送るとハッシュ値と適切な拡張子を名前としてR2に保存する
  • 保存が成功したら名前をテキストで返す
  • PUT /uploadにはBasic認証をかける
  • GET /<名前>で画像を取得できる
  • 画像はCache APIでキャッシュされている

面白いのは、macOSの場合「ショートカットアプリ」をうまく使えば、パソコンで撮ったスクリーンショットを簡単にアップロード、公開できる点です。オレオレGyazoみたいです。

ショートカット設定

以下がスクリーンキャスト。

Screen Cast

例: ホットリンク禁止

自サイト外で自分が所有している画像が使われること(ホットリンク)を禁止したいことがあります。Workersを使って実装してみましょう。過去の記事と同じ内容ですがもう一度書きます。

https://zenn.dev/yusukebe/articles/647aa9ba8c1550

リファラをみる

これはリファラ用のヘッダをみて自サイト以外なら403レスポンスを返します。

app.get('/images/:id', async (c) => {
  const referer = c.req.header('Referer')
  if (referer && /^https:\/\/yusukebe.com/.test(referer)) {
    const id = c.req.param('id')
    return fetch(`https://ss.yusukebe.com/${id}`)
  }
  return c.text('Forbidden', 403)
})

しかし、ユーザーのブラウザでリファラの送出をオフにしてる場合は見れなくなってしまいます。そこで次の署名付きURLが使えます。

署名付きURL

有効期限付きのURLを生成、検証することでホットリンクを禁止する作戦です。実装のフローは以下のようになります。

生成側

  1. 生成と検証でシークレットキーを共有する。
  2. URLパスと有効期限をデータとし、シークレットキーでキーを生成する。
  3. URLにキーと有効期限をクエリパラメータとして追加する。

検証側

  1. URLクエリパラメータからキーと有効期限を取り出す。
  2. キーをシークレットキーを元に検証する。
  3. 有効期限を検証する。
  4. 有効であれば、プロキシする。

これをHonoのミドルウェアとして実装してみています。

https://github.com/yusukebe/signed-request-middleware

このSignedRequestミドルウェアとHTMLRewriterを組み合わせます。守りたい画像をHTMLRewriterで探して、見つかったらimgタグのurl属性の値をgenerateSignedURLで生成したURLに置換してあげます。また、画像を配信するパスには検証用のハンドラverifySignedRequestを通します。これで検証に失敗したら403を返します。

import { generateSignedURL, verifySignedRequest } from '../../../src'

// ...

app.get('/static/images/*', verifySignedRequest({ secretKey }))

app.get('/', async (c, next) => {
  await next()
  class AttributeRewriter {
    private attributeName: string
    constructor(attributeName: string) {
      this.attributeName = attributeName
    }
    async element(element: Element) {
      const attribute = element.getAttribute(this.attributeName)
      if (attribute) {
        const url = new URL(attribute, c.req.url)
        const generatedURL = await generateSignedURL(url, {
          secretKey,
          expirationMs: 1000 * expirationSec
        })
        element.setAttribute(this.attributeName, generatedURL.toString())
      }
    }
  }

  const rewriter = new HTMLRewriter().on('img', new AttributeRewriter('src'))
  c.res = rewriter.transform(c.res)
})

実際に動いている様子です。

SS

紹介しなかったこと

Cloudflare Polishという画像最適化の機能がImagesにあるのですが、それは紹介していません。

まとめ

以上、Cloudflareの画像配信の6つパターン、画像変換の仕方、パフォーマンス比較、実装例を紹介してきました。Workersを使うパターンが多くのことができて、Cloudflareっぽいので個人的には推したいです。パフォーマンスもWorkersを使わない場合と比べても(複雑な処理が入ると遅くなりますが)一桁台msの差で悪くないと思います。

やってみないと分からないことが多いです。CloudflareのプロダクトはWorkersは無料枠も多く、実装とデプロイが簡単なので、試してみましょう!

52

Discussion