AIコーディングエージェントが作る7つの隠れたプロダクションバグ(クラッシュする前に見つける方法)
AIコーディングエージェントが45秒で機能を作ってくれた。コンパイルOK。テストも通る。PRもきれい。金曜の夜、自信を持って本番デプロイ。
土曜の朝、データベースがCPU 100%、Redisクラスタが応答なし、3人の顧客が「他人のデータが見える」と報告してくる。オンコールエンジニアは見たこともないAI生成コードを前に途方に暮れている。エラーログには何も残っていない。AIがまともなエラーロギングを入れてなかったから。
これ、仮定の話じゃないんです。業界全体で毎日起きている。2025年のEndor Labsの研究では、AI生成コードの62%にセキュリティ上の弱点または設計上の欠陥が見つかりました。でも本当に怖いのは明らかなバグじゃない。隠れたバグなんです。正しく見えて、ユニットテストを通過して、コードレビューも突破して、誰もテストしなかった本番環境の条件で爆発するコード。
このガイドでは、AIコーディングエージェントが一貫して生み出す7つの最も危険な隠れバグパターンを徹底解剖します。各パターンについて、AIがなぜそう書くのか、本番に行く前にどう検出するか、実戦で検証済みの修正方法をカバーします。理論的なリスクじゃなく、2025-2026年にAI支援開発を運用している企業の実際のプロダクション障害から抽出されたパターンです。
パターン 1: キャッシュスタンピード
AIが書くコード
エージェントにキャッシュを追加してと頼むと、教科書通りのcache-asideロジックを生成します:
async function getProduct(id: string): Promise<Product> { const cached = await redis.get(`product:${id}`); if (cached) { return JSON.parse(cached); } const product = await db.query('SELECT * FROM products WHERE id = $1', [id]); await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 3600); return product; }
単体では完璧。テストも全部通る。
なぜ爆発するか
本番トラフィック下で人気のキャッシュキーが期限切れになると、数百の同時リクエストが一斉にキャッシュミスする。全部が同じクエリでDBを叩く。CPU 100%、クエリがタイムアウトし始め、関係ないサービスまで連鎖的に落ちる。
これがキャッシュスタンピード(thundering herd問題)。AIがこれを作るのは、トレーニングデータがシングルスレッド・低トラフィックを前提としたチュートリアル中心だから。
検出戦略
it('同時キャッシュミスでスタンピードが発生しないこと', async () => { await redis.del('product:popular-item'); const requests = Array.from({ length: 100 }, () => getProduct('popular-item') ); const dbQuerySpy = vi.spyOn(db, 'query'); await Promise.all(requests); // DBへのヒットが1回だけなら正常 expect(dbQuerySpy).toHaveBeenCalledTimes(1); });
プロダクション修正
import { Mutex } from 'async-mutex'; const locks = new Map<string, Mutex>(); async function getProduct(id: string): Promise<Product> { const cacheKey = `product:${id}`; const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } if (!locks.has(cacheKey)) { locks.set(cacheKey, new Mutex()); } const mutex = locks.get(cacheKey)!; return mutex.runExclusive(async () => { // ロック取得後に再チェック(double-checked locking) const rechecked = await redis.get(cacheKey); if (rechecked) { return JSON.parse(rechecked); } const product = await db.query( 'SELECT * FROM products WHERE id = $1', [id] ); await redis.set(cacheKey, JSON.stringify(product), 'EX', 3600); return product; }); }
キーインサイトはdouble-checked locking。最初のリクエストがロックを取得してDBから取得しキャッシュを埋める。他の同時リクエストはロック待ちの後、すでに埋まったキャッシュを即座にリターン。DBは1回しか叩かれない。
パターン 2: コネクションプール枯渇
AIが書くコード
async/awaitは大好きだけど、DBコネクションをリークするコードを一貫して生成する:
async function processOrder(orderId: string) { const client = await pool.connect(); const order = await client.query( 'SELECT * FROM orders WHERE id = $1', [orderId] ); const inventory = await client.query( 'SELECT * FROM inventory WHERE product_id = $1', [order.rows[0].product_id] ); await client.query( 'UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2', [order.rows[0].quantity, order.rows[0].product_id] ); client.release(); return { success: true }; }
なぜ爆発するか
どのクエリかがエラーを投げると、client.release()が実行されない。コネクションがリークする。蓄積するとpool.connect()が無限に待機し始める。全コネクションが放棄されたハンドラに掴まれているから。エラーログゼロでアプリ全体がフリーズする。
検出戦略
// プロダクションプールメトリクスのモニタリング setInterval(() => { const { totalCount, idleCount, waitingCount } = pool; logger.info('pool_metrics', { total: totalCount, idle: idleCount, waiting: waitingCount, active: totalCount - idleCount, }); if (waitingCount > 5) { logger.warn('pool_pressure', { message: 'コネクションプールに圧力', waiting: waitingCount, }); } }, 10_000);
プロダクション修正
async function processOrder(orderId: string) { const client = await pool.connect(); try { await client.query('BEGIN'); const order = await client.query( 'SELECT * FROM orders WHERE id = $1', [orderId] ); const inventory = await client.query( 'SELECT * FROM inventory WHERE product_id = $1', [order.rows[0].product_id] ); await client.query( 'UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2', [order.rows[0].quantity, order.rows[0].product_id] ); await client.query('COMMIT'); return { success: true }; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); // 何が起きても必ず解放 } }
try/catch/finallyパターンが何が起きてもコネクション解放を保証する。AIエージェントが最も一貫して省略するパターン。
パターン 3: サイレントデータ破損
AIが書くコード
APIデータを処理するとき、外部入力を無条件に信頼する:
app.post('/api/users/:id/profile', async (req, res) => { const { name, email, role } = req.body; await db.query( 'UPDATE users SET name = $1, email = $2, role = $3 WHERE id = $4', [name, email, role, req.params.id] ); res.json({ success: true }); });
なぜ爆発するか
このエンドポイントは認証済みユーザーなら誰でも自分のroleをadminに変更できてしまう。AIがrequest bodyからroleをデストラクチャしたのは、DBスキーマにroleカラムがあったからで、認可を考慮せずにパターンマッチングしただけ。
さらにnameやemailはどんな文字列でも受け入れる。保存は「成功」するが、下流システムが壊れていく。
検出戦略
import { z } from 'zod'; const UpdateProfileSchema = z.object({ name: z.string().min(1).max(100).trim(), email: z.string().email().max(254).toLowerCase(), // 'role'はスキーマにない }); // 統合テスト:禁止フィールドが拒否されることを検証 it('プロフィール更新でロールエスカレーションが不可能であること', async () => { const res = await request(app) .post('/api/users/user-1/profile') .send({ name: 'Hacker', email: '[email protected]', role: 'admin' }) .set('Authorization', `Bearer ${userToken}`); const user = await db.query('SELECT role FROM users WHERE id = $1', ['user-1']); expect(user.rows[0].role).toBe('member'); // 'admin'ではない });
プロダクション修正
import { z } from 'zod'; const UpdateProfileSchema = z.object({ name: z.string().min(1).max(100).trim(), email: z.string().email().max(254).toLowerCase(), // 'role'はスキーマにない }); app.post('/api/users/:id/profile', async (req, res) => { const parsed = UpdateProfileSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: 'Validation failed', issues: parsed.error.issues, }); } const { name, email } = parsed.data; if (req.params.id !== req.user.id) { return res.status(403).json({ error: 'Forbidden' }); } await db.query( 'UPDATE users SET name = $1, email = $2 WHERE id = $3', [name, email, req.params.id] ); res.json({ success: true }); });
3層の防御:スキーマ検証(role除去)、認可チェック(自分のプロフィールのみ)、制約付きデータ型(メール形式、名前長さ)。
パターン 4: 未処理のレースコンディション
AIが書くコード
「いいね」トグル機能の実装:
async function toggleLike(userId: string, postId: string) { const existing = await db.query( 'SELECT id FROM likes WHERE user_id = $1 AND post_id = $2', [userId, postId] ); if (existing.rows.length > 0) { await db.query('DELETE FROM likes WHERE id = $1', [existing.rows[0].id]); await db.query( 'UPDATE posts SET like_count = like_count - 1 WHERE id = $1', [postId] ); return { liked: false }; } else { await db.query( 'INSERT INTO likes (user_id, post_id) VALUES ($1, $2)', [userId, postId] ); await db.query( 'UPDATE posts SET like_count = like_count + 1 WHERE id = $1', [postId] ); return { liked: true }; } }
なぜ爆発するか
接続が不安定なスマホでいいねボタンをダブルタップ。最初のリクエストが確認した時点でlikeレコードはない。2番目のリクエストも同時にない(最初のINSERTがまだコミットされていないから)。両方がINSERTする。like_countが2増える。テストでは見えない。テストは逐次実行だから。
検出戦略
it('同時いいねトグルを正しく処理すること', async () => { const results = await Promise.all([ toggleLike('user-1', 'post-1'), toggleLike('user-1', 'post-1'), ]); const likes = await db.query( 'SELECT COUNT(*) FROM likes WHERE user_id = $1 AND post_id = $2', ['user-1', 'post-1'] ); // 0か1が正解、絶対に2になってはいけない expect(Number(likes.rows[0].count)).toBeLessThanOrEqual(1); });
プロダクション修正
async function toggleLike(userId: string, postId: string) { return await db.transaction(async (tx) => { const existing = await tx.query( `SELECT id FROM likes WHERE user_id = $1 AND post_id = $2 FOR UPDATE`, // ← 行レベルロック [userId, postId] ); if (existing.rows.length > 0) { await tx.query('DELETE FROM likes WHERE id = $1', [existing.rows[0].id]); await tx.query( 'UPDATE posts SET like_count = like_count - 1 WHERE id = $1', [postId] ); return { liked: false }; } else { await tx.query( `INSERT INTO likes (user_id, post_id) VALUES ($1, $2) ON CONFLICT (user_id, post_id) DO NOTHING`, // ← 冪等性保証 [userId, postId] ); await tx.query( 'UPDATE posts SET like_count = like_count + 1 WHERE id = $1', [postId] ); return { liked: true }; } }); }
FOR UPDATEが同時操作をシリアライズし、ON CONFLICT DO NOTHINGがセーフティネット。AIがFOR UPDATEを生成することはほぼない。
パターン 5: リトライストーム
AIが書くコード
「このAPI呼び出しをもっと安定させて」と頼むと:
async function callPaymentAPI(data: PaymentRequest): Promise<PaymentResult> { const MAX_RETRIES = 3; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { const response = await fetch('https://api.payment.com/charge', { method: 'POST', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' }, }); if (!response.ok) { throw new Error(`Payment API error: ${response.status}`); } return await response.json(); } catch (error) { if (attempt === MAX_RETRIES - 1) throw error; } } throw new Error('Unreachable'); }
なぜ爆発するか
決済APIがパフォーマンス劣化中(200msではなく5秒)の場合、すべてのリクエストがすでに苦しんでいるサービスに3倍の火力を浴びせる。1,000人の同時ユーザーで3,000リクエストになる。さらに深刻なのはPOST /chargeをリトライしていること。最初のリクエストが実際には成功していてレスポンスが遅いだけだった場合、リトライで二重課金が発生する。
検出戦略
it('即時リトライではなく指数バックオフを実装すること', async () => { let callTimestamps: number[] = []; vi.spyOn(global, 'fetch').mockImplementation(async () => { callTimestamps.push(Date.now()); throw new Error('Service unavailable'); }); await expect(callPaymentAPI(mockData)).rejects.toThrow(); // リトライ間の指数的な遅延を検証 for (let i = 1; i < callTimestamps.length; i++) { const delay = callTimestamps[i] - callTimestamps[i - 1]; expect(delay).toBeGreaterThan(500 * Math.pow(2, i - 1)); } });
プロダクション修正
async function callPaymentAPI(data: PaymentRequest): Promise<PaymentResult> { const idempotencyKey = crypto.randomUUID(); const MAX_RETRIES = 3; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); const response = await fetch('https://api.payment.com/charge', { method: 'POST', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, // ← 二重課金防止 }, signal: controller.signal, }); clearTimeout(timeout); if (response.status === 429 || response.status >= 500) { throw new RetryableError(response.status); } if (!response.ok) { throw new NonRetryableError(response.status); } return await response.json(); } catch (error) { if (error instanceof NonRetryableError) throw error; if (attempt === MAX_RETRIES - 1) throw error; const baseDelay = 1000 * Math.pow(2, attempt); const jitter = Math.random() * 500; await new Promise(resolve => setTimeout(resolve, baseDelay + jitter) ); } } throw new Error('Unreachable'); }
4つの重要な追加:冪等性キー、ジッター付き指数バックオフ、リトライ可能/不可能エラー分類、明示的タイムアウト。
パターン 6: 低速メモリリーク
AIが書くコード
WebSocketハンドラやイベント処理の構築時:
class NotificationService { private listeners: Map<string, Set<(data: any) => void>> = new Map(); subscribe(userId: string, callback: (data: any) => void) { if (!this.listeners.has(userId)) { this.listeners.set(userId, new Set()); } this.listeners.get(userId)!.add(callback); } notify(userId: string, data: any) { this.listeners.get(userId)?.forEach(cb => cb(data)); } } wss.on('connection', (ws, req) => { const userId = extractUserId(req); const callback = (data: any) => { ws.send(JSON.stringify(data)); }; notificationService.subscribe(userId, callback); ws.on('message', (msg) => { /* ハンドリング */ }); });
なぜ爆発するか
WebSocket切断時にunsubscribeがない。接続のたびにコールバックがSetに追加されるが、削除されるものはない。1週間の本番トラフィックで、listeners Mapに数百万のデッドコールバックが蓄積。メモリが線形に増加し、Node.jsプロセスがOOMでクラッシュする。
開発環境では見えない。プロセスが頻繁に再起動されるから。本番では数日から数週間かかって表出する。
検出戦略
it('切断時にリスナーがクリーンアップされること', async () => { const ws = new MockWebSocket(); simulateConnection(ws, 'user-1'); const listenersBefore = notificationService.getListenerCount('user-1'); expect(listenersBefore).toBe(1); ws.emit('close'); const listenersAfter = notificationService.getListenerCount('user-1'); expect(listenersAfter).toBe(0); }); // プロダクションモニタリング setInterval(() => { let total = 0; notificationService.listeners.forEach((set) => { total += set.size; }); logger.info('listener_count', { total }); }, 60_000);
プロダクション修正
wss.on('connection', (ws, req) => { const userId = extractUserId(req); const callback = (data: any) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } }; notificationService.subscribe(userId, callback); // 切断時のクリーンアップ — 欠けていた重要なピース ws.on('close', () => { notificationService.unsubscribe(userId, callback); }); ws.on('error', () => { notificationService.unsubscribe(userId, callback); }); ws.on('message', (msg) => { /* ハンドリング */ }); });
AIはsubscribeは確実に実装するがunsubscribeを忘れる。トレーニングデータにクリーンアップコードがほとんどないから。
パターン 7: 認証コンテキストリーク
AIが書くコード
function authMiddleware(req: Request, res: Response, next: NextFunction) { const token = req.headers.authorization?.split(' ')[1]; const decoded = jwt.verify(token!, process.env.JWT_SECRET!); req.user = decoded as AuthUser; next(); } app.get('/api/orders', authMiddleware, async (req, res) => { const orders = await orderService.getOrders(req.query.userId as string); res.json(orders); });
なぜ爆発するか
認証ミドルウェアはJWTを正しく検証してreq.userを設定する。しかしコントローラがreq.user.id(検証済みトークンから)ではなくreq.query.userId(URLクエリストリングから)をサービスに渡している。認証済みユーザーなら誰でもuserIdパラメータを変えるだけで他人の注文にアクセスできる。IDOR脆弱性。
検出戦略
it('他ユーザーの注文にアクセスできないこと', async () => { const tokenA = generateToken({ id: 'user-a', role: 'member' }); const res = await request(app) .get('/api/orders?userId=user-b') .set('Authorization', `Bearer ${tokenA}`); const orders = res.body; orders.forEach((order: any) => { expect(order.user_id).toBe('user-a'); }); });
プロダクション修正
app.get('/api/orders', authMiddleware, async (req, res) => { // ✅ JWTの検証済みIDを使用 const orders = await orderService.getOrders(req.user.id); res.json(orders); });
ルールはシンプル:検証済みの身元がある場合、ユーザー提供の身元は絶対に使わない。
メタパターン: AIがこれらのバグを生む理由
7つのパターンすべて同じ根本原因を共有している:AIエージェントはハッピーパスに最適化されている。
トレーニングデータ分布 vs. プロダクションコードの現実:
AIトレーニングデータ: プロダクションコード:
─────────────────── ──────────────────
ハッピーパス: 85% ハッピーパス: 20%
エラー処理: 10% エラー処理: 35%
エッジケース: 3% エッジケース: 25%
並行処理: 1% 並行処理: 10%
クリーンアップ: 1% クリーンアップ: 10%
AIは正常動作を処理する20%のコードは美しく書くが、コードをプロダクションレディにする80%は無視するか表面的にしか処理しない。
検出チェックリスト
| カテゴリ | 質問 | 「いいえ」なら → |
|---|---|---|
| 並行性 | 1000同時リクエストで動くか? | ロック / FOR UPDATE / 冪等性を追加 |
| コネクション | finallyブロックで解放されているか? | try/finallyを追加 |
| 入力検証 | 全ての外部入力が検証・制約されているか? | Zodスキーマを追加 |
| 認証 | 検証済みIDを使っているか? | req.queryをreq.userに置換 |
| リトライ | 指数バックオフ+冪等性キーを使っているか? | ナイーブなリトライループを置換 |
| クリーンアップ | リスナー、タイマー、サブスクリプションが整理されるか? | close/errorハンドラを追加 |
| エラー | デバッグに十分なコンテキストがあるか? | 構造化ロギングを追加 |
| 一貫性 | 2つの書き込み間でクラッシュしたらデータ不整合になるか? | トランザクションで囲む |
防御レイヤーの構築
最も効果的な防御は、AI生成コードを1行ずつレビューすることじゃない。これらのパターンを自動的にキャッチするインフラを構築すること。
1. 並行性の必須統合テスト
コードが動くかだけテストしない。同時負荷下でも動くかテストする。すべての書き込み操作にPromise.all()テストを追加して、レースコンディション下での正確性を検証する。
2. コネクションプールモニタリング
リアルタイムのプールメトリクス(アクティブコネクション、待機コネクション、アイドルコネクション)をオブザーバビリティスタックに追加。waiting > 5でアラート。コネクションリークを障害前にキャッチできる。
3. すべての境界でスキーマ検証
データが信頼境界を越えるすべてのポイントで検証ライブラリ(Zod、Valibot、ArkType)を使う。APIエンドポイント、キューコンシューマー、webhookハンドラ、サードパーティAPIレスポンスすべて。データの形が期待通りだと絶対に信頼しない。
4. Auth-by-Defaultアーキテクチャ
認証と認可をうっかりスキップできないコードベース構造を作る。サービスは生のユーザーIDではなく検証済みのAuthContextパラメータを受けるようにする。AuthContextがないサービスメソッドはコンパイルエラーにする。
5. 静的解析ルール
既知のアンチパターンをフラグするカスタムESLintまたはBiomeルールを作る:
finallyブロックにrelease()がないpool.connect()- ディレイなしの
fetch()リトライループ - クリーンアップなしの
addEventListener()/subscribe() - ユーザー特定に
req.queryやreq.paramsを使っているDBクエリ
結論
AIコーディングエージェントは規律とセットで使えば生産性のブースターです。規律なしで使えば負債のブースター。
このガイドの7パターンはエッジケースじゃない。AI生成コードの最も一般的で、最もダメージが大きく、最も予測可能な障害パターンです。すべてインフラで防げる。並行性テスト、コネクション監視、スキーマ検証、auth-by-defaultアーキテクチャ、静的解析で。
AIエージェントが最初のドラフトを書く。エンジニアリング規律が最終版を書く。正しく見えるコードこそが、最も危険なコードなんです。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう