Astroの静的サイトにCloudflare Workers KVで「わかる」ボタンを付けた話!run_worker_firstと無料枠の書き込み1,000回の落とし穴
🙌 結論から
Astro で作った静的サイトに「いいね」的なボタンを付けるなら、Cloudflare Workers の静的アセット配信+KV がかなり手軽です!
このブログの「エンジニアあるある」に、共感したら押せる 「わかる」ボタン を付けました(^^)
ページは今までどおり静的に配信して、/api/* へのリクエストだけ Worker を通す構成です。
ただ、KV はカウンター向きのストレージではありません。
無料プランの書き込みは1日1,000回まで で、同じキーへの書き込みは1秒に1回までという制限もあります。
「わかる」の数くらいなら KV で十分ですが、正確さが必要な数字なら Durable Objects を選ぶべき、というのが結論です(`・ω・´)
👀 静的サイトのままAPIだけWorkerに通す
このブログは Astro でビルドした dist を、Cloudflare Workers の静的アセットとして配信しています。
ボタンの数を保存するには、どこかでリクエストを受けるサーバー側の処理が必要です。
かといって、全ページを Worker 経由にするのはもったいないんですよね・・・
そこで使ったのが run_worker_first です。
// wrangler.jsonc(抜粋)
{
"main": "./worker/index.ts",
"kv_namespaces": [{ "binding": "WAKARU_KV", "id": "<KV の ID>" }],
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "404-page",
"run_worker_first": ["/api/*"]
}
}
run_worker_first は、true / false だけでなく、パスのパターンを配列で書ける ようになっています。
/api/* に合うリクエストだけ先に Worker が動いて、それ以外は静的ファイルがそのまま返ります👍
!/api/docs/* のように ! を付けると除外もできて、除外の方が優先されます。
静的アセットへのリクエストは無料・無制限 で、Workers の無料プランの「1日10万リクエスト」に数えられるのは Worker が動いた分だけです。
ページの閲覧は数に入らず、ボタンまわりの API だけがカウントされるので、無料枠の心配がほぼなくなりました✨
🔧 KVでカウントする実装
API は2つだけです。
GET /api/wakaru?ids=2026-10-03-1,2026-10-03-2で、表示中のあるあるの数をまとめて返すPOST /api/wakaru(本文は{ "id": "2026-10-03-1" })で1つ増やす
POST の中身は、だいたいこんな感じです。
const WAKARU_ID = /^\d{4}-\d{2}-\d{2}-\d{1,2}$/;
const DEDUPE_TTL = 60 * 60 * 24 * 30; // 30日
const body = (await request.json().catch(() => null)) as { id?: string } | null;
const id = body?.id ?? '';
if (!WAKARU_ID.test(id)) return json({ error: 'bad id' }, 400);
// 同じ人の連打は数えない(IP をそのまま保存せず、ハッシュにして30日だけ覚える)
const ip = request.headers.get('cf-connecting-ip') ?? '';
const who = await sha256Base64Url(`${ip}:${id}`);
const current = Number((await env.WAKARU_KV.get(`wakaru:${id}`)) ?? 0);
if (await env.WAKARU_KV.get(`wakaru-by:${who}`)) return json({ count: current });
const next = current + 1;
await Promise.all([
env.WAKARU_KV.put(`wakaru:${id}`, String(next)),
env.WAKARU_KV.put(`wakaru-by:${who}`, '1', { expirationTtl: DEDUPE_TTL }),
]);
return json({ count: next });
ポイントは3つです。
- ID を正規表現でしばる: 好き勝手なキーを KV に作られないようにしています
- IP はハッシュにして保存: 生の IP を残したくないので、ID とくっつけて SHA-256 にしています
expirationTtlで勝手に消える: 連打防止の記録は30日で KV から消えるので、掃除のバッチがいりません
GET は ids を最大50件に絞ってから Promise.all で読んでいます。
ここを絞っておかないと、細工したリクエスト1回で KV の読み取りを大量に使われてしまうんですよね🤔
💡 ハマった点と回避策
1. 無料プランの書き込みは1日1,000回まで
KV の無料枠は、読み取りが1日10万回、書き込みが1日1,000回、保存容量が1GBです。
上限は毎日 0:00 UTC(日本時間の朝9時)にリセットされます。
最初は気づかなかったんですが、上のコードは 1回押されるごとに2回書き込んでいます(・_・;)
カウント本体と、連打防止の記録の2つです。
つまり無料プランのままだと、1日に数えられる「わかる」は500回まで という計算になります。
いまのアクセス数なら十分ですが、伸びてきたら有料プランにするか、連打防止をブラウザ側だけにして書き込みを1回に減らすつもりです。
2. 同じキーへの書き込みは1秒に1回まで
KV は、同じキーに1秒以内に何度も書き込むと 429 エラーになります。
人気のあるあるに同時にボタンが押されると、ここに引っかかる可能性があります。
3. 読んだ値がすぐ最新とは限らない
KV は結果整合性で、書き込みが世界中に行き渡るまで最大60秒ほどかかります。
書き込んだのと同じデータセンターからの読み取りはすぐ反映されますが、別の地域から読むと少し古い値が返ることがあります。
しかも「読んで +1 して書く」なので、ほぼ同時に2人が押すと、片方の +1 が上書きで消えることがあります😴
「わかる」の数が1つずれても誰も困らないので、ここは割り切りました。
🤔 KVで足りる場面とDurable Objectsにする場面
| Workers KV | Durable Objects | |
|---|---|---|
| 向いている数字 | 多少ずれてもいい(いいね・閲覧数の目安) | 正確でないと困る(在庫・投票・レート制限) |
| 同時の +1 | 上書きで消えることがある | 1つのオブジェクトで順番に処理できる |
| 書き込みの頻度 | 同じキーは1秒に1回まで | 高頻度でも扱いやすい |
| 手軽さ | binding を足すだけ | クラスを書いて設定も増える |
「ずれても困らない数」なら KV、「ずれたら困る数」なら Durable Objects で考えるとシンプルです。
KV でレート制限を組んだ人の失敗談でも「1秒以内の連続リクエストで書き込みが反映されていなかった」という話があったので、私ならそこは Durable Objects にします🔥
🔗 関連記事と使い分け
- Astro 6にアップグレードした話: このブログ本体の話です。ページを作る側が Astro、ボタンの API が Worker という分担です
- HonoでAPIサーバーを書いた話: API が増えてきたら、Worker の中のルーティングを Hono に寄せると型もそろって楽です。今回は API が1本なので素の
fetchハンドラーで書いています
パパ系Webエンジニア〜愛用ガジェット一覧 🛍️
この記事を書いている作業環境のガジェットを楽天ROOMにまとめています ✨ 在宅エンジニアのデスク作りの参考にどうぞ 🙌
🎉 まとめ
静的サイトはそのままに、run_worker_first: ["/api/*"] で API だけ Worker に通すのが、いちばん手軽な「動く部分」の足し方でした。
KV は手軽ですが、無料枠の書き込み1,000回と、同じキー1秒1回の制限はちゃんと押さえておきたいところです。
数字の正確さが必要になったら Durable Objects、くらいの気持ちで始めるのがちょうどいいと思います😊
Cloudflare いいわ〜・・!
※ バージョン番号・料金・機能は今後も変わる可能性があります。最新情報は公式サイトをご確認ください👀