🔑

Cloud Build で Prisma マイグレーション(Secret Manager利用)

2022/01/28に公開

Google Cloud Build でのマイグレーションについて調べる機会があり、Prismaで試してみることにしました。その記録を残します。

要点

  • Prismaは、開発用コマンドでマイグレーションファイルを作成し、本番用コマンドでそれを本番DBへ適用する流れです
  • Cloud Build から本番用コマンドを実行するためには、Cloud SQL Proxy を通して Cloud SQL へつなげる必要がある。ここSecret Managerを使います
  • Cloud Build ではAppEngineの環境をラップしたコンテナイメージが呼べるので、それを使ってマイグレーションコマンドを実行します

バージョン情報

名前 バージョン
Prisma 3.8.1
Google Cloud SQL PostgreSQL 14

ローカル環境でPrismaのマイグレーションファイルを生成する

Prismaは開発用コマンドでマイグレーションファイルを作成します。実際に試して、流れを追ってみましょう。

Prisma CLIをインストール

まずはPrisma CLIからです。公式ドキュメントをみながら進めます。

https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases-typescript-postgres

ターミナル
mkdir google-cloud-prisma-migrate
cd google-cloud-prisma-migrate
yarn init --yes

yarn add -D prisma typescript ts-node @types/node 

次にtsconfig.jsonを用意します。Prismaですが、データーベースのスキーマ情報から便利なTypeScriptの型を自動生成してくれます。セットでTypeScriptも使うことになるため、tsconfig.jsonも一緒に用意します。

tsconfig.json
{
  "compilerOptions": {
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "lib": ["esnext"],
    "esModuleInterop": true
  }
}

初期化します。

ターミナル
yarn prisma init

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

この時点ではデータベースへ接続するための設定ファイルを作成するだけですので、DBが起動している必要はありません。

ローカルのDBと接続する

https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases/connect-your-database-typescript-postgres

yarn prisma initschema.prismaが生成されるはずです。このファイルを正としてDBへ接続したりマイグレーションファイルを生成します。次のような記述があるはずです。

prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

環境変数DATABASE_URLを利用して接続することがわかります。ローカルDBのために、docker composeを使いましょう。

docker-compose.yml
version: "3"

volumes:
  db-data:

services:
  db:
    image: postgres:14
    container_name: google-cloud-prisma-migrate-db
    volumes:
      - db-data:/var/lib/postgresql/google_cloud_prisma_migrate_db_development/data
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password

起動します。

ターミナル
docker compose up

次に環境変数DATABASE_URLをセットします。公式ドキュメントでは.envを使う方針となっていますが、準備が楽という理由でdirenvを使います。

ターミナル
pwd
> google-cloud-prisma-migrate
direnv edit .

.envrc の編集画面になるはずです。

.envrc
export DATABASE_URL="postgresql://postgres:password@localhost:5432/google_cloud_prisma_migrate_db_development?schema=public"

保存後、ターミナルでdirenv allowとしてください。これで、google-cloud-prisma-migrateディレクトリにおいてDATABASE_URLが設定されます。準備OKです。

マイグレーションファイルの生成

DBと接続する準備ができたら、スキーマファイルを編集します。さきほど「正になる」と話をした schema.prismaファイルへテーブル情報を追加してください。ブログサイトを想定して、記事情報を格納するためのテーブルとします。

prisma/schema.prisma
+model Post {
+  id           String    @id @default(uuid())
+  title        String
+  emoji        String?
+  published    Boolean?  @default(false)
+  publishDate  DateTime? @map("publish_date")
+  like         Int       @default(0)
+  createdAt    DateTime  @default(now()) @map("created_at")
+  updatedAt    DateTime  @updatedAt @map("updated_at")
+
+  @@map("posts")
+}

デフォルトだとテーブル名がパスカルケースに、カラム名がキャメルケースになるため、できるだけ馴染み深いRailsのモデル構造と合わせたい理由から@@mapを使って名前を変えています。詳細はスキーマリファレンスを参考にしてください。

あとは開発用マイグレーションコマンドを実行します。

ターミナル
yarn prisma migrate dev --name init

このコマンドでふたつの仕事が行われます。

  1. prisma/migrations/20220127104449_init/migration.sql の生成
  2. ローカルDB(DATABASE_URLでつながっている先)にマイグレーション実行

マイグレーションファイルは、本番適用時にこれが差分として参照されます。よって、gitなどでのバージョン管理対象です。SQLビューワでローカルDBへアクセスすると、データベースとテーブルが生成されていることがわかります。

ローカルで行うDBへの作業はこれで完了です。せっかくなので、postsと一緒に作成されているテーブル、_prisma_migrationsについても軽く触れておきます。Ruby on Railsなどを触ったことがある方は察するところがあるかもしれません。ドキュメントによると以下のチェック用途です:

  • データベースに対してマイグレーションが実行されたかどうか
  • 適用されたマイグレーションが削除されたかどうか
  • 適用されたマイグレーションが変更されたかどうか

というわけでマイグレーションの履歴を保持しておき、整合性を維持するのに役立っているようですね。

Cloud Build から Cloud SQL へマイグレーション実行

本題です。Cloud SQLにたてたPostgreSQLに対して、Cloud Buildからマイグレーションを実行します。次のように作業します。

  • cloudbuild.ymlとDockerfileを作成
  • Terraform で Cloud SQL と Artifact Registry を作成
  • Cloud Build トリガーを実行

cloudbuild.ymlとDockerfileを作成

まずは本番環境に対するPrismaのマイグレーション方法を確認しましょう。

https://www.prisma.io/docs/concepts/components/prisma-migrate#production-and-testing-environments

In production and testing environments, use the migrate deploy command to apply migrations:
npx prisma migrate deploy

というわけで prisma migrate deploy コマンドを打つことで適用できるようです。シンプルですね。

次に、Google Cloud Build でDBマイグレーションを行う方法を調べます。

[cloud build migration]

https://qiita.com/kshibata101/items/fd8f5f10ff4c99504b9a

https://qiita.com/Taillook/items/d3854620983a00f9445f

https://medium.com/@jiraffestaff/実は難しい-cloud-build-から-cloud-sql-への接続-c77cb75bc813

およ...?ちょっとここは先人も苦労している様子?ただ、Cloud SQL Proxyを使う点は共通していそう。DBに接続できさえすればあとはprisma migrate deployでいけることはわかっているので、調べ方を変えます。

[cloud build sql proxy]

https://cloud.google.com/sql/docs/postgres/connect-admin-proxy

もう少し具体的な例が欲しいですね。DBマイグレーションといえばRailsなので、その線で探してみます。

[cloud build rails migration]

https://cloud.google.com/ruby/rails/run#automation_with

cloudbuild.yaml ファイルは、一般的なイメージ ビルドステップ(コンテナ イメージを作成して Container Registry に push する)だけでなく、Rails データベースの移行も行います。これにはデータベースへのアクセスが必要です。これは、app-engine-exec-wrapper(Cloud SQL Auth プロキシのヘルパー)を使用して実行されます。

コレダ。「Cloud Buildによる自動化」というさらりとしたパラグラフですが少し強調してもいいと思います!まさにこれですね。この記述です。

サイト上のcloudbuild.yml(一部)
  - id: "apply migrations"
    name: "gcr.io/google-appengine/exec-wrapper"
    entrypoint: "bash"
    args:
      [
        "-c",
        "/buildstep/execute.sh -i gcr.io/${PROJECT_ID}/${_SERVICE_NAME} -s ${PROJECT_ID}:${_REGION}:${_INSTANCE_NAME} -e RAILS_MASTER_KEY=$$RAILS_KEY -- bundle exec rails db:migrate"
      ]
    secretEnv: ["RAILS_KEY"]

掘り下げたい要素がふたつあります:

  1. gcr.io/google-appengine/exec-wrapper って何
  2. secretEnv: ["RAILS_KEY"]って何

gcr.io/google-appengine/exec-wrapperって何

AppEngineフレキシブル環境のラッパーだそうです。Google Cloud の人が、このようなマイグレーション用途で便利なイメージがあるといいよねってことで用意してくれてるみたいですね。

