マイグレーション履歴のないDBの怖さ
事業の引き渡しで、コードもドメインも権限も一通り渡し終えて、あとは買い手が動かすだけ、という段階まで来てから止まるケースがあります。リポジトリは全部揃っている。手順書もある。買い手のエンジニアがそのとおりに新しい環境を立ち上げて、データベースを作って、アプリケーションを起動する。起動は、します。それなのに、本番と同じ画面が出てこない。
原因を追っていくと、たいてい同じところに行き着きます。本番のデータベースにあるテーブルと、手元のコードから作られたテーブルが、違う。カラムが1本足りない。型が違う。あるはずのインデックスがない。コードは正しく、手順書も正しい。どちらも嘘をついていないのに、出来上がるものが本番と一致しない。
この「一致」を保証するはずだった仕組みが、マイグレーション履歴です。そして事業の売買では、これが無い、あるいは本番と食い違っているという状態が、驚くほど頻繁に見つかります。この記事では、なぜそれが起きるのか、買い手にとって何を意味するのか、そして数分で確かめる方法を、順番に整理していきます。
マイグレーション履歴は、DBの設計図の版管理です

まず、言葉の整理から始めます。
アプリケーションのコードには、Gitがあります。いつ誰が何を変えたかが全部残っていて、任意の時点の状態を再現できる。あまりに当たり前で、ありがたみを意識することもありません。
データベースには、それがありません。
データベースが持っているのは、「今」の状態だけです。テーブルがあり、カラムがあり、インデックスがある。でも、そのカラムがいつ足されたのか、なぜ足されたのか、以前は何という名前だったのかは、どこにも書かれていません。データベースに履歴を尋ねても、何も答えてくれない。今の姿を黙って見せてくれるだけです。
この欠落を埋めるために生まれたのが、マイグレーションという仕組みです。「このテーブルにこのカラムを足す」「ここにインデックスを張る」という変更を、1つずつファイルに書いて、コードと一緒にリポジトリへ置く。ファイルには順番があり、上から順に当てていくと、空っぽのデータベースが今の本番と同じ形になる。RailsでもLaravelでもDjangoでも、FlywayでもLiquibaseでも、Drupalの更新フックでも、呼び名も書き方も違いますが、やっていることは全部同じです。
つまりマイグレーション履歴は、データベースの設計図に対する版管理です。そして、これがあることの本当の価値は、履歴が読めることではありません。空のデータベースに全部当てれば、本番と同じ形が出てくるという、再現性のほうです。
この再現性が保たれている限り、データベースは「コードから作れるもの」です。開発環境を増やすのも、検証環境を立てるのも、事故で吹き飛んだ構造を作り直すのも、コードから出発できる。逆に、再現性が失われた瞬間、データベースは「コードから作れないもの」に変わります。この世に1つしかない、本番のあれだけが正解。あとは全部、なんとなく似ている別物。
本番にだけ残る「あの夜のALTER」

では、その再現性はどうやって失われるのか。悪意でも怠慢でもありません。事業を止めないという、その瞬間には完全に正しい判断の積み重ねで失われます。
典型的なのは、こういう夜です。深夜、特定のページだけが極端に遅くなる。原因を追うと、数年かけて肥大したテーブルを毎回まるごと走査していた。インデックスを1本張れば直る。でも、マイグレーションファイルを書いて、レビューを通して、デプロイの手順を踏んで、という段取りを踏んでいたら朝までに間に合わない。だから本番に直接つないで、ALTER文を1行打つ。直る。事業は止まらずに朝を迎える。
ここまでは、何ひとつ間違っていません。むしろ、この判断を即座にできる人がいたから、その事業は生き延びています。
問題は、その後です。朝が来て、障害対応の疲れが残ったまま次のタスクが積まれ、「あのインデックス、マイグレーションに書き戻さないと」というメモが、誰の手元にも残らない。そして忘れられます。1回なら、何も起きません。半年後にもう一度あり、翌年に2回あり、その次の年にもある。5年動いた事業には5年分の夜があります。
入り口は、緊急対応だけではありません。
- 管理ツールから直接いじる。テーブルを眺めるために開いたGUIから、そのままカラムを1本足せてしまう。手が早い人ほど、この経路を通ります
- マイグレーションが本番で途中まで失敗する。前半だけ適用され、残りを手で当てて辻褄を合わせる。これが最も厄介で、履歴テーブルには「適用済み」と記録されるため、ズレているのに、ズレていないことになります
- 検証用に足した列が、そのまま住み着く。一時的に本番へ足して、消し忘れる
- 外部ツールが勝手にテーブルを作る。導入した監視や連携ツールが、自前のテーブルを本番に置いていく
共通しているのは、どれも記録に残らないことです。ALTER文には作者名も日付も理由も入りません。データベースは言われたとおりに構造を変え、変えたという事実を、どこにも書き残さない。翌日にはもう、それが「昔からあったカラム」と見分けがつかなくなります。
そして、長く続いた事業ほどこの痕跡が多いのは、当たり前のことです。10年動いた事業には10年分の「あの夜」があり、その一つひとつに、そうしなければ事業が止まっていた理由がありました。痕跡の多さは、その事業が何度も危機を乗り越えてきたことの記録でもあります。それを雑だと切り捨てるのは、事業に対しても、その夜に叩き起こされた人に対しても、筋が違う。
ただ、乗り越えてきた事実と、設計図が現物と一致している事実は、別の話です。売買の場で問われるのは、後者だけです。
買い手が環境を立て直すと、本番と違うテーブルができる

ズレたまま引き渡された事業に、何が起きるのか。順番に追ってみます。
買い手のエンジニアは、まず開発環境を作ります。リポジトリをクローンして、空のデータベースを用意して、マイグレーションを全部当てる。これで本番と同じ形のテーブルができる、はずでした。実際にできるのは、マイグレーションファイルに書かれた分だけのテーブルです。あの夜のインデックスはファイルに書かれていないので、当然、できません。
ここから先、症状の出方は3つに分かれます。厄介な順に並べると、こうなります。
1. 動く。ただし、遅い
インデックスの欠落は、機能テストでは絶対に出ません。開発環境のデータは数十件しかなく、テーブルをまるごと走査しても一瞬で終わるからです。全部の画面が正常に表示され、全部のテストが通り、誰も異常に気づかない。
この差分が牙を剥くのは、本番規模のデータが入ったときだけです。つまりこの問題は、買い手が本番を引き継いだその日まで、発見されないように出来ています。引き継ぎ初日に「なぜか本番だけ重い」と言われた買い手は、自分の設定ミスを疑って半日を溶かします。まさか、渡されたコードから作った構造が本番と別物だとは思わないので。
2. 滅多に通らない経路でだけ落ちる
カラムの欠落は、アプリが常時そのカラムを読んでいるなら、起動した瞬間に落ちてくれます。これは幸運なパターンです。すぐ気づくので。
本当に厄介なのは、日常の動作確認では一度も通らない経路でだけ使われるカラムです。月末に走る集計、年に一度の請求処理、退会フロー、解約時の返金計算。買い手が引き継ぎ直後の数週間で触る画面には、まず出てきません。そして月が変わった夜中に、静かに落ちます。月次バッチの停止が引き渡しの数週間後に発覚するケースの何割かは、この形をしています。
3. データが静かに欠ける
最悪なのがこれです。型や桁やデフォルト値の食い違い。
あの夜に急いで足したカラムが、長めの文字列を入れられる定義だったとします。後日、別の誰かが「そういえばあのカラム、書き戻してなかったな」と気づいて、マイグレーションファイルを追記する。記憶を頼りに書くので、桁数が本番と違う。開発環境では長い値を誰も入れないから、誰も気づかない。本番のデータには、その桁を超える値が実在する。移行した瞬間に、切り捨てられるか、弾かれます。
弾かれるほうが、まだましです。切り捨てられた場合、処理は成功として通り、データだけが静かに欠ける。しかも、欠けたことに気づくのは、たいてい顧客からの問い合わせです。
3つに共通するのは、差分が「静か」だということです。派手に壊れてくれるなら、引き渡し当日に見つかって、その場で交渉できます。現実の差分は、買い手が本番を持ち、売り手との連絡が細くなった頃に、一つずつ順番に出てきます。
どのカラムが生きているか、誰も知らない

ここまでが「再現できない」話です。実は、査定にもっと重く効くのは、この先にあります。
マイグレーション履歴が失われた事業では、そのカラムがなぜあるのかを、誰も説明できません。
ユーザーのテーブルを開くと、40本のカラムが並んでいる。明らかに現役なのは15本ほど。残りは、名前から用途が推測できるものもあれば、まったく分からないものもある。似た名前が3つ並んでいることもあります。3年前のキャンペーンのために足されたフラグ。今は畳んだ外部サービスとの連携用ID。途中で方針が変わって、片方だけ使われなくなった2本組。
ここで運営者が取る行動は、いつも同じです。消しません。当然です。消していい確証がない以上、消す行為はリスクしか生まない。残しておけば、少なくとも何も壊れない。日々の意思決定として、これも完全に正しい。
そうやって「消していいか分からないから残す」が積み重なった結果、スキーマは地層になります。上の層は今の事業、下の層は3年前の事業、その間に何度かの方針転換の跡がある。考古学者なら喜ぶ構造ですが、これから機能を足す人にとっては地獄です(発掘しながら建築することになるので)。
買い手にとって、これが何を意味するか。見積もりが立ちません。「このカラムを消したら何が壊れますか」に、誰も答えられないからです。答えられないので、買い手は消しません。触りません。結果、買収の動機だったはずの機能追加を、正体不明のカラムに囲まれた狭い場所で、恐る恐るやることになります。買収後に自由に手を入れられる前提で価格を出したのに、実際には手を入れられない。この落差が、そのまま失望と減額の理由になります。
そして、この状態はマイグレーション履歴があれば、かなりの部分が防げていたものです。「この日にこのカラムを足した」というファイルが1本残っていれば、その前後のコミットを見れば、何のために足されたかがだいたい分かる。コミット履歴の読み方を心得た買い手が最初にやるのは、まさにこの照合作業です。マイグレーションファイルは、スキーマに対する唯一のコメント欄として機能します。無いということは、コメントが1行も無いコードを渡されるのと同じです。
確認は、空のDBに全部当ててdiffを取るだけ

ここまで重い話を続けてきましたが、確認そのものは拍子抜けするほど簡単です。慣れていれば数分で終わります。
手順
- 空のデータベースを1つ用意する
- リポジトリのマイグレーションを、最初から最後まで全部当てる
- 出来上がったスキーマの定義だけをダンプする(データは1件も要りません。MySQLなら
mysqldump --no-data、PostgreSQLならpg_dump --schema-only) - 本番から、同じやり方で定義だけのダンプを取る
- 2つのファイルを diff する
これだけです。専用のツールも、特別な知識も要りません。
デューデリジェンスの実務としてこの検査が優れているのは、データを一切必要としない点です。動くのはテーブルの定義だけで、顧客の個人情報は1件も出てきません。だから、まだ信頼関係が薄い交渉の初期でも、売り手が渋る理由がほとんどない。「スキーマだけのダンプをください」は、買い手からの依頼の中でいちばん通りやすい部類です。そして、通りやすいわりに、返ってくる情報の密度が異常に高い。
実務上の注意を1つだけ。ダンプにはノイズが混ざります。テーブルの出力順、連番の現在値、生成日時のコメント。ここに反応すると差分が膨れて読めなくなるので、見るべき対象を先に決めておきます。テーブルの有無、カラムの有無と型と桁、NULLを許すかどうか、デフォルト値、インデックスと制約の有無。この6点だけです。
差分の読み方
出てきた差分は、おおむね3段階で読めます。
- 差分ゼロ。設計図と現物が一致しています。これは技術力の証明ではなく、規律の証明です。誰かが何年ものあいだ、面倒な夜にも書き戻す作業をサボらなかったということ。この事業は、環境をいくつでも作れます
- 差分が数行から数十行。あの夜のインデックスが数本、といった状態です。是正できます。差分をマイグレーションとして書き起こして、履歴に追記して終わり。1日仕事です。むしろ、この diff の結果そのものが、そのまま作業指示書になります
- 差分が数百行、あるいはマイグレーションが存在しない。この場合、設計図は事実上ありません。本番だけが唯一の正本です
3つ目の状態が本当に意味しているのは、スキーマの話にとどまりません。その事業の存続が、本番のデータベース1台の無事に懸かっているということです。壊れても、コードから作り直せない。作り直せるのは、バックアップから戻す場合だけ。だからこの状態の事業では、バックアップが本当に戻せるかどうかの重要度が、他の事業とは比べものにならないほど跳ね上がります。設計図のない建物では、写真だけが復元の手がかりになる。その写真が現像できるかどうかを一度も試していない、という状態だけは避けたい。
売り手は、歴史を復元しなくていい

