Cloudflare ハンズオン教科書|2026-08 版
第 17 章

総合演習 — ミニ SaaS を丸ごと作る

KV・R2・D1・Queues・Workers AI を総動員して 1 つのアプリに仕上げる

この章で学ぶこと

何を作るか — 記事クリップ & AI 要約サービスのデータフロー

最終章では、これまでの章で個別に学んだ部品を総動員して「記事クリップ & AI 要約サービス」を作ります。ブラウザから記事の URL・タイトル・本文を投稿すると、メタデータが D1 に、本文が R2 に保存され、Queues 経由で Workers AI が非同期に要約を生成します。一覧・詳細は KV キャッシュ付きの API で返し、短縮 URL も KV で提供、古いデータは Cron Trigger が毎日掃除します。新しい API はほとんど登場しません。この章の主題は「どの仕事をどの部品に割り当てるか」という設計判断です。

全体の流れを 1 リクエストの旅として表にすると次のようになります。迷ったら該当章に戻って復習してください。

ステップ何が起きるか使う部品復習
1ブラウザが UI(HTML/JS)を受け取る静的アセット第4章
2POST /api/clips を Worker が受けるfetch ハンドラ第2章
3メタデータ(URL・タイトル・状態)を INSERTD1第7章
4記事本文をオブジェクトとして保存R2第6章
5短縮 URL コードを TTL 付きで保存KV第5章
6要約ジョブを send() し、即座に 201 を返すQueues(producer)第9章
7queue ハンドラがジョブをバッチで受信Queues(consumer)第9章
8本文を読み込み、LLM で要約を生成Workers AI第11章
9要約を UPDATE し、詳細 GET は KV キャッシュ経由で応答D1 + KV第5章
10毎日 03:00 UTC に 30 日超のデータを削除Cron Trigger第3章

ストレージの割り当ては次の判断に基づきます。本文は R2 — 数百 KB になりうるテキストを D1 の行に入れるとクエリ全体が重くなるため、オブジェクトとして分離します。R2 の書き込みは強整合で、put() が解決した時点で全世界から読めます。メタデータは D1 — 一覧の並べ替えや期限切れ判定は SQL が最も素直です。キャッシュと短縮 URL は KV — 読み取りが速い一方で結果整合性(他リージョンへの反映に 60 秒程度、時にそれ以上かかる)のため、「多少古くても困らない読み取り」だけに使います。要約は Queues + Workers AI — LLM 推論は数秒かかることがあり、失敗もありえます。HTTP 応答から切り離してキューに逃がせば、投稿は即応答でき、失敗はリトライに任せられます(at-least-once 配送なので、要約の再実行が起きても同じ行を上書きするだけで壊れない冪等な設計にします)。

同期パート — POST /api/clips が 201 を返すまで ブラウザ Worker fetch ハンドラ POST R2 — 記事の本文(大きい) D1 — メタデータ行(pending) KV — 短縮 URL(30 日 TTL) キュー — 要約ジョブを 1 件 ① put() ② INSERT ③ put() ④ send() 要約を待たずに 201 を返す 非同期パート — queue ハンドラが要約して D1 を更新 キュー summarize-jobs Worker queue ハンドラ Workers AI 3 文以内に要約 D1 を UPDATE status='done' 最大 5 件 本文を渡す 要約文 R2 — 記事の本文 get() 失敗時は再配送
図 17-1 応答時間に乗るのは上段の 4 本の書き込みだけで、数秒かかる要約は下段へ切り離されています。だから投稿は即座に 201 を返すことができ、要約が失敗しても破線の再配送で勝手に回復します。

土台の設計 — 1 つの Worker に全バインディングを集める

このアプリは Worker 1 つで完結させます。fetch(API + 静的アセット配信)・queue(要約 consumer)・scheduled(掃除)の 3 ハンドラを同じ src/index.ts に同居させ、必要なリソースはすべてバインディングで注入します。

バインディング名プロダクト役割
DBD1記事メタデータの CRUD(clips テーブル)
BUCKETR2記事本文の保存(clips/<id>.txt)
CACHEKV詳細 API のキャッシュと短縮 URL(TTL で自動失効)
SUMMARIZE_QUEUEQueues要約ジョブの投入口(producer 側)
AIWorkers AILLM による要約生成
ASSETS静的アセットpublic/ のフロント UI 配信

