本番環境を壊さないデータベースマイグレーションスクリプトの書き方

新しい機能の準備が整いました。コードはレビューされ、テストされ、マージされました。しかし、デプロイの前に立ちはだかるものが一つあります。それはデータベースの変更です。カラムの追加、テーブルのリネーム、新しいインデックスの導入など、変更内容は様々です。問題は、その変更があなたのローカルマシンで動くかどうかではありません。問題は、本番環境で何も壊さずに動作するかどうかです。

ここでマイグレーションスクリプトの出番です。これらは単なるSQLファイルではありません。推測や手作業、そしてデプロイが失敗したときのあの嫌な感覚なしに、データベーススキーマを進化させるための規律ある方法です。

基本パターン:1ファイル、1変更

核となる考え方はシンプルです。すべてのデータベース変更は、それぞれ独立したファイルに格納します。ファイルには、通常はタイムスタンプやシーケンス番号の一意な識別子を付け、変更を適用するために必要なSQLを記述します。また、変更を元に戻すためのロールバックファイルも作成します。

usersテーブルにphoneカラムを追加する必要があるとします。本番環境にログインして直接ALTER TABLEを実行する代わりに、20241101_add_phone_to_users.sqlという名前のファイルを作成します。中身は次のように記述します。

ALTER TABLE users ADD COLUMN phone VARCHAR(20);

次に、20241101_add_phone_to_users_rollback.sqlを作成します。

ALTER TABLE users DROP COLUMN phone;

両方のファイルをアプリケーションコードと一緒にリポジトリに格納します。これらはコードレビューを受け、他の変更と同様にマージされます。

なぜファイルを分けるのでしょうか? すべての変更にはリスクが伴うからです。変更を個別のファイルに分割することで、一つの変更を適用し、その影響を確認してから次の変更に進むことができます。すべてが一つの巨大なスクリプトにまとめられていると、どの部分が障害を引き起こしたのか特定できません。さらに悪いことに、マイグレーションが途中で失敗した場合、どこで止まったのかさえ分かりません。

順序は想像以上に重要

タイムスタンプやシーケンス番号は、単なる命名規則ではありません。実行順序を定義するものです。マイグレーションパイプラインはすべてのファイルを読み込み、この識別子でソートし、古いものから新しいものへと実行します。これにより、開発環境、ステージング環境、本番環境のすべてで、同じ順序で変更が適用されることが保証されます。

「自分のマシンでは動くのにサーバーでは動かない」という、マイグレーションの順序が異なるために発生する問題はもうありません。ある環境でステップがスキップされるという、黙ったままの不整合もなくなります。

スクリプトを冪等にする

冪等(べきとう)とは、簡単に言えば「同じスクリプトを2回実行しても同じ結果が得られ、エラーが発生しない」という性質のことです。

次の2つのステートメントを比較してみてください。

-- 冪等ではない:カラムが既に存在するとエラーになる
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- 冪等:複数回実行しても安全
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(20);

2番目のバージョンの方が安全です。パイプラインは、例えば新しいステージングデータベースを立ち上げる時など、最初からマイグレーションを再実行する必要がある場合があります。スクリプトが冪等でないと、その単純な操作がデバッグ作業に変わってしまいます。

冪等性は開発中にも役立ちます。開発者は頻繁にマイグレーションを適用し、結果を確認し、最初からやり直したいと考えます。冪等なスクリプトがあれば、データベース全体を削除して再作成することなく、再実行できます。

ロールバックは必須

すべての前方マイグレーションには、対応するロールバックが必要です。これは本番環境の緊急時だけの話ではありません。開発者はローカル開発中に変更をテストして繰り返すために、ロールバックを常に使用します。ロールバックスクリプトがないと、データベース全体を削除してすべてのマイグレーションを最初から実行し直す必要があり、時間がかかりイライラするものです。

しかし、正直なところ、ロールバックは常にデータを復元できるとは限りません。マイグレーションがカラムを削除した場合、ロールバックでそのカラムを再作成することはできても、データは失われています。マイグレーションがテーブルをリネームした場合、ロールバックで元の名前に戻すことはできても、その間に行われた書き込みは失われます。

これはロールバックが無意味だという意味ではありません。ロールバックが実際に何を復元するのかを理解する必要があるということです。スキーマ構造のみを復元し、データは復元しない場合もあります。それでも価値はあります。既知の状態に戻すことで、復旧や変更の再適用が可能になります。

破壊的な変更(カラムの削除、テーブルの削除、データ型の変更など)には、別の戦略が必要です。これについては後で説明します。今のところ、ルールはシンプルです。たとえ構造のみを復元する場合でも、すべてのマイグレーションにロールバックを用意することです。

並行作業を安全に行う方法

複数の開発者が異なる機能に取り組んでいる場合、データベースの変更が必要になることがよくあります。ある開発者は機能Aのためにカラムを追加し、別の開発者は機能Bのためにテーブルを作成します。両者は異なるタイムスタンプを持つマイグレーションファイルを作成します。両方のブランチがマージされると、パイプラインはファイルをタイムスタンプでソートし、順番に適用します。

競合が発生するのは、2つの変更が同じスキーマオブジェクトに影響を与える場合のみです。これはコードの競合と同様、人間による解決が必要な真の競合です。マイグレーションファイルのパターンはこれを排除するものではありませんが、競合を可視化し、明確にします。

追跡テーブル

パイプラインは、どのマイグレーションが既に実行されたかをどのようにして知るのでしょうか? データベース自体の中に特別なテーブルを使用します。このテーブルには、適用されたすべてのマイグレーションと、タイムスタンプまたはチェックサムが記録されます。パイプラインの実行時、このテーブルを確認し、マイグレーションファイルのリストと比較して、不足しているものだけを適用します。

このメカニズムはほとんどのマイグレーションツールに組み込まれていますが、理解しておくと問題が発生した時のデバッグに役立ちます。マイグレーションが部分的に適用された場合や、誰かがパイプラインの外で手動でスクリプトを実行した場合でも、追跡テーブルがその情報を教えてくれます。

マイグレーションスクリプト作成の実践的チェックリスト

マイグレーションファイルをマージする前に、この簡単なリストを確認してください。

  • スクリプトに一意な識別子(タイムスタンプまたはシーケンス)がありますか?
  • 対応するロールバックスクリプトがありますか?
  • スクリプトは冪等ですか? 2回実行してもエラーになりませんか?
  • ロールバックは実際に以前の状態を復元しますか?
  • 前方マイグレーションとロールバックの両方を、本番データのコピーでテストしましたか?
  • マイグレーションはテーブルをロックしますか? ロックする場合、トラフィックが少ない時間帯に実行できますか?

まとめ

データベースマイグレーションは単なるSQLではありません。チームと本番データとの間の契約です。すべてのマイグレーションファイルは、何を、どの順序で変更し、問題が発生した場合にどう元に戻すかという決定を表しています。各ファイルをアプリケーションコードと同じ注意深さで扱ってください。レビューし、テストし、ロールバックを用意してください。本番データベースが感謝することでしょう。