【Cloudflare】D1 と Turnstile で記事に「参考になった」ボタンを付けてみた

この記事でわかること
- Workers の有料プランが何に使われていたのか(CPU 時間の実測)
- D1 の料金と、支出の上限を設定できない点の考え方
- Turnstile と Origin チェックで、件数を1増やす API を作る流れ
- D1 と Turnstile を Terraform で管理して、Pages を管理外にした理由
Cloudflare Pages(@cloudflare/next-on-pages)で Next.js を動かしている方向けです。Workers は有料プラン($5/月)の前提で書いています。
概要
この記事では、このブログの記事の一番下に「参考になった」ボタンを付けた話についてまとめています。 件数は Cloudflare D1 に保存して、bot 対策には Turnstile を使いました。D1 と Turnstile の設定は Terraform で管理しています。
このボタン自体は前から Issue に積んでいたのですが、件数を保存するには DB が要るので、個人ブログでそこまでやるのは割と大変だなと思って見送っていました。(Issue にも「新しいデータストアは追加しない」と書いていました...)
ただ、Workers は有料プラン(月 $5)にしているのに、それを活かせているのか正直よく分かっていませんでした。そこでちゃんと調べてみたら、D1 を使ってもほぼ追加の費用なしでいけそうだったので、やってみることにしました。
ちなみに実装の多くは Claude Code と相談しながら進めています。
対象者
対象者は下記のような方々になります。
- Cloudflare Pages で Next.js のサイトを動かしている方
- 個人ブログに「いいね」的なボタンを付けたいけど、費用や bot が気になっている方
- D1 や Turnstile を触ってみたい方
動作環境
| 項目 | バージョンなど |
|---|---|
| Next.js | 15.5(App Router / Edge Runtime) |
| @cloudflare/next-on-pages | 1.13 |
| wrangler | 4.152 |
| Terraform | 1.10 以上 |
| Cloudflare provider | 5.28 |
| ホスティング | Cloudflare Pages(Workers 有料プラン) |
有料プランは何に使われていたのか
まずは、そもそも有料プランが必要なのかを確認しました。 ダッシュボードで直近24時間を見てみると下記のような感じでした。
| 項目 | 値 |
|---|---|
| リクエスト数 | 約4,159 |
| CPU 時間 p50 | 約6.5ms |
| CPU 時間 p75 | 約74ms |
| CPU 時間 p99 | 約373ms |
(グラフの単位表示は ms になっていたのですが、実際の数字はμsで出ていたので読み替えています。ここで少し混乱しました...)
Workers の無料プランは1リクエストあたりの CPU 時間が 10ms までなので、p75 の時点で大きく超えています。 このブログは記事を表示するときに、シンタックスハイライトや KaTeX の数式、OG 画像の生成などを Worker 側でやっています。その重い描画を回すために有料プランが必要だった、ということがここで分かりました。
なので有料プランをやめるという選択肢はなくて、どうせ払っているならその枠で使えるものを使おう、という感じです。
D1 の料金と不安だったところ
D1 は Cloudflare が提供している SQLite のデータベースです。 有料プランに含まれている枠は下記の通りです。
| 項目 | 含まれる枠 |
|---|---|
| 書き込み | 5,000万行/月 |
| 読み込み | 250億行/月 |
| ストレージ | 5GB |
超えた分は、書き込みなら100万行あたり $1 です。 ボタンを1回押すと書き込みが1行なので、正直このブログのアクセス数だと枠に収まらない方が難しいです。
ただ、1つ不安だったのが、有料プランは超過すると自動で課金されて、支出の上限(ハードキャップ)を設定できないことです。 Budget alerts という仕組みで、金額を超えたらメールで通知はしてくれますが、止めてはくれません。 調べてみたら $10 のアラートが自動で作成されていたので、とりあえず気づくことはできそうです。
一応、悪用された場合も計算してみました。 書き込み5,000万行を1ヶ月で使い切るには、ずっと毎秒約19回押し続ける必要があります。 しかもそれだけリクエストが来たら、D1 より先に Worker のリクエスト数の超過(100万リクエストあたり $0.30)の方が効いてきます。それは D1 を入れなくても既にあるリスクなので、D1 を入れたから急に危なくなるわけではないなと思いました。
とはいえ bot に連打されるのは嫌なので、Turnstile を入れることにしました。
Turnstile とは
Turnstile は Cloudflare が無料で提供している bot 判定の仕組みです。 reCAPTCHA の Cloudflare 版みたいなイメージで、基本的に読者は何もしなくてよくて、怪しいと判断されたときだけチェックボックスが出ます。 判定回数も無制限なのでありがたいです。
実装
この章では実装について説明していきます。
全体の流れ
記事の HTML は CDN に7日間キャッシュしているので、件数を HTML に埋め込むとキャッシュが使えなくなってしまいます。 なので HTML はそのままにして、件数だけクライアントから API に取りに行く形にしました。
GET /api/reactions/[slug]: 件数を返す。CDN で60秒キャッシュするPOST /api/reactions/[slug]: 件数を1増やす
ほかの読者に件数が1分遅れて見えるくらいなら困らないので、GET はキャッシュしています。D1 の読み込みもこれで減らせます。
テーブル
テーブルは1記事1行のシンプルなものです。 誰が押したかは持たないようにしています。
migrations/0001_reactions.sql はこんな感じです。
CREATE TABLE IF NOT EXISTS reactions (
slug TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT
);POST の処理
POST は Edge Runtime の Route Handler で、下記の順番でチェックしています。
- Origin が自分のサイトかを確認する
- Turnstile のトークンをサーバー側で検証する(siteverify)。hostname が自分のドメインかも見る
- Notion に問い合わせて、公開中の記事かを確認する
- D1 の件数を1増やす
Notion を引く前に Turnstile を見ているのは、bot に Notion の往復を使わせないためです。 また、存在しない slug の行が作られないように、公開中の記事かどうかも確認しています。
件数の更新は、INSERT と UPDATE を1文で済ませています。
const row = await db
.prepare(
`INSERT INTO reactions (slug, count, updated_at) VALUES (?, 1, ?)
ON CONFLICT(slug) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at
RETURNING count`
)
.bind(articleId, new Date().toISOString())
.first<{ count: number }>();
return json({ count: row?.count ?? 1 });値は prepare().bind() で渡しているので、文字列をつなげて SQL を作ることはしていません。
RETURNING で更新後の件数をそのまま返せるので、押した人の画面はこの件数で更新しています。
ボタン側
ボタン側で気をつけたのは下記の2つです。
- 押したことは localStorage に残して、同じブラウザからは2回押せないようにする
- Turnstile のスクリプトは押したときだけ読み込む
厳密に1人1回にしようとすると IP などを保存する必要が出てくるので、そこまではやらないことにしました。個人ブログの「参考になった」なのでこれくらいで十分かなという感じです。
Turnstile も、記事を読むだけの人にまで外部のスクリプトを配りたくなかったので、ボタンを押したタイミングで読み込んでいます。
widgetId = turnstile!.render(container, {
sitekey: SITE_KEY,
action: 'reaction',
// 普段は見えない。Cloudflare が怪しいと判断したときだけチェックボックスが出る
appearance: 'interaction-only',
theme: 'auto',
language: 'ja',
retry: 'never',
callback: resolve,
'error-callback': () => reject(new Error('turnstile error')),
});Terraform で管理する
D1 と Turnstile のウィジェットは、リポジトリの infra/ で Terraform で管理するようにしました。
state は非公開の R2 バケットに置いています。
infra/main.tf の一部です。
resource "cloudflare_d1_database" "reactions" {
account_id = var.account_id
name = "kt-tech-blog-reactions"
read_replication = {
mode = "disabled"
}
# 作り直すとデータが全部消える
lifecycle {
prevent_destroy = true
}
}
resource "cloudflare_turnstile_widget" "reactions" {
account_id = var.account_id
name = "kt-tech.blog reactions"
domains = [var.site_domain]
mode = "managed"
}Pages のプロジェクト自体は Terraform の管理外にしました。理由は下記です。
- シークレットの環境変数が入っている Pages プロジェクトは import できない(公式ドキュメントに書いてありました)
- 管理すると
NOTION_API_KEYなどの値を全部 state に入れることになる - プロバイダーの v5.28 時点で、シークレットを更新すると
plain_textに化けてしまう不具合(#7450)がある
なので D1 のバインディングは Cloudflare の API から、Turnstile のシークレットは wrangler pages secret put で Pages に設定しています。シークレットは terraform output からそのままパイプで渡しているので、画面には出していません。
小ネタ: R2 のキーは API トークンから作れる
state を R2 に置くには S3 互換のアクセスキーが必要なのですが、R2 の権限を持っている API トークンからそのまま作ることができます。
- Access Key ID = トークンの ID
- Secret Access Key = トークンの値の SHA-256
infra/tf.sh では下記のようにしています。
export AWS_ACCESS_KEY_ID="$token_id"
AWS_SECRET_ACCESS_KEY=$(printf '%s' "$CLOUDFLARE_API_TOKEN" | shasum -a 256 | cut -d' ' -f1)
export AWS_SECRET_ACCESS_KEYトークンの ID は /user/tokens/verify を叩いて取っています。
R2 専用のキーを別で発行して管理しなくてよくなったので、割と便利でした。
動作確認
本番で実際にボタンを押してみて、件数が1になるのを確認しました。 確認で押した1件は D1 から消しています。
感想
DB が要る機能は個人ブログだと重いなと思って避けていたのですが、料金をちゃんと調べてみたら、すでに払っている有料プランの枠に余裕で収まりました。 「難しそうだから見送り」にする前に、まず自分が何にお金を払っているのかを確認するのは大事だなと思いました。
支出の上限を設定できない点は正直まだ少し気になっていますが、アラートで気づける状態にはなっているので、しばらくはこれで様子を見ようと思います。
おわりに
ボタンは記事の一番下に付いているので、参考になったら押していただけると嬉しいです。 間違っている点等ありましたら指摘いただけると嬉しいです。






