個人開発の趣味サイトで、Cloudflare D1の読み取り課金が2ヶ月で約$240に膨らんだ。原因は自分の書いた非効率なクエリと、それをクローラーに叩かれ続けたことだ。

サイトはしりとり用の日本語単語辞典で、広告も課金機能もない。人間の訪問者は一日に数えるほどしかいない。それでも請求は一日あたり最大$56に達していた。

同じ構成で個人開発をしている人にとって他人事ではないと思うので、何が起きたか、どう直したか、そしてCloudflareサポートがどう対応してくれたかを書いておく。

気づいたのは請求ではなく偶然だった

問題に気づいたのは7月21日。まったく別の調べ物をしていて、たまたま使用量のグラフを開いたときだった。

D1の読み取り行数が、一日で数百億行に達していた。

白状すると、課金アラート自体は設定してあった。通知メールも届いていた。ただ、私のGmailには一日に物凄い量のメールが届く。サービスからの通知、メルマガ、各種レポート。その山の中に、請求アラートは静かに埋もれていた。

「アラートを設定したから大丈夫」と思っていた。だが、届いた通知を読まなければ、設定していないのと同じだった。

6月23日付の請求は$52.05。次の請求サイクル(6/23〜7/23)は、この時点で$190程度になる見込みだった。

原因: 1ページ表示で120万行スキャンするクエリ

調べると、あるページのクエリが意図せず非効率なJOINになっていた。EXPLAINで確認したところ、1ページ表示するたびに約120万行をスキャンしていた。

D1の課金は「読み取った行数」で決まる。返した行数ではない。インデックスが効かないJOINは、結果が10件でも裏で数十万行を読む。ここが今回の急所だった。

そこに検索エンジンのクローラーが重なった。ページネーションのパラメータ付きURLがクロール対象になっていて、クローラーは組み合わせを次々と辿っていく。人間の訪問者が数人でも、クローラーは一日に何千ページも取得する。

120万行 × 数千リクエスト。これが数百億行の正体だった。

1日目: その日のうちにやった4つのこと

原因が分かってからは、その日のうちに以下を済ませた。

1. クエリをインデックスで完結するよう修正した

JOINの条件と絞り込みに合わせて複合インデックスを張り直し、EXPLAINでインデックスだけで解決されることを確認した。スキャン行数は1ページあたり120万行から数千行になった。おおよそ数百分の一だ。

2. パラメータ付きURLのクロールをrobots.txtで遮断した

ページネーションやフィルタのパラメータが付いたURLは、そもそもクローラーに巡回してもらう必要がない。検索結果に出したいのは実体のあるコンテンツページだけだ。

3. ページネーションにサーバー側の上限を設けた

?page=99999 のような値をそのままクエリに渡さないよう、サーバー側で上限を切った。robots.txtは行儀の良いクローラーにしか効かないので、こちらが本命の防御になる。

4. メールを毎日見ることにした

アラートは既に設定してあった。足りなかったのは受け手の習慣のほうだ。この日を境に、毎日メールに目を通す癖を付けた。技術的な対策に比べると地味だが、通知は読まれて初めて機能する。

修正後、読み取り量は込み枠の中に収まった。金額だけ見れば、ここで一件落着でもよかった。

2日目: DBを従量課金から固定費に移した

だが翌日、もっと根本的な対策に手を付けた。D1をやめて、VPSを契約し、PostgreSQLを自前で立てた。WorkersからはHyperdrive経由で接続する。

理由は単純で、固定費のDBなら何があっても請求が青天井にならない。クローラーに何をされても、また非効率なクエリを書いてしまっても、最悪起きるのはサイトが遅くなることであって、請求額は変わらない。個人開発では「事故ったときの上限が最初から決まっている」ことの安心感は大きい。

クエリ修正で込み枠には収まっていたので、急ぐ必要はなかった。それでも移したのは、「気を付ける」に頼る対策は今回のように破られると分かったからだ。設計で潰せるリスクは設計で潰しておきたかった。

使用量レポートをAIに毎日読ませることにした

もうひとつ、開発マシンに定期ジョブを仕掛けた。毎日、使用量レポートをAPI経由で収集し、AIにチェックさせて、問題があればSlackに通知が飛ぶようにした。

メールと違って、この通知は「異常があったときだけ」届く。日々の大量のメールに埋もれようがない。

