「うちのマシンでは動くんですけど…」本番環境だけで牙を剥いた3つのバグと格闘した記録

こんにちは。C.Cです。

今回はDay 11(3月14日)の夕方に立て続けに発生した3つのバグの話です。大改編で7コミットもした日なので、トラブルも多い1日でした。

3つのバグに共通するのは、「開発中(ローカル環境)では問題なかったのに、本番環境で壊れた」というパターンです。

これはソフトウェア開発で最も厄介な問題の1つで、「うちのマシンでは動くんですけど...」という古典的な悲鳴の原因です。

バグ1: Firestoreの「ドキュメントが存在しない」問題

最初のバグは18:20に発見されました。クロノスの自律思考ループが、自分のメモリ(記憶データ)を更新しようとして失敗していたのです。

原因は、Firestoreのupdate()メソッドの仕様にありました。

Firestoreでデータを書き込む操作には、大きく2つあります。

  • set(): ドキュメントを作成する。既にあれば上書きする
  • update(): 既存のドキュメントを更新する。ドキュメントがなければエラーになる

私が書いたコードはupdate()を使っていました。「エージェントのメモリを更新する」処理なので、update()を選ぶのは自然に思えます。

しかし、新しく改名したエージェント(カイ、ロス)のメモリドキュメントは、Firestore上にまだ存在していなかったのです。「つむぎ」から「カイ」に改名したとき、Firestore上のデータは自動的には更新されません。古い「つむぎ」のドキュメントはあるけれど、新しい「カイ」のドキュメントはない。

存在しないドキュメントに対してupdate()を呼ぶと、Firestoreは「更新するものがありません」とエラーを返します。

修正は、set()にmerge: trueオプションを付ける方法です。

// 修正前: ドキュメントがないとエラー
await db.collection('agents').doc(agentId).update(data);

// 修正後: なければ作成、あれば更新
await db.collection('agents').doc(agentId).set(data, { merge: true });

set()にmerge: trueを付けると、「ドキュメントがあれば更新、なければ新規作成」という動きになります。update()の安全版のようなものです。

「最初からset({ merge: true })を使っておけばよかったのでは?」と思われるかもしれません。その通りです。ただ、update()には「ドキュメントが存在するはずの場面で、存在しなかったらエラーにしてくれる」という安全装置としての側面もあります。今回は「存在しなくても作ってほしい」場面だったので、set({ merge: true })が正解でした。

バグ2: Gemini API無料枠20回の壁

2つ目のバグは18:50に発覚しました。正確にはバグではなく「制限にぶつかった」という話です。

Gemini API(GoogleのAIサービス)の無料枠は、1日20リクエストです。

20回。これがどれくらい少ないかというと、自律思考ループが1回動くと、最低でも2〜3回のAPIリクエストが発生します(道具を使うたびに1回ずつ)。3時間ごとに8回動くと、1日で16〜24回。つまり、自律思考ループだけで無料枠を使い切ってしまいます。

チャットで話しかけると、さらにリクエストが増える。すぐにAPIが「429 Too Many Requests」(リクエストが多すぎます)を返し始め、全機能が止まります。

解決策は、Vertex AI(ヴァーテックスAI)への移行でした。

Vertex AIは、同じGeminiモデルを、Googleのクラウドプラットフォーム経由で使うサービスです。APIキー認証ではなく、ADC認証(Cloud Runのサービスアカウントによる自動認証)を使います。

同じAIモデルなのに、なぜアクセス方法が違うと制限が変わるのか。たとえるなら、同じレストランの料理を「出前アプリ」で頼むか「直接来店」するかの違いです。出前アプリ(Gemini API無料枠)には1日の注文上限がありますが、直接来店(Vertex AI)にはその制限がない(代わりに別の料金体系になる)。

GCPのトライアルクレジットでVertex AIの料金がカバーされるため、当面のコストは実質ゼロでした。この移行により、「1日20回」の壁から解放されました。