静的アセットのルーティング既定値を思い出してください。リクエスト URL がアセットのファイルにマッチすれば Worker を起動せずそのまま配信され、マッチしなかったリクエストだけが Worker に届きます。つまり / は public/index.html が直接返り、/api/... は(対応するファイルがないので)必ず Worker に届く、という分担が設定なしで成立します。

入口は 3 つ。アセットに一致したリクエストは Worker を起動しない GET / アセットに一致 public/index.html Worker を起動しない そのまま配信(ログにも出ない) 1 つの Worker fetch ハンドラ queue ハンドラ scheduled ハンドラ GET /api/clips 一致なし キューの配送 summarize-jobs 毎日 03:00 UTC Cron Trigger Worker 起動 バッチ配送 毎日 1 回 D1 / R2 / KV / Queues R2 → Workers AI → D1 30 日超の行と本文を削除 読み書き 要約 掃除
図 17-2 同じ Worker が 3 つの入口を持ち、入口ごとに別のハンドラが呼ばれます。最上段だけは Worker を素通りするので、ブラウザで / を開いても wrangler tail に何も出ないのは正常です。
ハンズオン 17-1|プロジェクトと 4 つのリソースを用意する

プロジェクトを作成し、D1・KV・R2・Queues のリソースを作って 1 枚の wrangler.jsonc に束ねます。所要時間は 15 分程度です。

課金に関する注意: Workers AI はローカル開発(wrangler dev)でも実際にアカウント経由で推論が実行され、使用量としてカウントされます(第11章の無料枠を消費)。Queues は Free プランでも動きますが、メッセージ保持 24 時間・1 日 10,000 オペレーションの制約があります(第9章参照。2026-08 時点)。

  1. プロジェクトを作成する(テンプレートは Hello World / Worker only / TypeScript を選択)
npm create cloudflare@latest -- clip-summarizer
cd clip-summarizer
  1. 4 つのリソースを作成する。d1 create と kv namespace create の出力に表示される database_id / id を控えておく
npx wrangler d1 create clip-db
npx wrangler kv namespace create CACHE
npx wrangler r2 bucket create clip-bodies
npx wrangler queues create summarize-jobs
  1. wrangler.jsonc を次の内容に置き換える。<YOUR_DATABASE_ID> と <YOUR_NAMESPACE_ID> は手順 2 で控えた値に置き換える
{
  "name": "clip-summarizer",
  "main": "src/index.ts",
  "compatibility_date": "2026-08-10",
  "assets": { "directory": "./public", "binding": "ASSETS" },
  "d1_databases": [
    { "binding": "DB", "database_name": "clip-db",
      "database_id": "<YOUR_DATABASE_ID>" }
  ],
  "kv_namespaces": [
    { "binding": "CACHE", "id": "<YOUR_NAMESPACE_ID>" }
  ],
  "r2_buckets": [
    { "binding": "BUCKET", "bucket_name": "clip-bodies" }
  ],
  "queues": {
    "producers": [
      { "binding": "SUMMARIZE_QUEUE", "queue": "summarize-jobs" }
    ],
    "consumers": [
      { "queue": "summarize-jobs", "max_batch_size": 5,
        "max_batch_timeout": 10, "max_retries": 3 }
    ]
  },
  "ai": { "binding": "AI" },
  "triggers": { "crons": ["0 3 * * *"] }
}
  1. スキーマファイル schema.sql をプロジェクト直下に作成し、ローカル DB に適用する
