- D1・R2・KV・Queues・Workers AI・静的アセット・Cron Trigger を 1 つの Worker に束ねる構成法
- 「記事クリップ & AI 要約サービス」のデータフロー設計と、ストレージの使い分けの判断基準
- fetch / queue / scheduled の 3 ハンドラを同居させた実装と、リソース作成からデプロイまでの一連の手順
wrangler tailとダッシュボードで複数部品をまたぐ処理を観測する方法- この本を読み終えたあとの学習リソース(Learning Paths・テンプレート・コミュニティ)
何を作るか — 記事クリップ & AI 要約サービスのデータフロー
最終章では、これまでの章で個別に学んだ部品を総動員して「記事クリップ & AI 要約サービス」を作ります。ブラウザから記事の URL・タイトル・本文を投稿すると、メタデータが D1 に、本文が R2 に保存され、Queues 経由で Workers AI が非同期に要約を生成します。一覧・詳細は KV キャッシュ付きの API で返し、短縮 URL も KV で提供、古いデータは Cron Trigger が毎日掃除します。新しい API はほとんど登場しません。この章の主題は「どの仕事をどの部品に割り当てるか」という設計判断です。
全体の流れを 1 リクエストの旅として表にすると次のようになります。迷ったら該当章に戻って復習してください。
| ステップ | 何が起きるか | 使う部品 | 復習 |
|---|---|---|---|
| 1 | ブラウザが UI(HTML/JS)を受け取る | 静的アセット | 第4章 |
| 2 | POST /api/clips を Worker が受ける | fetch ハンドラ | 第2章 |
| 3 | メタデータ(URL・タイトル・状態)を INSERT | D1 | 第7章 |
| 4 | 記事本文をオブジェクトとして保存 | R2 | 第6章 |
| 5 | 短縮 URL コードを TTL 付きで保存 | KV | 第5章 |
| 6 | 要約ジョブを send() し、即座に 201 を返す | Queues(producer) | 第9章 |
| 7 | queue ハンドラがジョブをバッチで受信 | 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 配送なので、要約の再実行が起きても同じ行を上書きするだけで壊れない冪等な設計にします)。
土台の設計 — 1 つの Worker に全バインディングを集める
このアプリは Worker 1 つで完結させます。fetch(API + 静的アセット配信)・queue(要約 consumer)・scheduled(掃除)の 3 ハンドラを同じ src/index.ts に同居させ、必要なリソースはすべてバインディングで注入します。
| バインディング名 | プロダクト | 役割 |
|---|---|---|
DB | D1 | 記事メタデータの CRUD(clips テーブル) |
BUCKET | R2 | 記事本文の保存(clips/<id>.txt) |
CACHE | KV | 詳細 API のキャッシュと短縮 URL(TTL で自動失効) |
SUMMARIZE_QUEUE | Queues | 要約ジョブの投入口(producer 側) |
AI | Workers AI | LLM による要約生成 |
ASSETS | 静的アセット | public/ のフロント UI 配信 |
静的アセットのルーティング既定値を思い出してください。リクエスト URL がアセットのファイルにマッチすれば Worker を起動せずそのまま配信され、マッチしなかったリクエストだけが Worker に届きます。つまり / は public/index.html が直接返り、/api/... は(対応するファイルがないので)必ず Worker に届く、という分担が設定なしで成立します。
/ を開いても wrangler tail に何も出ないのは正常です。プロジェクトを作成し、D1・KV・R2・Queues のリソースを作って 1 枚の wrangler.jsonc に束ねます。所要時間は 15 分程度です。
課金に関する注意: Workers AI はローカル開発(wrangler dev)でも実際にアカウント経由で推論が実行され、使用量としてカウントされます(第11章の無料枠を消費)。Queues は Free プランでも動きますが、メッセージ保持 24 時間・1 日 10,000 オペレーションの制約があります(第9章参照。2026-08 時点)。
- プロジェクトを作成する(テンプレートは Hello World / Worker only / TypeScript を選択)
npm create cloudflare@latest -- clip-summarizer
cd clip-summarizer
- 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
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 * * *"] }
}
- スキーマファイル
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 型を生成すれば成功です。以降のコードはこの型を前提に書きます。
投稿を受けて 3 つのストレージへ書き分け、要約ジョブをキューに積む producer 側を実装します。
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>;
- 投稿処理を実装する。「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);
}
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 が空のため)。
queue ハンドラで本文を読み出して LLM 要約を生成し、詳細 API に KV キャッシュを挟みます。
- 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();
}
},
- 詳細 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);
}
wrangler devを再起動して再度投稿し、数秒待ってから詳細を取得する
curl http://localhost:8787/api/clips/<ID>
# => {"id":"...","status":"done","summary":"この記事は…",...}
status が done になり summary に日本語要約が入っていれば、Queues → Workers AI → D1 のパイプラインが通っています。/s/<shortCode> が元 URL へ 302 リダイレクトすることも確認してください。
UI と定期クリーンアップを足して全部品を揃え、本番デプロイして wrangler tail で観測します。
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>
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`);
},
- cron をローカルで発火させて動作確認する(第3章で学んだ専用ルート)
curl "http://localhost:8787/cdn-cgi/handler/scheduled?format=json"
# => {"outcome":"ok","noRetry":false}
- 本番の 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 に入れないでください。
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 を完成させました。
- 設計の核は「仕事ごとのストレージ選択」— SQL で扱うメタデータは D1、大きな非構造データは R2、確定済みデータの高速読み取りは KV、時間のかかる処理は Queues + Workers AI
- fetch / queue / scheduled の 3 ハンドラは 1 つの Worker に同居でき、バインディングは wrangler.jsonc 1 枚で宣言できる
- 静的アセットは Worker を起動せず配信されるため、UI と API の分担が設定なしで成立する
- 本番の挙動は
wrangler tailとダッシュボードの Live ログで即座に観測できる
ここから先は、実際に自分のアプリを作りながら広げていくのが最短です。
- Workers Learning Path — 概念の復習から Developer Platform 全体像までを体系的に辿れる公式学習コース
- Examples と Tutorials — コピーして動かせるコード例と、本章のようなアプリを 1 本ずつ作るチュートリアル集
- cloudflare/templates — フルスタック構成のスターター。
npm create cloudflare@latest -- --template <SOURCE>で任意のリポジトリから開始できる - Cloudflare Developers Discord — 公式コミュニティ。製品ごとのチャンネルで質問・フィードバックができる