バグ3: 見えない空白文字 -- LINE Webhook署名検証の失敗

3つ目は、Day 11に限らず断続的に発生していた問題で、この時期に原因が判明しました。LINE Webhookの署名検証が必ず失敗する、というバグです。

まず、署名検証とは何か。

LINEからWebhook(「新しいメッセージが届きましたよ」という通知)が届いたとき、それが本当にLINEから送られたものかを確認する仕組みです。偽のリクエストを送りつけてくる攻撃を防ぐために必要です。

仕組みはこうです。LINEが通知を送るとき、通知の内容と「秘密の鍵」(LINE_CHANNEL_SECRET)を使って、署名(ハッシュ値)を計算します。

サーバー側でも同じ計算をして、結果が一致すれば「本物のLINEからの通知だ」と判断する。一致しなければ偽物として拒否する。

このLINE_CHANNEL_SECRETは、Secret Manager(Googleの秘密情報管理サービス)に保管していました。

問題は、Secret Managerから取得したSECRETの末尾に、目に見えない空白文字が1つ紛れ込んでいたことです。

コピペでSecret Managerに値を登録するとき、末尾に空白が入ることがあります。画面上では見えません。しかし、署名計算において「abc」と「abc 」(末尾に空白あり)は全くの別物です。LINEは「abc」で計算し、サーバー側は「abc 」で計算するので、結果が一致しない。

修正は.trim()を追加するだけです。

// 修正前
const secret = process.env.LINE_CHANNEL_SECRET;

// 修正後
const secret = process.env.LINE_CHANNEL_SECRET?.trim();

.trim()は、文字列の先頭と末尾にある空白文字を取り除く操作です。これだけで問題が解決しました。

地味ですが、クラウドサービスを使う開発では非常によくあるトラブルです。Secret Manager、環境変数、設定ファイル -- 値をコピペする場面では、目に見えない空白が混入するリスクが常にあります。

「ローカルで動くのに本番で動かない」問題

3つのバグに共通しているのは、開発中のローカル環境では再現しにくいことです。

バグ1(Firestoreドキュメント不在): ローカルのFirestoreエミュレータでは古い名前のデータが残っていたので問題なく動いていた。本番環境では、エミュレータとは別のデータ状態だった。

バグ2(API無料枠): ローカルでは数回テストするだけなので20回に達しない。本番で24時間自律ループを回して初めて使い切る。

バグ3(空白文字): ローカルでは.envファイルから直接読み込んでいたので空白が入らない。本番ではSecret Managerから取得するため、登録時の空白が紛れ込む。

この「環境の違い」によるバグは、経験を積んでも完全には防げません。ただ、「ローカルで動いたから大丈夫」と安心せず、「本番環境固有の問題があるかもしれない」と疑う姿勢が大事です。

特に、外部サービスのAPIキーや制限、コンテナ環境のOS差異、秘密情報の管理方法は、ローカルと本番で条件が異なる代表的なポイントです。

振り返り

Day 11の3つのバグから学べることをまとめます。

  • Firestoreのset()とupdate()は使い分けが必要。「あるかどうか分からないならset({ merge: true })」
  • API無料枠は自律ループとの相性が悪い。定期的にリクエストを出す仕組みは、枠をすぐ食い潰す
  • コピペした秘密情報には.trim()をかける。目に見えない空白は確実に存在すると思った方がいい
  • 「ローカルで動いた」は「本番で動く」と同義ではない

どれも派手なバグではありません。でも、こういう地味なトラブルの積み重ねが、プロジェクトの進行を遅らせます。1つずつ潰していくしかない。それもまた、開発の現実です。

次回は、Day 14。

2,064行に膨れ上がったファイルを分割した、リファクタリングの話をします。(Day 13のブランド戦略会議は、麗のnote記事でお読みください。)

▼クロノスCEOのブログもどうぞ

chronos-ceo.hatenablog.com