
昔は家計簿アプリはZaimを使っていたが、最近はクレカを利用したときの速報がメールで届くので、自作した家計簿アプリにそのメールを読み込ませて自動登録させて自作の家計簿アプリを使っている。
そのデータをChatGPTやそのほかのAIから確認して分析とかをまとめてくれると便利だと思った。
そこでChatGPTのコーディングエージェントに、Laravel製の家計簿アプリへMCPサーバーを実装してもらうことにした。依頼した内容は次のようなものだった。
- ログインユーザーごとにトークンを発行する
- トークンにひもづくユーザーのデータだけを返す
- 家計簿ブログを書くために利用する
- 既存データを失わないように作業する
要求としては、特におかしなところはない。私自身が危険なSQLを実行したわけでもない。「本番DBを初期化してほしい」と頼んだわけでもない。それでも、家計簿データは消えた。
結論からいうと、作業を任せていたChatGPTが、テストのつもりで実データベースに対して migrate:fresh を実行したためである。
もちろん、業務で本番環境を使いながら直接バイブコーディングなんかはしないが、半分は自己満足で運用している家計簿アプリの改修だったため、つい油断した(言い訳である)
⚠ 注意事項
設定内容やプログラムの内容は、用途・環境に応じて適切なものが変わります。
各種設定値や環境情報についてよく理解を深め、壊れてもよい環境で十分に検証してください。
なるべく正確に書くよう心掛けていますが、本投稿内容を実施される際には自己責任の下でお願いいたします。
MCPサーバーの実装自体は順調だった
ChatGPTはLaravel公式MCP SDKとSanctumを導入し、次の機能を実装した。
- ユーザーごとのMCPトークン発行
- トークンのハッシュ保存
mcp:read権限による読み取り制限- 期間内の収支取得
- カテゴリ別の支出集計
- 収入・支出明細の取得
- 他ユーザーのデータを返さないテスト
MCPから利用できるツールも用意された。
get-household-summary
get-category-breakdown
list-household-transactions
検索処理は認証ユーザーを起点にしていた。
$request->user()
->expenses()
->whereBetween('date', [$start, $end])
->get();
ここまでは、依頼どおりだった。
問題はテスト工程で起きた。
ChatGPTが実行したコマンド
ChatGPTはテストのため、次のコマンドを実行した。
php artisan migrate:fresh --env=testing --force
意図としては、テスト用データベースを作り直すことだったらしい。
しかし実際には、テスト用SQLiteではなく、家計簿の実データが保存されたMySQLの kakeibo データベースへ接続していた。結果として、次のようなテーブルがまとめて削除された。
users
accounts
categories
expenses
incomes
transfers
receipts
events
account_histories
...
migrate:fresh は、既存テーブルをすべて削除してからマイグレーションをやり直すコマンドである。
さらに --force まで付いていたため、本番環境向けの確認も働かなかった。
ChatGPTは、
- 接続先DBを確認せず
- 破壊的コマンドを選び
- 安全装置を外す
--forceを付け - 実データベースへ実行した
という、なかなか完成度の高い事故を起こした。人間が一つずつ地雷を踏むところを、AIは自動化して一気に踏み抜いてくれた。
しかも「データを消すな」と伝えていた
今回重要なのは、AIが勝手に判断しなければならない曖昧な状況ではなかったことだ。
マイグレーションを依頼した際、私は明確にこう伝えていた。
くれぐれも既存データを消失しないようにしてください。
それに対してChatGPTは、
fresh、refresh、ロールバックは使用しません。
と回答していた。
だが、実際にはその少し前のテスト工程で、すでに migrate:fresh を実行していた。
つまり「使いません」と説明した時点で、もう使ったあとだった。このあたりは、笑い話として見るとなかなか味わい深い。火災報知器を設置しながら、
火は使っていません。安全です。
と報告しているが、後ろの建物はすでに燃えている。
最初の症状は「ログインできない」
マイグレーション後、家計簿アプリへログインできなくなった。登録済みのメールアドレスで認証を試しても、ログインできなかった。調査を依頼すると、現在のDBでは次の状態だった。
users_count: 0
user_found: false
パスワードが間違っているのではなかった。ユーザーそのものが消えていた。ChatGPTへログイン障害の調査を頼んだところ、最初は「現在接続しているDBが空」と判明した。
さらに調査を進めると、ChatGPT自身が実行した migrate:fresh が原因だと分かった。
ここでChatGPTは、ようやく次のように認めた。
原因は私の操作ミスです。
本当にそうである。
MySQLのバイナリログにはすべて残っていた
MySQLのバイナリログを確認すると、事故の瞬間が記録されていた。
2026-07-20 19:01:59
DROP TABLE
account_histories,
accounts,
categories,
expenses,
incomes,
transfers,
users,
...
犯行時刻は19時01分59秒。実行者は作業を任されていたChatGPT。
凶器は migrate:fresh。
安全装置を外したのは --force。
MySQLだけが冷静にすべてを記録していた。
復旧もChatGPTに任せることになった
事故を起こした本人、正確には本人ではなくAIだが、そのChatGPTへ復旧も依頼することになった。
幸い、次のものが残っていた。
- 2026年2月23日のSQLフルバックアップ
- 2026年6月10日以降のMySQLバイナリログ
- 事故直前までの変更履歴
- レシート画像などのファイル
まず、ChatGPTは別の復旧用DBを作成した。
kakeibo_recovery_20260720
そのうえで次の作業を行った。
- 現在のDBを退避
- 別DBへフルバックアップを復元
- 現在のスキーマまでマイグレーション
- バイナリログを事故直前まで再生
- ユーザーとデータ件数を確認
- 復旧DBへ切り替え
この方針自体は正しい。最初からこの慎重さでテストしてほしかった。
復旧でも一度失敗した
最初の復旧後、ChatGPTはこう報告した。
復旧操作が完了しました。
しかし確認すると、直近のデータが戻っていなかった。
原因は、バイナリログ再生時のデータベース名書き換えとフィルター指定の組み合わせだった。
ログには直近の取引が存在していたが、再生対象から除外されていた。つまり、
データを消したAIが、復旧時にもデータを取りこぼし、その状態で「復旧完了」と報告した。
なかなか厳しい。
バックアップが消えていなくて本当によかった。
条件を修正して再度ログを適用した結果、最終的には次のデータまで戻った。
- 支出:1,659件
- 収入:96件
- 振替:892件
- レシート:745件
- 最終更新:事故当日の2026年7月20日
ただし、すべてが戻ったわけではない。
復旧できない空白期間があった
フルバックアップは2026年2月23日。残っていた最古のバイナリログは6月10日。
MySQLのバイナリログ保存期間は30日だったため、それより古いログは削除されていた。そのため、次の期間に空白ができた。
2026年2月23日〜2026年6月10日
欠落が確認された範囲は次のとおり。
- 支出:ID 1375〜2743
- 収入:ID 257〜348付近
- 振替:ID 738以降の一部
- バックアップ後に作られたイベント情報
つまり、事故直前のデータと古いバックアップはあるが、その間を完全につなぐログがなかった。
タイムマシンはあった。ただし、途中の駅をいくつか通過するタイプだった(´・ω・`)ショボーン
それでも、完全復旧を諦めなかった
ここまでの復旧で、事故直前のデータと古いバックアップは確認できた。
しかし、その間には埋められない空白が残っていた。
一度は「すべてを戻すのは難しい」と考えたが、ほかに事故前のデータが残っていないかを探し続けた。
そこで見つけたのが、Windowsの「以前のバージョン」だった。この機能の実体として利用されていたVSS(Volume Shadow Copy Service)のスナップショットに、事故前のMySQLデータディレクトリが残っていた。
「以前のバージョン」に事故前のMySQLが残っていた

添付画像はイメージだが、WindowsでCドライブのプロパティを開き、「以前のバージョン」タブを確認したところ、事故前の復元ポイントが残っていた。
この機能の裏側で使われているのが、VSS(Volume Shadow Copy Service)である。
VSSは、ある時点のボリュームの状態をスナップショットとして保持するWindowsの機能だ。
今回は「以前のバージョン」から事故前のCドライブを開き、その中にあったMySQL 8.0のデータディレクトリを別ドライブへコピーした。
ここで重要なのは、元のMySQLデータディレクトリへ直接上書きしなかったことだ。コピー先は次の場所にした。
B:\MySQL Server 8.0
事故後の本番環境にVSSのファイルを直接戻していたら、復旧作業でもう一度事故を起こす可能性がある。
「復旧中に追い打ちで破壊する」という、ブログ的にはおいしいが現実では一切いらない展開を避けた。
ChatGPTにVSSの内容を調査してもらった
コピーしたデータをChatGPT(Codex)に調査させたところ、次のファイルがそろっていた。
- 各テーブルの
.ibdファイル ibdata1- undoログ
- redoログ
- MySQLのbinlog
kakeiboデータベースの物理データ一式
更新日時を確認すると、事故前のデータであることも分かった。
さらに、VSS内のbinlogには、事故原因となった DROP TABLEが含まれていなかった。つまり、データベースを壊す直前の物理状態が残っていたことになる。
VSSのMySQLを別ポートで起動
VSSから取り出したデータディレクトリは、作業用ディレクトリへもう一度コピーした。そして、現在稼働している本番MySQLには触れず、復旧専用のMySQLとして次の条件で起動した。
本番MySQL: 3306
復旧用MySQL: 3307
復旧用MySQLには、次の安全策を設けた。
- localhostだけで待ち受ける
- binlogを新たに出力しない
- 本番とは異なるポートを使う
- VSSからコピーしたデータディレクトリを使用する
- 独立したプロセスとして起動する
これで、VSSのデータを本番へ影響させずに確認できた。
消えていた途中のデータを発見
復旧用MySQLへ接続して件数を確認すると、欠けていたデータが残っていた。VSSスナップショット内の主な件数は次のとおりだった。
expenses: 3,010件
incomes: 208件
transfers: 1,767件
receipts: 1,384件
特に、以前の復旧では戻せなかった期間だけでも、expensesに約1,350件のデータが見つかった。
データは消滅したのではなく、現在のMySQLから見えなくなっていただけだった。
VSSに残っていた事故前の物理データが、その空白を埋めてくれた。
VSSだけでは直前の数時間が足りなかった

ただし、VSSのスナップショットは事故発生より少し前のものだった。
そのため、スナップショット作成後から事故直前までの更新は含まれていない。
そこで、VSSの物理データを復旧の基準点にし、現行環境に残っていたMySQLのbinlogから、その後の差分だけを適用した。
binlogを解析した結果、次の位置が判明した。
VSS側binlogの終端: 6,836,684
破壊処理の開始位置: 7,020,373
そして、次の範囲だけを復旧対象にした。
6,836,684以上
7,020,373未満
7,020,373からは、事故原因となったDROP TABLEトランザクションが始まっていた。
つまり、事故直前までの正常な更新だけを再生し、テーブル削除処理は再生しないようにした。
ここを間違えると、せっかく復旧したデータベースに対して、事故を忠実に再現することになる。
復旧作業なのに事故の再放送である。今回は日時だけに頼らず、binlogのイベント位置を確認して停止位置を決めた。
いきなり本番には戻さない
VSSのデータとbinlogの差分は、まず隔離した復旧用データベースへ適用した。復旧後の件数は次のとおりになった。
expenses: 3,013件
incomes: 208件
transfers: 1,767件
receipts: 1,386件
users: 1件
最新の家計簿データも、事故当日の18時35分26秒まで戻った。ログインユーザーのレコードも存在していた。
その後、MCPサーバー機能で必要になったPersonal Access Token用テーブルのマイグレーションを、復旧用データベースにだけ適用した。
さらに、全24テーブルへ整合性検査を実施し、すべてOKであることを確認した。
最後は旧データベースと復旧済みデータベースを安全に入れ替えた
検証が終わった後も、復旧データを本番へ直接上書きはしなかった。
まず、切り替え直前の本番DBをSQLファイルへバックアップした。
さらに、既存の本番テーブルを次の退避用データベースへ残した。
kakeibo_before_vss_20260720
そのうえで、MySQLの単一のRENAME TABLE文を使い、旧本番と復旧済みデータベースを入れ替えた。
この方法なら、切り替え途中に「半分のテーブルだけ新しく、残りは古い」という状態になるのを避けられる。切り替え後も、旧本番の全24テーブルは削除せず残した。
完全復旧後の状態
最終的な本番データは次の状態になった。
expenses: 3,013件
incomes: 208件
transfers: 1,767件
receipts: 1,386件
users: 1件
最新入力日時:
2026-07-20 18:35:26
Laravelの全マイグレーションも適用済みで、アプリケーションを通常稼働へ戻した。
途中のデータを含め、VSSとbinlogを組み合わせることで完全復旧できた。
今回の復旧で役に立ったもの
今回、データを救ってくれたのは次の組み合わせだった。
- WindowsのVSS
- 「以前のバージョン」機能
- MySQLの物理データファイル
- MySQLのbinlog
- 古いSQLバックアップ
- 事故後のDBを消さずに退避したこと
- 復旧用MySQLを別ポートで起動したこと
- 復旧データを別DBで検証したこと
- ChatGPTにbinlogの境界を解析させたこと
SQLバックアップだけでは空白期間を埋められなかった。binlogだけでも、必要な期間の先頭部分が残っていなかった。しかし、VSSを新しい復旧起点として使い、その後の差分をbinlogで補うことで、両者の弱点を埋められた。
ChatGPTに壊され、ChatGPTと一緒に直した
今回の事故は、ChatGPT(Codex)にマイグレーションを依頼して進めていた際に発生した。
そして復旧も、同じChatGPTと一緒に進めた。ChatGPTに壊され、ChatGPTに謝られ、ChatGPTとbinlogを読み、最後はChatGPTに復旧してもらった。
何か壮大なAI活用事例のように見えるが、実態はAIと人間が二人三脚で穴に落ち、VSSというロープを見つけて這い上がった話である。
ただし、最終判断と安全確認は人間側にも必要だ。AIがコマンドを提案・実行できても、対象が本番なのか、バックアップがあるのか、破壊的操作なのかを確認する仕組みがなければ、同じ事故は起こり得る。
「AIに任せたから安心」ではない。むしろ、AIはとても速く操作できるため、間違えたときも非常に(悪い意味で)速い。
今後の再発防止策
今回の経験を踏まえ、最低限次の対策を行う。
- 本番DBとテストDBで、接続先と認証情報を完全に分離する
- テスト用DBユーザーには本番DBへの権限を与えない
migrate:freshを本番接続では実行できないようにする- 破壊的コマンドの前に接続先を表示して確認する
- マイグレーション前に自動バックアップを取得する
- SQLバックアップを世代管理する
- binlogを十分な期間保存する
- VSSを補助的な復旧手段として維持する
- 定期的にリストア訓練を行う
- バックアップの存在ではなく、復元できることを確認する
- AIへ本番操作を任せる際の承認ルールを設ける
特に重要なのは、バックアップ方法を一つに依存しないことだ。
今回はSQLバックアップ、binlog、VSSの三つがあり、それぞれの不足部分を組み合わせて復旧できた。
まとめ
データベースを消したとき、本当に大切なのは慌てて上書きしないことだった。
事故後のデータも、古いバックアップも、binlogも、VSSも、すべて残しておく。
どれが最後のピースになるかは、調べるまで分からない。
今回は、Windowsの「以前のバージョン」に残っていたVSSスナップショットが、欠けていた期間を丸ごと救ってくれた。
そして最終的に、VSSの物理データを起点に、事故直前までのbinlogを適用することで、家計簿を完全復旧できた。
AIに任せてデータベースを壊し、AIと一緒に復旧する。二度と経験したくはないが、ブログのネタとしては満額回答だった。