実装の遅さは、意思決定のゲートだった ── AIで積み上がるコストを仕組みで止める

15 min read

目次

  1. 実装の遅さは、意思決定のゲートだった
  2. 起点は「事後にアプリ単位で按分できない」という構造
  3. 監視と可視化は、削減施策より先に要る
  4. 入口のゲート: 閾値を超える変更は人間レビューへ
  5. 継続的な可視化: 1日2回、Slackに投稿する
  6. 最後の防波堤: 従量課金には天井を設定する
  7. 実例1: 既定値が高い側だと、速く作るほど負債が積まれる
  8. 名前が意味と逆に読める
  9. 除外条件は、思っているより狭い
  10. 結果と、再発を止める仕組み
  11. 実例2: 運用実態を知る人間にしか下せない判断
  12. 実例3: レビューの往復分、CIが丸ごと走っていた
  13. skipとfail-closedの非対称性
  14. ついでに: やらない判断も期待値で決める
  15. 実例4: 最初はタダに見えるが、データが育つと効いてくる
  16. 重複判定をどこでやるか
  17. 気付ける仕組みが要る理由
  18. まとめ: 浮いた時間をどこに配分するか

みなさまこんにちは!エアークローゼットでCTOをしているです。

社内AI基盤の運用コストを1週間かけて棚卸ししました。結果を先に言うと:

ただ、この記事で書きたいのは削減額ではありません。実装コストが下がった結果、「本当にそれが必要か」を問う時間だけが相対的に高くなったという話です。

実装の遅さは、意思決定のゲートだった

AI自体の話ではありません。でもAIで開発するとここが確実に膨らむ、という前置きから始めます。

これまで、何かを作るには実装コストがかかりました。見積もりを出し、設計をレビューし、工数を確保する。この一連の手続きは面倒ですが、副作用として「本当にこれが必要か」を問う時間を強制的に作っていました。実装が遅いこと自体が、意思決定のゲートとして機能していたわけです。

AIで実装が速くなると、このゲートが外れます。思いついた仕組みをその日のうちにデプロイできる。これは純粋に良いことで、「作ってみないと分からないから作る」という判断が正しい場面は確実に増えました。

ただし同時に、作る判断を留保するインセンティブも消えます。1つ1つは小さいので、個別には誰も気にしない。そして残るのは実装工数ではなく、作ったものが動き続けるランニングコストです。これは作った瞬間には見えず、後から静かに積み上がります。

起点は「事後にアプリ単位で按分できない」という構造

きっかけは、特定の変更をきっかけに従量課金が想定より伸びる場面が何度かあったことでした。BigQueryのスキャン量とVertex AI / Geminiの実行量です。

厄介だったのは、事後にアプリ単位で按分できなかったことです。BigQueryのクエリジョブにはラベルを付けられますし、実行ジョブの履歴も残ります。ただ私たちの環境では複数のアプリが同じサービスアカウントで動いていたため、実行主体を見ても「どのアプリが使ったか」まで割り切れませんでした。ラベルを全経路に徹底させる手もありますが、それが行き渡るまでは同じ状態が続きます。

事後に按分できないなら、増える前に止めるしかない。ここから「コストの入口にゲートを置く」という発想になりました。

監視と可視化は、削減施策より先に要る

先に結論を書くと、この一連の作業で一番効いたのは個別の削減施策ではなく、コストの入口に置いたゲートと日次の可視化でした。削減は一度やれば終わりますが、放っておけばまた積み上がるからです。

入口のゲート: 閾値を超える変更は人間レビューへ

AIレビュアーの観点にコストを追加し、従量課金の増加が閾値を超えるPRは人間レビューに引き継ぐようにしました。閾値は3種類です。

区分 閾値
定常の日次コスト増 2,000円/日
単発実行コスト 10,000円
ストレージ月次増分 1,000円/月

