0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Django】後方互換性を保つデータベース変更をしよう(SeparateDatabaseAndState)

Last updated at Posted at 2024-11-16

前置き

サービスの継続的な運営のために後方互換性を保つ開発は重要です。

DjangoのようなWebバックエンドフレームワークを利用する際、一度作成したテーブルにカラムを追加したり削除するのはよくあるでしょう。

その際、ORMの定義と実際のテーブル定義にギャップが生じる状態がありえます。

この記事では、Djangoにおいて後方互換性を保って安全にリリースすることで継続的なサービス開発を行う方法を紹介します。

なお、この記事を書くにあたって検証したコードのバージョンは以下の通りです。

  • Python: 3.13
  • Django 5.1.2
  • データベース: SQLite3

問題が起きるケース

DB変更を伴うDjangoアプリケーションをリリースするときに以下の2点を完全に同時に行うことは難しいです。

  • ソースコードの変更(差し替え)
  • DBマイグレーション実施(DDL適用)

※たとえリリースが自動化されていたとしても。

以下のテーブルがあるとします。

models.py
# 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)。

image.png

(2/4) Install

リリースパイプラインによって新しいアプリケーションが起動する(Green)

image.png

ここまでは稼働中のアプリケーションには一切の変更がなく、問題なく動作します。
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

マイグレーション適用のステップを実行する

image.png

bioカラムを削除した後はORMが発行するクエリとテーブル定義が一致しないため、クエリ実行がエラーとなります。

>>> User.objects.all()
Traceback (most recent call last)
...
OperationalError: no such column: app_user.bio

(4/4) BlueからGreenへトラフィックを切り替える

トラフィックの切り替え以降、ORMとテーブル定義が一致するためエラーは起きなくなります。

image.png


上記で紹介した例の2から3の間の時間帯でDjangoのクエリ実行が失敗する場合があります。
※例の場合では順序を「2. マイグレーション適用」、「3. トラフィック切り替え」としましたが、仮にこれを逆の順序にしてもカラム追加のケースで同様の事象が起きます。

自動化されたリリース処理で数秒~1分以内程度なら小さな影響と見なせるかもしれませんが、リリースの度にこのようなエラーを前提とするのはよろしくありません。

これを解決できるのがDjangoマイグレーションの機能の一つである SeparateDatabaseAndState です。

SeparateDatabaseAndStateとは

以下の記事の説明文が分かりやすいと思ったので引用します。

[Django]モデルを別のアプリに移動する方法

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カラムを削除します。

0003_remove_user_bio_step2.py
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開発者の助けになれば幸いです。

参考リンク:

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?