Gormが本番テーブルの数億件のデータを消そうとした話
GoでDBのライブラリを探してGormというライブラリを知った人は多いと思うkeroxp.icon2020/5/4 そして今でも使っている人がいると思う
今回はそんな方々に送る実際にあったとても怖い話である…
hr.icon
有益な情報(追記2020/5/7)
songmuさんがが本件に言及してくれました
MySQLの場合、--safe-updatesオプションを利用することでこういった不慮のUPDATE/DELETEを防げるようです
僕は当然知りませんでした😅
hr.icon
まず「Go ORM」で検索して出てきたのがGormというライブラリだった
使いだして色々不満はあったのだが、可もなく不可もなくという感じだったが、しばらくしてとんでもない事件が起こった
とあるサービスのコードにこういうコードを書いてデプロイした
code:go
var metadata []*model.Metadata
// ...中略
err := db.Delete(metadata).Err
このコードは、Metadataというモデルから配列の中にあるエンティティだけを削除するというコードだった
しかしこのコードはちょっとしたミスが入ってしまった
本来はこういうコードを書くべきだった
code:go
// スライスの要素を1件ずつ削除する
for _, m := range metadata {
err := db.Delete(m).Err
}
しかしGormはリフレクションを使っているので上のコードでもコンパイルエラーは出ない
加えて言うと、上記のコードはGorm的には間違ったコードではなく、実行時のエラーも出ない
そしてコードの都合上metadataのスライスは空になる可能性のあるコードであり、空のスライスを渡してしまった
さてこのコードがどんなことを引き起こしたかというと…
code:SQL
DELETE FROM Metadata
( д) ゚ ゚
なんとテーブルのデータをすべて吹き飛ばそうとしたのである
あたりまえだが動いているサービスのDBに対してこんなクエリを発行するわけがない
僕が書いたコードは、空のモデルのスライスを.Delete()に渡してしまっただけである
どんな考え方をしたとしても、空の配列を受け取ったメソッドがテーブル全部を消す動作をすると思うわけがない
原因
本当にバカバカしいのだけど、第N、第N+1の被害者(僕は何人目だったのだろうか…)を出さないために理由を書いておく
結論から言うと、Gormの.Delete()は主キーがデフォルト値(intなら0)に設定されている構造体か、その構造体の空のスライスを渡すとテーブル全部を吹き飛ばします
以下のコードはだいたい全部同じ意味になる
code:go
db.Delete(&model.Model{})
db.Delete(model.Model{})
modelPtr := new(model.Model)
db.Delete(modelPtr)
var models []*Model
db.Delete(models)
db.Delete(&models)
いや、ありえないでしょ
Goの構造体は宣言した時点では初期化されて値はデフォルト値のままになっているわけだが、主キーが0の構造体や空のスライスを受け取ったらテーブル全体へのSQLを発行する意味が分からない
こんな操作をしようとすること通常はありえないし、こんなよくある間違いでテーブル全削除しようとする挙動を実装するのはどういうことなんですかね…
ちなみに同じような被害にあった人はたくさんいるようだがマトモに対応される気配がない
Warning レコードを削除する際、主キーが値を持っているかを確認してください。GORMはレコードを削除する際に主キーを使うので、主キーが空の場合、GORMはそのモデルの全レコードを削除してしまいます。
ちなみにこんなことは書かれているがWarningのレベルの話じゃないし、空のスライスを渡した際の挙動は書かれていない
だって空だぞ? 空の配列渡したら全部削除になる意味がわからんだろ!せめてエラーにするべき
後日談
結局その全件削除のクエリはDBロックタイムアウト設定によってテーブルロックに失敗し、データが消えることはなかった
色々な奇跡的条件が重なり、その問題は直ちに発覚してデプロイはロールバックされた
以下実際にあった奇跡
その前日にたまたまDBのロック待ちタイムアウトの設定を短くしていた
そのコードが実行された際にたまたまリアルタイムでDBに接続してスロークエリの情報を監視していた
たまたま削除しようとしたテーブルがとある物のメタデータ(そこまで重要なデータではない)だった
たまたま削除しようとした間も絶えずデータが書き込まれ続けていた(ことでテーブルロックを取得できなかった)
しかしGormが削除しようとしたのは本番DBの数億件にものぼるデータであり、これが実際に起ってしまったら一日単位でバックアップからDBをロールバックする必要があった
もしこれがユーザデータだったらと思うとゾッとして夜も眠れません
批判
開発環境で気付けなかったのか?
はい
テストで気付けなかったのか?
はい
コードレビューで気付けなかったのか?
はい
ライブラリの挙動をよくよく調べなかったのか?
部分的には「はい」
しかしドキュメントのどこにも空のスライスを渡したらテーブルが消えるとは書いていなかった
教訓
どんなSQLのクエリが生成されるのか分からないORMやDBライブラリは使うべきではない
特にGoのリフレクションを使ったコードを使う場合は要注意
弊社ではこの問題が起きたその日の時点ですべてのGormを使っているコードベースを凍結し、Gormの削除を決定した