payload-cms-cloudflare-stack-components

Payload CMSをCloudflare Workers/D1/R2で動かす

 
0
このエントリーをはてなブックマークに追加
Kazuki Moriyama
Kazuki Moriyama (森山 和樹)

Payload CMSをCloudflare Workers/D1/R2で動かす

Payload CMSの本体はNext.jsアプリだ。管理画面、REST/GraphQL API、コンテンツDBの操作、ファイルアップロード、認証、権限制御まで、同じプロジェクトのコードに入っている。

デプロイ先の候補はVercel、Node.jsサーバ、Cloudflare Workersなど。Next.jsアプリ本体をどこで動かすかを選ぶ話になる。Cloudflare構成では、Next.jsをOpenNextでWorkers向けにビルドする。コンテンツDBはD1、メディアはR2、管理画面の入口はCloudflare Access、bindingとdeployはWranglerで組み立てる。

各部品をCloudflareのどこにデプロイするか

flowchart LR editor["編集者"] visitor["公開サイト / API利用者"] deploy["wrangler deploy"] subgraph build["ビルド"] nextapp["Payload + Next.js"] opennext["OpenNext で Workers 向けに変換"] end subgraph cloudflare["Cloudflare runtime"] access["Cloudflare Access"] workers["Workers(管理画面 + API)"] d1["D1(コンテンツ DB)"] r2["R2(メディア)"] secrets["Wrangler secrets"] end nextapp --> opennext --> workers editor --> access --> workers visitor --> workers workers --> d1 workers --> r2 secrets --> workers deploy --> workers deploy --> d1
担当 Cloudflare側
Next.js(Payload本体)のHTTPサーバを動かす OpenNextでビルドしてWorkersで動かす
コンテンツDBのSQLを処理する D1が受ける
画像やPDFをアップロードする R2に書き込む
管理画面の入口で本人確認する Cloudflare Accessで通す
認証鍵や署名secretを注入する Wrangler secretsとして渡す
bindingとdeployを定義する wrangler.jsoncとwrangler deployで設定する
エラー・レイテンシ・利用量を観測する Worker observability、D1 metrics、R2 metricsを見る

縦に並べると、Payload側の仕事をCloudflare側のサービスが受け持つ関係が見える。

OpenNextは、Next.jsアプリケーションをCloudflare Workersで動く形へ変換するadapterだ。Payload v3はNext.jsで動く。Workersにデプロイするときは、Vercel向けでもNode.jsサーバ向けでもなく、OpenNextを通したビルドを使う。

Workers + OpenNextで動かす

ビルド成果物とdeploy

OpenNextでビルドした成果物をWranglerでWorkerにアップロードする。Workerの実体は次の設定とコマンドで定まる。

// wrangler.jsonc — Wranglerに「どのファイルをWorkerとして配るか」を教える
{
  "main": ".open-next/worker.js"  // OpenNextが出力するWorkerエントリ
}
# Next.jsをOpenNextでWorkers向けに変換し、.open-next/worker.jsを出力する
opennextjs-cloudflare build

# wrangler.jsoncを読み取り、Workerをアップロードする
wrangler deploy

bindingとは何か

bindingはWorkerからD1やR2といったCloudflareリソースを参照する名前付きのハンドルを指す。設定ファイルにbindingを書いておくと、Worker内のコードからenv.D1env.R2としてアクセスできる。

// wrangler.jsonc — WorkerからD1/R2を触るためのbindingを宣言する
{
  "main": ".open-next/worker.js",
  "d1_databases": [
    // Worker内ではenv.D1で参照できる
    { "binding": "D1", "database_id": "xxxx-xxxx" }
  ],
  "r2_buckets": [
    // Worker内ではenv.R2で参照できる
    { "binding": "R2", "bucket_name": "my-media" }
  ]
}

binding値はWorkerが起動するときにCloudflareランタイムが注入する。

bindingを使うコードはどこに書く

OpenNextはビルド時にコードを静的に解析する。binding値はWorker実行時にしか与えられない。ビルド時に評価されるパス(モジュールのトップレベルやReact Server Componentの同期処理)でbindingを参照するとundefinedになり、ビルドが落ちる。bindingを使うコードは、route handlerの中やmigrationスクリプトといった実行時にだけ動くパスに置く。

NG(モジュールトップレベルでbindingを参照する)。

// app/page.tsx — NG: モジュールトップレベルでbindingに触る
import { getCloudflareContext } from '@opennextjs/cloudflare'