設計で意識したのは、AIに金額計算をさせないことです。AIがやるのは変更を読んで「数量」(スキャン量、実行回数、トークン数)を実測することだけ。円換算と閾値判定は決定論的なスクリプトが行います。AIに金額計算まで任せると、算術ミス・単価の取り違え・実行ごとのブレでゲートの信頼性が落ちるからです。判断が揺れるゲートは、無いのと変わりません。

単価はカタログ価格ではなく、Billing Exportの実測値(支払額÷使用量)を使っています。カタログ価格をハードコードすると、USD建て・為替・確約割引の3重のズレが乗るためです。

ストレージを別枠にしたのは、累積するコストは実行時のコストと性質が違うからです。日次の実行コストは止めればゼロに戻りますが、書き込んだデータは消すまで課金され続けます。だから「一定期間で消える設定になっているか」「毎月の想定費用の増分がいくらか」を、実行回数×実行量から未来の見積もりとして出させています。

継続的な可視化: 1日2回、Slackに投稿する

app別のコストを1日2回、Slackに投稿しています。ここも設計判断がいくつかあります。

閾値超過が無い日も必ず投稿する。「今日は問題ない」という情報自体に価値があるのと、投稿が止まったのか正常なのかを受け手が区別できる必要があるからです。無音の監視は、壊れていても気付けません。

比較は前日比ではなく前週同曜日比。曜日で業務量(バッチの実行量)が変わるので、前日比だと月曜が毎回スパイク扱いになって、すぐに誰も見なくなります。

前週にデータが無いapp(baseline = 0)も増加として検知する。ここが一番危ない場所だからです。新規に走り始めたバッチこそ、想定外のコストを生みます。「前週比◯%」という指標は、前週がゼロのときに定義できずに取りこぼしがちなので、明示的にケースを分けています。

最後の防波堤: 従量課金には天井を設定する

ゲートと可視化を入れても、すり抜けは起こります。ゲートを通らない経路(手動実行、外部要因、そもそも想定していなかった使われ方)があるからです。

なので、使えば使うほど課金が増えるサービスには、運用実態に合わせた上限を設定しておくのが最後の防波堤になります。BigQueryやVertex AI / Geminiのような従量課金は、クォータで天井を切れます。

大事なのは、上限を「制度上の最大値」ではなく「通常時の実測値」から決めることです。

BigQueryを例にすると、オンデマンド課金のデフォルト上限は1プロジェクトあたり200 TiB/日です(2025年9月以降。それ以前は無制限がデフォルトでした)。オンデマンドの単価はリージョンによって違いますが、1 TiBあたり数ドルの水準なので、この天井は1日あたり千数百ドルに相当します。1ヶ月フルに使えば数百万円規模です。

つまりデフォルト設定とは「毎月それだけ使っても何も言わずに走る」という意味です。これは天井として機能していません。

私たちはBigQueryのスキャン量に対して、通常時の1.5倍程度で設定しています。「余裕を持って10倍」でも同じで、暴走したときに止まる位置になって初めて意味があります。

ただし、この設定ができるのは社内向けの基盤だからです。ここは前提として明記しておきます。1.5倍で切るということは「上限に当たったら止まる」を受け入れるということで、社内基盤なら翌日に再実行すれば済みますが、お客さま向けのサービスで同じことをすれば障害になります。止まってよいものにしか、この天井は設定できません。エンドユーザーに影響する経路なら、天井はもっと高く置いた上で、アラートと自動スケールの設計で受けるべきです。

その前提の上で、社内基盤では副作用も許容できます。正常な処理が上限に当たって落ちることは起こり得ますが、その場合は「なぜ通常時の1.5倍を使う処理が走ったのか」を確認する機会になるので、それ自体が検知として機能します。アラートは事後に知らせますが、クォータはその場で止めます。この差は、1日で大きく動きうる従量課金では効いてきます。

コストの積み上がりを止める3層