https://github.com/GoogleCloudPlatform/ruby-docker/tree/master/app-engine-exec-wrapper

This is a wrapper Docker image that sets up an environment similar to the Google App Engine Flexible Environment, suitable for running scripts and maintenance tasks provided by an application deployed to App Engine. In particular, it ensures a suitable Cloud SQL Proxy is running in the environment.
Its driving use case is running production database migrations for Ruby on Rails applications, but it is also useful for Django applications, and we expect similar uses for other languages and frameworks.

READMEに書いてある利用例をみると、

steps:
- name: "gcr.io/google-appengine/exec-wrapper"
  args: ["-i", "gcr.io/my-project/appengine/some-long-name",
         "-e", "ENV_VARIABLE_1=value1", "-e", "ENV_2=value2",
         "-s", "my-project:us-central1:my_cloudsql_instance",
         "--", "bundle", "exec", "rake", "db:migrate"]

このようになっています。

  • -i: 実行イメージを指定。この記事の場合、Prismaが実行できるコンテナイメージを用意することになりそう。
  • -e: 環境変数を指定。Prismaを使うならば、DATABASE_URLをここで指定することになりそう。
  • -s: Cloud SQLのインスタンスを指定。
  • 以降、マイグレーションコマンド

基本はRuby on Railsを想定しているようですが、コンテナイメージを指定するのでどんな環境でもいいはずです。最初、このラッパーを見つけるまでは大変そうな印象だったのですが、gcr.io/google-appengine/exec-wrapperを使えばCloud SQLに接続した状況が楽に作れそうです。

secretEnv: ["RAILS_KEY"]って何

もうひとつ明らかにしたいポイントです。RAILS_KEYにように、マイグレーション実行時に秘匿情報が必要な状況もあります。Prismaを使う例でいくと、DATABASE_URLにはDBのユーザー名やパスワードも含むことになりそうなので、DATABASE_URLが秘匿情報にあたります。Cloud Build でシークレットを扱うには以下の記事が参考になります。

https://zenn.dev/catnose99/articles/6cb0fc434a4a62

ポイントを抜粋すると、

シークレットを参照するためにはentrypointとしてbashを指定する必要があります。
argsは-cから始めるようにします。-cの後に続く文字列が実行コマンドとなります。
シークレット名には$$というプレフィックスをつけるようにします。
https://cloud.google.com/build/docs/securing-builds/use-secrets#configuring_builds_to_access_the_secret_from

この部分で、gcr.io/google-appengine/exec-wrapperとSecret Managerを組み合わせるために、Google Cloud のRailsチュートリアルであったような書き方になっているのですね。

いったん今わかる範囲で、cloudbuild.ymlの成果物はこんな感じとイメージできます。

cloudbuild.yml
steps:
  - id: "apply-migrations"
    name: "gcr.io/google-appengine/exec-wrapper"
    entrypoint: "bash"
    args:
      - "-c"
      - "/buildstep/execute.sh -i Prismaが使えるコンテナイメージ -e DATABASE_URL=$$DATABASE_URL -s $_CLOUDSQL_INSTANCE_FULL_NAME -- yarn prisma migrate deploy"
    secretEnv: ["DATABASE_URL"]
    dir: "google-cloud-prisma-migrate"
timeout: 2000s
substitutions:
  _CLOUDSQL_INSTANCE_FULL_NAME: "Cloud SQL インスタンスの名前(my-project:us-central1:my_cloudsql_instance みたいなやつ)"
  _ARTIFACT_REPOSITORY_IMAGE_NAME: "Artifact Registry にあるイメージ名"
availableSecrets:
  secretManager:
    - versionName: projects/$PROJECT_ID/secrets/DATABASE_URL/versions/latest
      env: DATABASE_URL

イメージが必要とわかったので、マイグレーションを実行できるDockerイメージを作ります。

Dockerfileを作成

この例ではアプリケーションを動かすわけではないので、PrismaのCLIが打てればOKです。あまり難しく考えずに以下のように定義します。

Dockerfile
# syntax=docker/dockerfile:1
FROM node:16

# Prisma CLIをdevDependenciesでインストールしたため
ENV NODE_ENV=development

