Liquibaseによるデータベースマイグレーション:スキーマのバージョン管理、安全なロールバック、CI/CD自動化

本番リリースのトラブル原因として、手動によるスキーマ更新ほど厄介なものはありません。ステージング環境で誰かが ALTER TABLE スクリプトの実行を忘れていたり、不用意なカラム名変更によって1,000万件のレコードがロックされ、決済APIがダウンしたりといった、夜遅くのSlackでのパニックは誰もが一度は経験しているのではないでしょうか。

Liquibaseは、このような危うい運用を排除します。データベーススキーマの変更をアプリケーションコードと同じように扱い、Gitでバージョン管理し、プルリクエストで検証した上で、自動化パイプラインを通じてデプロイします。PostgreSQLやMySQLなどのリレーショナルデータベースでは、アドホックなパッチ適用によって収拾がつかなくなる前に、厳格なバージョン管理を導入することが不可欠です。

クイックスタート:5分で始める初めてのマイグレーション

準備に必要なのは、わずか2つの小さな設定ファイルだけです。既存のデータベースを大幅に刷新することなく、すぐに導入できます。

1. Liquibase CLIの導入

Homebrewや各種パッケージマネージャー、または公式アーカイブからバイナリをインストールします。

# macOS
brew install liquibase

# インストールの確認
liquibase --version

2. データベース接続情報の設定

プロジェクトのルートディレクトリに liquibase.properties を作成します。これにより、接続先データベースの場所と実行対象の変更ログ(changelog)をLiquibaseに指定します。

changeLogFile=changelog.xml
url=jdbc:postgresql://localhost:5432/appdb
username=postgres
password=secret
driver=org.postgresql.Driver
classpath=/path/to/postgresql-42.7.2.jar

3. 最初のChangeSetの作成

Liquibaseでは、ルートの変更ログファイル内でスキーマの変更内容を「ChangeSet」という単位で構造化します。changelog.xml を作成しましょう。

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
    xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
    http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">

    <changeSet id="001-create-users-table" author="alex">
        <createTable tableName="users">
            <column name="id" type="BIGINT" autoIncrement="true">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="email" type="VARCHAR(255)">
                <constraints unique="true" nullable="false"/>
            </column>
            <column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
        </createTable>
        <rollback>
            <dropTable tableName="users"/>
        </rollback>
    </changeSet>

</databaseChangeLog>

4. マイグレーションの実行

ターミナルから直接マイグレーションを実行します。

liquibase update

LiquibaseがPostgreSQLに接続し、users テーブルを作成して、実行履歴を管理テーブルに記録します。これでスキーマがコードとして管理されるようになりました。

内部の仕組み:Liquibaseがスキーマ状態を追跡する方法

liquibase update を一度実行すると、スキーマ内に2つの追跡用テーブルが自動的に生成されていることが確認できます。

  • DATABASECHANGELOG: 監査履歴テーブル。適用された各ChangeSetが、一意の識別子、実行日時、実行順序、MD5チェックサムとともにここに記録されます。
  • DATABASECHANGELOGLOCK: 排他制御用テーブル。単一のブール値フラグ(LOCKED)により、同時に1つのプロセスのみがマイグレーションを適用できるようにします。これにより、4つのKubernetesレプリカが一斉に起動した際などの競合状態(レースコンディション)を防ぎます。

識別情報とチェックサム

Liquibaseは、すべてのChangeSetを id、author、変更ログのファイルパス という3つのプロパティで一意に識別します。updateを実行すると、以下の処理が行われます。

  1. Liquibaseが DATABASECHANGELOG を検索し、複合キー (id, author, filepath) が存在するか確認します。
  2. 存在する場合、ローカルのChangeSetファイルの内容からハッシュ値を生成し、テーブル内のMD5ハッシュと比較します。ハッシュが一致していれば、LiquibaseはそのChangeSetの実行をスキップします。
  3. すでに適用済みのChangeSetが改変されていた場合、Liquibaseは直ちに ValidationFailedException をスローして処理を中断します。これにより、誤ったSQLが本番環境に適用される前に、意図しない設定ドリフトを確実に防ぎます。

ChangeSetのフォーマット:XML、YAML、それともフォーマット済みSQL?

XMLやYAMLはデータベースの移植性に優れており、裏側でPostgreSQL、MySQL、Oracleなど各データベース固有の方言(ダイアレクト)に応じた適切なSQLを生成します。一方で、コードレビュー時の可読性やわかりやすさを重視して、フォーマット済みSQL(Formatted SQL)を好む開発チームも多く存在します。

--liquibase formatted sql

--changeset alex:002-add-user-status
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'ACTIVE';
--rollback ALTER TABLE users DROP COLUMN status;

フォーマット済みSQLを使用すると、データベース固有のキーワードや構文を開発チームが正確にコントロールできます。一方、複数のデータベース製品を同時にサポートするマルチテナント型SaaSなどを構築する場合は、XMLやYAMLの利用が適しています。

防御的パターン:事前条件(Preconditions)、コンテキスト(Contexts)、ロールバック

本番環境の運用では、複雑なデータ移行やテスト用初期データ、予期せぬ状態の不整合といった課題がつきまといます。