ここまで読んで「うちのデータベースにはマイグレーションが無い」と青くなった方に、先に結論をお伝えします。過去の歴史を再現する必要は、まったくありません。
10年分のALTERを発掘して、当時の順番どおりにファイルへ書き起こす。想像しただけで気が遠くなりますし、やる意味もありません。買い手が知りたいのは過去ではなく、今の本番と一致する設計図が存在するかどうか、それだけです。
やることは3つです。
1. 今の本番を「初期状態」として1本に畳む
本番のスキーマ定義をダンプして、それをリポジトリに置き、「ここが出発点」と宣言します。Flywayならベースライン、Railsならスキーマファイルの再生成、Laravelならスキーマのダンプ機能。名前は違いますが、どれもここまでの歴史を1本に畳んで、今の形を初期状態にするための機能です。歴史はここで切り捨てていい。というより、切り捨てるための機能がわざわざ用意されている時点で、それが正しい運用だということです。
2. その日以降だけ、ズレを止める
ここが肝心なところで、「本番に直接ALTERを打つな」という禁止にしないことをお勧めします。深夜に事業が止まりかけている場面で、手順を守れというのは現実的ではありませんし、その判断が事業を救ってきたことは、この記事の前半で書いたとおりです。
禁止ではなく、打ってもいいから、その日のうちに書き戻す。障害対応の完了条件に「本番に当てた変更をマイグレーションへ反映する」を1行足すだけです。順番が逆になるのは構いません。緊急時は本番が先、ファイルが後。それでもズレは残らない。
この運用に変えるだけで、ドリフトの発生はほとんど止まります。過去の分は畳んだので、あとは今日から積み上がる分を取りこぼさなければいい。
3. カラムの生死を、分かる範囲で表にする
完璧を目指さないでください。まず、コードを全文検索して、どこからも参照されていないカラムを洗い出します。アプリのコードに一度も名前が出てこないカラムは、少なくとも現役ではない候補です(バッチや手書きのSQLから触っている可能性があるので、断定はできませんが、当たりはつきます)。
そして表を作ります。分かるものには「使用中」、分からないものには「不明」と正直に書く。ここが逆説的なところで、買い手が恐れているのは、不明なカラムがあることではありません。不明であることを、売り手も知らないことです。
「40本のうち25本は用途不明です」と最初に申告された買い手は、それを前提に見積もれます。何も言われないまま買った買い手は、同じ25本を、自分で1本ずつ発掘することになる。カラムの数は同じでも、価格への効き方はまるで違います。前者は費用で、後者は不信です。
設計図と現物が一致していること自体が、資産です

データベースのスキーマは、事業の中でいちばん地味な部分です。画面にも出てきません。売上の資料にも載りません。案件の概要書に「マイグレーション履歴あり」と書かれているのを見たことがある人も、たぶんいないと思います。それでも、その事業を別の場所でもう一度立ち上げられるかどうかは、ここだけで決まります。
技術的DDで確かめるべきなのは、コードの綺麗さではありません。「この事業を、今と同じ形でもう一度作れるか」という問いに、材料が揃っているかどうかです。マイグレーション履歴の diff は、その問いに数分で答えが出てしまう、数少ない検査の一つです。しかも、データを一切持ち出さずに。
そして、この構造はスキーマだけの話ではありません。本番にしか書かれていない状態は、事業のあちこちに潜んでいます。Webhookの宛先が外部サービスの管理画面の中にだけ存在していて、リポジトリのどこを探しても出てこない、というのはその典型です。リポジトリを全部読んでも見つからない設定が、本番の中で静かに動き続けている。スキーマのドリフトは、その一族の中でいちばん検査しやすく、いちばん影響が大きい一人にすぎません。
長く動いてきた事業のデータベースには、必ず歴史があります。急いで足したカラムも、名前が妙な列も、もう誰も見ていないテーブルも、その一つひとつに、その日そうしなければならなかった理由がありました。それは、事業が生き延びてきたということです。誇っていいことだと思います。
ただ、その歴史は今のところ、本番のデータベースの中にしか書かれていません。設計図の側に書き戻す作業だけが、まだ終わっていない。それは、事業を否定する作業ではなく、事業が積み上げてきたものを、他人にも読める形に翻訳する作業です。
売却を考え始めた日に、空のデータベースを1つ作って、マイグレーションを全部当てて、本番のスキーマと diff を取ってみてください。差分が1行も出なければ、その事業は、いつでも、どこでも、誰の手でも、もう一度立ち上げられます。月商の何ヶ月分という数字には、それは表れません。でも、買い手が本当に確かめたかったことは、最初からそれだけです。