2児のパパ系Webエンジニア
の開発日記
A Dad-of-Two Engineer's Development Diary

Astroの静的サイトにCloudflare Workers KVで「わかる」ボタンを付けた話!run_worker_firstと無料枠の書き込み1,000回の落とし穴

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 KVDurable 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 いいわ〜・・!

※ バージョン番号・料金・機能は今後も変わる可能性があります。最新情報は公式サイトをご確認ください👀

参考になったらシェアしてもらえるとうれしいです🙌

Xでポスト Threads はてブ LINE