この記事は Zenn に投稿したものの再掲です。
https://zenn.dev/reona777/articles/ai-tools-broke-in-production
教育系の事業所で現場の運営をしています。エンジニアではありません。2026年の春から Claude Code で社内ツールを作り始めて、5か月で6本を本番稼働させました。
前の記事では、その6本がどうやって動き続けているかを書きました。
この記事はその逆です。動かなくなった話を5つ書きます。全部、実際に本番で起きて、原因を突き止めて、直したものです。
先に結論を書きます。5つとも、AIが書いたロジックのバグではありませんでした。 テストは通っていました。関数単体を読んでも、どこもおかしくありません。壊れたのは全部その外側——実行環境の癖、プラットフォームの既定値、デプロイの仕組み——でした。
これは「AIは信用できない」という話ではありません。むしろ逆で、AIに任せられる範囲とそうでない範囲の境界が、実際に踏んでみると思っていた場所と違った、という話です。
1. 排他ロックの中でスプレッドシートを触っていた
一番ひどかったやつから書きます。
職員がSlackに書いた連絡を、関係者のLINEへ自動で転送するGASがあります。ある日、16:18の連絡が届きませんでした。エラーも出ていません。転送できなかったときに出るはずの警告も出ていません。無言で消えました。
直前に「直した」ことが原因だった
その前日、同じ本文が2通届く事故がありました。原因は重複排除の作りです。
doPost
→ isDuplicate(処理済みか調べる)
→ ...転送処理...
→ saveEventId(処理済みとして記録する)
調べるのと記録するのが離れていて、その間が無防備でした。転送処理はSlackのユーザー情報を引いて、チャンネル名を引いて、スプレッドシートを読んで、LINEに送るので3秒を超えます。Slackは3秒で応答が返らないと同じイベントを再送するので、1回目が記録し終える前に2回目が「まだ処理されていない」と判定を通過します。
なおGASの doPost からはHTTPヘッダが読めないので、Slackが再送時に付けてくれる X-Slack-Retry-Num では弾けません。排他制御しか手がありませんでした。
そこで、調べるのと記録するのをロックで囲んで原子的にしました。
function claimEventId_(eventId) {
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
if (isDuplicate(spreadsheetId, eventId)) return false; // シートを読む
saveEventId(spreadsheetId, eventId); // シートに書く
return true;
} finally {
lock.releaseLock();
}
}
これが当日中に不達事故を起こしました。
原因
ロックの中でスプレッドシートを読み書きしていたことです。
GASのスプレッドシートI/Oは、遅いときは本当に遅い。この日は claimEventId_ だけで329秒かかっていました(実行全体で331秒)。その5分半のあいだ、ロックは握られたままです。
Slackの再送(16:18:49と16:19:53)は waitLock(30000) のタイムアウトに引っかかって false で戻り、黙って捨てられました。
「届かなかった」というのが、そもそも誤診でした
ここが個人的に一番の学びでした。
元の実行はステータス「完了」で終わっていて、実際には16:24にLINEへ届いていました。 claim完了が16:24:15、実行終了が16:24:17、LINE着が16:24。一致します。
つまり不達ではなく、5分半の配信ラグでした。待ちきれずに16:23に人が手で送っていたので、結果として同じ文面が2通並び、余計に事故っぽく見えていただけです。
実行のステータスが「失敗」でない限り、不達だと断定しない。 これは今も守っています。
見つけ方
https://script.google.com/home/projects/<scriptId>/executions を開くと、関数名・実行の種類・開始時刻・所要秒・ステータスが一覧で出ます。行をクリックすると Logger.log の中身まで展開されます。
この「期間」列が診断の起点になります。 同じ秒数がきれいに並んでいたら、それは処理時間ではなくタイムアウト値です。30.8秒が2件並んでいて LockService を使っているなら、waitLock(30000) の待ち死にです。
長時間実行の時刻と、処理が残しているログの時刻を突き合わせると、どこで時間を食ったかが分かります。
直し方
判定を CacheService に移して、ロックの保持をミリ秒に抑えました。
const EVENT_CLAIM_TTL_SEC = 21600; // 6時間。Slackの再送ウィンドウより十分長い
function claimEventId_(eventId) {
const cache = CacheService.getScriptCache();
const key = "claim_" + eventId;
const lock = LockService.getScriptLock();
try {
lock.waitLock(30000);
} catch (err) {
return "locked";
}
try {
if (cache.get(key)) return "duplicate";
cache.put(key, "1", EVENT_CLAIM_TTL_SEC);
return "claimed";
} finally {
lock.releaseLock();
}
}
シートへの記録は監査ログとして必要なので捨てず、ロックの外・転送処理のあとに分離して、finally で必ず残すようにしました。記録に失敗しても転送は止まりません。
戻り値も真偽値から claimed / duplicate / locked の3値に変えました。duplicate は黙って捨てる(再送のたびに通知が鳴るので)、locked のときだけ「数分待っても届かなければ再投稿してください」と出す。locked はキャッシュに記録が残っていない=Slackの自動再送が成功しうる状態なので、即座に再投稿を促すと二重になります。
一般化すると
排他ロックの中では、即答する処理しかしない。
ロックは「短時間だけ握るもの」という前提で設計されています。その中に、遅くなりうるI/Oを入れた瞬間に、待っている側が全部死にます。しかも待たされた側は例外を出さずに false を返すだけなので、ログには何も残りません。
そしてもうひとつ。バグを直した修正が、次のバグを作ります。 二重送信を直した当日に不達を起こしました。直したあとしばらくは、直した箇所そのものを疑える状態にしておくべきでした。
2. トリガーを作る関数を、トリガーに登録していた
GASには「時間主導トリガー」があります。10分おき、毎日何時、といった指定で関数を自動実行する仕組みです。
トリガーの登録自体をコードでやりたくなるので、こういうセットアップ関数を書きます。
function setupTrigger() {
ScriptApp.getProjectTriggers().forEach((t) => {
ScriptApp.deleteTrigger(t); // 既存を消して
});
ScriptApp.newTrigger('onFormSubmit') // 張り直す
.forSpreadsheet(SPREADSHEET_ID).onFormSubmit().create();
}
この setupTrigger 自身が、10分おきの時間主導トリガーとして登録されていました。
何が起きたか
10分ごとに、自分でトリガーを作り続けます。
そして当時の実装は「全部消してから張り直す」でした。つまり、無関係な別機能のトリガーまで巻き添えで消えます。実際、応募メールを取り込む処理のトリガーが道連れになりました。
最終的にトリガー数が上限に達して、こうなります。
This script has too many triggers.
Triggers must be deleted from the script before more can be added.
このエラーで92回失敗し、メール取込は1ヶ月止まりました。
直し方
引数を見るだけで防げます。トリガー経由の呼び出しにだけ、イベントオブジェクトが渡ります。
function setupTrigger(e) {
if (e) {
Logger.log('setupTrigger は手動実行専用です。トリガーからは実行できません');
return;
}
// 以下、本来の処理
}
手動実行には引数が渡らないので、通常の使い方には一切影響しません。同じガードを、トリガーを触る関数すべてに入れました。テストも書いてあります。
もうひとつ、全削除もやめました。ScriptApp.getProjectTriggers() は実行しているアカウント自身が作ったトリガーしか返しません。 複数人でスクリプトを共有していると、他人のトリガーは見えないし消せない。「消してから張り直す」は、他人の分を残したまま自分の分を足す動きになります。これはこれで、1回のイベントでハンドラが2回走る事故になりました。
気づくのが1ヶ月遅れた理由
GASの実行数ページは7日分しか残りません。 期間フィルタも「最後の5分/30分/1時間/24時間」しかなく、1ヶ月前には遡れません。
古い事故を追うときは、トリガー所有者のGmailを検索します。
"Summary of failures for Google Apps Script" after:2026/07/01 before:2026/07/31
GASは失敗をまとめてメールで送ってくれます。関数名・エラー文・実行時刻・トリガー種別が載っていて、実行数ページより情報が多いくらいです。ラベルで振り分けて受信トレイをスキップさせていたので、1ヶ月気づきませんでした。振り分けるのはいいとして、定期的に見る導線は要ります。
一般化すると
自動実行される関数が、自分の実行環境そのものを書き換えないようにする。
「セットアップ用」と名前に書いてあっても、それは人間向けのラベルでしかありません。実行する側は何も読みません。コードで表明しないと守られない、というだけの話でした。
3. 関数とデータが太平洋を挟んでいた
ここから毛色が変わります。壊れて止まったのではなく、遅かった話です。
個人用に作った学習記録アプリ(Next.js + Vercel + Upstash Redis)で、記録を1件付けるたびに毎回もたつく感じがありました。処理は単純です。JSONを1件読んで、書き戻すだけ。
原因
Vercelの関数リージョンが既定の iad1(米東部)のままでした。
保存先のUpstashは東京(hnd1)に作ってあります。つまり毎回こうなっていました。
端末(日本) → 米東部 → 東京 → 米東部 → 端末
1回の記録で太平洋を3回渡っていました。 ロジックの問題ではないので、コードをいくら読んでも出てきません。
直し方
Project Settings → Functions → Function Region で hnd1 を選ぶだけです。Hobbyプランでは1つだけ選べます。反映は次のデプロイからです(設定を変えただけでは切り替わらない)。
本番の応答は0.4秒前後から0.1秒前後になりました。
確認はレスポンスヘッダの x-vercel-id を見ます。hnd1::hnd1 になっていれば東京で動いています。
一般化すると
既定値は、自分の環境に合わせて選ばれたものではありません。
これは自分にとって、一番学びのある種類の失敗でした。エラーも出ないし、テストも通るし、動いてはいるからです。「なんとなく遅い」を放置せずに一度計測すればいいのですが、遅い理由がコードの外にありうるという発想自体が最初は無かった。
AIは、頼めばリージョンの話もしてくれます。ただ聞かないと言ってきません。 「これはどこで動いて、データはどこにあるのか」を毎回聞くようにしました。
4. アイコンを直したのに、Androidだけ古いままだった
同じアプリの話です。ホーム画面に追加して使うタイプ(PWA)にしてあります。
起動画面のアイコンがぼやけていたので直しました。favicon.ico しか置いていなかったのが原因で、起動画面で引き伸ばされていました。実寸の画像を用意してマニフェストに登録します。
public/icon-192.png
public/icon-512.png
public/icon-maskable-512.png
app/apple-icon.png
デプロイして、直りました。PCでは。
Androidのホーム画面のアイコンは、古いままでした。何度リロードしても、キャッシュを消しても変わりません。
原因
Androidはホーム画面に追加した時点で WebAPK というものを生成していて、アイコンをそちらが持っています。
サイト側を直しても、既にインストールされたWebAPKには反映されません。ホーム画面からアイコンを消すだけでも消えません(WebAPKはアプリとして残っています)。
直し方
設定 → アプリ からアンインストールして、入れ直します。放置しても1日程度で自動更新されます。
状態は chrome://webapks で見られます。どのマニフェストのどのバージョンを持っているかが分かるので、「サイトは直っているのにアプリが古い」を確定できます。
一般化すると
自分が配っているものの外側に、コピーを持っている相手がいる。
サーバーを直せば全員に届く、という感覚が通じない層があります。ブラウザのキャッシュはまだ想像がつきますが、OSがアプリとして固めて持っているところまでは考えていませんでした。
自分ひとりで使うアプリだったので実害はありませんでしたが、これを人に配っていたら「直したはずなのに直っていない」という報告に何日も悩んだはずです。
5. 同じスクリプトを指すローカルフォルダが2つあった
最後は、コードの話ですらありません。
毎朝、その日の予定や集計をまとめてSlackに自動投稿するGASがあります。ある日から、それが自分の名義ではなくボット名義で届くようになりました。
投稿自体は届いています。内容も合っています。名義だけが違う。
原因
2日前に、古いローカルフォルダから clasp push していました。
同じスクリプトIDを指すローカルのコピーが、~/gas-seito-kanri と ~/gas-shimoya の2箇所にありました。前者には7月の改修が入っていて、後者は6月10日相当のまま止まっていました。
Slackのテスト投稿を消すための一時的な関数を足して push したのですが、開いていたのが古い方でした。
clasp push は、そのフォルダの中身でGAS側を丸ごと置き換えます。 差分を送るのではありません。関数を1つ足すだけの操作でも、7ファイル全部が上書きされます。結果、7月に入れた改修3点が黙って消えました。ユーザートークンを優先して本人名義で投稿する処理も、そのひとつです。
気づくのに2日かかった理由
Slack上で見分けがつかないからです。
本人名義の投稿は user フィールドを持ち、ボット名義は bot_id を持ちます。ただ表示上のプロフィール名はどちらも同じなので、パッと見では違いが分かりません。 「なんか今日のこれ、編集できないな」くらいの違和感で気づきました。
直し方
clasp pull で本番側を取り直して、消えた3点を当て直して push し直しました。diffで一致を確認済みです。古い方のフォルダは削除して一本化しました。
再発防止として、そもそも重複しているかを先に調べます。
grep -h '"scriptId"' ~/gas-*/.clasp.json | sort | uniq -d
全プロジェクトを点検したら、重複が3組ありました。 古い側を消して、現在はゼロです。
一般化すると
AIに作業を頼むとき、作業する場所の正しさは人間側の責任として残ります。
コードの内容はレビューできても、「そのフォルダは本番と同期しているか」はプロンプトの外にあります。AIから見れば、指定されたフォルダはただのファイル群で、それが最新かどうかを知る手段がありません。
そして push の意味論も、ここで初めて理解しました。git push の感覚でいると差分を送るつもりになりますが、clasp は同期ではなく上書きです。「編集の前に必ず clasp pull」を運用として固定しました。
5つに共通していたこと
並べてみて気づいたことが3つあります。
壊れたのは全部、コードの外側でした。
排他ロックの粒度、トリガーという実行モデル、リージョンの既定値、OSが持つキャッシュ、デプロイの意味論。どれもコードを読んでも書いていない情報です。AIはコードの中は見てくれますが、そのコードが置かれる環境の癖までは、聞かない限り教えてくれません。
壊れ方が静かでした。
5つのうち、例外を投げて止まったのは②だけです。①は「捨てられた」、③は「遅いだけ」、④は「古いまま」、⑤は「名義が違うだけ」。エラーが出るのは、むしろ運が良い方でした。
時間が経ってから壊れました。
作った直後は全部動いています。①は再送が重なったときだけ、②はトリガーが上限に達したときだけ、⑤は2つ目のフォルダを作ってしまったあとだけ。設計の段階では絶対に出てこず、本番に出して使い続けて初めて出てきます。
壊れたときに最初に見る場所
結局のところ、防ぐより気づく方が現実的でした。自分が今使っている診断の入口を置いておきます。
GAS: 実行数ページ
https://script.google.com/home/projects/<scriptId>/executions
関数名・種類・所要秒・ステータスが一覧で出ます。「期間」列に同じ秒数が並んでいたらタイムアウト値を疑う。 行をクリックするとログが展開されます。
GAS: 7日より前の失敗
トリガー所有者のGmailで "Summary of failures for Google Apps Script" を検索します。実行数ページより情報が多いです。
Vercel: どのリージョンで動いているか
レスポンスヘッダの x-vercel-id。hnd1::hnd1 なら東京です。
Android: サイトとアプリのどちらが古いか
chrome://webapks でインストール済みのマニフェストの状態が見られます。
clasp: 触る前に
clasp pull を先にやる。フォルダの重複は grep -h '"scriptId"' ~/gas-*/.clasp.json | sort | uniq -d。
最後に
「AIに作らせたから壊れた」わけではありませんでした。同じものを人が書いても、この5つは全部踏むと思います。
ただ、AIを使うと作れる量が増えるぶん、こういうものを踏む機会も増えます。 5か月で6本というのは、自分ひとりでは絶対に届かない本数です。作る速度が上がったぶん、運用の面積が増えました。
なので今は、作るときに「どこで動くのか」「壊れたらどこを見るのか」を先にAIに聞くようにしています。コードを書かせるより先に、そっちを言葉にさせた方が効きます。
その「言葉にさせる」ところをどうやっているかは、次の記事に書きます。
- Claude Codeに CONTEXT.md と SPEC.md と ADR を書かせると、途中で破綻しなくなる
ほかに作ったものは GitHub に置いています。
書いている人
エンジニアではありません。勤務先の業務ツールを Claude Code で作って運用していて、作ったものと、壊れたときに直した話を書いています。
Claude Code の実務運用については X(@KouritsuONI)でも書いています。スプレッドシート・GAS・LINE・Salesforce まわりの業務自動化について、ご相談は X のDMからどうぞ。
この記事のシリーズ
Claude Code の実務運用について、順に9本書いています。
- 非エンジニアがClaude Codeで社内ツールを6本 本番稼働させるまでにやったこと
- AIに作らせたツールが本番で壊れた5つの原因と、直し方(この記事)
- Claude CodeにCONTEXT.mdとSPEC.mdとADRを書かせると、途中で破綻しなくなる
- Claude Codeのスラッシュコマンドには、手順ではなく踏んだ罠を書く
- Claude Codeのメモリに53本ためて分かった、書く価値のある事実とない事実
- 実行は成功、でも誰にも届いていない。無言で失敗する自動化に気づく仕掛け
- Salesforceの項目は「ある」と「使える」が別だった。外から自動化して踏んだ6つ
- Salesforceのパスワードを変えたら、連携が8箇所いっせいに止まった
- Salesforceのログインが2027年6月に廃止される。自動化10本を調べたら、直す場所は1つではなかった