Cloudflare Workers入門:wranglerでKV/D1/R2を使い、注文APIをエッジにデプロイする
wranglerのローカル開発からデプロイまで。KV/D1/R2/Durable Objectsの使い分け、CPU時間とNode非互換の壁、Claude Codeへの頼み方を、実際に動くコードで解説します。
「APIサーバー、どこに置く?」
そう聞かれて、僕はずっとVPSとか小さなコンテナを思い浮かべていました。常時起動して、月いくらか払って、たまに再起動して。当たり前だと思っていたんです。
ところが、このブログ(claudecode-lab.com)をCloudflare Pagesに載せ替えたとき、考えが変わりました。サーバーらしいサーバーがどこにもない。なのに世界中どこからアクセスしても速い。料金は無料枠でほぼ収まる。「あれ、APIもこっち側に寄せられるんじゃないか」と。
その「こっち側」がCloudflare Workersです。Cloudflareの拠点(世界中にある)でJavaScriptやTypeScriptを動かす仕組みで、東京の人には東京近くで、ロンドンの人にはロンドン近くで処理が走ります。サーバーを立てるんじゃなくて、関数を世界中にばらまく感覚に近い。
ただ、普通のNode.jsの感覚で書くと、最初に必ずつまずきます。僕も派手に転びました。この記事では、wrangler でローカル開発してデプロイするまでの最短ルートと、KV / D1 / R2 / Durable Objects の使い分け、そしてエッジ特有の「壁」を、実際に動くコードと一緒に整理します。題材は小さな注文APIです。
この記事の要点
- Cloudflare Workersは「世界中の拠点で動く関数」。常駐サーバーではなく、リクエストごとに
fetchハンドラが呼ばれる - 開発は
wrangler一本。wrangler devでローカル実行、wrangler deployで本番反映、wrangler tailでログ追跡 - データの置き場所は役割で決める。設定はKV、関係データはD1(SQLite互換)、ファイルはR2、強い整合性が要るカウンタや排他はDurable Objects
- 最大の壁は2つ。無料プランのCPU時間は1リクエスト10ms(有料はデフォルト30秒・最大5分)、そして
fsやExpressのlistenは動かない - Claude Codeには「変更するファイル名」「binding名」「curlでの検証手順」「やってはいけないこと」を先に渡すと、レビューしやすい差分が返ってくる
Workersは「サーバー」じゃない、と腹落ちさせる
最初に頭を切り替えてほしいのがここです。普通のNode.jsアプリは、app.listen(3000) でプロセスがずっと起きていて、リクエストを待ち受けます。Workersは違います。リクエストが来たときだけ、関数が呼ばれて、終わったら消える。
入口はこの形だけ覚えれば十分です。
export default {
async fetch(request, env, ctx) {
return new Response("hello from the edge");
},
};
request は来たHTTPリクエスト、env は後で渡す外部リソース(DBやストレージ)、ctx はレスポンス後に走らせたい後処理用。これだけ。Expressのルーティングもミドルウェアも、最初は要りません。
この「常駐しない」性質が、後で出てくる落とし穴の根っこになります。プロセスのメモリに何かを溜めておく、みたいな設計は通用しません。次のリクエストは別の拠点の、別のインスタンスで処理されるかもしれないからです。
データの置き場所を、役割で決める
Workers本体はデータを持ちません。外に置きます。ここでよく事故るのが「全部KVに入れる」パターン。僕も最初それをやって、後から検索できなくて泣きました。役割で分けるのが正解です。
| 置き場所 | 向いているもの | 向かないもの |
|---|---|---|
| KV | フラグ、設定値、参照が多くて更新は少ないキー値 | 関係データ、強い整合性が要るもの |
| D1 | 注文・ユーザーなどの関係データ(SQLite互換) | 重いOLTP、巨大な集計 |
| R2 | 画像・PDF・CSVなどのファイル本体 | 検索したい構造データ |
| Durable Objects | カウンタ、ロック、WebSocketの状態、強整合が要る処理 | ただの読み取りキャッシュ |
ざっくりの判断基準はこうです。「数えたい・順番を守りたい・排他したい」ならDurable Objects、「関係で引きたい」ならD1、「ただ置いて出すだけ」ならR2、「軽い設定」ならKV。 KVは速くて安いぶん、書き込みが世界中に伝わるまで少し時差がある(結果整合)ので、「さっき書いた値がすぐ読めないと困る」用途には使いません。
Workerにこれらを渡す差し込み口を binding(バインディング)と呼びます。env.DB ならD1、env.SETTINGS ならKV、というふうにコードから触れます。この差し込み口を宣言するのが、次に出てくる設定ファイルです。
wranglerのセットアップと設定ファイル
wrangler は Cloudflare の開発CLIです。プロジェクト作成、ローカル実行、KV/D1/R2の作成、secret登録、デプロイ、ログ確認まで全部これ一本でやります。まず雛形を作ります。
npm create cloudflare@latest claude-worker-api -- --type=hello-world
cd claude-worker-api
npm install -D typescript wrangler
npx wrangler --version
新しい雛形だと設定ファイルが wrangler.jsonc で生成されることがありますが、wrangler はTOMLでもJSONCでも読めます。この記事は見やすさ優先で wrangler.toml で書きます。
name = "claude-worker-api"
main = "src/index.ts"
compatibility_date = "2026-06-07"
[vars]
PUBLIC_ENV = "production"
[observability]
enabled = true
head_sampling_rate = 1
[[d1_databases]]
binding = "DB"
database_name = "claude-worker-api"
database_id = "replace-with-d1-database-id"
[[kv_namespaces]]
binding = "SETTINGS"
id = "replace-with-kv-namespace-id"
[[r2_buckets]]
binding = "RECEIPTS"
bucket_name = "claude-worker-receipts"
[[ratelimits]]
name = "API_RATE_LIMITER"
namespace_id = "1001"
[ratelimits.simple]
limit = 60
period = 60
vars は公開されても困らない値だけ。APIトークンなどの秘密情報は、ここに書かず後で wrangler secret put で登録します(toml直書きは事故のもとです)。database_id やKVの id は、次のコマンドで実際に作った値に差し替えます。
npx wrangler login
npx wrangler d1 create claude-worker-api
npx wrangler kv namespace create SETTINGS
npx wrangler r2 bucket create claude-worker-receipts
npx wrangler secret put API_TOKEN
ひとつ注意。[[ratelimits]] のRate Limitingバインディングは wrangler 4.36.0以降が必要です。しかもこの機能、ローカル開発では完全には再現されないので、レート制限の最終確認は本番かリモート環境でやる前提にしてください。僕はローカルで「効いてない!」と一時間悩んで、本番では普通に効いていた、というオチを経験しました。
D1スキーマを用意する
D1はSQLite互換のサーバーレスDBです。小さなAPI、管理画面、Webhookの受信履歴あたりには気持ちいいくらい手軽。一方で、重い集計を全部任せる相手ではありません。スキーマを書きます。
-- schema.sql
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
amount INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_orders_email ON orders(email);
これをローカルと本番、それぞれに流します。--local は手元の開発用、--remote はCloudflare側の本番D1です。最初はこの2つを混同して、本番に流したつもりがローカルだけだった、というのもよくある罠です。
npx wrangler d1 execute claude-worker-api --local --file=./schema.sql
npx wrangler d1 execute claude-worker-api --remote --file=./schema.sql
コピペで動く注文API(src/index.ts)
ここが本体です。外部ライブラリなしで動かします。Honoを使う手もありますが、まず素のWorkersを理解しておくと、後でClaude Codeに差分をレビューさせるときに「何が標準で何が独自か」を見分けやすくなります。
/health は認証なしで状態を返す確認用。/orders/:id はD1から1件取って30秒だけキャッシュ。POST /orders はJSONを検証してD1に保存し、控えをR2に置きます。全レスポンスにセキュリティヘッダーを付け、ログはJSONで残します。
// src/index.ts
export interface Env {
API_TOKEN: string;
PUBLIC_ENV: string;
DB: D1Database;
SETTINGS: KVNamespace;
RECEIPTS: R2Bucket;
API_RATE_LIMITER: RateLimit;
}
type OrderInput = {
email: string;
amount: number;
};
// 全レスポンスに付ける最低限のセキュリティヘッダー
const securityHeaders = {
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"x-content-type-options": "nosniff",
"x-frame-options": "DENY",
"referrer-policy": "no-referrer",
"permissions-policy": "camera=(), microphone=(), geolocation=()",
};
function json(data: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(data), {
...init,
headers: {
"content-type": "application/json; charset=utf-8",
...securityHeaders,
...init.headers,
},
});
}
// Authorization: Bearer <token> を環境変数と照合
function requireAuth(request: Request, env: Env) {
const expected = `Bearer ${env.API_TOKEN}`;
return request.headers.get("authorization") === expected;
}
// 入力検証。ここで弾けば、おかしなデータがDBに入らない
async function readOrderInput(request: Request): Promise<OrderInput> {
const body = await request.json<OrderInput>();
if (!body.email || !body.email.includes("@")) {
throw new Error("email must be valid");
}
if (!Number.isInteger(body.amount) || body.amount <= 0) {
throw new Error("amount must be a positive integer");
}
return body;
}
export default {
async fetch(request, env, ctx): Promise<Response> {
const url = new URL(request.url);
const requestId = crypto.randomUUID();
// ログは文字列ではなくJSONで。後から requestId で追える
console.log({
event: "request_started",
requestId,
method: request.method,
path: url.pathname,
});
// 死活確認は認証なしで通す
if (url.pathname === "/health") {
const maintenance = await env.SETTINGS.get("maintenance");
return json({ ok: true, env: env.PUBLIC_ENV, maintenance: maintenance === "true" });
}
if (!requireAuth(request, env)) {
return json({ error: "unauthorized" }, { status: 401 });
}
// レート制限のキーはIPではなくトークン由来にする
const rateKey = request.headers.get("authorization")?.slice(-16) ?? "anonymous";
const { success } = await env.API_RATE_LIMITER.limit({ key: rateKey });
if (!success) {
return json({ error: "rate_limited" }, { status: 429 });
}
const orderMatch = url.pathname.match(/^\/orders\/([a-zA-Z0-9_-]+)$/);
// GET: まずキャッシュを見て、無ければD1から取って短時間キャッシュ
if (request.method === "GET" && orderMatch) {
const cache = caches.default;
const cacheKey = new Request(url.toString(), { method: "GET" });
const cached = await cache.match(cacheKey);
if (cached) {
return cached;
}
const order = await env.DB.prepare(
"SELECT id, email, amount, status, created_at FROM orders WHERE id = ?"
).bind(orderMatch[1]).first();
if (!order) {
return json({ error: "not_found" }, { status: 404 });
}
const response = json({ order }, {
headers: {
"cache-control": "public, max-age=30",
"cache-tag": `order-${orderMatch[1]}`,
},
});
// キャッシュ保存はレスポンス後ろに回す
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}
// POST: 検証 → D1へ保存 → 控えをR2へ
if (request.method === "POST" && url.pathname === "/orders") {
try {
const input = await readOrderInput(request);
const id = crypto.randomUUID();
await env.DB.prepare(
"INSERT INTO orders (id, email, amount, status) VALUES (?, ?, ?, ?)"
).bind(id, input.email, input.amount, "pending").run();
await env.RECEIPTS.put(`orders/${id}.json`, JSON.stringify({
id,
email: input.email,
amount: input.amount,
}), {
httpMetadata: { contentType: "application/json" },
});
console.log({ event: "order_created", requestId, orderId: id });
return json({ id, status: "pending" }, { status: 201 });
} catch (error) {
return json({ error: error instanceof Error ? error.message : "bad_request" }, { status: 400 });
}
}
return json({ error: "not_found" }, { status: 404 });
},
} satisfies ExportedHandler<Env>;
このコードで効いているポイントは3つ。secretをコードに埋め込まないこと、ctx.waitUntil でキャッシュ保存をレスポンスの後ろに逃がすこと(応答が遅くならない)、そして console.log を文字列ではなくJSONで出すこと。最後のやつが地味に効きます。Workers LogsはJSONのフィールドで絞り込めるので、あとから requestId や orderId で1件だけ追える。障害調査の体感速度がまるで違います。
D1で .bind(...) を使っているのも重要です。SQL文字列にユーザー入力を直接つなぐとSQLインジェクションの穴になりますが、? プレースホルダ+bindなら安全に値を渡せます。
ローカルで確認して、本番にデプロイする
ローカル開発では .dev.vars に開発用のsecretを置けます。本番secretとは別ファイルなので混ざりません。
printf "API_TOKEN=dev-token\n" > .dev.vars
npx wrangler dev
別ターミナルでcurlを叩いて確認します。
curl http://localhost:8787/health
curl -X POST http://localhost:8787/orders \
-H "authorization: Bearer dev-token" \
-H "content-type: application/json" \
-d "{\"email\":\"[email protected]\",\"amount\":1200}"
curl http://localhost:8787/orders/replace-with-created-id \
-H "authorization: Bearer dev-token"
通ったら本番です。secret登録 → 本番D1へのスキーマ反映 → デプロイ → ログ追跡、の順で進めます。
npx wrangler secret put API_TOKEN
npx wrangler d1 execute claude-worker-api --remote --file=./schema.sql
npx wrangler deploy
npx wrangler tail
wrangler tail はリアルタイムのログ確認に便利ですが、腰を据えた調査にはWorkers Logsの画面かLogpushを使います。公開したら curl -i で、セキュリティヘッダー・cache-control・連打したときの429が想定どおりか必ず見てください。ここを飛ばすと「ヘッダー付けたつもり」のまま本番が動きます(経験談)。
エッジの「壁」を先に知っておく
ここがこの記事でいちばん伝えたいところです。Workersは速くて安いけれど、普通のサーバーとは制約が違います。知らずに書くと必ずぶつかります。
壁1:CPU時間がシビア。 無料プランは1リクエストあたりCPU時間が10ms。有料プラン(Workers Paid)はデフォルト30秒、設定で最大5分まで上げられます。ここで言うのは「待ち時間」ではなく「実際にCPUを使った時間」なので、外部APIの応答待ちは含みません。それでも、画像変換や巨大JSONのループ処理を無料枠でやると簡単に超えます。重い処理はWorkersでやらない、が原則です。
壁2:Node.jsのつもりで書くと動かない。 Workersの実体はWeb標準API中心のランタイムです。fs でローカルファイルを読む、Expressを listen する、プロセス常駐メモリを信頼する——このあたりは全部アウト。Node互換は年々広がっていますが、最初から fetch / Request / Response / crypto といったWeb標準で書くほうが安全です。npmの古いライブラリをそのまま持ち込もうとして動かない、はよくある詰まりどころです。
壁3:メモリは1インスタンス128MBまで。 大きなファイルを丸ごとメモリに展開する処理は危険です。R2から大きいオブジェクトを扱うときはストリームで流します。
壁4:状態をメモリに溜められない。 さっき書いたとおり、リクエストごとに別インスタンスかもしれない。グローバル変数にカウンタを置いても、それは「たまたまそのインスタンスの値」でしかありません。きちんと数えたいならDurable Objectsを使います。
この4つを先に頭に入れておくだけで、「動くはずなのに動かない」の8割は避けられます。
どんな用途に効くか(4つ)
-
小さな注文・問い合わせAPI。 今回作ったやつそのものです。D1に本文と状態、R2に添付や控え。利用者に近い拠点で受けるので、海外読者が多い個人サービスとも相性がいい。
-
Webhookの受け口。 GitHub、Stripe、GumroadなどからのPOSTを受け、署名を検証してD1にイベントIDを保存します。同じIDが再送されても処理しない冪等性を持たせると、一気に堅くなります。署名検証の考え方は Claude Code Webhook実装 と合わせて読むと整理しやすいです。
-
軽量なBFF(フロント専用の薄いAPI層)。 ブラウザに見せたくないAPIキーをWorker側で握り、外部APIのレスポンスを整形して、Cache APIで短時間キャッシュ。個人開発のダッシュボードや多言語ブログのメタデータAPIに向きます。このブログのメタ取得も、まさにこの発想です。
-
ファイルの配布制御。 R2にファイルを置き、Workerで認可・署名URL・ダウンロードログを扱う。ただし、重い変換や長時間ジョブはWorkers単体では無理(壁1)。Cloud RunやLambda、Queuesに逃がします。
やりがちな落とし穴
最初の落とし穴は、もう何度も言いましたが、Node.jsサーバーのつもりで書くこと。 fs とExpressのlistenはエッジでは動きません。
2つ目は、KV・D1・R2・Cache APIの役割を混ぜること。 何でもKVに突っ込むと検索と整合性で詰みます。表に戻って役割で分けてください。
3つ目は、キャッシュに個人情報を入れること。 Set-Cookie 付きのレスポンス、ユーザーごとに違うレスポンス、メールアドレス入りのレスポンスは、共有キャッシュに入れてはいけません。キャッシュキーにユーザーIDを混ぜる場合でも、漏れたときの影響を考えてTTLは短く。
4つ目は、レート制限のキーをIPだけにすること。 モバイル回線や社内ネットワークでは、大勢が同じIPに見えます。公式でもユーザーIDやAPIキーなど安定した識別子が推奨されています。上のコードでトークン由来のキーにしたのはこのためです。
5つ目は、ログにsecretや個人情報を出すこと。 authorization ヘッダー、Cookie、メール本文、APIトークンは出さない。ログは検索できる形で残るぶん、出した情報も残り続けます。
Workers、Pages Functions、Cloud Run、Lambdaの選び分け
迷ったときの僕の判断はこうです。
- Workers:HTTP APIを低遅延で返したい、Cloudflareのキャッシュやbindingを直接使いたい、グローバルに近い場所で軽い処理。今回の注文API・Webhook・BFFはここ。
- Pages Functions:すでにCloudflare Pagesで静的サイトを配信していて、その横に少しだけサーバー処理を足したい場合。フォーム送信や認証補助など。独立したAPI基盤に育てるならWorkersのほうが責務を分けやすい。
- Cloud Run:コンテナで好きな言語・フレームワークを動かしたい、画像変換やPDF生成で処理が長い、既存のExpress/FastAPI/Railsを持ち込みたい場合。
- Lambda:AWS内のS3・DynamoDB・EventBridge・SQSと近いイベント処理。AWSの権限設計やVPC接続が前提なら自然。
線引きはシンプルに。エッジでキャッシュに近いAPIならWorkers、AWSイベント中心ならLambda、重い処理ならCloud Run。 サーバーレス全体の比較は Claude Codeサーバーレス関数ガイド、エッジ実行という概念そのものは Claude Codeエッジコンピューティング も参考になります。
Claude Codeへの頼み方
Workersの実装はClaude Codeと相性がいいんですが、それは「曖昧に頼まない」場合に限ります。雑に「APIを作って」と言うと、動くけど運用できないサンプルが返ってきがち。ファイル単位の変更対象・binding名・curl検証・禁止事項を先に渡すのがコツです。
そして実装後は、生成ではなくレビューを頼みます。これが効きます。
このCloudflare Workers実装をレビューしてください。
観点:
- fetch handlerがWorkersランタイムで動くか(Node専用APIに依存していないか)
- wrangler.tomlのbinding名とEnv型が一致しているか
- API_TOKENなどのsecretがコード・ログ・設定ファイルへ漏れていないか
- D1クエリがbindを使い、SQL injectionに弱くないか
- Cache APIに個人情報や認可レスポンスを入れていないか
- Rate Limitingのkeyがip依存だけになっていないか
- security headersが全レスポンスへ付くか
- curlで再現できる検証手順が足りているか
出力は、重大度順の指摘・修正案・追加すべきテストだけにしてください。
binding名のズレ、secretの扱い、キャッシュの誤用は、人間が目で追うと意外と見落とします。機械レビューを一枚挟む価値があるところです。セキュリティヘッダーの細部は Claude Code Webセキュリティヘッダー実装 も併読してください。
よくある質問
Q. 無料プランだけで本番に使えますか? 小さなAPIやWebhook、個人サービスなら現実的に使えます。ただしCPU時間が1リクエスト10msなので、重い処理を入れた瞬間に詰まります。処理時間を伸ばしたい・本番のレート制限をきちんと使いたいなら有料プラン(Workers Paid)を検討してください。
Q. wrangler.toml と wrangler.jsonc、どちらを使うべき?
どちらでもwranglerは読めます。新しい雛形ではJSONCが生成されることがあります。チームの好みで揃えればOKで、この記事は説明しやすさからTOMLにしました。途中で変換しても問題ありません。
Q. D1とKVはどう使い分けますか? 「関係で引きたい・検索したい」ならD1(SQLite互換)。「軽い設定値を高速に読みたい」ならKV。KVは書き込みが世界に伝わるまで時差がある結果整合なので、「書いた直後に必ず読めないと困る」用途には向きません。
Q. 強い整合性が要るカウンタやロックは? Durable Objectsの出番です。1つのオブジェクトに対する処理が直列化されるので、在庫の取り合いや厳密なカウント、WebSocketの接続状態管理に向きます。逆に、ただの読み取りキャッシュにDurable Objectsは大げさです。
Q. ローカルで動いたのに本番で挙動が違います。
よくあるのはRate Limitingバインディングです。ローカルでは完全には再現されないので、レート制限の最終確認は本番かリモート環境で。あとは --local と --remote のD1取り違え、secretの未登録もありがちです。
実際に試した結果
このブログをCloudflare Pagesに載せてから、僕のなかで「サーバーを用意する」のハードルがぐっと下がりました。Workersも同じで、wrangler dev で書いて、curlで叩いて、wrangler deploy で世界中に出る——この一連が驚くほど短い。
今回の注文APIも、wranglerの設定・D1スキーマ・src/index.ts・curl確認を一通りつないで動作を確認しました。ただし、Rate Limitingの挙動、Workers Logsの保持期間、D1/R2の課金条件はアカウント設定に依存します。公開前に必ず自分のCloudflareアカウントで wrangler deploy → wrangler tail → 429 → キャッシュヘッダーの順に再確認してください。
最後にひとつだけ。いきなり全部を移さないでください。/health か、読み取り専用のGETか、Webhookの受け口——どれか1エンドポイントだけWorkersに移すのが、いちばん安全で、いちばん「あ、これで世界が変わった」と実感できる入口です。実務用のテンプレートやチェックリストは 教材一覧 にまとめてあります。公式情報は Cloudflare Workers ドキュメント を横に置いて進めるのが確実です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。