CREATE TABLE IF NOT EXISTS clips (
  id TEXT PRIMARY KEY,
  url TEXT NOT NULL,
  title TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  summary TEXT,
  created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_clips_created ON clips (created_at);
npx wrangler d1 execute clip-db --local --file=./schema.sql
npx wrangler types

wrangler types が全バインディングを反映した Env 型を生成すれば成功です。以降のコードはこの型を前提に書きます。

ハンズオン 17-2|クリップ投稿 API — D1・R2・KV・Queues をつなぐ

投稿を受けて 3 つのストレージへ書き分け、要約ジョブをキューに積む producer 側を実装します。

  1. src/index.ts をルーター骨格に置き換える。queue / scheduled は後のハンズオンで埋める
type QueueJob = { clipId: string };

export default {
  async fetch(request, env, ctx): Promise<Response> {
    const { pathname } = new URL(request.url);
    if (pathname === "/api/clips" && request.method === "POST") {
      return createClip(request, env);
    }
    if (pathname === "/api/clips" && request.method === "GET") {
      return listClips(env);
    }
    if (pathname.startsWith("/api/clips/") && request.method === "GET") {
      return getClip(pathname.slice("/api/clips/".length), env, ctx);
    }
    if (pathname.startsWith("/s/")) {
      return redirectShortUrl(pathname.slice(3), env);
    }
    return env.ASSETS.fetch(request); // それ以外は静的アセットに委譲
  },
  async queue(batch, env, ctx): Promise<void> { /* ハンズオン 17-3 */ },
  async scheduled(controller, env, ctx) { /* ハンズオン 17-4 */ },
} satisfies ExportedHandler<Env, QueueJob>;
  1. 投稿処理を実装する。「R2 に本文 → D1 にメタデータ → KV に短縮 URL → キューにジョブ」の順に書き、要約を待たずに応答する
async function createClip(request: Request, env: Env): Promise<Response> {
  const { url, title, body } = (await request.json()) as {
    url: string; title: string; body: string;
  };
  const id = crypto.randomUUID();
  await env.BUCKET.put(`clips/${id}.txt`, body);
  await env.DB.prepare(
    "INSERT INTO clips (id, url, title, status, created_at) VALUES (?, ?, ?, 'pending', ?)",
  ).bind(id, url, title, new Date().toISOString()).run();
  await env.CACHE.put(`s:${id.slice(0, 8)}`, url, {
    expirationTtl: 60 * 60 * 24 * 30, // 短縮 URL は 30 日で自動失効
  });
  await env.SUMMARIZE_QUEUE.send({ clipId: id }); // 128 KB までの JSON
  return Response.json(
    { id, shortCode: id.slice(0, 8), status: "pending" },
    { status: 201 },
  );
}

async function listClips(env: Env): Promise<Response> {
  const { results } = await env.DB.prepare(
    "SELECT id, url, title, status, summary, created_at FROM clips ORDER BY created_at DESC LIMIT 50",
  ).run();
  return Response.json(results);
}
  1. npx wrangler dev を起動し、curl で投稿して確認する
curl -X POST http://localhost:8787/api/clips \
  -d '{"url":"https://example.com/a","title":"テスト記事","body":"本文..."}'
# => {"id":"...","shortCode":"...","status":"pending"}

GET /api/clips で status: "pending" の行が返れば成功です。要約はまだ生成されません(consumer が空のため)。

ハンズオン 17-3|要約ジョブ — Queues consumer から Workers AI を呼ぶ

queue ハンドラで本文を読み出して LLM 要約を生成し、詳細 API に KV キャッシュを挟みます。

  1. 17-2 で空にしておいた queue ハンドラを実装する。メッセージ単位で ack() し、失敗した分だけ再配送させる(第9章の個別制御)
async queue(batch, env, ctx): Promise<void> {
  for (const msg of batch.messages) {
    const { clipId } = msg.body;
    const obj = await env.BUCKET.get(`clips/${clipId}.txt`);
    if (!obj) {
      msg.ack(); // 本文がないジョブは再試行しても無駄なので確定して捨てる
      continue;
    }
    const text = await obj.text();
    const result = (await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
      prompt: `次の記事を日本語で 3 文以内に要約してください。\n\n${text.slice(0, 6000)}`,
    })) as { response: string };
    await env.DB.prepare(
      "UPDATE clips SET summary = ?, status = 'done' WHERE id = ?",
    ).bind(result.response, clipId).run();
    msg.ack();
  }
},
  1. 詳細 API と短縮 URL リダイレクトを追加する。キャッシュに入れるのは status = 'done' の行だけにする(理由はつまずきポイント参照)