// 以下2行はimport時=ビルド時に実行されるため、Cloudflareランタイムが居ない。
// env.D1がundefinedになり、ビルドが落ちる。
const { env } = getCloudflareContext()
const rows = await env.D1.prepare('...').all()

export default function Page() {
  return <PostList rows={rows} />
}

OK(リクエストを受けたhandler内で参照する)。

// app/api/posts/route.ts — OK: route handler内(実行時にだけ呼ばれる)
import { getCloudflareContext } from '@opennextjs/cloudflare'

export async function GET() {
  // GET()はリクエストを受けた時点で実行される。この時点ではWorker内なのでbindingが渡っている。
  const { env } = getCloudflareContext()
  const rows = await env.D1.prepare('...').all()
  return Response.json(rows)
}

SSGが必要なときは

Payloadが提供するのは管理画面、REST/GraphQL API、preview routeで、どれもリクエスト時に動く。Next.jsのSSGやgenerateStaticParamsの出番はほとんどない。

同じプロジェクト内に公開フロント((frontend) group routeなど)を同居させる場合や、どうしても静的化したい画面がある場合は、ビルド時にbindingが渡らないという制約に当たる。代替は2つある。

1つはISRとCloudflareの前段キャッシュで実質的に静的化する手だ。Workersの前段にCloudflareのCDNがある。レスポンスにCache-ControlCDN-Cache-Controlヘッダを付けるとエッジでキャッシュされる。OpenNextにはR2やKVを裏に置くincremental cache adapterもある。revalidateunstable_cacheがNext.jsと同じ感覚で使える。記事メディアのように同じレスポンスを再利用できるワークロードでは、これでSSGと同等の応答時間に到達する。

もう1つは完全SSGを別ジョブで作る手だ。ビルド時にCloudflare REST APIやD1 HTTP API経由で取得する。binding経由ではなく、APIトークン付きのfetchでビルド環境からD1を読む。CMS本番運用より、別ジョブで生成する静的サイトの構築に向く。

Edge Runtimeを宣言しない

Next.jsにはruntime = "edge"という宣言がある。書くとそのページやAPIはNext.js内蔵のEdge Runtime(V8ベースの軽量ランタイム)でビルドされる。本来はVercel Edge Functions向けの宣言だ。Cloudflare Workersも「エッジで動くV8環境」を提供するので、Cloudflareに出すならruntime = "edge"を書けばよさそうに見える。実際はこれが落とし穴だ。

OpenNextは、Next.jsの通常のNode.js runtime出力を解析してWorkers向けに変換する。Edge Runtime用のビルド出力は別形式で、OpenNextの変換対象には入らない。OpenNextのCloudflare構成では、アプリ側にexport const runtime = "edge"を残さない方針に揃える。app/配下のpage.tsxやroute.tsにこの行を書くと、OpenNextが期待する入力とずれてビルドが落ちる。原因の特定が難しいため、最初から書かない。

D1とR2をPayloadにつなぐ

Payload側はsqliteD1AdapterにD1 binding、r2StorageにR2 bucket bindingを渡す。先述のwrangler.jsoncで紐づけたD1とR2を、Payloadの設定ファイル側で受け取る。

// payload.config.ts — PayloadにCloudflare D1とR2を渡す
import { sqliteD1Adapter } from '@payloadcms/db-d1-sqlite'
import { r2Storage } from '@payloadcms/storage-r2'
import { buildConfig } from 'payload'

export default buildConfig({
  collections: [Media, Pages, TechBlogs],
  db: sqliteD1Adapter({
    binding: cloudflare.env.D1,  // wrangler.jsoncで宣言したD1 binding
    push: false,                 // 起動時のスキーマ自動同期を切り、migration経由のみ許可する
  }),
  plugins: [
    r2Storage({
      bucket: cloudflare.env.R2,  // wrangler.jsoncで宣言したR2 binding
      collections: {
        media: {
          // 管理画面とAPIが返すメディアURLを組み立てる関数。
          // Workerドメインではなく、R2に接続した独自ドメイン側のURLを返す。
          generateFileURL: ({ filename, prefix }) => {
            const base = process.env.NEXT_PUBLIC_MEDIA_URL  // 例: https://media.example.com
            const key = prefix ? `${prefix}/${filename}` : filename
            return `${base?.replace(/\/+$/, '')}/${encodeURIComponent(key)}`
          },
        },
      },
    }),
  ],
})