こうした「作る前に問う」を人の心がけとして運用すると、忙しい日に必ず飛びます。仕組みにして初めて機能するというのが、今回はっきりした点でした。ゲート(入口で止める)、日次の可視化(継続的に見る)、クォータ(暴走を天井で止める)の3層で、ようやく「見えなかったものが見える」状態になります。

実例1: 既定値が高い側だと、速く作るほど負債が積まれる

ここからは棚卸しで見つかったものの話です。まずCloud Runから。

Cloud Runには2つの課金モデルがあります。CPUを常時割り当てるinstance-basedと、リクエスト処理中だけ割り当てるrequest-basedです。調べたところ、Cloud Run費用の97%がinstance-basedでした。

原因は構造的でした。リポジトリ内でCloud Runサービスを定義している箇所を見ると、cpuIdleを指定していないものが多数ある状態でした。共通のファクトリ関数が無いため、新しいサービスを追加するたびに、何も書かなければ高い側に倒れ続けます。

ここで注意が要るのは、コンソールやgcloudから普通にデプロイした場合の既定はrequest-based(安い側)だという点です。ところがIaCで宣言的に定義し、リソース量を明示的に書くとcpuIdlefalse扱いになり、instance-basedに落ちます。つまり手で作ると安い側、コードで作ると高い側という逆転が起きます。IaCで管理しているほど、この差に気付きにくい構造になっているわけです。

これがまさに冒頭の話とつながります。既定値が高い側に設定されていると、作る速度が上がるほど負債が自動的に積み上がる。1サービスあたりの差は小さいので、個別には誰も気付きません。

名前が意味と逆に読める

ここで1つ罠がありました。この設定はcpuIdleという名前です。

意味 課金
true CPUはリクエスト処理中のみ割り当て request-based(安い)
false(IaC時の既定) CPU常時割り当て instance-based(高い)

trueを「アイドル時も動き続ける」と読むと逆になります。私自身、作業中に一度取り違えました。しかも間違えたときの壊れ方が厄介で、例外を出さずに、レスポンスを返した後の処理だけが無音で欠落します。fire-and-forgetでバックグラウンド処理をしているサービスをうっかりtrueにすると、エラーログも出ないまま処理が消えます。

なので判定基準を「レスポンス後に処理を続けるサービスか(fire-and-forgetしているか)」の1軸に整理し直しました。

除外条件は、思っているより狭い

切り替え対象を選ぶとき、最初は「常時CPUが必要そうなサービス」をかなり広めに除外していました。作業を進めるうちに、その多くが除外不要だと分かります。理由は、Cloud Runの挙動に非自明な点が2つあるからです。

1つ目はコンテナの起動フェーズ中は、この設定に関係なくCPUが割り当てられること。たとえば起動時にBigQueryから大量のデータをロードして、Webサーバーの起動前に同期的に完了させるようなサービスは、一見「常時CPUが必要」に見えます。でもその処理は起動フェーズに収まるので、実際には切り替えても問題ありません。

2つ目はタイムアウトの長さは除外条件にならないこと。3,600秒かけて同期処理するバッチも、その間ずっとリクエストを処理している状態なのでCPUは割り当てられます。「重い処理だから常時CPU」は成り立ちません。

結局、本当に除外が要るのはレスポンスを返した後も処理を続けるサービス(fire-and-forget)だけでした。判定軸をこの1つに絞り込むまでに、除外リストは何度も書き直しています。

この手の設定は、直感で「必要そう」と判断すると必ず広めに倒れます。除外は「必要そうだから」ではなく「この条件に当てはまるから」で決める。条件を1行で書けるまで絞り込めたかどうかが、そのまま精度になります。

結果と、再発を止める仕組み

カナリアとして1サービスで試したところ、そのサービス単体で44%減。そこから全体に展開して、instance-based課金は84%減になりました。fire-and-forgetで処理を続ける少数のサービスを除いて、ほぼ全体に適用しています。