async function getClip(id: string, env: Env, ctx: ExecutionContext): Promise<Response> {
  const cached = await env.CACHE.get(`clip:${id}`, { type: "json", cacheTtl: 300 });
  if (cached) return Response.json(cached);
  const row = await env.DB.prepare("SELECT * FROM clips WHERE id = ?")
    .bind(id).first();
  if (!row) return new Response("Not found", { status: 404 });
  if (row.status === "done") {
    // 応答をブロックしないよう waitUntil で書き込む
    ctx.waitUntil(
      env.CACHE.put(`clip:${id}`, JSON.stringify(row), { expirationTtl: 3600 }),
    );
  }
  return Response.json(row);
}

async function redirectShortUrl(code: string, env: Env): Promise<Response> {
  const dest = await env.CACHE.get(`s:${code}`);
  if (!dest) return new Response("Not found", { status: 404 });
  return Response.redirect(dest, 302);
}
  1. wrangler dev を再起動して再度投稿し、数秒待ってから詳細を取得する
curl http://localhost:8787/api/clips/<ID>
# => {"id":"...","status":"done","summary":"この記事は…",...}

status が done になり summary に日本語要約が入っていれば、Queues → Workers AI → D1 のパイプラインが通っています。/s/<shortCode> が元 URL へ 302 リダイレクトすることも確認してください。

ハンズオン 17-4|フロント UI・cron 掃除・デプロイ・観測

UI と定期クリーンアップを足して全部品を揃え、本番デプロイして wrangler tail で観測します。

  1. public/index.html を作成する(最小限のフォームと一覧)
<!doctype html>
<html lang="ja">
<meta charset="utf-8" />
<title>Clip Summarizer</title>
<body>
  <h1>Clip Summarizer</h1>
  <form id="clip-form">
    <input name="url" placeholder="https://example.com/article" required />
    <input name="title" placeholder="タイトル" required />
    <textarea name="body" placeholder="本文を貼り付け" required></textarea>
    <button>クリップする</button>
  </form>
  <ul id="clips"></ul>
  <script>
    const form = document.getElementById("clip-form");
    form.addEventListener("submit", async (e) => {
      e.preventDefault();
      const data = Object.fromEntries(new FormData(form));
      await fetch("/api/clips", { method: "POST", body: JSON.stringify(data) });
      load();
    });
    async function load() {
      const clips = await (await fetch("/api/clips")).json();
      document.getElementById("clips").innerHTML = clips
        .map((c) => `<li>[${c.status}] ${c.title} — ${c.summary ?? "要約待ち"}</li>`)
        .join("");
    }
    load();
  </script>
</body>
</html>
  1. scheduled ハンドラを実装する。R2 の delete() は 1 回で最大 1,000 キーまで受け取れる
async scheduled(controller, env, ctx) {
  const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
  const { results } = await env.DB.prepare(
    "SELECT id FROM clips WHERE created_at < ?",
  ).bind(cutoff).run<{ id: string }>();
  if (results.length === 0) return;
  await env.BUCKET.delete(results.map((r) => `clips/${r.id}.txt`));
  await env.DB.prepare("DELETE FROM clips WHERE created_at < ?")
    .bind(cutoff).run();
  console.log(`cleanup: removed ${results.length} clips`);
},
  1. cron をローカルで発火させて動作確認する(第3章で学んだ専用ルート)
curl "http://localhost:8787/cdn-cgi/handler/scheduled?format=json"
# => {"outcome":"ok","noRetry":false}
  1. 本番の D1 にスキーマを適用してからデプロイし、ログを流し見しながらブラウザで一通り操作する
npx wrangler d1 execute clip-db --remote --file=./schema.sql
npx wrangler deploy
npx wrangler tail --format=pretty

払い出された https://clip-summarizer.<YOUR_SUBDOMAIN>.workers.dev を開いて投稿すると、tail に fetch 呼び出しと queue 呼び出しが順に流れます。ダッシュボードの Workers & Pages → 対象 Worker → Logs → Live でも同じログを確認でき、Cron の実行履歴は Settings 配下の Trigger Events(直近 100 件)で追えます。これで 7 部品すべてが本番で動いています。

つまずきポイント

/ へのアクセスが Worker のログに出ない — 静的アセットにマッチしたリクエストは Worker を起動せずに配信されるため、tail にも現れません。正常な動作です。アセットより先に Worker を通したいパスがある場合だけ、assets 設定の run_worker_first(例: ["/api/*"])を使います。

