FJCT Tech blog

富士通クラウドテクノロジーズ公式エンジニアブログです

富士通クラウドテクノロジーズ

FJCT/Tech blog

【CI戦術編 その1】 alembic check コマンドを活用したマイグレーションスクリプト生成忘れ防止

はじめまして、クラウドインフラ本部ネットワークサービス部の上野(id:uweno)です。 最近10GbE回線を自室に引いたことでTwitterが快適になりました。

今回はCI戦術編の1回目として、データベースのマイグレーションスクリプトの生成忘れを防止するために取った施策について、事例を交えてご紹介します。

TL;DR

CIパイプラインに alembic check を組み込むことで、データベースのマイグレーションスクリプトの生成忘れや不正なマイグレーションスクリプトの混入を防ぐことができるようになりました。

データベースのマイグレーションについて

私たちが開発しているアプリケーションではリレーショナルデータベースを用いたデータの永続化を行っており、SQLAlchemyを利用してPythonのコードベースでテーブル定義を管理しています。 一度作成したテーブル定義はそのまま使い続けるわけではなく、アプリケーションに機能を追加するために必要に応じて都度変更を行っています。

開発環境ではテーブル定義に変更があってもデータベースをリセットすればよいのですが、稼働中の本番環境では当然データベースをリセットすることはできません。 そこでリリース時に最新のテーブル定義をデータベースに反映させるために、Alembicを利用してマイグレーションスクリプトの生成を行っています。

例えば、SQLAlchemyを用いて下記のようなテーブルusersを新たに定義したとします。

from sqlalchemy import VARCHAR, Column, create_engine
from sqlalchemy.orm import declarative_base

Engine = create_engine(
    "sqlite:///test.db",
    echo=True,
)

Dao = declarative_base()

class User(Dao):
    __tablename__ = "users"

    id = Column(VARCHAR(26), primary_key=True)
    full_name = Column(VARCHAR(256))

このテーブル定義をもとに、Alembicでマイグレーションスクリプトを生成します(Alembicのセットアップの手順は割愛します)。 マイグレーションスクリプトを生成するにはalembic revision --autogenerateというコマンドを用います。 このコマンドはAlembicが管理する最新のリビジョン(過去に生成されたすべてのマイグレーションスクリプトを実行した状態のデータベース)と現在のテーブル定義ファイルを比較し、その差分を埋めるようなマイグレーションスクリプトを自動で生成してくれます。

$ alembic revision --autogenerate
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'users'
  Generating /home/uewno/test-alembic/alembic/versions/4111408c414b_.py ...  done

現在のデータベース(初回なので空)とPythonで記述したテーブル定義を比較した結果、users テーブルが新規に作成されたことが検知されました。 生成されたマイグレーションスクリプトは下記のとおりです。

