0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flask-Migrate導入後の空DBで「テーブルが存在しない」と失敗した原因と、初期マイグレーションを修復した記録

0
Posted at

はじめに

FlaskとPostgreSQLで開発している売上管理アプリのマイグレーション履歴を確認したところ、重大な問題が見つかりました。

既存のローカルDBでは正常に起動できるものの、まっさらなPostgreSQLデータベースでは、現在のマイグレーション履歴だけでテーブルを作成できない状態になっていました。

原因は、最初のマイグレーションがテーブル作成ではなく、既存テーブルへのカラム追加から始まっていたことです。

空のPostgreSQL
  ↓
productsテーブルへis_activeカラムを追加
  ↓
productsテーブルが存在しないため失敗

この記事では、以下の流れを記録します。

  • 問題を発見した経緯
  • マイグレーション履歴の調査
  • 初期マイグレーションの追加
  • 空DBからの構築検証
  • 既存DBへ影響しないことの検証
  • 実装時に注意した点

Codexを使った調査と検証

今回の調査・修正では、VS Code上のCodexを使用しました。

ただし、Codexへ全面的に任せたのではなく、作業を次の段階に分け、各段階で実行範囲を限定しました。

静的調査
  ↓
修正案の設計
  ↓
変更対象を限定した実装
  ↓
静的検証
  ↓
空DBでの動的検証
  ↓
既存DBの複製環境での検証
  ↓
最終確認
  ↓
commit・push

各工程では、以下のような条件を明示しました。

  • 調査段階ではファイルを変更しない
  • 実装対象をマイグレーション2ファイルだけに限定する
  • 通常のローカルDBへ直接マイグレーションを実行しない
  • 検証用のComposeプロジェクト名を分ける
  • 既存DBはpg_dumpから作成した複製DBで検証する
  • 問題が見つかっても勝手に修正しない
  • commit・pushは最終確認後にだけ許可する

Codexは、マイグレーション履歴の確認、差分の表示、検証用コマンドの実行、結果の整理に使用しました。

一方で、修正方針の採用、DBを扱う際の安全条件、削除対象、commit・pushの判断は、各工程の結果を確認したうえで人間側が決定しました。

今回の経験から、AIコーディング支援は単にコードを生成させるだけではなく、

変更範囲、禁止事項、検証条件、停止条件を明示して使う

ことが重要だと感じました。

開発環境

主な構成は以下です。

  • Python
  • Flask
  • Flask-SQLAlchemy
  • Flask-Migrate
  • Alembic
  • PostgreSQL
  • Docker Compose
  • pytest

Dockerコンテナの起動時には、次のようにマイグレーションを適用してからGunicornを起動する構成にしていました。

flask db upgrade && gunicorn ...

この構成では、マイグレーションが失敗するとGunicornまで到達せず、Webアプリも起動しません。

問題の発見

migrations/versions/を確認すると、存在するマイグレーションファイルは1件だけでした。

043c481b4069_add_is_active_to_products.py

このファイルは履歴上の最初のrevisionでした。

revision = '043c481b4069'
down_revision = None

しかし、upgrade()が行っていたのは、既存のproductsテーブルへis_activeカラムを追加する処理だけでした。

def upgrade():
    with op.batch_alter_table('products', schema=None) as batch_op:
        batch_op.add_column(
            sa.Column(
                'is_active',
                sa.Boolean(),
                server_default=sa.true(),
                nullable=False
            )
        )

つまり、次のテーブルを作成するマイグレーションがありませんでした。

  • products
  • daily_sales

プロジェクト全体を調べても、以下の処理は存在しませんでした。

op.create_table('products', ...)
op.create_table('daily_sales', ...)
db.create_all()

なぜ既存DBでは動いていたのか

既存DBでは、Flask-Migrateを導入する以前に別の方法でテーブルが作成されていたと考えられます。

履歴としては、次のような状態です。

Alembic管理外でproductsとdaily_salesを作成
  ↓
Flask-Migrateを導入
  ↓
productsへis_activeを追加

そのため、すでにテーブルが存在する既存DBでは問題なく動きます。

一方、空DBでは最初から次の処理が実行されます。

ALTER TABLE products
ADD COLUMN is_active BOOLEAN DEFAULT true NOT NULL;

しかし、空DBにはproductsが存在しません。

その結果、概ね次の流れで失敗します。

productsテーブルが存在しない
  ↓
