
落ちて止まるバグより、落ちずに走り続けるバグの方が怖い。
例外で止まれば気づく。ログが赤ければ調べる。でも 何事もなく動いているように見えて、裏で課金メーターだけが回り続けている バグは、誰も通報してくれない。請求が来るまで気づけない。実は、200 で返ってくるのに期待通り動かない「失敗が見えない失敗」の怖さは、別記事(200 で返るのに動かないデバッグ記録)でも書いた。今回はその課金版だ。
今回これを踏んだ。自宅の Mac で動かしている個人用の Telegram bot——スマホから話しかけると LLM が応答を返すだけの、自分しか使わない雑なツールだ。久しぶりにコードをレビューしたら、送信に失敗するたび同じメッセージへ LLM 応答を生成し続ける「自己 DoS」 になっていた。ついでに掘ったら、日次ログに API キーが平文で残り、それを Google Drive へ同期していた。
個人運用のバッチは「動いてればヨシ」で放置されがちだ。でも放置されたコードは、静かに事故を仕込む。踏んだ順に全部書いておく。
📋 この記事でわかること
- ポーリング型 bot で「送信失敗時に offset を進めない」設計が自己 DoS になる仕組み
- LLM を挟むと、その自己 DoS が単なる無限ループではなく「課金される無限ループ」になる理由
- クラウド同期するログに鍵・トークンが混入するのを正規表現でマスクする実装
- 認可チェックを fail-open にしないための環境変数フォールバック設計
- 自分しか使わない個人ツールでも最低限見ておくべき観点
構成を先に整理する
踏んだ環境はこんな構成だ。
- Telegram bot: Python。Telegram の
getUpdatesAPI をポーリングして、新着メッセージに LLM 応答を返す - offset 管理:
getUpdatesはoffsetを渡すと「それ未満の update を確認済みとして消す」仕様。処理済みの update_id + 1 をファイルに保存して次回に渡す - 日次ログバッチ: Claude Code のセッションログや git 活動を集めて
enriched_log.jsonにまとめる - クラウド同期: その
enriched_log.jsonを毎日 Google Drive へアップロードする
ポーリング型 bot を書いたことがある人なら、もう offset の話で嫌な予感がしているかもしれない。その予感は正しい。
罠1: 送信失敗で offset を据え置くと、bot が自分を攻撃する
問題のコードはこうだった。返信の送信に成功したときだけ offset を進めていた。
if send_message(response_text, str(chat_id)):
replied += 1
logger.info("返信しました: %s", text[:80])
save_offset(update_id + 1)
else:
logger.error("返信送信に失敗しました(オフセット未更新): %s", text[:80])
一見、まっとうに見える。「送信に成功したら確認済みにする。失敗したら確認済みにしない=次回もう一度処理する」。リトライの定石だ。
問題は、この bot が「処理」と呼んでいるものの中身が LLM 呼び出しだ という点にある。
処理の流れを分解するとこうなる。
1. getUpdates で未処理メッセージを取る
2. LLM を呼んで応答テキストを生成する(ここで課金が発生する)
3. 生成したテキストを Telegram へ送信する
4. 送信成功なら offset を進める
送信(3)が失敗したとき、offset は進まない。次のポーリングで同じメッセージがまた未処理として返ってくる。すると また LLM を呼ぶ(2)。また送信を試みて、また失敗する。offset は進まない。
通常のリトライなら「失敗した送信だけ」をやり直せばいい。でもこの設計は、送信のリトライのたびに その手前の LLM 生成まで丸ごとやり直していた。送信が失敗し続ける状況——たとえばネットワークが不安定、Telegram 側の一時障害、メッセージが長すぎて 400 が返る——では、bot は同じ 1 通に対して LLM 応答を無限に生成し続ける。
これは無限ループであると同時に、1 周ごとに API 課金が発生する無限ループ だ。自分の bot が、自分の API キーに対して、自分の財布で DoS をかけている。誰も悪意を持っていないのに、構成だけで成立してしまう。
幸い、自分宛ての 1:1 チャットで送信が連続失敗する状況が滅多に起きなかったので、請求が爆発する前にコードレビューで見つかった。でも「たまたま発火条件を踏まなかっただけ」で、地雷は埋まっていた。
修正: 応答は破棄してでも offset は必ず進める
直し方はシンプルだ。生成した応答を捨ててでも、その update は確認済みにする。
if send_message(response_text, str(chat_id)):
replied += 1
logger.info("返信しました: %s", text[:80])
else:
# 送信失敗でも offset は進める。据え置くと同一メッセージに対して
# 毎回 LLM 応答を再生成し続け、コスト増・実質的な自己 DoS になるため。
logger.error("返信送信に失敗しました(応答は破棄しオフセットを進めます): %s", text[:80])
save_offset(update_id + 1)
save_offset を if/else の外に出して、成功・失敗どちらでも必ず実行する。これで「1 通につき LLM 呼び出しは最大 1 回」が保証される。送信が失敗したメッセージへの応答は失われるが、自分用 bot なら「もう一度送って」で済む話で、無限課金より圧倒的にマシだ。
ここで効いている原則を一般化するとこうだ。
> 冪等でない高コスト処理(LLM 呼び出し・課金 API・メール送信)の手前にあるカーソルは、後続が失敗しても必ず進める。
「失敗したら最初からやり直す」というリトライの直感は、やり直す範囲に高コスト処理が含まれている時点で破綻する。リトライすべきは失敗したステップだけで、その手前を巻き込んではいけない。どうしても再送したいなら、生成済みの応答テキストをキューに退避してから offset を進め、送信だけを別途リトライする——カーソルの前進と再送を分離するのが筋だ。そもそも AI の自動実行が暴走しないよう人間の承認ゲートを挟む設計については、AI が書いた X 投稿を人間が承認してから出す仕組みにも別の角度で書いた。