要約が終わったのに詳細 API が古い内容を返す — KV は結果整合性で、書き込みの他リージョン反映に 60 秒程度(時にそれ以上)かかり、さらに cacheTtl の間は各ロケーションのキャッシュが残ります。本章で status = 'done' の行だけをキャッシュしたのはこのためです。処理中の行をキャッシュすると「要約完了後もしばらく pending に見える」状態が起きます。頻繁に変わるデータを KV に入れないでください。

要約が終わった直後、詳細 API を GET したときに辿る経路の違い pending もキャッシュした場合 — 経路が KV で止まる 2 回目の GET 詳細 API KV pending を保存済み pending が返る 古いまま get() ヒット — D1 を見ない D1 には done が入っているのに done の行だけキャッシュ(本章) — D1 まで辿り直す 2 回目の GET 詳細 API KV pending は入れない D1 status = done done が返る 常に最新 get() ミス 読む done は確定値なので KV に保存(次回はヒット)
図 17-3 違いは 1 回目の GET で KV に pending を書いたかどうかだけですが、書いた側は経路が KV で止まり、書かなかった側だけが D1 まで辿り着きます。KV に入れた値は「そこで経路を止める」宣言だと考えると、確定済みの行しか入れてはいけない理由が見えます。

Cron Trigger の変更が反映されない — Cron Trigger の追加・変更・削除は全ネットワークへの伝播に最大 15 分かかります。また wrangler deploy は設定ファイルの triggers.crons で既存の Cron を毎回置き換えます。"crons": [](空配列)は全削除、キー自体を書かなければ現状維持です。時刻はすべて UTC で解釈される点にも注意してください。

wrangler tail のログが欠ける・複数人で見られない — トラフィックが多いとサンプリングモードに入り、一部のメッセージがドロップされます(警告がログに出ます)。--status error や --method POST などのフィルタで量を絞ってください。また 1 つの Worker のログを同時に見られるクライアントはダッシュボードと tail 合わせて 10 までです。恒久保存が必要なら 第16章の Workers Logs を有効にします。

理解度チェック

Q1. 要約の生成を fetch ハンドラ内で同期実行せず、Queues に逃がしたのはなぜ?

LLM 推論は数秒かかることがあり、失敗もありえるためです。キューに積めば投稿 API は即座に 201 を返すことができ、失敗時のリトライは Queues の配送保証(at-least-once + max_retries)に任せられます。再配送で同じジョブが 2 回実行されても、同じ行を UPDATE するだけの冪等な設計なので壊れません。

Q2. 記事本文を D1 の列ではなく R2 に保存したのはなぜ?

本文は数百 KB になりうる大きな非構造データで、一覧クエリのたびに読む必要がないためです。オブジェクトストレージの R2 に分離し、D1 には SQL で並べ替え・絞り込みしたいメタデータだけを置きます。R2 の書き込みは強整合なので、put() 完了直後から consumer が確実に読める点もジョブ処理と相性が良いです。

Q3. KV キャッシュに status = 'done' の行だけを入れたのはなぜ?

KV は結果整合性で、書き込みの他リージョン反映に 60 秒程度(時にそれ以上)かかり、cacheTtl の間は古い値が返り続けるためです。変化中の pending 行をキャッシュすると、要約完了後も古い状態が見え続けます。確定して以後変わらないデータだけをキャッシュするのが KV の正しい使い方です。

Q4. デプロイせずに scheduled ハンドラの動作を確認する方法は?

wrangler dev 起動中に curl "http://localhost:8787/cdn-cgi/handler/scheduled?format=json" を叩きます。cron クエリパラメータで発火する cron 式を、time パラメータで controller.scheduledTime を指定することもできます。

Q5. ブラウザで / を開いたのに wrangler tail に何も流れないのは異常?

異常ではありません。静的アセットにマッチしたリクエストは Worker を起動せずに配信されるため、Worker のログには現れません。/api/clips のようにアセットへマッチしないパスだけが Worker に届き、tail に記録されます。

まとめと次の学び

この章では、17 章分の部品を 1 つの Worker に束ねてミニ SaaS を完成させました。

ここから先は、実際に自分のアプリを作りながら広げていくのが最短です。