push: falseは、Payloadの「起動時にコード上のコレクション定義をDBスキーマへ差分同期するモード」を切る設定だ。開発時はスキーマ変更が即反映されて便利だが、本番ではコード更新のたびに想定外のスキーマ変更が起動の瞬間に走る。データ消失や互換性の崩壊につながる。本番ではpush: falseを入れる。スキーマ変更はmigrationコマンドで明示的に流す(payload migrate:createで差分SQLを生成、payload migrateで適用)。git履歴に残るので追跡もできる。

generateFileURLはupload collectionが返すメディアの公開URLを組み立てる関数だ。何も書かないとPayloadのAPI経由のURLが返り、画像配信のたびにWorkerが間に入ってR2を読みに行く。Workers requestとCPU時間が画像トラフィック分だけ増える。R2にはbucketへ独自ドメインを接続する機能がある。そのドメインをgenerateFileURLが返すように書いておけば、クライアントは直接R2から取得する。Workersは間に入らない。

R2のr2.devエンドポイントは、bucketを有効化すると付くデフォルトの公開URLだ。検証用で、本番用途には使わない案内になっている。rate limitが低く、production用途には不向きだとdocs上で示されている。本番のメディア配信は独自ドメインを接続する。前段にCloudflareのCDN(エッジでのキャッシュレイヤー)とWAF(不正なリクエストを止めるWeb Application Firewall)が立つ構成になる。

なお、PayloadのD1 adapterとR2 storage adapterは、Payloadのドキュメント上でbetaとして案内されている。Payload本体・D1 adapter・R2 storage adapterのバージョン組み合わせを揃え、migrationと基本操作を一度通してから本番に載せる。

管理画面の入口はCloudflare Access

Payloadの管理画面のURLは公開Workerと同じドメインに出る。Worker側でログイン画面やIPアドレス制限を自前で作るより、前段にCloudflare Accessを置く方が省力だ。Accessは本人確認を済ませてからWorkerにリクエストを通す。

Cloudflare Access側の設定

Cloudflare AccessでSelf-hostedのApplicationを作る。対象URLには管理画面のパス(cms.example.com/admin*など)を指定する。Identity providerにはGoogle WorkspaceやEmail OTPなどを紐づける。Policyで「特定のメールアドレスや組織ドメインだけ通す」というルールを書いておく。

通過したリクエストには、CloudflareがCf-Access-Jwt-Assertionヘッダを付ける。中身はJWTで、認証済みユーザのemailなどがclaimとして入る。

Payload側の認証strategy

Payloadはコレクションに独自の認証strategyを追加できる。Worker内でCf-Access-Jwt-Assertionヘッダを読み、Cloudflareのteam domainが公開しているJWKで署名検証する。検証できたemailをusersコレクションのドキュメントに対応づける。

// payload.config.ts のusers collection — Cloudflare Access経由のログインを受ける
import type { CollectionConfig } from 'payload'

const Users: CollectionConfig = {
  slug: 'users',
  auth: {
    strategies: [
      {
        name: 'cloudflare-access',
        authenticate: async ({ payload, headers }) => {
          // Cloudflare Accessが通過させたリクエストに付けるJWTを取り出す
          const token = headers.get('Cf-Access-Jwt-Assertion')
          if (!token) return { user: null }

          // Cloudflareのteam domainが公開するJWKで署名を検証してclaimsを取り出す
          const claims = await verifyAccessJwt(token, {
            teamDomain: process.env.CF_ACCESS_TEAM_DOMAIN!,
            audience: process.env.CF_ACCESS_AUD!,
          })

          // 検証できたemailを、事前登録された編集者ドキュメントへ対応づける
          const found = await payload.find({
            collection: 'users',
            where: { email: { equals: claims.email } },
          })
          return { user: found.docs[0] ?? null }
        },
      },
    ],
  },
  fields: [{ name: 'email', type: 'email', required: true, unique: true }],
}

Payloadのusersコレクションには、各編集者のemailをあらかじめ登録しておく。Accessを通った編集者はそのまま管理画面へ入る。あとはPayloadのaccess controlで操作単位の制御が走る。

二段で持たせる責務

Accessは「管理画面のURLに誰が辿り着けるか」を制御する。Payloadのaccess controlは「ログイン後にどのコレクションのどの操作ができるか」を制御する。サービス用APIキーや編集者ロールを扱うときは両方を組み合わせる。

性能はD1 rows readとWorkers CPUの2軸

CMSが遅くなったときに見る指標は2つに分かれる。

Workers CPU時間

Workers CPU時間はNext.jsとPayloadのコードがWorker上で動いた時間だ。重いSSR、画像URLの組み立て、認証チェック、JSON整形などでWorkers CPUが伸びる。