罠2: クラウド同期するログに API キーが平文で残っていた
offset の件を直したついでに、日次ログバッチも眺めた。こちらはもっと静かで、もっと怖い問題だった。
このバッチは Claude Code のセッションの最終出力を enriched_log.json に書き出す。そして別のスクリプトが、その JSON を毎日 Google Drive へアップロードする。振り返り用のログを手元とクラウドに残すための、ありふれた構成だ。
問題は、セッションの最終出力に何が含まれているか保証がない こと。AI に「この API キーで疎通確認して」と頼めば、応答に鍵がそのまま載ることがある。環境変数をデバッグ出力させれば、トークンが本文に紛れ込む。そうして混入した鍵が enriched_log.json に書かれ、Google Drive へ同期される。手元なら自分しか見ないログが、クラウドに出た瞬間に流出経路になる。
対策として、ログへ書き出す直前に鍵・トークンらしき文字列をマスクする関数を挟んだ。
import re
_SECRET_PATTERNS = [
re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"), # Anthropic
re.compile(r"sk-[A-Za-z0-9]{20,}"), # OpenAI
re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), # GitHub PAT
re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # Slack
re.compile(r"AKIA[0-9A-Z]{16}"), # AWS Access Key
re.compile(r"\b\d{8,10}:[A-Za-z0-9_-]{30,}\b"), # Telegram Bot Token
re.compile(r"Bearer\s+[A-Za-z0-9._-]{12,}"), # Bearer トークン
re.compile(
r"(?i)(api[_-]?key|token|secret|password|passwd|authorization)"
r"(\s*[=:]\s*)['\"]?[A-Za-z0-9._/+\-]{12,}"
),
]
def _mask_secrets(text: str) -> str:
"""文字列中の鍵・トークンらしき部分を [REDACTED] に置換する。"""
if not text:
return text
for pat in _SECRET_PATTERNS:
if pat.groups >= 2:
# key=value 形式は鍵名と区切りを残し、値だけを伏せる
text = pat.sub(r"\1\2[REDACTED]", text)
else:
text = pat.sub("[REDACTED]", text)
return text
2 系統のパターンを用意しているのがポイントだ。
- 既知のプレフィックスを持つ鍵(
sk-ant-、ghp_、AKIA、Telegram の数字:英数字など)は、そのまま丸ごと[REDACTED]に置換する api_key = "..."のような汎用の key=value 形式 は、鍵名と区切り文字(=や:)をキャプチャグループで残し、値だけ を伏せる。何という設定項目が伏せられたかは残るので、ログとしての可読性を保てる
呼び出し側はこうだ。
sessions[sid]["outcomes"].append(_mask_secrets(last_msg[:500]))
完璧な DLP ではない。正規表現ベースのマスキングは「見たことのない形式の鍵」を取りこぼす。でも、クラウドに出る直前に最後の関門を 1 枚噛ませる のと、素通しでアップロードするのとでは、事故の確率が桁で違う。「個人用ログだから」で素通しにしていたのが甘かった。
ちなみにこのとき、巨大化した transcript を read_text() で全部メモリに載せていた処理も、with open(...) の 1 行ずつ読みに変えた。数十 MB のログを丸ごと読む必要はない。マスキングのついでに、メモリ的にも行儀よくしておいた。
![ログをクラウド同期する直前に鍵を[REDACTED]へマスクする関門のイメージ](https://teatree01.com/wp-content/uploads/2026/06/personal-bot-self-dos-log-leak-debug-section-redact-1-20260613.jpg)
罠3: 認可チェックを fail-open にしていた
もう一つ、認可まわりにヒヤッとする実装があった。bot は「許可した送信者からのメッセージだけ処理する」ために、送信者 ID を環境変数 TELEGRAM_SENDER_ID と突き合わせている。問題はその初期化だ。
# 修正前
SENDER_ID = os.environ.get("TELEGRAM_SENDER_ID", "")
ALLOWED_SENDER_ID = int(SENDER_ID) if SENDER_ID else None
TELEGRAM_SENDER_ID を設定し忘れると ALLOWED_SENDER_ID が None になる。このあと「送信者 ID が ALLOWED_SENDER_ID と一致するか」のチェックを、None のときどう扱うか次第で 「誰でも通す(fail-open)」に倒れる 危険があった。認可の設定漏れがそのまま「全開放」になるのは、最悪の倒れ方だ。
そこで、未設定時は chat_id の許可値(ALLOWED_CHAT_ID)にフォールバックするようにした。
# 修正後
SENDER_ID = os.environ.get("TELEGRAM_SENDER_ID", "")
# TELEGRAM_SENDER_ID 未設定時は chat_id を送信者許可値に流用する。
# 1:1 チャットでは「送信者 ID == chat_id」のため追加設定なしで fail-safe に働き、
# Bot をグループへ追加した場合のみ(chat_id がグループ ID になり送信者と一致しなくなるため)
# 明示的に TELEGRAM_SENDER_ID を設定しない限り全メッセージが弾かれる。
ALLOWED_SENDER_ID = int(SENDER_ID) if SENDER_ID else ALLOWED_CHAT_ID
これで挙動がこう変わる。
- 1:1 チャット(個人利用)では、Telegram の仕様上「送信者 ID == chat_id」なので、
TELEGRAM_SENDER_IDを設定しなくても自分のメッセージだけ通る。設定不要で安全側に倒れる - グループに追加した場合 は chat_id がグループ ID になり、送信者個人の ID と一致しなくなる。よって
TELEGRAM_SENDER_IDを明示しない限り、グループの全メッセージが弾かれる
設定漏れの初期値が「全開放」ではなく「自分だけ通る / 全部弾く」のどちらか安全側になる。未設定時のデフォルトをどちらに倒すかは、それ自体がセキュリティ設計 だという、当たり前だが忘れがちな話だ。

個人運用ツールこそ起きやすい失敗
3 つの罠に共通していたのは、どれも 「自分しか使わないから」で見逃されていた ことだ。整理するとこうなる。
| 罠 | 表面的な症状 | 本当のリスク | 直し方 |
|---|---|---|---|
| offset 据え置き | たまに返信が来ない | LLM 再生成の無限ループ(自己 DoS・課金) | 高コスト処理の手前のカーソルは失敗時も進める |
| ログに鍵が平文 | 見た目は何も起きない | クラウド同期で鍵が外部流出 | 出力直前に正規表現で [REDACTED] マスク |
| 認可が fail-open | 普段は問題なく動く | 設定漏れ=全開放 | 未設定デフォルトを安全側に倒す |
どれも「壊れて止まる」バグではない。むしろ普段は何の問題もなく動く。だから個人ツールでは放置される。でも放置されたコードは、発火条件を踏んだ瞬間に、課金爆発や情報流出という取り返しのつかない形で噴く。
業務コードならレビューや CI が拾ってくれるこの手の問題を、個人ツールは全部すり抜けさせる。「自分しか使わない」は「雑でいい」の理由にはならない——むしろ自分しか見張っていないぶん、一度くらいは真面目に読み返した方がいい、という教訓だった。なお、自分の不具合ではなく外部の AI bot に従量課金を焚かれるパターンの対策は、個人サイトを狙う AI bot から従量課金を守るに分けて書いた。

よくある質問
Q. なぜ送信失敗時にリトライしないのが正解なんですか?
リトライ自体は正しい考え方です。問題は「リトライの範囲に LLM 呼び出しという高コスト・非冪等な処理が含まれていた」ことです。送信だけを再試行するなら害はありませんが、offset を据え置くと手前の LLM 生成ごとやり直してしまう。どうしても再送したいなら、生成済みの応答をキューに退避してから offset を進め、送信だけ別途リトライしてください。カーソルの前進と再送を分離するのがポイントです。
Q. 正規表現マスキングだけで鍵漏洩は防げますか?
完全には防げません。未知の形式の鍵は取りこぼします。これはあくまで「クラウドに出る直前の最後の関門」です。根本的には、そもそもログに鍵が載らない設計(AI に鍵を渡さない・環境変数を出力させない)が先で、マスキングは保険という位置づけで使ってください。
Q. 個人ツールでもここまでやる必要はありますか?
全部を最初からやる必要はありません。ただし「課金が発生する処理」と「クラウド・外部に出ていくデータ」の 2 点だけは、個人ツールでも最初に確認することをおすすめします。この 2 つは事故ったときのダメージが個人の手に負えない規模になりやすいからです。
✅ まとめ
結論として、今回の事故は「動いているから問題ない」という個人ツール特有の油断が生んだものだった。再発防止のために覚えておきたいのは次の 3 点だ。
- 高コスト・非冪等な処理の手前のカーソルは、後続が失敗しても必ず進める。リトライしたいなら巻き込まずに分離する
- クラウドや外部へ出ていくログには、出力直前にマスキングの関門を 1 枚噛ませる。正規表現でも素通しよりは桁違いに安全
- 設定漏れ時のデフォルトは安全側に倒す。fail-open は最悪の初期値
次のアクションとして、まずは自分が動かしている個人 bot やバッチの中から「課金が発生する処理」と「外部に同期されるファイル」を 1 つずつ洗い出してみてください。その 2 点だけ確認するだけでも、放置された地雷の大半は見つかります。


コメント