Prismaのマイグレーションをダウンタイムなしで行う運用

Prismaのマイグレーションをダウンタイムなしで行う運用

はじめに

PrismaはNode.js/TypeScript向けのORMで、弊社ではPostgreSQLと一緒に一部プロダクトで採用しています。

Prismaはschema.prismaの定義からPrisma Clientを生成し、アプリケーションはそのClientを通じてDBにアクセスします。このとき、Prisma Clientが想定するDBスキーマと実際のDBスキーマが一致していなければなりません。

デプロイ時にはアプリケーション(Prisma Client)とDBマイグレーションのどちらかが先に反映されるため、一時的にこの2つで不整合を起こす瞬間があります。その際に一部のケースではエラーを起こすようなクエリがDBに対して行われ、結果的にプロダクトにダウンタイムが生まれます。

たとえば弊社にはQANT スピークと呼ばれる24時間電話を受け付けるボイスボットプロダクトがあり、わずかなダウンタイムであってもエンドユーザーからクライアント企業への電話の瞬断といった大きな問題を引き起こします。

この不整合がエラーを引き起こすかどうかは、概ねカラムの追加か削除か、そしてどちらを先に反映するかといった要因によって変わります。この記事では実際に検証した結果をもとに、弊社での例を紹介します。

ここからは以下のUserモデルを例に説明します。

model User {
  id     Int     @id @default(autoincrement())
  email  String  @unique
  name   String
}

Prisma Clientが発行するクエリ

まず、Prisma Clientがどのようなクエリを発行するかを確認します。

const users = await prisma.user.findMany()

このコードは、以下のようなSQLを発行します。

SELECT "public"."User"."id", "public"."User"."email", "public"."User"."name"
FROM "public"."User" WHERE 1=1 OFFSET $1

重要なポイントとして、Prisma Clientはselectを指定しない場合スキーマに定義されたカラムを明示的にすべてSQLに含めるということが挙げられます。SELECT * を利用しているわけではなく、この挙動がマイグレーション時の問題に関わってきます。

カラム追加の場合

Userモデルに status カラムを追加するケースを考えます。

model User {
  id     Int     @id @default(autoincrement())
  email  String  @unique
  name   String
  status String  @default("active")
}

この新しいスキーマでPrisma Clientを生成すると、発行されるクエリが以下のようになります。

SELECT "public"."User"."id", "public"."User"."email", "public"."User"."name", "public"."User"."status"
FROM "public"."User" WHERE 1=1 OFFSET $1

もしアプリケーションを先にデプロイしてDBマイグレーションがまだの状態だと、DBに status カラムが存在しないため、エラーが発生します。

The column `User.status` does not exist in the current database.

カラム追加の場合、DBマイグレーションを先に実行すればこの問題はほぼ起こりません。新しいカラムがDBに存在していても、古いPrisma Clientはそのカラムを知らないため、単に無視するだけです。

そのため、CIでDBマイグレーションをアプリのデプロイより先に実行する形にしておくのが基本的な対策になります。

カラム削除の場合

カラム追加とは逆に、カラムを削除する場合はやや手順が複雑になります。

カラム追加と同じ理由で、DB側を先に削除してしまうとPrisma Clientがまだそのカラムを参照するクエリを発行してエラーとなります。そのためアプリ側で先にカラムの参照をなくす必要がありますが、CIでDBマイグレーションを先行して実行する運用にしている場合、単純に反転させることはできません。場合によってマイグレーションとアプリケーションデプロイを変えるのはオペレーションの複雑化を招くため、弊社では行っていません。

そこで、以下の3ステップに分けてデプロイします。対象カラムがDEFAULTを持つ場合はステップ1をスキップできます。

// ステップ1: カラムをnullableにする
model User {
  id     Int     @id @default(autoincrement())
  email  String  @unique
  name   String?
}

// ステップ2: @ignore追加
model User {
  id     Int     @id @default(autoincrement())
  email  String  @unique
  name   String? @ignore
}

// ステップ3: フィールド削除(DROP COLUMN)
model User {
  id     Int     @id @default(autoincrement())
  email  String  @unique
}

以下、各ステップの意図を説明します。

ステップ1

ステップ2で @ignore をつけると、Prisma ClientはINSERT時にそのカラムの値を送らなくなります。カラムがNOT NULLのままだとDBがINSERTを拒否してしまうため、先んじてnullableにしておく必要があります。

DEFAULTが設定されているカラムであればDB側がデフォルト値を補完するため、このステップはスキップできます。

ステップ2

@ignore をつけるとPrisma Clientにとって存在しないカラムとして扱われ、発行されるクエリにも含まれなくなります。DBにはまだカラムが存在していますが、アクセスしないため問題ありません。

また、@ignore は実際のDBに対しては影響を与えないので、prisma migrate dev を実行してもDROPマイグレーションは生成されません。これにより、アプリ側だけを先に更新できます。

型定義からもカラムが消えるため、そのカラムを参照しているアプリケーションコードも同時に修正する必要があります。

ステップ3

ステップ2の時点でPrisma Clientはこのカラムを参照していないため、DBからカラムを削除してもエラーは発生しません。スキーマから @ignore つきのフィールドを削除し、DROPマイグレーションを実行します。

まとめ

  • Prisma Clientの参照するカラムがDBに常に存在するような状態を維持する
  • DBのマイグレーションを先に行うようにCIを設定する
  • DROPのオペレーションには複数の段階が必要

終わりに

必要なら、『業務でのPrismaの利用』『ボイスボットプロダクトの開発』といったあなたの関心に合わせて、以下で気軽にお話しできます。