"""empty message

Revision ID: 4111408c414b
Revises:
Create Date: 2023-02-27 14:00:09.662301

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '4111408c414b'
down_revision = None
branch_labels = None
depends_on = None


def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('users',
    sa.Column('id', sa.VARCHAR(length=26), nullable=False),
    sa.Column('full_name', sa.VARCHAR(length=256), nullable=True),
    sa.PrimaryKeyConstraint('id')
    )
    # ### end Alembic commands ###


def downgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_table('users')
    # ### end Alembic commands ###

マイグレーションスクリプトには、1つ前のリビジョンから新しいリビジョンにupgradeする関数と、このリビジョンから1つ前のリビジョンにdowngradeする関数が定義されています。 これらの関数はSQLAlchemyを用いてSQLを実行し、アプリケーションのテーブル定義とデータベースのテーブルを一致させます。

マイグレーションやらかしエントリー

これまで弊チームで実際に起こった、マイグレーション関連でのやらかしを共有させていただきます。

マイグレーションスクリプトの生成忘れ

テーブル定義の変更とマイグレーションスクリプトの生成は必ずセットで行う必要があります。 なぜならマイグレーションスクリプトの生成を忘れてしまうと、リリース後にアプリケーションが期待しているテーブル定義と実際のデータベースのテーブル定義に差異が生じてしまうからです。 その結果アプリケーションが正しく動作しなくなります(1敗目)。

マイグレーションスクリプトの修正忘れ

例えばテーブル定義を修正しマイグレーションスクリプトを生成した後に、再度テーブル定義を修正したとします。 この場合は当然マイグレーションスクリプトも修正する必要があります。 これを忘れてしまうと不正なマイグレーションスクリプトが紛れ込んでしまいます(2敗目)。

Botを用いた対策

弊社では開発プラットフォームとしてGitLabを活用しています。 GitLabにはイベントをWebhookに送信する機能があり、他のアプリケーションやBotと連動させることができます。 この機能を活用して、マイグレーションスクリプト生成忘れを対策するBotを作成しました。

  • Webhookを用いて「新規MR作成」「MR中のコミット変更」のイベントをBotに通知する
  • Botでコミットのdiffを確認し、テーブル定義ファイルに変更があった場合にはGitLabのMRにコメントを付ける

BotからGitLabへのコメント
BotからGitLabへのコメント

MRにこのコメントが付けられていた場合、レビュアーは以下の手順でマイグレーションスクリプトが正しく生成されていることを確認します。

  • alembic upgrade head コマンドを実行して開発環境のデータベースを最新のリビジョンにマイグレートする
  • alembic revision --autogenerate コマンドを実行した結果、upgrade関数・downgrade関数ともに内容がpassのみである空のマイグレーションスクリプトが生成されることを確認する
def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    pass
    # ### end Alembic commands ###


def downgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    pass
    # ### end Alembic commands ###

変更を検知しなかった場合でもalembic revision --autogenerateマイグレーションスクリプトを生成します。 upgrade関数・downgrade関数ともに中身がないということは、マイグレーションスクリプトに反映されていない変更が、テーブル定義中にないこと(Alembicは検知しなかったこと)を意味します。 スクリプトの中身が空であるかどうかはPythonのコードの内容を解釈する必要があり、その点がマイグレーションスクリプトのチェックをCIに組み込むことを困難にしていました。

alembic check コマンドを用いた対策

Botによる対策を1年ほど続けていましたが、2022年12月にリリースされた alembic 1.9.0alembic check というサブコマンドが追加されました。 このコマンドは alembic revision --autogenerate を実行した場合に、空のマイグレーションスクリプトが生成される状態にあるかどうかを確認します。 これはBotを用いた対策で行っていたコメントが付けられた際にレビュアーが手動で実施する手順と同じ確認を、コマンドの終了ステータスとして取得できることを意味します。

先ほど定義したusers テーブルに、新たにemailのカラムを追加し、マイグレーションスクリプトを生成せずにalembic checkを実行します。

class User(Dao):
    __tablename__ = "users"

    id = Column(VARCHAR(26), primary_key=True)
    full_name = Column(VARCHAR(256))
    email = Column(VARCHAR(256))
$ alembic check
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added column 'users.email'
ERROR [alembic.util.messaging] New upgrade operations detected: [('add_column', None, 'users', Column('email', VARCHAR(length=256), table=<users>))]
  FAILED: New upgrade operations detected: [('add_column', None, 'users', Column('email', VARCHAR(length=256), table=<users>))]

$ echo $?
255

Alembicが新しいカラムの追加を検知したため、エラーで終了しました。 マイグレーションスクリプトを生成しデータベースを最新の状態にマイグレートした後に、再度alembic checkを実行します。

$ poetry run alembic revision --autogenerate
$ poetry run alembic upgrade head
# 結果省略

$ alembic check
No new upgrade operations detected.

$ echo $?
0

新しい変更は検出されず、コマンドは正常終了しました。

alembic check コマンドはCIパイプラインに組み込むことを想定して作られており、checkの成功時には0、失敗時には非0の終了コードを返します。 CIパイプラインにalembic check を組み込むことで、マイグレーションスクリプトの生成を忘れた場合や不正なマイグレーションスクリプトが混入した場合にはCIパイプラインが失敗します。 私たちのチームで設定しているGitLabのポリシーでは、CIパイプラインが失敗した場合はそのMRのマージは失敗するため、マイグレーションスクリプトに問題のあるMRが誤ってマージされることがなくなりました。

さいごに

今回はalembic check を用いたマイグレーションスクリプトの生成忘れ防止についてご紹介させていただきました。 Botで変更を検知して指先確認ヨシ! とするのも1つの対策ではありますが、CIでチェックすることで機械的にミスを無くすことができ、またレビューの工数を減らすこともできました。 この記事がAlembic を用いてデータベースのマイグレーションの管理を行っている方の参考情報になれば幸いです。 次回は 【CI戦術編 その2】コードレビューを充実したものにする方法、あるいは一生残る恥ずかしい履歴を作らないように - FJCT Tech blog になります。ぜひお楽しみに。

tech.fjct.fujitsu.com