そして最後に、CIガードを入れました。cpuIdleが未指定のサービス定義を機械的に検出して落とします。ポイントは、cpuIdle: falseと明示した場合もviolation扱いにしたことです。常時CPUが本当に必要なサービスは、allowlistに理由を書いて登録します。「なぜ常時CPUが要るのか」を1行書かせるためです。

そしてこの手のガードは、書いた後に「違反を混入させて実際に落ちるか」まで確認するようにしています。ガードは書いただけでは動いている保証がなく、しかも動いていないことに気付きにくい。「守られているはず」という前提だけが残ると、無いよりかえって危険です。

実例2: 運用実態を知る人間にしか下せない判断

コンテナイメージのストレージも大きな項目でした。ここは削減自体は単純で、イメージの保持世代を10から2に変えただけです。これでストレージは65%減りました。

面白いのは判断のプロセスです。データからは「10世代必要かもしれない」としか言えません。何世代前まで戻る可能性があるかは、データに現れないからです。

実際の判断はこうでした。イメージが無くても、gitからコードの履歴を復元してデプロイし直せばいい。つまりイメージの保持は「過去のあらゆる時点に戻れるようにするためのアーカイブ」ではなく、緊急時に1つ前へ即座に戻すための手段でしかありません。そう捉え直すと、必要な世代数は2で足ります。

ここで効いているのは、データではなくその組織で復旧がどう行われるかの理解です。gitから復元できる前提が成り立つか、デプロイが何分で終わるか、緊急時に「1つ前に戻す」以上の操作が実際に必要になるか。これはログにもメトリクスにも出てきません

AIに調査させると「安全のため保持世代は多めに」という結論になりがちです。データだけを見ればそれが合理的だからです。人間が持っているのは、その保持が何のためにあるのかという目的の側です。

実例3: レビューの往復分、CIが丸ごと走っていた

一番効いたCIの改善は、実行のタイミングを変えたことでした。

レビューで指摘が出る、修正をプッシュする、また見る。この往復のたびに、全ジョブ(build / lint / test / knip)が走っていました

構造としては人間のレビューでも同じです。ただAIがレビューすると往復の回数も速度も上がるので、それまで見えていなかった無駄がはっきり出ます。実測すると1つのPRあたり平均3.4ラウンド(指摘が出て修正をプッシュする往復を1ラウンドと数えています)、1ラウンドで重いジョブ群に6.8分。レビューが収束するまでに同じテストを3回も4回も回していたことになります。まだ指摘が残っている段階のコードに対して、フルテストを毎回払っているわけです。

そこで、重いジョブをレビューが通った後にだけ走らせる形に変えました。プッシュのたびに走るのは軽量なチェック群(1.2分)だけにして、レビューがAPPROVEを出した時点でbotが重いCIを起動します。

往復のたびに軽量チェックだけを払い、重いジョブは最後に1回。3.4 × 6.8分3.4 × 1.2分 + 6.8分 になり、約53%減です。

skipとfail-closedの非対称性

ここには実装上の落とし穴がありました。最初は同じワークフロー内で条件分岐(if:)を使ってジョブをスキップする方式を試したのですが、これは危険な設計でした。

状態 ブランチ保護の扱い
ジョブをif:でスキップ 合格として通る
ワークフローを起動しない(チェック自体が存在しない) 未報告としてブロックされる

必須チェックをスキップさせると、ブランチ保護が実質的に無効化されます。テストが走っていないのに「合格」の緑が付き、そのままマージできてしまう。これはfail-openです。

一方、ワークフローごと起動しなければチェックは生成されず、未報告の必須チェックがマージをブロックします。こちらはfail-closedです。

この非対称性が設計の核でした。最終的に、重いジョブは別ワークフローに分離してworkflow_dispatchだけで起動する形にしています。dispatchに失敗した場合もチェックが未報告のまま残るので、マージはブロックされたままになります。

