はじめまして、クラウドインフラ本部ネットワークサービス部の上野(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にコメントを付ける
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.0
で alembic 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 になります。ぜひお楽しみに。