月額0円で本番運用する — Cloudflare Workers + Durable Objects SQLite フルスタック構成の全て
Ember 編集部 ・ 2026-07-15 ・ 約6分 ・ infra-sre
このサイト(Ember)は、DB・画像ストレージ・定期ジョブ・SEO 対応・カスタムドメイン・TLS まで含めて、 インフラ固定費 月額0円 で本番運用されています。使っているのは Cloudflare Workers の無料プランだけ。VPS もマネージド DB もオブジェクトストレージも契約していません。 「無料枠」と聞くと Hello World の域を出ないデモを想像しがちですが、Ember は次を全部この構成でまかなっています。 SQLite による永続データベース(ユーザー・記事・ポイント台帳・全部) 画像のアップロードと配信(アイキャッチ、アバター) 5分毎の定期ジョブ(ポイント失効スイープ、リマインダー配信) クローラ向けの SEO レンダリング(OGP・本文注入) SPA の静的配信、カスタムドメイン、TLS 本文では、この構成の全体像と「なぜ D1 ではなく Durable Objects の SQLite なのか」「R2 なしで画像をどこに置くのか」「フル SSR をやめて何をしたのか」という設計判断、そして実際に踏んだ落とし穴(バインド型、トランザクションのネスト、DO のロケーション固定)をコード付きで解説します。個人開発の本番構成として、そのまま流用できるはずです。
全体像: Worker 1つ、Durable Object 1つ 構成要素は驚くほど少ないです。 ポイントは Durable Object(DO)を「アプリケーションサーバ + データベース」として1インスタンスだけ使う ことです。 idFromName('main') で常に同じインスタンスに当たるため、全リクエストが単一の SQLite を同期 API で触れます。「分散システムを書かずに済む」ことが、この構成最大の価値です。 なぜ D1 でも KV でもなく DO SQLite か Cloudflare で SQLite というと D1 が思い浮かびますが、Ember は DO の SqlStorage を選びました。理由は3つあります。 1. 同期 API であること。 sql.exec() は同期で返ります。ドメインロジックから await を一掃でき、Node の node:sqlite と同一インターフェースに畳めます(この話は本シリーズ第2回で詳しく)。 2. 本物のトランザクションが書けること。 ctx.storage.transactionSync(fn) で複数文をアトミックに実行できます。ポイント台帳のような「残高検査 → 焼却 → 記録」を守るには必須でした。 3. コンピュートとデータが同居すること。 DB への「接続」がそもそも存在しないので、コネクション管理もレイテンシもありません。 単一インスタンス集約はスループットの上限にもなりますが、「読み取り中心・書き込みは少数」のサービスでは、まずボトルネックになりません。 静的配信と SEO: フル SSR はやらない SPA は wrangler の assets binding で配信します。肝は run worker first です。 API と SEO が必要なパスだけ Worker に通し、それ以外の静的ファイルはエッジが直接返します。Worker の無料枠(1日10万リクエスト)を静的アセットで消費しない、という節約にもなっています。 SEO はフル SSR をやめ、「 SEO シェル 」方式にしました。ビルド済みの index.html に <title ・OGP メタ・エスケープ済み本文を文字列注入して返すだけです。クローラには本文が見え、ブラウザでは直後に SPA が root を置き換えます。ユーザー投稿は全て HTML エスケープして注入する(Markdown をサーバでは描画しない)ことで、XSS の考慮点を「エスケープしたか」の一点に絞っています。ハイドレーションの不整合とも無縁です。 画像: R2 すら使わず DB の BLOB アイキャッチとアバターは、DO SQLite の BLOB カラムに直接入れています。 DO SQLite は 値1つあたり 2MB の上限 があるため、アップロードは安全側の 1.5MB に制限 配信は /images/:id で Cache Control: public, max age=31536000, immutable 。ID は不変なのでエッジキャッシュに載り、DB 読み出しは実質初回のみ 「画像はオブジェクトストレージへ」が定石ですが、上限サイズを決められる用途なら DB 直入れは運用部品が1つ減る合理的な選択です。 定期ジョブ: cron trigger → DO RPC 失効スイープや期限切れ処理は、Workers の cron trigger(無料)から DO のメソッドを RPC で呼ぶだけです。 ジョブは全て冪等に書いてあるので、cron の重複発火や取りこぼしを心配する必要がありません(冪等設計は台帳の回で扱います)。 踏んだ落とし穴 実際にハマった順に。 SqlStorage のバインド型は null / number / string / ArrayBuffer のみ。 Uint8Array を渡すと落ちます。ドライバ層で u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) に変換して吸収しました。 transactionSync はネスト不可。 ドメインロジックが transaction() の中で transaction() を呼ぶ構造だったため、ドライバ側で「トランザクション中なら外側に合流する」フラグを持たせました。 DO は単一ロケーションに固定される。 最初にアクセスされたリージョン付近に作られ、以後動きません。全リクエストがそこへ往復するので、主要ユーザー圏から遠い場所で初回アクセスさせないこと。 workers.dev のデフォルト URL は消す。 workers dev: false / preview urls: false を明示しないと、本番ドメイン以外の入口(とアカウント名)が公開されたままになります。 費用の内訳 費目 月額 Workers / DO / SQLite ストレージ 0円(無料プラン) 静的配信・画像配信(エッジキャッシュ) 0円 cron trigger / カスタムドメイン / TLS 0円 ドメイン代 年額のみ(実質唯一のコスト) 無料枠の主な天井は Worker への 1日10万リクエストです。超える規模になったら、それは有料プラン($5/月)に上げる十分な理由ができたということなので、喜んで払えばいい — というのがこの構成の思想です。 次回は、この構成の心臓部である「 node:sqlite と DO SqlStorage を同一ドライバ IF に畳み、コアロジック無改修でローカルとエッジを両対応にする」話をします。