D1 rows read

rows readは、D1がSQLの処理中に読み込んだ行数を意味する。返した行数ではなく、内部で何行読み込んだかを表す利用量だ。

WHERE句やソートで使う列にindexが無いと、D1は条件に合うかを1行ずつ確かめる。テーブル全体を読み込む(全件走査)。たとえば10万件のpostsテーブルからWHERE slug = 'foo'を引いたとき、slugにindexがあれば数行のreadで済む。無ければ10万行を読んで1件を返す。APIレスポンスが1件でもrows readは10万に跳ねる。

D1のクエリ処理量

D1は1つのデータベース内でクエリを順に処理する。並列で叩いても、同じD1の中ではキューに並ぶ。1クエリの処理時間(SQL duration)が短いほど、捌けるクエリ数は増える。

SQL duration 1つのD1データベースで見込む処理量の目安
1ms 約1000クエリ/秒
10ms 約100クエリ/秒
100ms 約10クエリ/秒

CMSで詰まる典型は、indexのない一覧検索、深いrelation読み込み、公開APIでの過剰なフィールド返しだ。一覧でよく絞り込む更新日時、公開状態、slugにindexを張る。relationのdepthは抑え、公開APIのレスポンスから編集用フィールドを除く。画像やPDFはAPI経由ではなく、R2の独自ドメインから直接配信させる。

編集頻度が低い記事メディアでは、Workersのリクエスト数とD1のrows readは基本的に詰まらない。書き込みの多い業務アプリ、巨大な一覧検索、複雑な集計を同じD1に置く場合は別だ。1つのD1で順に処理する以上、ピーク時のクエリ数が処理上限を超えないかを設計時点で見ておく。

料金はWorkersとD1とR2で別の指標

Cloudflare Workers、D1、R2は、それぞれ別の指標で料金が決まる。Workersはrequest数とCPU時間。D1はrows read、rows written、保存容量。R2は保存容量、Class A操作(アップロード、削除、prefix list)、Class B操作(ダウンロード、HEAD)だ。

例えば月200万requestのCMSを想定する。

サービス 利用量
Workers CPU 1600万ms
D1 rows read 2億行、rows written 200万行、保存2GB
R2 20GB-month、Class A操作20万回、Class B操作500万回
項目 月間利用量 追加料金の例
Workers requests 2,000,000 requests $0.00
Workers CPU 16,000,000 ms $0.00
D1 rows read 200,000,000 rows $0.00
D1 rows written 2,000,000 rows $0.00
D1 storage 2 GB $0.00
R2 Standard storage 20 GB-month $0.15
R2 Class A operations 200,000 requests $0.00
R2 Class B operations 5,000,000 requests $0.00
Workers Paid base $5.00
合計 $5.15

このスケールで追加料金が乗るのはR2のストレージ分だけだ。伸びる指標で打つ手は変わる。API呼び出しならWorkers request、SSRや画像加工ならWorkers CPUを見る。一覧検索ならD1 rows read、サムネイル再生成やprefix listならR2 Class Aを見る。R2はインターネット向けのデータ転送料金がない点も、料金軸として読むうえでは前提になる。代わりに、操作回数(list、put、deleteなど)が積み上がる。

この構成のpros/cons

Cloudflareを選ぶ理由は、コストとパフォーマンスだ。R2のegress無料とWorkers Paidの広い無料枠でコストが抑えられ、Cloudflare CDNが前段に立ってレイテンシも下がる。

pros cons
料金 R2のegressが無料、Workers Paidの無料枠内に中規模CMSが収まる indexなしの絞り込みでD1 rows readが跳ねる
性能 Cloudflare CDNがWorkersの前段に立つ D1は1DB内でクエリを順に処理する。Workers CPU時間にも上限がある
構成統合 bindingとdeployをwrangler.jsoncに集約できる Next.jsの新機能はOpenNextの対応待ちになる
認証 Cloudflare Accessがログイン画面を代替する Payload側の認証strategyを自前で書く
SSG/SSR ビルド時にbindingが渡らず、generateStaticParamsでのSSGが成立しない。ISRや別ジョブで代替
成熟度 D1/R2 adapterがbeta。Payload本体とバージョンを揃える

向く用途

編集頻度がそれほど高くなく、画像やPDFを多く配信するメディアサイトや社内CMS、中小規模のオウンドメディアに向く。書き込み量が大きい業務システム、複雑な集計、重い変換処理を同居させたい構成では、別のスタックを検討したい。

info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。