個人開発のbotを放置していたら、自分のAPIに自分でDoSしていた話

個人開発のbotを放置していたら、自分のAPIに自分でDoSしていた話 コラム
個人開発botが自分のAPIに自己DoSしていた話のイメージ

落ちて止まるバグより、落ちずに走り続けるバグの方が怖い。

例外で止まれば気づく。ログが赤ければ調べる。でも 何事もなく動いているように見えて、裏で課金メーターだけが回り続けている バグは、誰も通報してくれない。請求が来るまで気づけない。実は、200 で返ってくるのに期待通り動かない「失敗が見えない失敗」の怖さは、別記事(200 で返るのに動かないデバッグ記録)でも書いた。今回はその課金版だ。

今回これを踏んだ。自宅の Mac で動かしている個人用の Telegram bot——スマホから話しかけると LLM が応答を返すだけの、自分しか使わない雑なツールだ。久しぶりにコードをレビューしたら、送信に失敗するたび同じメッセージへ LLM 応答を生成し続ける「自己 DoS」 になっていた。ついでに掘ったら、日次ログに API キーが平文で残り、それを Google Drive へ同期していた。

個人運用のバッチは「動いてればヨシ」で放置されがちだ。でも放置されたコードは、静かに事故を仕込む。踏んだ順に全部書いておく。

📋 この記事でわかること

  • ポーリング型 bot で「送信失敗時に offset を進めない」設計が自己 DoS になる仕組み
  • LLM を挟むと、その自己 DoS が単なる無限ループではなく「課金される無限ループ」になる理由
  • クラウド同期するログに鍵・トークンが混入するのを正規表現でマスクする実装
  • 認可チェックを fail-open にしないための環境変数フォールバック設計
  • 自分しか使わない個人ツールでも最低限見ておくべき観点

構成を先に整理する

踏んだ環境はこんな構成だ。

  • Telegram bot: Python。Telegram の getUpdates API をポーリングして、新着メッセージに LLM 応答を返す
  • offset 管理: getUpdatesoffset を渡すと「それ未満の 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_offsetif/else の外に出して、成功・失敗どちらでも必ず実行する。これで「1 通につき LLM 呼び出しは最大 1 回」が保証される。送信が失敗したメッセージへの応答は失われるが、自分用 bot なら「もう一度送って」で済む話で、無限課金より圧倒的にマシだ。

ここで効いている原則を一般化するとこうだ。

> 冪等でない高コスト処理(LLM 呼び出し・課金 API・メール送信)の手前にあるカーソルは、後続が失敗しても必ず進める。

「失敗したら最初からやり直す」というリトライの直感は、やり直す範囲に高コスト処理が含まれている時点で破綻する。リトライすべきは失敗したステップだけで、その手前を巻き込んではいけない。どうしても再送したいなら、生成済みの応答テキストをキューに退避してから offset を進め、送信だけを別途リトライする——カーソルの前進と再送を分離するのが筋だ。そもそも AI の自動実行が暴走しないよう人間の承認ゲートを挟む設計については、AI が書いた X 投稿を人間が承認してから出す仕組みにも別の角度で書いた。

自己DoSループ——送信失敗のたびLLM生成をやり直す構造のイメージ

罠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]へマスクする関門のイメージ

罠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_IDNone になる。このあと「送信者 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 を明示しない限り、グループの全メッセージが弾かれる

設定漏れの初期値が「全開放」ではなく「自分だけ通る / 全部弾く」のどちらか安全側になる。未設定時のデフォルトをどちらに倒すかは、それ自体がセキュリティ設計 だという、当たり前だが忘れがちな話だ。

認可の初期値を全開放ではなく安全側(fail-safe)に倒す設計のイメージ

個人運用ツールこそ起きやすい失敗

3 つの罠に共通していたのは、どれも 「自分しか使わないから」で見逃されていた ことだ。整理するとこうなる。

表面的な症状 本当のリスク 直し方
offset 据え置き たまに返信が来ない LLM 再生成の無限ループ(自己 DoS・課金) 高コスト処理の手前のカーソルは失敗時も進める
ログに鍵が平文 見た目は何も起きない クラウド同期で鍵が外部流出 出力直前に正規表現で [REDACTED] マスク
認可が fail-open 普段は問題なく動く 設定漏れ=全開放 未設定デフォルトを安全側に倒す

どれも「壊れて止まる」バグではない。むしろ普段は何の問題もなく動く。だから個人ツールでは放置される。でも放置されたコードは、発火条件を踏んだ瞬間に、課金爆発や情報流出という取り返しのつかない形で噴く。

業務コードならレビューや CI が拾ってくれるこの手の問題を、個人ツールは全部すり抜けさせる。「自分しか使わない」は「雑でいい」の理由にはならない——むしろ自分しか見張っていないぶん、一度くらいは真面目に読み返した方がいい、という教訓だった。なお、自分の不具合ではなく外部の AI bot に従量課金を焚かれるパターンの対策は、個人サイトを狙う AI bot から従量課金を守るに分けて書いた。

課金が発生する処理と外部同期されるファイルの2点を点検するチェックリストのイメージ

よくある質問

Q. なぜ送信失敗時にリトライしないのが正解なんですか?

リトライ自体は正しい考え方です。問題は「リトライの範囲に LLM 呼び出しという高コスト・非冪等な処理が含まれていた」ことです。送信だけを再試行するなら害はありませんが、offset を据え置くと手前の LLM 生成ごとやり直してしまう。どうしても再送したいなら、生成済みの応答をキューに退避してから offset を進め、送信だけ別途リトライしてください。カーソルの前進と再送を分離するのがポイントです。

Q. 正規表現マスキングだけで鍵漏洩は防げますか?

完全には防げません。未知の形式の鍵は取りこぼします。これはあくまで「クラウドに出る直前の最後の関門」です。根本的には、そもそもログに鍵が載らない設計(AI に鍵を渡さない・環境変数を出力させない)が先で、マスキングは保険という位置づけで使ってください。

Q. 個人ツールでもここまでやる必要はありますか?

全部を最初からやる必要はありません。ただし「課金が発生する処理」と「クラウド・外部に出ていくデータ」の 2 点だけは、個人ツールでも最初に確認することをおすすめします。この 2 つは事故ったときのダメージが個人の手に負えない規模になりやすいからです。

✅ まとめ

結論として、今回の事故は「動いているから問題ない」という個人ツール特有の油断が生んだものだった。再発防止のために覚えておきたいのは次の 3 点だ。

  • 高コスト・非冪等な処理の手前のカーソルは、後続が失敗しても必ず進める。リトライしたいなら巻き込まずに分離する
  • クラウドや外部へ出ていくログには、出力直前にマスキングの関門を 1 枚噛ませる。正規表現でも素通しよりは桁違いに安全
  • 設定漏れ時のデフォルトは安全側に倒す。fail-open は最悪の初期値

次のアクションとして、まずは自分が動かしている個人 bot やバッチの中から「課金が発生する処理」と「外部に同期されるファイル」を 1 つずつ洗い出してみてください。その 2 点だけ確認するだけでも、放置された地雷の大半は見つかります。

コメント