# マイグレーションで必要
RUN apt-get -qy update
RUN apt-get -qy install openssl

WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
COPY prisma ./prisma
RUN yarn install
COPY . .
CMD ["yarn", "prisma", "migrate", "deploy"]

このイメージを作成する作業もcloudbuild.ymlで行います(後述)。

Terraform で Cloud SQL と Artifact Registry を作成

Google Cloud 上のリソースを作成します。Cloud SQL はいいとして、Artifact Registryが必要な理由を説明します。AppEngineのラッパーイメージであるgcr.io/google-appengine/exec-wrapperはコマンド実行にあたりイメージを要求します。もともと、CloudRunアプリケーションを想定しているようで、任意のオペレーションコマンド(マイグレーションやRake Taskなど)を実行する手段としてこのラッパーを提供しているようです。今回はアプリケーションを動かす予定はありませんが、イメージを作成します。そして、そのイメージを保存し、Cloud Build から読み出せる場所としてArtifact Registryのリポジトリが必要となります。

準備(別記事に任せる)

Google Cloud のリソースを Terraform で扱うには準備が必要です。こちらの記事にまとめているので、plan可能なところまで進めます。

https://zenn.dev/waddy/articles/terraform-google-cloud

Cloud SQL の定義

今回は Artifact Registry に加えて Cloud SQL も必要です。モジュールを定義します。