最初のマイグレーションが失敗
  ↓
flask db upgradeが非ゼロ終了
  ↓
Gunicornが起動しない
  ↓
Webアプリが起動しない

現在のモデル

アプリには、次の2モデルが存在します。

Product

class Product(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    year = db.Column(db.Integer, nullable=False)
    month = db.Column(db.Integer, nullable=False)
    name = db.Column(db.String(100), nullable=False)
    price = db.Column(db.Integer, nullable=False)
    is_active = db.Column(
        db.Boolean,
        nullable=False,
        server_default=db.true()
    )

DailySales

class DailySales(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    product_id = db.Column(
        db.Integer,
        db.ForeignKey('products.id'),
        nullable=False
    )
    date = db.Column(db.Date, nullable=False)
    quantity = db.Column(
        db.Integer,
        nullable=False,
        default=0
    )

修正方針

検討した主な方法は以下です。

1. 初期マイグレーションを履歴の先頭へ追加する

初期テーブル作成
  ↓
is_active追加

既存のカラム追加履歴を残しながら、自然なマイグレーション履歴へ修正できます。

2. 既存の最初のマイグレーションを書き換える

既存revision内で、現在のテーブルをすべて作成する方法です。

ただし、適用済みマイグレーションの処理内容そのものを大きく変更するため、履歴の信頼性が下がります。

3. テーブルの有無を判定する条件付きマイグレーションにする

productsがなければ作成し、存在すればカラム追加する方法です。

複数のDB状態に対応できますが、状態依存の処理が増え、検証が複雑になります。

4. db.create_all()stampを使う

モデルから直接テーブルを作成し、Alembicのrevisionだけを記録する方法です。

短時間では実現できますが、モデルとマイグレーション履歴がずれる可能性があるため、今回は採用しませんでした。

採用した方法

今回は、初期マイグレーションを履歴の先頭へ追加する方法を採用しました。

変更後の履歴は次のとおりです。

base
  ↓
b7e2c4a91f30
create products and daily_sales
  ↓
043c481b4069
add is_active to products
  ↓
head

初期マイグレーションの実装

次のファイルを追加しました。

migrations/versions/b7e2c4a91f30_create_initial_tables.py

内容は以下です。

"""create initial products and daily_sales tables

Revision ID: b7e2c4a91f30
Revises:
Create Date: 2026-08-06

"""
from alembic import op
import sqlalchemy as sa


revision = 'b7e2c4a91f30'
down_revision = None
branch_labels = None
depends_on = None


def upgrade():
    op.create_table(
        'products',
        sa.Column(
            'id',
            sa.Integer(),
            nullable=False
        ),
        sa.Column(
            'year',
            sa.Integer(),
            nullable=False
        ),
        sa.Column(
            'month',
            sa.Integer(),
            nullable=False
        ),
        sa.Column(
            'name',
            sa.String(length=100),
            nullable=False
        ),
        sa.Column(
            'price',
            sa.Integer(),
            nullable=False
        ),
        sa.PrimaryKeyConstraint('id')
    )

    op.create_table(
        'daily_sales',
        sa.Column(
            'id',
            sa.Integer(),
            nullable=False
        ),
        sa.Column(
            'product_id',
            sa.Integer(),
            nullable=False
        ),
        sa.Column(
            'date',
            sa.Date(),
            nullable=False
        ),
        sa.Column(
            'quantity',
            sa.Integer(),
            nullable=False
        ),
        sa.ForeignKeyConstraint(
            ['product_id'],
            ['products.id']
        ),
        sa.PrimaryKeyConstraint('id')
    )


def downgrade():
    op.drop_table('daily_sales')
    op.drop_table('products')

テーブルの作成順序

daily_sales.product_idproducts.idを参照します。

そのため、upgradeでは必ずproductsを先に作成します。

1. products
2. daily_sales

downgradeでは逆順に削除します。

1. daily_sales
2. products

参照元のdaily_salesを先に削除しないと、外部キー制約によってproductsを削除できない可能性があります。

is_activeを初期マイグレーションに含めなかった理由

初期マイグレーションでは、あえてis_activeを作成していません。

基礎revision
  └── productsをis_activeなしで作成
        ↓
既存revision
  └── is_activeを追加

これにより、既存の「あとからis_activeを追加した」という履歴を維持できます。

初期マイグレーション単体ではなく、headまで適用した最終状態が現在のモデルと一致する構成です。

既存revisionの変更

既存の043c481b4069では、revisionの接続情報だけを変更しました。

変更前:

revision = '043c481b4069'
down_revision = None

変更後:

revision = '043c481b4069'
down_revision = 'b7e2c4a91f30'

ドキュメント文字列のRevisesも更新しました。

-Revises:
+Revises: b7e2c4a91f30

既存のupgrade()downgrade()の処理本体には触れていません。

quantityへserver_defaultを追加しなかった理由

DailySales.quantityには、モデル側でdefault=0があります。

quantity = db.Column(
    db.Integer,
    nullable=False,
    default=0
)

このdefault=0は、SQLAlchemyがINSERT時に補うPython側のデフォルトです。

DB側のデフォルトを表すserver_defaultではありません。

そのため、初期マイグレーションには次の設定を追加しませんでした。

server_default='0'

追加してしまうと、現在のモデルが表していないDB側の仕様を新たに作ることになります。

静的検証

DBを起動する前に、マイグレーションファイルの構文を確認しました。

PYTHONPYCACHEPREFIX=/tmp/sales_data_app_pycompile \
python -m py_compile \
migrations/versions/b7e2c4a91f30_create_initial_tables.py \
migrations/versions/043c481b4069_add_is_active_to_products.py

結果は正常でした。

終了コード: 0
構文エラー: なし

revisionも次の1本道になっていることを確認しました。

base
  ↓
b7e2c4a91f30
  ↓
043c481b4069
  ↓
head

空DBからの初期構築検証

通常のローカルDBへ影響を与えないよう、Composeのプロジェクト名を変更して、完全に分離した検証環境を作成しました。

docker compose \
  -p sales_data_app_migration_test \
  up --build -d

通常環境と検証環境では、コンテナ・ネットワーク・ボリュームが別名になります。

通常環境:
sales_data_app_postgres_data

検証環境:
sales_data_app_migration_test_postgres_data

検証用DBは完全な空DBです。

起動ログでは、次の順序でマイグレーションが実行されました。

Running upgrade  -> b7e2c4a91f30,
create initial products and daily_sales tables

Running upgrade b7e2c4a91f30 -> 043c481b4069,
add is_active to products

その後、Gunicornも正常に起動しました。

Starting gunicorn 26.0.0
Listening at: http://0.0.0.0:5000
Booting worker

HTTPアクセスも成功しました。

200 text/html; charset=utf-8

空DBに作成された構造

作成されたテーブルは次の3つです。

alembic_version
products
daily_sales

products

NULL DBデフォルト
id integer 不可 IDシーケンス
year integer 不可 なし
month integer 不可 なし
name varchar(100) 不可 なし
price integer 不可 なし
is_active boolean 不可 true

daily_sales

NULL DBデフォルト
id integer 不可 IDシーケンス
product_id integer 不可 なし
date date 不可 なし
quantity integer 不可 なし

外部キーも想定どおり作成されました。

daily_sales.product_id
  ↓
products.id

削除規則は次のとおりです。

ON DELETE: NO ACTION
ON UPDATE: NO ACTION

モデルに存在しないON DELETE CASCADEは追加されていません。

また、以下も追加されていないことを確認しました。

  • 独立したUNIQUE制約
  • CHECK制約
  • モデルにないインデックス
  • quantityのDB側デフォルト

最終revisionは次のとおりです。

043c481b4069

既存DB相当の検証

次に確認したのは、すでに043c481b4069まで適用済みの既存DBで、追加した初期revisionが再実行されないことです。

既存DBそのものへ直接マイグレーションを実行するのではなく、次の手順を取りました。

通常DBを読み取り専用で起動
  ↓
pg_dumpを取得
  ↓
通常DBを停止
  ↓
別の検証用PostgreSQLへ復元
  ↓
複製DBだけにflask db upgradeを実行

通常DBのWebコンテナは起動していません。

ダンプ取得時には、読み取り専用設定を使用しました。

PGOPTIONS=-c default_transaction_read_only=on

複製DBでのupgrade

複製DBの状態は次のとおりでした。

alembic_version = 043c481b4069
products = 16件
daily_sales = 16件

この状態で、複製DBだけにマイグレーションを実行しました。

docker compose \
  -p sales_data_app_existing_migration_test \
  run --rm --no-deps --build \
  web flask db upgrade

結果は正常終了しました。

終了コード: 0

ログには次の表示がありませんでした。

Running upgrade -> b7e2c4a91f30
create initial products and daily_sales tables

CREATE TABLEやテーブル重複エラーも発生していません。

つまり、Alembicは現在のDBをすでにheadへ到達している状態として扱い、あとから祖先に追加された基礎revisionを再実行しませんでした。

upgrade前後の比較

upgrade前後で、以下を比較しました。

  • alembic_version
  • テーブル一覧
  • 列名
  • データ型
  • NULL制約
  • DBデフォルト
  • 主キー
  • 外部キー
  • UNIQUE制約
  • CHECK制約
  • インデックス
  • シーケンス状態
  • productsの全行
  • daily_salesの全行

全行を主キー順のCSVに出力し、cmpとSHA-256ハッシュで比較しました。

結果はすべて完全一致でした。

比較対象 upgrade前 upgrade後
revision 043c481b4069 043c481b4069
products 16件 16件
daily_sales 16件 16件
スキーマ 一致 一致
制約 一致 一致
インデックス 一致 一致
シーケンス 一致 一致
全データ 一致 一致

これにより、次の2点を確認できました。

空DB:
基礎revisionが実行される

既存DB:
基礎revisionは再実行されない

pytest

最後に、既存テストを実行しました。

PYTHONDONTWRITEBYTECODE=1 \
pytest -p no:cacheprovider

結果:

3 passed in 0.06s

Git差分

変更したのは、マイグレーション関連の2ファイルだけです。

migrations/versions/
├── b7e2c4a91f30_create_initial_tables.py
└── 043c481b4069_add_is_active_to_products.py

コミット内容:

9a4422e fix: add initial database migration

今回の学び

1. 既存DBで動くことと、空DBから再構築できることは別

既存DBに必要なテーブルが残っていれば、マイグレーション履歴が不完全でもアプリは動くことがあります。

しかし、新しい開発環境、テスト環境、別PC、再デプロイ先では、空DBから構築できることが重要です。

既存環境で起動できる
≠
マイグレーション履歴が正しい

2. マイグレーションは現在のスキーマだけでなく、到達経路も重要

モデルと現在のDB構造が一致していても、それだけでは十分ではありません。

空DB
  ↓
各revisionを順番に適用
  ↓
現在のモデルと一致

この到達経路が成立している必要があります。

3. 適用済みrevisionの変更は慎重に検証する

今回は既存revisionのdown_revisionを変更しました。

設計上は成立していても、既存DBでどのように扱われるかは、実際の複製DBで確認する必要があります。

今回は、既存DBを直接変更せず、pg_dumpから作った複製DBで検証しました。

4. 空DBと既存DBの両方を試す

マイグレーション修正では、少なくとも次の2経路を確認する必要があります。

1. 空DB → upgrade head
2. 適用済みDB → upgrade head

片方だけでは、新規環境か既存環境のどちらかを壊す可能性があります。

5. 検証環境の分離は名前だけでも大きな効果がある

Composeのプロジェクト名を変更することで、通常環境とは別のコンテナ・ネットワーク・ボリュームを作成できます。

docker compose -p migration_test ...

ただし、実行前には必ず実際のボリューム名や接続先を確認することが重要です。

6. ⚠ AIには変更内容だけでなく、停止条件も伝える ⚠

Codexへ指示するときは、実装内容だけでなく以下も明示しました。

  • 今回は調査だけ
  • まだDBを起動しない
  • 問題を見つけても勝手に直さない
  • 通常DBには書き込まない
  • 削除前に対象を提示して承認を待つ
  • commit・pushは最終確認後に行う

AIに作業を依頼する場合、「何をするか」 だけでなく 「どこで止まるか」 を指定することで、意図しない変更を防ぎやすくなります。

まとめ

今回の問題は、最初のマイグレーションがテーブル作成ではなく、既存テーブルへのカラム追加から始まっていたことでした。

修正前:

空DB
  ↓
productsへis_active追加
  ↓
productsが存在せず失敗

修正後:

空DB
  ↓
products作成
  ↓
daily_sales作成
  ↓
productsへis_active追加
  ↓
アプリ起動

さらに、既存DB相当の複製環境でも、基礎revisionが再実行されず、スキーマやデータが変化しないことを確認しました。

マイグレーションは、現在のDBでアプリが動くかだけではなく、

空のデータベースから、履歴だけを使って現在の状態へ到達できるか

まで確認する必要があると学びました。


0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?