AIの請求が、なぜサーバ代を超えたのか

毎度おなじみ、うちのCFOが2日で本番まで作ったSaaSを引き取って運用している話の続きです。 非エンジニアの役員がClaude Codeでガッと作って、エンジニアの私が裏側を1個ずつ点検していくと、毎回なにかが出てくる。 今回は「秘密情報の置き場所」でも「テストが1本も無い」でもなく、お金が燃えた話です。

ある日、LLM APIのコストのグラフを眺めていたら、1日だけ富士山みたいに突き出ていた。 他の日は地を這うように低いのに、その日だけ天を突いてる。 その月の請求の、だいたい半分が、たった1日に乗っている。

金額を見て、正直ひやっとしました。 だって、その1日のAI利用料だけで、ひと月ぶんのサーバ代を超えていたんです。 サーバを丸ごと1か月動かすより、AIに1日しゃべらせたほうが高い。 そんなことある?

で、作った本人(CFO)に聞くわけです。 「この日、何しました?」 返ってきた答えが、

「正直、何をしたか覚えてないです」

おい。

でも、これは責める話じゃない(半分は)。 調べていくと、覚えてないのはむしろ当然だった、という結論にたどり着きます。 燃やしたのは人間じゃなくて、リトライ機構だったからです。

犯人探し。最初は「本人が一日中ぶん回したんだろう」と思ってた

最初の私の見立ては、雑に言うとこうでした。 「その日、新機能をいっぱい作って、本番で何度もテストして、そのたびに高いLLMを叩いたんでしょ。塵も積もれば、ってやつ」

実際、その日のコミット履歴は朝から夕方までびっしりで、AI生成まわりの改修が20件以上並んでいた。 だから「人手の反復でジワジワ燃えた」説は、それっぽく見えた。

ところが本人がアプリ側のログ(タスクキューとDBとリクエスト)をちゃんと掘ったら、絵が全然ちがった。 ジワジワじゃない。 同じ重いバッチが、機械によって何度も丸ごと再実行されていた。 あるテナント1件だけで、通常1回で済む処理が 21回走っていた。

人間は1日に同じボタンを21回も押さない。 押していたのは人間じゃなかった、というわけです。

一番こわいのは「成功してから落ちる」だった

ここがこの事件の核なので、ゆっくりいきます。

このバッチは、複数のLLMを順番に叩いて、その結果をDBに保存する作りでした。 処理の流れはざっくり、

  1. たくさんのクエリを、複数のLLMに投げる(ここでお金がかかる)
  2. 返ってきた結果をDBに書き込む

問題は、2の書き込みで、新しく足したはずのカラムを参照して落ちていたこと。 DBにそのカラムがまだ無くて、列が存在しないというエラーで処理が500を返す。

普通「失敗した」と聞くと、「あー、呼び出しがコケて無駄撃ちしたのね」と思いますよね。 ちがうんです。 LLMの呼び出しは全部成功している。全部200。つまり全部ちゃんと課金されている。 お金を払って結果を受け取ったあとの、最後の「保存」のところでコケていた。

レストランで例えるなら、 フルコースを完食して、お会計も済ませて、 最後の「ごちそうさま」を言う瞬間に転んで記憶を失う。 で、気づいたら席に戻ってまた同じフルコースを食べ始めている。 それを21回。 食べた(=課金された)分は消えないのに、毎回ゼロからやり直す

「リトライ嵐」という言葉があります。 普通は「呼び出しが失敗して、失敗して、また失敗して」と空振りを連打するイメージ。 でも今回は空振りじゃない。 当たり(成功)を捨てて、また当たりを引き直す嵐だった。 これが反直感で、一番こわいところです。

なんでこんなことに?犯人は2人いた

機械が21回も繰り返した理由は、2つの落とし穴の合わせ技でした。

落とし穴1: デプロイの順番が逆だった コードは「新しいカラムを使う」前提で本番に出ていたのに、そのカラムを足すマイグレーション(DBの変更)が、まだ本番に当たっていなかった。 コードが先、スキーマが後。 この順番だと、コードは存在しないカラムを触りにいって確定的にコケます。 しかも「確定的に」がミソで、何回やっても直らない種類の失敗です。

落とし穴2: 失敗したらタスクキューが優しく再実行してくれる マネージドのタスクキューは、ジョブが500で落ちると「お、失敗したな、もう一回やってあげよう」と自動で再実行してくれる。 一時的なネットワークエラーなら、これは正しい優しさです。 でも今回の失敗は「カラムが無い」。 何回再実行しても、カラムは生えてこない。 直らない失敗を、優しさで無限に繰り返してくれたわけです。

そしてバッチが冪等じゃなかった(=処理済みをスキップしない作りだった)ので、再実行のたびに頭から全部やり直す。 だから毎回、満額のLLM課金が乗る。

確定的な失敗 × 自動リトライ × 非冪等。 この3つが噛み合うと、お金が静かに燃えます。 本人が覚えていないのも当然で、本人は何もしていない。 ボタンを押し続けたのはキューだった。

種明かしをすると、CFOは「んんん?」と眉間に皺を寄せていました。 (非エンジニアからすると、「成功してるのに課金されて、しかもその成功を捨ててる」は、そうとう飲み込みにくい話なんだと思います)

リトライは優しさじゃない、という学び

この事件の教訓を、自分用にまとめておきます。 同じ立場の人(誰かの”動いてる本番”を引き取った人)に刺さると思うので。

  • 確定的な失敗はリトライしても直らない スキーマ不整合とか、4xx相当の「こっちが間違ってる系」のエラーは、何回投げても結果は同じ。 こういうのは即「中断」扱いにして、リトライの上限も必ず付ける。リトライは万能の保険じゃない。
  • 副作用が高い処理ほど冪等にする 特にお金がかかる処理(課金API・LLM呼び出し)を回すバッチは、「処理済みは飛ばす」を最初から入れる。 これが無いと、再実行が”やり直し”じゃなくて”二重請求”になる。
  • デプロイは「スキーマ→コード」の順 DBの変更を先に当ててから、それを使うコードを出す。逆をやると、その隙間で確定エラーが量産される。
  • コストは可観測にしておかないと「燃えてから」しか気づけない 今回そもそも気づけたのは、たまたまコストのグラフを見たから。 キーを本番とテストで分ける、予算アラートを張る、みたいな”煙感知器”が無いと、請求が来るまで誰も気づかない。

vibe codingで非エンジニアが本番を作れる時代になって、「作る」ハードルは本当に下がりました。 でも、「どう壊れ得るか」「どう高くつき得るか」を見るのは、まだ別のスキルです。 そこが、引き取る側のエンジニアの仕事として残っている。

機能は2日で作れる。 でも「失敗を優しくリトライする」が「成功を捨てて二重課金する」に化ける瞬間を予防するのは、2日じゃ身につかない。

リトライは、いつも優しさとは限らない。

サーバ代より高いAIの請求書なんて、二度と見たくない。 なので、これは戒めとして残しておきます。