つまり再発防止は三段構えになった。

  1. 設計で潰す — DBは固定費のPostgreSQL。そもそも青天井にならない
  2. 能動的に知らせる — 毎日AIがレポートをチェックし、異常があればSlackへ
  3. 習慣で拾う — メールは毎日見る

どれか一枚が破られても、残りが拾う。今回の教訓は「アラートは一枚では防壁にならない」だった。

それでもCloudflareはやめない

誤解のないように書いておくと、Cloudflareから離れる気はまったくない。

Workersは使い続けている。正直なところ、VPSでアプリまでホスティングするよりWorkersのほうがコストは安い。そもそもサイトへのアクセスの大半はCloudflareのCDNで捌けていて、Workersまで届くリクエスト自体が少ない。従量課金そのものが悪いのではなく、「読み取り行数」のような事故りやすい軸の課金を、通知が機能しない状態で使っていたことが問題だった。

オブジェクトストレージもR2のままだ。料金だけならもっと安い選択肢もあるが、後述するサポート対応で付与されたクレジットで、おそらく一生分のR2代が賄える。

AIのAPIも使っているが、こちらはアプリ側で消費クレジットを計算していて、上限に達したら遮断するようにしてある。使用量は毎日のレポートでも見えるので、遮断と監視の二重の防壁になっている。

Cloudflareサポートに相談した

正直、払うしかないと思っていた。完全に自分の設定ミスで、Cloudflare側には何の落ち度もない。

それでも、状況を説明するだけしてみようと思ってサポートに問い合わせた。書いたのは次のことだ。

  • 非営利の趣味サイトであること
  • 何が原因で、どういう仕組みで課金が膨らんだのか
  • 発見したその日のうちに何を直したか
  • DB自体を従量課金の外に移し、再発しない構成にしたこと
  • 自分の落ち度であり、Cloudflareに非はないと理解していること
  • そのうえで、初回であり意図しない超過なので、一部でも減額を検討してもらえないか

言い訳をせず、原因と対策を具体的に書き、決定権は相手にあるという姿勢で書いた。この書き方が良かったのかどうかは分からないが、少なくとも「何が起きて、もう起きない」ということは伝わる内容にしたつもりだ。

返信は、要点だけ書くとこうだった。請求ポリシーは原則として返金不可であること。ただし今回は意図しない超過であり、この種の問題としては初回であり、原因を即座に是正したことから、一回限りの好意的対応としてD1超過分のクレジットをアカウントに付与できる、というものだった。

念のため書いておくと、これは返金ではなくクレジット付与だ。現金が戻るのではなく、アカウントに残高が積まれ、今後の請求と相殺される。Cloudflareを使い続けるなら実質的な価値は変わらないが、正確に書いておきたい。

そして当然ながら、これは保証された対応ではなく、あくまで個別の好意的判断だ。同じことをすれば同じ結果になると期待して読まないでほしい。

学んだこと

請求$0は「正常」の証明にならない

無料枠に収まっている状態と、超えそうな状態と、超えている状態は、請求額の上では長い間$0で区別がつかない。$0が続いているうちに監視をやめてしまうと、超えた瞬間から気づくまでの時間がそのまま請求になる。

従量課金では、クエリの効率がそのまま金額になる

自前のサーバーなら、非効率なクエリのコストはレスポンスの遅さとして現れる。数百ミリ秒の遅延で済んでいたものが、従量課金では請求書に現れる。しかも遅くならないことが多いので、体感で気づけない。

クローラーはトラフィックの主役になり得る

「人間の訪問者が数人だから負荷は低いはず」という前提が完全に間違っていた。パラメータの組み合わせを機械的に辿られると、実質的に無限のページが存在することになる。個人サイトほどこの比率は極端になる。

アラートは「設定した」だけでは機能しない

課金アラートは設定してあったのに、$240の事故は起きた。通知は受け手が読んで初めて意味を持つ。埋もれる通知は、無いのと同じだ。だから今は一枚のアラートに頼らず、青天井にならない設計・AIによる能動的なSlack通知・毎日メールを見る習慣、の三段構えにしている。

その後

DBの移行から3週間ほど経つが、PostgreSQLは安定して動いていて、D1の読み取り課金という項目自体がなくなった。毎日の使用量チェックは動き続けていて、Slackが鳴らない静かな日が続いている。

最後に。Cloudflareのサポートは、規約を盾に一行で終わらせることもできた場面で、状況を読んで判断してくれた。プラットフォームの良さは平常時ではなく、こちらがミスをしたときの対応に出るのだと思う。これからも使い続けます。