modules/cloud-sql/cloud-sql.tf
terraform {
variable "target_region" {
  description = "デプロイするリージョン"
  type        = string
  default     = "us-central1"
}

# https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/sql_database
resource "google_sql_database_instance" "prisma-migrate" {
  name                = "prisma-migrate"
  database_version    = "POSTGRES_14"
  region              = var.target_region
  deletion_protection = false # 検証で作成するため、あとで消したい

  settings {
    tier              = "db-f1-micro"
    availability_type = "REGIONAL"
    disk_size         = "20"
    disk_type         = "PD_SSD"

    ip_configuration {
      ipv4_enabled = "true"
    }
  }
}

resource "google_sql_database" "google-cloud-prisma-migrate-db" {
  name     = "google_cloud_prisma_migrate_db"
  instance = google_sql_database_instance.prisma-migrate.name
}

# ref: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/sql_database_instance#attributes-reference
output "prisma_migrate_connection_name" {
  value = google_sql_database_instance.prisma-migrate.connection_name
}

余談ですが、us-central1リージョンでかなり費用は抑えられるものの、割高ですのであとで削除を忘れないようにしましょう。

Cloud Build の定義

cloudbuild.ymlを使ってビルドを実行するためのリソースです。正確には、ビルド自体はcloudbuild.ymlを使うので、Terraform で作成するのはCloud Build トリガーというリソースです。

modules/cloud-build/cloud-build.tf
variable "github_owner" {}
variable "github_app_repo_name" {}
variable "region" {}
variable "gcp_project_id" {}
variable "app_name" {}
variable "cloudsql_instance_full_name" {}

resource "google_cloudbuild_trigger" "deploy-migrate" {
  name        = "deploy-migrate"
  description = "prisma deployを実行する"
  github {
    owner = var.github_owner
    name  = var.github_app_repo_name
    push {
      branch = "^main$"
    }
  }
  included_files = ["google-cloud-prisma-migrate/**"]
  filename       = "google-cloud-prisma-migrate/cloudbuild.yml"
  substitutions = {
    _CLOUDSQL_INSTANCE_FULL_NAME    = var.cloudsql_instance_full_name
    _ARTIFACT_REPOSITORY_IMAGE_NAME = "${var.region}-docker.pkg.dev/${var.gcp_project_id}/backend/${var.app_name}"
  }
}

substitutionsでCloud SQLのインスタンス名とArtifact Registryにあるイメージ名を渡しています。

main.tf を修正

モジュールを定義したら、これらを main.tf から呼びます。

main.tf
terraform {
  required_version = "~> 1.0.0"
  backend "gcs" {
+    # 他のおためしリソースとバッティングしないよう、専用の状態管理ファイルをつくる
+    prefix = "tfstate/google-cloud-prisma-migrate" 
  }
}

## project ##
provider "google" {
  project = var.gcp_project_id
  region  = var.primary_region
}

# イメージを保存する Artifact Registry のリポジトリ
module "artifact-registry" {
  source                     = "./modules/artifact-registry"
  gcp_project_id             = var.gcp_project_id
  artifact_registry_location = var.primary_region
}

+# Cloud SQL
+module "cloud-sql" {
+  source        = "./modules/cloud-sql"
+  target_region = var.primary_region
+}
+
+# マイグレーションを実行する Cloud Build
+module "cloud-build" {
+  source                      = "./modules/cloud-build"
+  gcp_project_id              = var.gcp_project_id
+  region                      = var.primary_region
+  cloudsql_instance_full_name = module.cloud-sql.prisma_migrate_connection_name
+  app_name                    = "google-cloud-prisma-migrte"
+  github_owner                = "cm-wada-yusuke"
+  github_app_repo_name        = "gql-nest-prisma-training"
+}

gcp_project_idなど、いくつか足りない変数があります。これらは作業する方に依存するいわば秘匿情報ですので、バージョン管理対象にできません。terrafrom.tfvarsを各々作成してください。

terraform.tfvars
# https://www.terraform.io/cli/config/environment-variables

primary_region = "us-central1" # 検証目的ではこのリージョンが安くて無難です
gcp_project_id = "your-project-id"

Terraform のファイル作成は完了です。

apply 前に、GitHubリポジトリをマッピング

GitHubトリガーでCloud Buildを作る場合、あらかじめGitHubリポジトリと接続しておく必要があります。Cloud Build の画面から以下のようにして接続してください。

apply

ここまでできたらapplyできます。

ターミナル
./tf.sh init # 定義したモジュールの読み込み
./tf.sh apply 

...(Cloud SQL のデプロイでかなり時間がかかります)

Artifact Registry のリポジトリ、Cloud SQL、Cloud Build トリガーが作成されていればOKです。もし、Google Cloud admin APIが必要と言われたら、指示にしたがって有効にし、再実行してください。

cloudbuild.yml を修正

いまの状態でもCloud Build トリガーは実行できますが、cloudbuild.ymlが不完全なため、失敗します。うやむやにしていたところが明らかになったので、cloudbuild.ymlを完成させましょう。

  • コンテナイメージのビルド
  • Artifact Registry へプッシュ
  • プッシュしたイメージを使ってマイグレーション実行

この流れをビルドファイルへ反映します。

cloudbuild.yml
steps:
  - id: build
    name: "docker"
    args:
      - build
      - --file=Dockerfile
      - "--tag=$_ARTIFACT_REPOSITORY_IMAGE_NAME:latest"
      - --cache-from=$_ARTIFACT_REPOSITORY_IMAGE_NAME:latest
      - .
    dir: "google-cloud-prisma-migrate"
  - id: push
    name: "docker"
    args:
      - push
      - --all-tags
      - $_ARTIFACT_REPOSITORY_IMAGE_NAME
    dir: "google-cloud-prisma-migrate"
    waitFor: ["build"]
  - id: "apply-migrations"
    name: "gcr.io/google-appengine/exec-wrapper"
    entrypoint: "bash"
    args:
      - "-c"
      - "/buildstep/execute.sh -i $_ARTIFACT_REPOSITORY_IMAGE_NAME:latest -e DATABASE_URL=$$PRISMA_MIGRATE_DATABASE_URL -s $_CLOUDSQL_INSTANCE_FULL_NAME -- yarn prisma migrate deploy"
    secretEnv: ["PRISMA_MIGRATE_DATABASE_URL"]
    dir: "google-cloud-prisma-migrate"
    waitFor: ["push"]
timeout: 2000s
substitutions:
  _CLOUDSQL_INSTANCE_FULL_NAME: by-terraform
  _ARTIFACT_REPOSITORY_IMAGE_NAME: by-terraform
availableSecrets:
  secretManager:
    - versionName: projects/$PROJECT_ID/secrets/PRISMA_MIGRATE_DATABASE_URL/versions/latest
      env: PRISMA_MIGRATE_DATABASE_URL

# ビルド結果に生成したイメージ情報を表示する
# https://cloud.google.com/build/docs/building/build-containers
images:
  - $_ARTIFACT_REPOSITORY_IMAGE_NAME:latest

Secret Manager へ DATABASE_URL を登録

マイグレーションに必要なPRISMA_MIGRATE_DATABASE_URLを登録します。登録方法はいろいろありますが、一般的にSecret Managerへ登録するということは秘匿情報なので手作業が安全ではなかろうかという考えです。画面から登録します。

  • 名前: PRISMA_MIGRATE_DATABASE_URL
  • シークレットの値: postgres://postgres:<postgresユーザーのパスワード>@localhost:5432/google_cloud_prisma_migrate_db?host=<CloudSQLのインスタンス名>
    • 例: postgres://postgres:password@localhost:5432/google_cloud_prisma_migrate_db?host=/cloudsql/sample-project:us-central1:prisma-migrate

DBユーザーのパスワードは Cloud SQL の画面から変更できますので、新しいパスワードに設定した上でこちらのシークレットを作成してください。

マイグレーション実行

mainブランチのpushへ反応するようCloud Buildトリガーを作成したので、変更をプッシュします。これで、Cloud Build トリガーが起動するはずです。

完了後、ビルドログで、マイグレーションが実行されたっぽいテキスト出力を確認できれば成功です。

Cloud SQL Proxy でローカル接続

ローカルから接続して確認します。docker compose のファイルを作成します。

docker-compose.sql-proxy.yml
version: "3"

# Cloud SQL 環境のDBにアクセスするプロキシ
services:
  prisma-migrate-db:
    image: "gcr.io/cloudsql-docker/gce-proxy:latest"
    container_name: prisma-migrate-db
    command:
      [
        "/cloud_sql_proxy",
        "-instances=$CLOUDSQL_PROXY_CONNECTION_NAME=tcp:0.0.0.0:5432",
        "-credential_file=/gcp-key.google-cloud-prisma-migrate.json",
      ]
    volumes:
      - type: bind # ファイルが存在しなければエラーとする
        source: "~/.config/gcloud/application_default_credentials.json"
        target: "/gcp-key.google-cloud-prisma-migrate.json"
    ports:
      - "45432:5432" # ローカルのDBとぶつからないようにポート変更

direnv edit . として CLOUDSQL_PROXY_CONNECTION_NAMEを追加します。設定する値は、Cloud SQL の接続名ですので、your-project-name:us-central1:prisma-migrateのような文字列です。その後、docker composeで起動します。

ターミナル
docker compose -f docker-compose.sql-proxy.yml up

このDockerコンテナを起動している間、SQLクライアントからは、ローカルDBへ接続する要領で設定できます。この場合のデータベースURLはpostgresql://localhost:45432/google_cloud_prisma_migrate_dbです。ユーザー名とパスワードはCloud SQL上のものを指定する必要があります。

Cloud SQL上のデータベースもマイグレーションが実行され、テーブルとカラムが作成されていることがわかります。

後片付け

Cloud SQL インスタンスはそれなりに高いので、検証が終わったら掃除しましょう。

ターミナル
./tf.sh destroy

以上で完了です。

おわりに

先人様方の知恵を集めて、Cloud Build から Prisma マイグレーションを実行できました。一度実行できたら、以降は同じ流れです。

  1. ローカル環境で yarn prisma migrate dev --name modify など開発作業
  2. main ブランチにマージなりプッシュなり行う
  3. Cloud Build トリガーが走り、yarn prisma migrate deployが走る
  4. 未適用のマイグレーションがすべてCloud SQLへ適用される

最初の設定がやや大変ですが、一度やってしまえばあとは自動でローカルDBとCloud SQLの同期がとれるので便利ですね。皆様が Cloud Build でマイグレーションを実行する際の助けになれば幸いです。

ソースコード

GitHubで公開しています。大丈夫だとは思うのですが万が一秘匿情報が含まれていたらこっそり教えてください...

https://github.com/cm-wada-yusuke/gql-nest-prisma-training/tree/main/google-cloud-prisma-migrate

Discussion