- ダウンロードおよび更新が可能なアプリケーションのDB Migration管理 -
1. はじめに
アプリケーションを開発していると、機能の変更に伴ってデータベーススキーマも継続的に変更されます。新しいテーブルを追加したり、既存のカラムの型を変更したりすることもあれば、データ構造そのものを変更し、既存のデータを新しい構造へ移行しなければならない場合もあります。
一般的なWebサービスであれば、このようなデータベースの変更をデプロイパイプラインの一部として管理できます。開発チームがサーバーとデータベースの環境をすべて管理しているのであれば、アプリケーションのデプロイと同時に必要なSQLを実行する方法も利用できます。
しかし、私が携わっているVizend Platformには、少し異なる要件があります。
VizendのApplicationは、1つの中央サーバーだけで運用される形態ではなく、ユーザーが自分の環境にダウンロードしてインストールできます。インストール後は、新しいバージョンのApplicationを再度ダウンロードしたり、更新したりすることもできます。
つまり、Applicationはダウンロード可能であると同時に、更新可能です。
この構造では、Applicationのコードだけを更新しても十分ではありません。新しいバージョンのコードが新しいDatabase Schemaを必要とする場合、Applicationの更新過程でDatabase Schemaも同時に変更されなければなりません。
例えば、7.1バージョンのApplicationが次のようなテーブルを使用しているとします。
user
├─ id
└─ name
7.2バージョンで新機能を追加し、statusカラムを使用するように変更した場合、次のようなSchemaが必要になります。
user
├─ id
├─ name
└─ status
Applicationだけが7.2バージョンに更新され、Databaseが7.1の状態のまま残っていると、Applicationは起動できたとしても正常に動作しません。
結局、私たちにとってApplication Updateは、次の2つを同時に意味していました。
Applicationの更新
│
├─ Application Versionの更新
│
└─ Database Schemaの更新
さらに重要なのは、ユーザーがインストールしたすべての環境のDatabaseに、開発者が直接アクセスしてSQLを実行することはできないという点です。
したがって、Application自体が更新できるのであれば、そのApplicationが必要とするDatabase SchemaもRuntimeで自動的に更新できなければなりませんでした。
この問題を解決するためにFlywayを導入し、Database MigrationをApplication Runtimeに組み込みました。
この記事では、Flyway自体の使用方法よりも、実際のプロジェクトにFlywayを適用する中で経験した問題と、その過程で整理したMigrationの原則を中心に説明します。
2. Flywayの導入
Flywayを初めて適用したときに期待していた役割は、比較的単純なものでした。
各Databaseの変更にVersionを付与したMigration Scriptを作成し、Applicationの起動時に、まだ実行されていないMigrationを自動的に適用することです。
例えば、次のようにMigration Scriptを作成します。
V7_1_1__Create_User.sql
V7_1_2__Modify_User.sql
V7_1_3__Create_Position.sql
FlywayはDatabaseのflyway_schema_historyを確認し、すでに実行されたMigrationと、まだ実行されていないMigrationを区別します。
そのため、新しいバージョンのApplicationが起動すると、必要なSchemaの変更も同時に実行できます。
私たちはSpring Boot ApplicationでFlywayを有効化し、HibernateはSchemaを直接変更せず、検証のみを実行するように構成しました。
spring:
flyway:
enabled: true
jpa:
hibernate:
ddl-auto: validate
こうすることで、Database Schemaの変更はFlywayが担当し、HibernateはEntityと実際のDatabase Schemaの整合性を確認することになります。
当初は、これだけでDB Migrationの問題の大部分を解決できると考えていました。
しかし、実際にサービスを開発し、何度もVersion Updateを行う中で、単に「SQLを順番に自動実行する」だけでは、安全なMigrationを実現するのは難しいということを経験しました。
その過程で、現在はMigrationを作成する際に、次の3点を最も重要な確認事項としています。
同じMigrationが再度実行されても安全か?
問題が発生した場合、以前の状態に戻せるか?
以前の状態に戻るために必要なデータが保持されているか?
それぞれ 冪等性、Rollback、Backupに関する問題です。
3. Migrationの第一原則:冪等性
FlywayのVersioned Migrationは、正常に実行が完了すると、同じMigrationを再度実行しません。
それでは、Migration SQLをわざわざ冪等に記述する必要があるのか、という疑問が生じるかもしれません。
実際の運用では、Flywayが常に完全な状態でのみ実行されると仮定することは困難です。
Migrationの過程で一部のSQLが実行された後にエラーが発生することもあれば、障害を復旧する過程で特定のSQLを手動で再実行しなければならないこともあります。また、Downloadable Applicationの特性上、異なるインストール環境のDatabaseが想定と少し異なる状態になっている可能性も考慮する必要がありました。
このような状況で同じSQLを再実行した際に別のエラーが発生すると、復旧作業はさらに困難になります。
そのため、私たちは可能な限りすべてのMigrationについて、冪等性を保証することを基本原則としました。
最も基本的に使用する方法は、IF EXISTSとIF NOT EXISTSです。
テーブルを作成する際は、次のように記述します。
CREATE TABLE IF NOT EXISTS user_history (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL
);
カラムを追加する場合も、可能であれば次のように記述します。
ALTER TABLE user
ADD COLUMN IF NOT EXISTS status VARCHAR(20);
削除処理では、反対にIF EXISTSを使用します。
DROP TABLE IF EXISTS user_history;
DatabaseがIF EXISTSまたはIF NOT EXISTSを直接提供していないDDLについては、別途Functionを作成し、同じ方式で処理できるようにしました。
しかし、適用する中で一つ注意すべき点も分かりました。
IF NOT EXISTSがあるからといって、そのMigrationの冪等性が完全に保証されるわけではありません。
例えば、次のSQLがあるとします。
ALTER TABLE user
ADD COLUMN IF NOT EXISTS status VARCHAR(20);
Databaseにすでにstatusカラムが存在する場合、SQLはエラーなしで終了します。
しかし、実際のカラムが次のように作成されている可能性もあります。
기대 상태
status VARCHAR(20)
실제 상태
status INTEGER
SQLは成功していますが、DatabaseはApplicationが期待する状態になっていません。
そのため、私たちは単にSQLを2回実行してもエラーが発生しないことだけを、冪等性とは判断していません。
Migrationを1回実行しても複数回実行しても、最終的なDatabaseの状態が同じになるかを基準に判断することにしています。
IF EXISTSとIF NOT EXISTSは、これを実現するためのツールの一つです。
4. Forward Migrationだけでは十分ではありませんでした
Migrationを初めて作成したときは、主に新しいバージョンへDatabaseを変更する方法に集中していました。
しかし、Applicationを運用していると、新しいバージョンに問題があり、以前のバージョンへRollbackしなければならない場合も考慮せざるを得ません。
Applicationだけを以前のバージョンに戻したにもかかわらず、Database Schemaが新しいバージョンの状態のままになっていると、以前のApplicationが正常に動作しない可能性があります。
したがって、Forward Migrationを作成するだけでは十分ではありませんでした。
現在は、Incremental Migrationを作成する際に、それに対応するRollback Scriptも併せて作成することを原則としています。
例えば、次のようなMigrationがある場合は、
postgresql/V7_1/
└─ V7_1_3__Modify_User.sql
対応するRollback Scriptを別のディレクトリで併せて管理します。
rollback/postgresql/V7_1/
└─ V7_1_3__Modify_User_Rollback.sql
Rollback ScriptはFlywayによって自動的に実行される対象ではありません。
Application Rollbackや障害復旧が必要な状況で、担当者が変更内容を確認して実行できるよう、別途管理します。
単純なカラム追加であれば、次のようなForward Migrationを作成できます。
ALTER TABLE user
ADD COLUMN IF NOT EXISTS status VARCHAR(20);
Rollback Scriptでは、反対にそのカラムを削除します。
ALTER TABLE user
DROP COLUMN IF EXISTS status;
最初は、これでRollbackの準備は十分だと考えるかもしれません。
しかし、実際のデータが変更されるMigrationでは、Schemaだけを元に戻しても以前の状態に戻るわけではないという問題があります。
5. Rollbackを作成する前にBackupを考える
最も重要な原則の一つとして整理するようになったのが、BackupとRollbackを一つの作業として考えることです。
例えば、既存のカラムを削除したとします。
ALTER TABLE user
DROP COLUMN legacy_code;
Applicationに問題が発生して以前のバージョンへRollbackする必要がある場合、次のようにカラムを再作成できます。
ALTER TABLE user
ADD COLUMN legacy_code VARCHAR(100);
Database Schemaだけを見ると、元の状態に戻ったように見えます。
しかし、既存のlegacy_codeカラムに保存されていたデータはすでに失われています。
つまり、
Schema RollbackとData Rollbackは別々の問題です。
この経験から、既存データが変更または削除される可能性のあるMigrationでは、必ずBackupも併せて検討するようにしました。
たとえば既存のカラムを変更する必要がある場合、既存のカラムをすぐに削除せず、Backupカラムとして残しておくことができます。
ALTER TABLE sample
RENAME COLUMN A TO A_7_1_3;
ALTER TABLE sample
ADD COLUMN A VARCHAR(255);
UPDATE sample
SET A = A_7_1_3;
既存のAカラムは、A_7_1_3という名前で保存されます。
新しいバージョンで問題が発生した場合は、新しく作成したカラムを削除し、既存のカラムを再び復元します。
ALTER TABLE sample
DROP COLUMN IF EXISTS A;
ALTER TABLE sample
RENAME COLUMN A_7_1_3 TO A;
テーブル全体のBackupが必要な場合は、Backup Tableを作成します。
CREATE TABLE user_7_1_3 AS
SELECT *
FROM user;
Rollback時には、これを利用して元のテーブルを復元できます。
DROP TABLE IF EXISTS user;
ALTER TABLE user_7_1_3
RENAME TO user;
私たちは、Backup Objectの名前にもMigration Versionを含めるルールを使用しています。
<column>_<major>_<minor>_<patch>
<table>_<major>_<minor>_<patch>
たとえば、次のようになります。
email_7_1_3
user_7_1_3
これにより、どのMigrationのために作成されたBackupなのかを確認できます。
結局、現在は1つのMigration作業を次の3つとして捉えています。
Migration
│
├─ Backup
├─ Forward Migration
└─ Rollback
特に Forward Migrationを先に作成してからRollbackの方法を考えるのではなく、Migrationを記述する時点でRollbackとBackupを併せて設計するという点が重要でした。
Migrationを完了した後にRollbackの方法を考え始めると、すでに必要なデータが削除された後である可能性があるためです。
現在のガイドでも、カラム名の変更、データ型の変更、データの修正、テーブル構造の変更、削除などの作業では、Backupを必須とするルールを設けています。
6. 最初の試行錯誤: Baseline管理
Downloadable Applicationは、既存環境のUpdateだけでなく、新しい環境への新規インストールもサポートする必要があります。
問題は、Applicationの運用期間が長くなるほど、Migration Scriptが増え続けるという点です。
たとえば、次のように数十個のMigrationが存在することがあります。
V7_0_1
V7_0_2
V7_0_3
...
V7_1_1
V7_1_2
V7_1_3
...
新しい環境をインストールする際、初期バージョンからすべてのMigrationを順番に実行するのは、不要な作業が多くなります。
あるMigrationではテーブルを作成し、数バージョン後にすぐ削除することもあります。新規インストールではすでに最終的な構造がわかっているにもかかわらず、このような過去の変更過程をすべて経なければなりません。
当初は、この問題を解決するために、Minor Versionごとの最終Schemaを表す別個のBaseline SQLを管理していました。
baseline/
└─ postgresql/
├─ 7.0.sql
└─ 7.1.sql
Baselineには、そのMinor Version時点におけるすべてのTable、Index、Constraintなどの最終Schemaを記述しました。
Flyway Migrationとは別に、もう1つSnapshotを管理していたのです。
当初は、新規インストールを簡素化できるというメリットがありました。
しかし、時間が経つにつれて問題点も明確になりました。
最大の問題は 同じDatabase Schemaを、実質的に2か所で管理しなければならないことでした。
Incremental Migration
→ 既存DBがどのように変更されるか
Baseline
→ 新規DBがどのような状態で作成されるか
開発者が新しいMigrationを作成したにもかかわらず、Baselineを同時に修正しなければ問題が発生します。
既存DatabaseをUpdateした結果と、新規Databaseをインストールした結果が異なる可能性があります。
結局、実際のMigration履歴とSnapshot Baselineの間の整合性を、人が継続的に維持しなければならない構造でした。
Flywayを使用して自動化する一方で、別の場所では再び手動管理の要素を作ってしまったわけです。
7. Baseline構造をInitial Migrationに変更しました
この問題を改善するため、Baseline構造を再び整理しました。
別個のBaseline Snapshotファイルを削除し、 各Minor Versionの最初のMigrationに、そのバージョンの全Schema DDLを含めるように変更しました。
たとえば7.1の場合、次のファイルが必ず存在します。
V7_1_0__Initial.sql
Patch Version 0を該当するMinor VersionのInitial Migrationとして使用します。
全体の構造は次のとおりです。
postgresql/
├─ V7_1/
│ ├─ V7_1_0__Initial.sql
│ ├─ V7_1_1__Create_User.sql
│ └─ V7_1_2__Modify_User.sql
│
└─ V7_2/
├─ V7_2_0__Initial.sql
├─ V7_2_1__Create_History.sql
└─ V7_2_2__Modify_User.sql
V7_1_0__Initial.sqlには、7.1バージョンを新規インストールするために必要な全Schema DDLを記述します。
7.1の開発過程でIncremental Migrationが追加され、7.2の開発を開始することになったら、7.1の最終Database Schemaを基準にV7_2_0__Initial.sqlを作成します。
V7_1_0__Initial
↓
V7_1_1
↓
V7_1_2
↓
7.1 Final Schema
↓
V7_2_0__Initial
このようにすれば、それぞれのMinor Versionが独立した新規インストールの開始点を持つことができます。
また、別個のBaseline SnapshotとIncremental Migrationの間の整合性を管理する必要も減ります。
もちろん、既存の7.1 Databaseを7.2へUpdateする際に、V7_2_0__Initial.sql全体を既存のSchemaに対して再実行してはいけません。
したがって、新規インストールと既存環境のUpdateで実行対象を区別する戦略が別途必要になります。
この点も、単にSQLファイルを作成するだけでMigration設計が完了するのではなく、 新規インストールの経路とUpdateの経路を同時に考慮する必要があることを知るきっかけとなった事例でした。
8. 2つ目の試行錯誤:複数の開発者がMigrationを作成する場合
もう一つ、予想以上に頻繁に問題となったのがMigration Versionの管理でした。
一人がすべてのMigrationを順番に作成するのであれば、Versionの管理は難しくありません。
しかし実際のプロジェクトでは、複数の開発者がそれぞれ異なるFeature Branchで同時に開発を行います。
たとえば、現在の最新Migrationが次のようになっていると仮定します。
V7_2_2
Developer AとDeveloper Bが、それぞれのBranchで同時にSchemaを変更します。
Developer Aは次のMigrationを作成します。
V7_2_3__Create_User.sql
Developer Bも、自分が作業を開始した時点での最新VersionがV7_2_2だったため、次のMigrationを作成します。
V7_2_3__Create_Position.sql
各Branchでは何の問題も発生しません。
問題は、2つのBranchがMain BranchにMergeされるときに発生します。
V7_2_3__Create_User.sql
V7_2_3__Create_Position.sql
同じVersionのMigrationが2つ存在することになります。
まだどの環境でもMigrationが実行されていなければ、比較的簡単に解決できます。
Main Branchを最新の状態にした後、後からMergeされるMigrationのVersionを変更すればよいのです。
しかし、すでに開発環境または共有環境で該当するMigrationが実行されている場合は、単純なRenameだけでは済まないことがあります。
FlywayはMigration Versionと実行結果をflyway_schema_historyに記録するためです。
この問題を経験してからは、 Migration VersionはScriptの作成時点だけでなく、Main BranchへのMerge時点でも再確認するというルールを設けることにしました。
現在は、次のような流れを基準に管理しています。
Feature開発
↓
Migration作成
↓
Main Branchの最新化
↓
最新のMigrationを確認
↓
Versionの重複有無を確認
↓
必要に応じてVersionを再調整
↓
Main Merge
9. 上位Versionが先に実行される問題
同じVersionの衝突だけでなく、Migrationの実行順序が混乱する問題もありました。
例として、2つのMigrationがあるとします。
V7_2_7
V7_2_8
Versionだけを見ると、当然次の順序を期待します。
V7_2_7
↓
V7_2_8
しかし、2つのMigrationが別々のBranchで開発・デプロイされる過程では、実際のMergeまたはReleaseの順序が異なる場合があります。
上位VersionであるV7_2_8が、先に特定の環境へ適用される状況が発生する可能性があります。
V7_2_6
↓
V7_2_8
その後、V7_2_7がRepositoryに追加されると、Repositoryが想定するMigrationの順序と、実際にDatabaseに記録された実行履歴が異なります。
この状況を、単にファイル名を変更したりHistoryを修正したりして解決するのは危険です。
すでに実行されたMigrationは、実際のDatabase Schemaの変更と結び付いているためです。
この経験を通じて、Migration Versionは単なるファイルの並び順の基準ではなく、Database Schemaが変更される実際の順序を示す運用情報として捉えるべきだと分かりました。
そのため、現在は次の原則に従って管理しています。
-
Migrationを含むBranchをMergeする前に、最新のMigration Versionを確認します。
-
同じVersionが存在するか確認します。
-
下位Versionがまだデプロイされていない状態で、上位Versionが先にデプロイされないよう確認します。
-
すでに実行されたMigrationのVersionや内容を勝手に変更しません。
-
デプロイ前にPending Migrationと実行順序を確認します。
-
問題が発生した際、repairを単なるVersion整理の目的で使用しません。
Flywayを使用する中で、技術的なSQLの作成だけでなく、Git BranchとRelease ProcessもDatabase Migrationの一部であるという点を実感した事例でした。
10. 適用後に整理したMigrationの原則
Flywayを初めて導入した当時は、次のように考えていました。
Migration
=
Application 실행 시 자동으로 수행할 SQL
実際に複数のVersionを更新し、試行錯誤を重ねる中で、現在は少し異なる考え方をしています。
Migration
=
Databaseの変更方法
+ 繰り返し実行に対する安全性
+ データの保存方法
+ 障害発生時の復旧方法
+ Versionおよび実行順序の管理
現在、Migrationを作成する際は、次の内容を確認します。
新規インストール
-
Minor VersionのInitial Migrationが存在するか確認します。
-
Initial Migrationだけで、そのVersionの基本Schemaを作成できなければなりません。
-
新規インストール後、Applicationが正常に実行されることを確認します。
Incremental Migration
-
既存の運用Databaseの状態を基準に作成します。
-
可能な限り冪等性を保証します。
-
すでに実行されたMigrationは変更しません。
-
Databaseの変更によって既存データが影響を受けるか確認します。
BackupとRollback
-
Forward Migrationを作成する際は、Rollback Scriptも併せて作成します。
-
データの変更または削除が発生する場合は、まずBackup方法を決めます。
-
Rollback後、Schemaだけでなくデータも復元可能か確認します。
Version管理
-
Main Branchへのマージ前に、最新のMigration Versionを確認します。
-
Versionが重複していないか確認します。
-
実際のデプロイ順序とMigration Versionの順序が一致しているか確認します。
-
すでに実行されたMigration Versionを任意に変更しません。
現在のプロジェクトでは、これらの項目をMigration作成および検証ガイドとして整理し、使用しています。
11. おわりに
Flywayを導入した直接的な理由は、Vizend Applicationの特性にありました。
Applicationをユーザーが直接ダウンロードしてインストールし、新しいVersionに更新できるため、Application Updateと併せてDatabase Schemaも自動的に更新できる必要がありました。
Flywayは、こうした要件を実装するための優れた基盤を提供してくれました。
しかし、実際に適用する過程でわかったのは、Flywayを使ったからといってDatabase Migrationが自動的に安全になるわけではないという点でした。
Flywayは、どのMigrationが実行されたかを記録し、まだ適用されていないMigrationを定められた順序に従って実行します。
しかし、そのMigrationが繰り返し実行されても安全か、変更されたデータを復旧できるか、Rollback方法が準備されているかまでをFlywayが代わりに判断してくれるわけではありません。
また、複数の開発者が同時にMigrationを作成する環境では、Git BranchのMerge順序とRelease順序も併せて考慮する必要がありました。
結局、実際のプロジェクトでFlywayを安定して使用するために最も重要だったのは、ツール自体の機能よりも、Migrationをどのように作成し、管理するかについてのチームのルールでした。
現在、私がMigrationを作成する際に最初に確認するのは、次の3点です。
再度実行しても安全か?
問題が発生した場合、元に戻せるか?
元に戻すために必要なデータが残っているか?
当初はSQLを自動的に実行するためにFlywayを導入しましたが、実際の適用経験を通じて、Databaseの変更もApplicationコードと同様に、変更方法だけでなく、失敗と復旧まで併せて設計すべき対象であるということを学びました。
参考資料
本稿で紹介したFlyway Migrationの作成および運用ルールは、実際のプロジェクトで使用している内部ガイドを基に整理したものです。
-
FlywayベースのDB Migrationガイド
https://vizend.notion.site/Flyway-DB-Migration-2e635bc54c138022a9d0f6c6c3b47695?pvs=74
David