コスト削減の施策が、同時にセーフティネットの設計にもなっている。「安く済ませる」と「間違ったときに止まる」は、しばしば同じ設計判断から出てきます。

ついでに: やらない判断も期待値で決める

もう1つ、ドキュメントだけを変更したPRで重いジョブをスキップする最適化も入れました。ここでは「スキップ判定を専用のジョブとして切り出すべきか」という設計判断がありました。

判定ジョブを切り出すと構造はきれいになりますが、全PRに対してランナー起動分の固定費(約25秒)が乗ります。一方、ドキュメントのみのPRは直近の実績で全体の約4%で、そこで浮く重いジョブの時間が約300秒です。

期待値で見ると、96%のPRが払う25秒の損が、4%のPRで浮く300秒の得を上回ります。だから判定は既存のジョブの中でやることにして、専用ジョブには切り出しませんでした。

「きれいな構造にする」は常に正しそうに見えますが、コストの文脈ではやらない判断も数字で決められます。感覚で「こっちの方が美しい」と決めると、こういう逆転を見落とします。

実例4: 最初はタダに見えるが、データが育つと効いてくる

最後にもう1つ。BigQueryのMERGEの話です。

重複を作らずにデータを入れたいとき、MERGEは自然な選択に見えます。キーが一致したら更新、しなければ挿入。SQL 1文で書けて、冪等性も担保できます。

ただMERGEは、入れる行数に関係なく、突き合わせ先を毎回読みます。1行だけ入れる場合でも、重複していないことを確認するためにターゲット側を走査する必要があるからです。

これがコストの観点では厄介です。テーブルが小さいうちは、ほぼゼロに見える。作った時点では何も問題がありません。でもデータが積み上がると、1回の実行あたりのスキャン量が増えていきます。実行頻度は変わっていないのに、コストだけが静かに上がる。

しかもこれは、コードを変更していないのでPRのレビューでは捕まりません。入口のゲートは「この変更がコストをいくら増やすか」を見る仕組みなので、変更していないものが育っていく分は対象外です。冒頭で書いた「作った瞬間には見えず、後から静かに積み上がる」の、いちばん純粋な形と言えます。

重複判定をどこでやるか

そこで、重複判定をFirestoreに寄せて、BigQueryにはinsertだけする形に移しています。

キーの存在チェックはFirestoreで済ませ、新規と判定されたものだけをBigQueryにinsertする。BigQuery側は追記するだけになるので、突き合わせのための走査が消えます。

考え方は実例2と同じです。「MERGEをどう最適化するか」ではなく、そもそも重複判定をBigQueryにやらせる必要があるのかを問い直した結果です。BigQueryは大量データの集計には強いですが、キー1件の存在確認をさせるには重すぎます。Firestoreはその逆で、キー引きは得意でも集計は不得意。それぞれ得意な側に寄せると、両方とも自然な使い方になります

気付ける仕組みが要る理由

この件で言いたいのは「MERGEを使うな」ではありません。適した場面はありますし、私たちも全部を置き換えているわけではないです。

言いたいのは、コストの増え方には「作った瞬間から高い」ものと「育ってから高くなる」ものがあるということです。前者はレビューで捕まえられます。後者は捕まりません。

だからこそ日次の可視化が要ります。前週同曜日比で見ていれば、「何も変更していないのにこのappのコストが伸び続けている」は必ず目に入る。ゲートだけでは足りない理由が、ここにあります。

まとめ: 浮いた時間をどこに配分するか

実装が速くなったこと自体は、疑いなく良いことです。ただ速くなった分をそのまま「もっと作る」に全部使うと、作ったものの重さが後から効いてきます。私たちは1週間分の時間をこの棚卸しに使いましたが、次にやるべきは同じ棚卸しを繰り返すことではなく、そもそも積み上がらない仕組みを増やすことだと思っています。

comments (0)

まだコメントはありません。