事前条件(Preconditions)

事前条件(Preconditions)を使用すると、ChangeSetを実行する前にデータベースの状態を検証できます。例えば、インデックスがすでに存在していたり古いカラムが見つからなかったりした場合に、デプロイを失敗させるのではなく安全にスキップさせることが可能です。

<changeSet id="003-add-profile-bio" author="alex">
    <preConditions onFail="MARK_RAN">
        <not>
            <columnExists tableName="profiles" columnName="bio"/>
        </not>
    </preConditions>
    <addColumn tableName="profiles">
        <column name="bio" type="TEXT"/>
    </addColumn>
</changeSet>

コンテキスト(Contexts)による環境ごとの制御

ローカルやステージング環境には50件のテスト用顧客レコードを投入したいが、本番環境には絶対に投入したくないといったケースがあります。コンテキスト(Contexts)を利用すれば、実行環境ごとにマイグレーションを切り分けられます。

<changeSet id="seed-dev-users" author="alex" context="dev,test">
    <insert tableName="users">
        <column name="email" value="[email protected]"/>
    </insert>
</changeSet>

対応するコンテキストフラグを指定して実行することで、対象環境に応じたマイグレーションのみを適用できます。

liquibase --contexts=dev update

安全なロールバックの実行

アップデートによって予期せぬロックやエラーが発生した場合、Liquibaseでは実行回数、タグ、または指定したタイムスタンプを指定してロールバックを実行できます。

# 直近のChangeSetをロールバック
liquibase rollbackCount 1

# デプロイ前に現在の状態にタグを付け、カナリアチェック失敗時に切り戻す
liquibase tag v2.4.0
liquibase rollback v2.4.0

本番環境でいきなりロールバックを実行してはいけません。まずはLiquibaseが発行する実際のSQLをプレビューして確認しましょう。

liquibase rollbackCountSQL 1 > rollback_preview.sql

CI/CD自動化と本番環境のベストプラクティス

各エンジニアのローカル端末からマイグレーションを実行すると、バージョンの不一致や認証情報の漏洩リスクが生じます。LiquibaseをCI/CDパイプラインに組み込むことで、データベースのアップグレードを自動化し、追跡可能性(監査性)を確保できます。

GitHub Actions ワークフロー

以下のワークフローは、mainブランチへのプッシュ時に未適用のChangeSetを確認し、マイグレーションを実行します。

name: データベースマイグレーション

on:
  push:
    branches: [ main ]
    paths:
      - 'db/changelogs/**'

jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - name: コードのチェックアウト
        uses: actions/checkout@v4

      - name: Liquibaseのセットアップ
        uses: liquibase-github-actions/setup-liquibase@v1
        with:
          liquibaseVersion: '4.27.0'

      - name: 保留中の変更を確認
        run: |
          liquibase \
            --changeLogFile=db/changelogs/master.xml \
            --url="${{ secrets.STAGING_DB_URL }}" \
            --username="${{ secrets.STAGING_DB_USER }}" \
            --password="${{ secrets.STAGING_DB_PASS }}" \
            status --verbose

      - name: アップデートの実行
        run: |
          liquibase \
            --changeLogFile=db/changelogs/master.xml \
            --url="${{ secrets.STAGING_DB_URL }}" \
            --username="${{ secrets.STAGING_DB_USER }}" \
            --password="${{ secrets.STAGING_DB_PASS }}" \
            update

ダウンタイムゼロ(無停止)リリースのための実践ルール

  • 適用済みのChangeSetは絶対に編集しない:ステージングや本番環境で一度実行されたChangeSetのチェックサムは固定されます。カラムの長さを VARCHAR(50) から VARCHAR(100) に拡張したい場合などは、必ず新しいChangeSetを作成してください。
  • Expand-Contract(拡張・縮小)パターンを採用する:稼働中のカラムの削除やリネームを1回のリソースで行うのは避けてください。まず新しいカラムを追加し、新旧両方のフィールドに二重書き込み(Dual-write)を行うコードをデプロイします。その後、既存レコードのデータを非同期でバックフィル(移行)し、参照クエリを新しいカラムへと切り替えます。最後に、後続のリリースで不要になった旧カラムを削除します。
  • 変更ログをモジュール化して構成する:5,000行にも及ぶ巨大な変更ログファイルを作成するのはやめましょう。リリース単位(例: v1.2/01-orders.xml)などでマイグレーションをグループ化し、親となる master.xml から <include file="..."/> を使って読み込むように構成します。
  • ロールバック処理を明示的に記述する:自動的な逆マイグレーションが機能するのは、createTable のようなシンプルなDDL文に限られます。複雑なSQLやテーブルのリネーム、データのバックフィルを伴う変更では、手動で記述した <rollback> ブロックが不可欠です。
  • 大規模テーブルでのテーブルロックを防ぐ:2,000万件規模のテーブルに対して CREATE INDEX を実行すると、15分間にわたって書き込みがロックされる恐れがあります。フォーマット済みSQLのChangeSet内で、PostgreSQLの CREATE INDEX CONCURRENTLY やMySQLの ALGORITHM=INPLACE などのノンブロッキングコマンドを活用してください。
Share: