この記事の目次
タップすると同じページ内の見出しへ移動します
10 sections 01 この記事でわかること ↓ 02 coreの全体構成 ↓ 03 D1はauto-commitとtransactionを区別する ↓ 04 browser、Images binding、R2の責務を重ねる ↓ 05 vinextとNext.jsのversionはmanifestどおりに読む ↓ 06 build時とruntimeのconfigurationを混同しない ↓ 07 costはsnapshotとして書く ↓ 08 まとめ ↓ 09 公式参考資料 ↓ 10 関連記事 ↓ この記事でわかること このブログの公開画面と管理APIは、Cloudflare Workers、D1、R2、Cloudflare Images、vinextを組み合わせて動いています。
ここでは「全部serverlessだから簡単」と丸めず、どのrequestをどのbindingへ渡し、どこで整合性を守っているかを実装に沿って説明します。
Cloudflare WorkersはNode.jsの1 processを常駐させる仕組みではありません。
Cloudflare公式説明どおり、V8 isolateでcodeを実行する分散runtimeです。
したがってmodule scopeの可変stateを永続DBの代わりにはできません。
readerとadminのrequestがedge application、D1、R2を通るブログのdata flow *図: 公開閲覧と管理書込みは同じedge applicationを通る一方、D1 rowとR2 objectは別の責務として扱う。*
coreの全体構成 txt ⧉
browser
├─ static file ──────────────> Workers Assets
└─ dynamic request
▼
1つのWorker deployment(V8 isolateで実行)
├─ vinext App Router(SSR + API)
├─ D1 binding(記事・コメント・設定・media metadata)
├─ R2 binding(thumbnail / article media)
├─ Images binding(validation後のresize / encode)
└─ send_email binding(管理者OTP mail) 動的なSSR、API、 /_vinext/imageは1つのWorker deploymentで扱います。
一方、matchingする静的fileはWorkers AssetsがWorker codeより先に返せるため、「全requestが同じentry pointを通る」とは限りません。
どちらも同一originで公開し、D1、R2、Images、emailは外部REST APIではなくbindingで呼ぶため、core pathにbrowserのcross-origin CORSを持ち込みません。
ただし、repository全体が1 Workerだけで閉じているわけでもありません。
管理画面のweb searchはSearXNGとCrawl4AIを外部serviceとして使い、AI生成はDurable Object経由のCodex containerへ委譲します。
この分離を隠すと、障害範囲とdeploy対象を誤認します。
D1はauto-commitとtransactionを区別する 個別statementはauto-commitです。
複数statementを1回で送るD1の batch()はtransactionとして実行され、途中で失敗すればsequence全体がabortまたはrollbackされます。
このブログではmedia reservationの検証・消費とmetadata登録、記事保存と検索index更新など、同時に成立すべきwriteを batch()へまとめます。
一方、別requestにまたがる「readしてからwrite」は自動では保護されません。
管理画面で既存記事を更新するときは、読込時のtimestampをpreconditionにした次の形でcompare-and-swapします。
sql ⧉
UPDATE posts SET ... WHERE id = ? AND updated_at = ? 変更row数が0なら、別requestによる更新または削除との競合として扱います。
新規作成用の INSERT ... ON CONFLICT DO UPDATEと、既存記事のguard付きupdateを同じ安全性だと考えないことが重要です。
schema例は実schemaの簡略版です。
最初のmigrationには cover、 author、 likes、 viewsもあり、後続migrationで title_ja、 content_enなどのbilingual column、comment、FTS、media管理tableが追加されます。
sql ⧉
CREATE TABLE posts (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
excerpt TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL CHECK (status IN ('draft', 'published')),
cover TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL,
likes INTEGER NOT NULL DEFAULT 0,
views INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
); タグはJSON文字列として保存しています。
記事数が小さい現在は、公開記事の tagsだけを読んでapplication側で正規化・集計する設計です。
規模が変われば、tag relationを別tableに分ける判断も必要になります。
browser、Images binding、R2の責務を重ねる upload前にbrowserは画像を最大幅1600pxへ縮小し、元fileより小さくなる場合だけWebP quality 0.82を採用します。
これは転送量を減らすためのpreprocessingで、security boundaryではありません。
serverは受信後にも宣言MIMEと実byte signature、5MiB上限を検証します。
通常の記事内画像はImages bindingで最大幅1600px、WebP quality 82へ変換します。
記事coverだけは別契約で、PNG入力をcover cropした1200×675 PNGとして保存し、変換responseの content-typeと出力signatureも一致しなければfail closedにします。
その後、事前に確保したD1 storage reservationを検証・消費し、media metadata登録と同じ batch() transactionで確定してから、R2へ一時objectを private, no-storeで保存します。
失敗時はreservation、metadata、R2 objectをcleanupする設計です。
配信時の /_vinext/imageは、静的sourceならWorkers Assets、管理mediaならsame-origin APIからbytesを取得し、Images bindingへ渡します。
Cloudflare公式仕様ではImages binding responseは自動cacheされないため、cacheを語るときはframeworkとresponse headerを含む実経路で確認する必要があります。
つまり「browserで圧縮したからserverは信頼する」でも、「server変換だけにすべて任せる」でもありません。
browserは転送量、serverは検証・正規化、D1はmetadataとquota、R2はobject保存を担当します。
vinextとNext.jsのversionはmanifestどおりに読む このrepositoryはNext.js API surfaceをVite上へ再実装するvinextを使い、App RouterとServer Componentsを動かしています。
vinext自身のREADMEはexperimentalでheavy development中と明記しているため、Next.jsと完全互換だとは扱いません。
現在の package.jsonではNext.js canaryをexact指定し、vinextは ^ rangeです。
実際にinstallされるvinext versionはlockfileで固定されます。
したがって「両方ともexact pin」と説明するのは誤りです。
upgrade時はrangeだけを信じず、lockfile差分、build、全test、Worker local HTTP、Wrangler dry-runをまとめて確認します。
build時とruntimeのconfigurationを混同しない client bundleへ入る NEXT_PUBLIC_*はbuild時の値です。
D1やR2などのbindingとserver secretはruntimeの envから取得します。
このprojectのserver helperはruntime bindingを優先し、文字列がなければ環境判定をせず process.envを見ます。
repositoryではこれをlocal developmentやtestのfallbackとして使いますが、helper自体はNODE_ENVで制限していません。
ts ⧉
export async function getRuntimeValue(key: keyof BlogRuntimeEnv) {
const runtime = getCloudflareEnv()?.[key];
if (typeof runtime === "string" && runtime.length > 0) return runtime;
const nodeValue = process.env[key];
return nodeValue && nodeValue.length > 0 ? nodeValue : undefined;
} public値とsecretを同じものとして扱わず、secretはWrangler secret、bindingはWrangler configuration、client公開値はbuild結果まで確認します。
costはsnapshotとして書く この設計はfree plan内の運用を目標にしていますが、この記事だけではaccountの実利用量や請求状態を確認できません。
WorkersはrequestとCPU、D1はstorageとrows read/write、R2はstorageとoperation class、Imagesはunique transformationなど、課金軸が分かれます。
料金と上限は変わるため、「永久にdomain代だけ」とは断定せず、billing dashboardと公式pricingを定期確認します。
まとめ Cloudflare Workersは1 processではなくV8 isolateで実行される coreの動的pathは1 Worker deployment、static fileはWorkers Assets、追加AI/searchは別serviceにも依存する D1の batch() transactionと、request間競合を防ぐguard付きwriteは別の問題である browser、Images binding、D1、R2に画像処理の責務を分ける package manifestとlockfileを見て、実際のversion固定方法を説明する build時public値とruntime binding・secretを分離する 公式参考資料 関連記事