前置き
サービスの継続的な運営のために後方互換性を保つ開発は重要です。
DjangoのようなWebバックエンドフレームワークを利用する際、一度作成したテーブルにカラムを追加したり削除するのはよくあるでしょう。
その際、ORMの定義と実際のテーブル定義にギャップが生じる状態がありえます。
この記事では、Djangoにおいて後方互換性を保って安全にリリースすることで継続的なサービス開発を行う方法を紹介します。
なお、この記事を書くにあたって検証したコードのバージョンは以下の通りです。
- Python: 3.13
- Django 5.1.2
- データベース: SQLite3
問題が起きるケース
DB変更を伴うDjangoアプリケーションをリリースするときに以下の2点を完全に同時に行うことは難しいです。
- ソースコードの変更(差し替え)
- DBマイグレーション実施(DDL適用)
※たとえリリースが自動化されていたとしても。
以下のテーブルがあるとします。
# Before: 以下の例の「Blue」のコード
from django.db import models
class User(models.Model):
name = models.CharField(max_length=100)
bio = models.TextField()
サービスの仕様変更に伴ってカラム email
を削除する流れを考えてみましょう。
# After: 以下の例の「Green」のコード
class User(models.Model):
name = models.CharField(max_length=100)
- bio = models.TextField()
例えば一般的なリリース方式であるBlue/Greenデプロイの場合を考えてみます。
(1/4) Before
リリース前のアプリケーションが稼働中(Blue)。
(2/4) Install
リリースパイプラインによって新しいアプリケーションが起動する(Green)
ここまでは稼働中のアプリケーションには一切の変更がなく、問題なく動作します。
※ORM(Djangoモデル)の定義とテーブル定義が一致している
>>> # 発行クエリを確認
>>> from app.models import User
>>> print(User.objects.all().query)
SELECT "app_user"."id", "app_user"."name", "app_user"."bio" FROM "app_user"
(3/4) Apply Migration
マイグレーション適用のステップを実行する
bio
カラムを削除した後はORMが発行するクエリとテーブル定義が一致しないため、クエリ実行がエラーとなります。
>>> User.objects.all()
Traceback (most recent call last)
...
OperationalError: no such column: app_user.bio
(4/4) BlueからGreenへトラフィックを切り替える
トラフィックの切り替え以降、ORMとテーブル定義が一致するためエラーは起きなくなります。
上記で紹介した例の2から3の間の時間帯でDjangoのクエリ実行が失敗する場合があります。
※例の場合では順序を「2. マイグレーション適用」、「3. トラフィック切り替え」としましたが、仮にこれを逆の順序にしてもカラム追加のケースで同様の事象が起きます。
自動化されたリリース処理で数秒~1分以内程度なら小さな影響と見なせるかもしれませんが、リリースの度にこのようなエラーを前提とするのはよろしくありません。
これを解決できるのがDjangoマイグレーションの機能の一つである SeparateDatabaseAndState
です。
SeparateDatabaseAndStateとは
以下の記事の説明文が分かりやすいと思ったので引用します。
SeparateDatabaseAndState とは、Django のマイグレーションの操作で、その名の通り、データベース操作と状態操作を分離する機能です。
通常はこの2つは一致するのですが、 SeparateDatabaseAndState では、あえてこの二つを分離します。
- 移動元のアプリケーションからモデルを削除したことにする(データベースは何も変更しない)
- 移動先のアプリケーションに同じモデルを作成したことにする(データベースは何も変更しない)
これを使うことで、Djangoが「bio
カラムは削除されたもの」と見なしORMが発行するクエリからbio
カラムを除外させられるようになります。
カラム削除の実践例
リリースステップを2回に分ける方針です。
- Step1: 「
bio
カラムは削除されたもの」とDjangoに伝える(SeparateDatabaseAndStateを使う) - Step2: 実際に
bio
カラムを削除する
Step1
SeparateDatabaseAndStateを使います。
また、NOT NULL制約がある場合は先に消去しておきます。
# models.py
class User(models.Model):
name = models.CharField(max_length=100)
- bio = models.TextField()
# migrations/0002_remove_user_bio_step1.py
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0001_initial'),
]
operations = [
# 1-1. Remove NOT NULL constraint
migrations.AlterField(
model_name='user',
name='bio',
field=models.TextField(null=True),
),
# 1-2. Use SeparateDatabaseAndState migration
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(
model_name='user',
name='bio',
),
],
)
]
SeparateDatabaseAndState
によって実際のカラム削除はされません(NULLABLEにはなる)。
これにより、ORMのモデル定義としてbio
が存在する状態、しない状態ともにエラー無くクエリを実行できます。
from app.models import User
# --- 1. Before: bio が残っているコード(Blue)
print(User.objects.all().query)
# SELECT "app_user"."id", "app_user"."name", "app_user"."bio" FROM "app_user"
print(User.objects.all().query) # テーブルには bio カラムがまだ存在するのでエラーなく実行できる
# <QuerySet [...]>
# --- 2. After: bio が無いコード(Green)
print(User.objects.all().query)
# SELECT "app_user"."id", "app_user"."name" FROM "app_user"
print(User.objects.all().query) # もちろんエラー無く実行できる
# <QuerySet [...]>
Step2
マイグレーションコードによって実際にbio
カラムを削除します。
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('app', '0002_remove_user_bio_step1'),
]
operations = [
migrations.RunSQL(
"ALTER TABLE app_user DROP COLUMN bio",
reverse_sql="ALTER TABLE app_user ADD COLUMN bio TEXT NULL",
),
]
なおDjangoは既に「usersテーブルにbio
カラムは存在しない」と認識しているため、migrations.RemoveField
操作はそのまま使えません。
class Migration(migrations.Migration):
dependencies = [
('app', '0002_remove_user_bio_step1'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='bio',
),
]
python manage.py migrate app
...
KeyError: 'bio'
まとめ
サービスの継続開発において後方互換性を維持するのDB変更の方法を紹介しました。
必要となるシーンは多そうですが意外と知られていないように思います(私も昨日まで知らなかった)。
カラム削除の例を紹介しましたが、同様のアプローチでカラム追加やリネームにも対応できます(多分)。
Django開発者の助けになれば幸いです。
参考リンク: