🚅

Cloud Run JobsでAlloyDBへのスキーママイグレーションをしてみた

2023/03/30に公開

はじめに

こんにちは!

現在開発しているプロダクトにおいてアプリケーションをCloud Runにデプロイしています。

開発当初はデータベースにCloud SQL for PostgreSQL(以下 Cloud SQL)を選択していましたがプロダクトがグロースした際に高負荷に耐えうるようにAlloyDB for PostgresSQL(以下 AlloyDB)への移行をしました。

Cloud SQLの場合、例えばGKEですとInit Containersなどのサーバーアプリケーション実行前のプリプロセスで、Cloud RunだとCDワークフローの中でサーバーアプリケーションのロールアウトの前段でクレデンシャルを用いたCloud SQL Proxyを介してマイグレーションを実行することが多いと思います(主観)。

Proxy依存になってしまうのと追々に柔軟な技術選定の見直しなどができなくなってしまうのと開発環境では費用を抑えてCloud SQLを利用し本番環境ではAlloyDBを利用するなどコストの最適化も行えないです。

また、将来的にPostgresSQL互換でよりマッチしたデータベースが登場することもあり得るわけでCloud Runにアプリケーションをデプロイしてしていることを前提としてより汎用的なスキーママイグレーションの方法ないかと模索しCloud Run Jobsを用いたマイグレーションを選択した経緯をシェアしたいと思います。

想定読者

  • Cloud RunからPostgres互換のAlloyDBとは別のデータベースを利用しているがAlloyDBに移行しようと考えている方
  • Cloud Run Jobsを用いてAlloyDBへのスキーママイグレーションがしたい方

全体像

本記事で紹介する内容の全体像です。

前提として

今回はAlloyDBに関しては触れません。
AlloyDBクラスタとプライマリインスタンスが作成されていることを前提とします。

Cloud Run Jobsって何?

バッチ処理などの任意の言語で書かれたコンテナイメージを実行できるコンテナランタイムです。公式ドキュメントはこちら

Cloud Runとの違いは

  • ポートのリッスンを行えない
    • サーバーなどのリクエストをリッスンするようなリアクティブなプロセスは行えない
  • 正常に終了した際は終了ステータス(Exit)0を異常の場合は1を返すプロセスしか扱えない
    などです。

用途としては完結を前提としたプロセスに向いているようなので今回の要件であるスキーママイグレーションには適していると判断しました。

実践

実際にコード例やワークフロー例を元に実践的にスキーママイグレーション流れを紹介したいと思います。

利用技術

  • Go
  • Github Actions
    • CDパイプラインとして利用
  • entgo
    • テーブルスキーマ管理,ORM
  • Atlas
    • データベーススキーマ管理ツール
  • golang-migrate
    • マイグレーション実行ツール

ディレクトリ構成

.
├── .github
│   └── workflows
│       └── cd.yaml // CDワークフロー定義
├── Dockerfile // サーバー用のDockerfileと仮定
├── Dockerfile.migrate // マイグレーション用のDockerfile
├── ent
│   └── schema
│      └── company.go // データベーススキーマを定義するためのコード
├── go.mod
├── go.sum
├── migrate.go // Cloud Run Jobsでコンテナ化するコード
├── migrations  // マイグレーションファイルの格納場所 コンテナイメージにマウントされます。
└── server.go // Cloud Run で実行されるサーバーのコード

Cloud Run Jobsで実行するタスクのコードを作成する

migrate.go

Cloud Run Jobsでコンテナ化するGoのコードの例です。参考

  • write()
    • SQL を生成するメソッド
  • up()
    • マイグレーションをロールフォワードするメソッド
migrate.go
package main

import (
	"context"
	"database/sql"
	"flag"
	"log"
	"os"

	"ariga.io/atlas/sql/sqltool"
	"entgo.io/ent/dialect"
	entsql "entgo.io/ent/dialect/sql"
	"entgo.io/ent/dialect/sql/schema"
	"entgo.io/ent/entc"
	"entgo.io/ent/entc/gen"
	"github.com/golang-migrate/migrate/v4"
	_ "github.com/golang-migrate/migrate/v4/database/postgres"
	_ "github.com/golang-migrate/migrate/v4/source/file"
	_ "github.com/lib/pq"
)

var dataSource = "postgres://user:password@localhost:5432/db?sslmode=disable"

func main() {
	var (
		err           error
		ctx           = context.Background()
		method        = flag.String("method", "write", "method")
		migrationName = flag.String("name", "name", "migration name")
	)
	flag.Parse()

	switch *method {
	case "write":
		err = write(ctx, migrationName)
	case "up":
		err = up()
	}
	if err != nil {
		os.Exit(1)
	}
	log.Println("succeeded!")
}

func write(ctx context.Context, migrationName *string) error {
	db, err := sql.Open(dialect.Postgres, dataSource)
	if err != nil {
		return err
	}
	graph, err := entc.LoadGraph("./ent/schema", &gen.Config{})
	if err != nil {
		return err
	}
	tbls, err := graph.Tables()
	if err != nil {
		return err
	}
	dir, err := sqltool.NewGolangMigrateDir("./migrations")
	if err != nil {
		return err
	}
	s, err := schema.NewMigrate(entsql.OpenDB(dialect.Postgres, db),
		schema.WithDir(dir),
		schema.WithDialect(dialect.Postgres),
		schema.WithFormatter(sqltool.GolangMigrateFormatter),
	)
	if err != nil {
		return err
	}

	return s.NamedDiff(ctx, *migrationName, tbls...)
}

func up() error {
	m, err := migrate.New("file://migrations", dataSource)
	if err != nil {
		return err
	}
	for {
		if err := m.Steps(1); err != nil {
			if os.IsNotExist(err) {
				break
			}
			return err
		}
		v, _, err := m.Version()
		if err != nil {
			return err
		}
		log.Printf("current version is %v\n", v)
	}

	return nil
}

コンテナ化するためのDockerfileの例です。

Dockerfile.migrate
FROM golang:1.20.1-alpine AS base
WORKDIR /go/src/migrate
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -o /go/bin/migrate .
COPY migrations /migrations
FROM scratch
COPY --from=base /go/bin/migrate .
COPY --from=base /migrations migrations
ENTRYPOINT ["./migrate"]

テーブルスキーマ構造を定義する

マイグレーションを実行するSQLを手動で作成するとコード上の型との乖離が発生したりと何かと障害の起因になり得るのでentを用いてGoで定義したテーブルスキーマ構造体からSQLやORMのコードを生成します。

ent/schema/company.go
package schema

import (
	"entgo.io/ent"
	"entgo.io/ent/dialect"
	"entgo.io/ent/schema/field"
	"entgo.io/ent/schema/index"
)

type Company struct {
	ent.Schema
}

func (Company) Fields() []ent.Field {
	return []ent.Field{
		field.String("id").
			SchemaType(map[string]string{
				dialect.Postgres: "uuid",
			}).
			StorageKey("company_id").
			Immutable(),
		field.String("name"),
	}
}

func (Company) Indexes() []ent.Index {
	return []ent.Index{
		index.Fields("name").Unique(),
	}
} 

SQLを生成する

writeメソッドを利用してSQLを生成します。

$ go run . -method write -name init
succeeded!

実行すると./migrations以下にマイグレーションファイルが生成されています。

.
├── ent
│   └── schema
│      └── company.go
├── migrate.go
└── migrations
    ├── 20230330013250_init.down.sql // ロールバック
    ├── 20230330013250_init.up.sql //  ロールフォワード
    └── atlas.sum // マイグレーションの競合を検出するためのファイル

生成されたSQLが下記です。

migrations/20230330013250_init.up.sql
-- create "companies" table
CREATE TABLE "companies" ("company_id" uuid NOT NULL, "name" character varying NOT NULL, PRIMARY KEY ("company_id"));
-- create index "company_name" to table: "companies"
CREATE UNIQUE INDEX "company_name" ON "companies" ("name");
migrations/20230330013250_init.down.sql
-- reverse: create index "company_name" to table: "companies"
DROP INDEX "company_name";
-- reverse: create "companies" table
DROP TABLE "companies";

ここまでが下準備でした。。

Cloud Run Jobsを作成する


今回マイグレーションで使用するためのCloud Run Jobsを作成します。今回は例示なのでgcloudで作成します。

$ gcloud beta run jobs create company-service-migrate \ 
	--image asia.gcr.io/organization/company-service-migrate
	--project="{$GCP_PROJECT}" \
	--region="us-west1" \
	--args="-method up" // ロールフォワードを実行するための引数

その他のフラグに関してはこちら
また、Terraformで定義したい場合はこちらを参考ください。

Cloud Run Jobsを実行する


マイグレーションファイルやコードが改変されGithubへプッシュされたタイミングでGithub ActionsでJobsの実行とサーバーのデプロイを実行するGithub Actionsのワークフロー例です。

.github/workflows/cd.yaml
name: cd
on: push
jobs:
  build_and_push:
    steps:
      - name: Build
        run: |
        # サーバーとマイグレーションのコンテナイメージをビルド
          docker build -t asia.gcr.io/organization/company-service .
          docker build -t asia.gcr.io/organization/company-service-migrate -f Dockerfile.migrate .
      - name: Push
        run: |
        # GCR にプッシュ
          docker push asia.gcr.io/organization/company-service .
          docker push asia.gcr.io/organization/company-service-migrate
  deploy:
    steps:
      - uses: google-github-actions/setup-gcloud@v0.2.0
      - name: Run Migrate Job
        run: |
          gcloud components install beta --quiet
          gcloud beta run jobs update company-service-migrate \
            --image asia.gcr.io/organization/company-service-migrate \
            --region us-west1
          gcloud beta run jobs execute compnany-service-migrate --region us-west1 --wait
      - name: Deploy
        uses: google-github-actions/deploy-cloudrun@v0.10.3
        with:
          image: asia.gcr.io/organization/company-service
          region: 'us-west1'

gcloud beta run jobs update でコンテナイメージを更新し
gcloud beta run jobs execute で実際のJobsの実行を行います。

Cloud Logging で確認

Cloud Logging
current version is 20230330013250  // 更新されたマイグレーションのバージョン
succeeded!
Container called exit(0).

無事Jobsが実行されていることが確認できました。

テーブルが作成されているか確認する

VPC内のAlloyDBのインスタンスには直接アクセスすることができないのでVPCに中継役となるGCE[1]作成しPostgres ClientとAlloyDB Auth Proxyインストールしプライマリインスタンスとのプロキシプロセスをバックグラウンド実行します。

GCEインスタンス内における各種インストールとバックグラウンド実行例

sudo apt-get update
sudo apt-get install --yes postgresql-client wget
wget https://storage.googleapis.com/alloydb-auth-proxy/v1.2.1/alloydb-auth-proxy.linux.amd64 -O alloydb-auth-proxy
chmod +x alloydb-auth-proxy
./alloydb-auth-proxy projects/{$GCP_PROJECT}/locations/us-west1/clusters/cluster/instances/instance &

中継役のGCEインスタンスにはセキュリティの観点からPublicIPアドレスは公開せずともIdentity-Aware Proxy(以下 IAP)を利用しGCEインスタンスに対して送信元IP範囲:35.235.240.0/20[2]とSSHで接続するためのTCPポート22の許可ルールを適用することでGCEインスタンスでバッググラウンドプロセスしているAlloyDB Auth Proxyを介して安全にAlloyDBインスタンスと接続することができます。

ローカルからテーブルが作成されているか確認する

$ gcloud compute ssh gce-instance \
	--project $(PROJECT_ID) \
	--tunnel-through-iap \ // IAP を介して接続を明示
	--zone=us-west1-a \
	--ssh-flag="-NL 5432:localhost:5432"
--tunnel-through-iap

IAPを介しての接続を明示

--ssh-flag

-N ポートフォワーディングのみ行いたいとき(version2のみ)
-L ローカルフォワード
5432:localhost:5432 ローカルポート5432から接続先ポート5432にポートフォワード

db=# \dt
 Schema |       Name        | Type  | Owner 
--------+-------------------+-------+-------
 public | companies         | table | user   // 作成されたテーブル
 public | schema_migrations | table | user   // 履歴管理テーブル
(2 rows)

db=# \d companies                 
   Column   |       Type        | Collation | Nullable | Default 
------------+-------------------+-----------+----------+---------
 company_id | uuid              |           | not null | 
 name       | character varying |           | not null | 
Indexes:
    "companies_pkey" PRIMARY KEY, btree (company_id)
    "company_name" UNIQUE, btree (name)

無事作成されていることが確認されました。

[追い検証]カラムを追加してみる

テーブル作成のマイグレーションの確認は取れましたがテーブル構造の変更のマイグレーションも確認してみたいと思います。

Goのスキーマ構造を変更

ent/schema/company.go
  StorageKey("company_id").
  Immutable(),		
  field.String("name"),
+ field.String("address"),

カラム追加のSQL作成

$ go run . -method write -name add_address
migrations
  ├── 20230330013250_init.down.sql
  ├── 20230330013250_init.up.sql
+ ├── 20230330050530_add_address.down.sql
+ ├── 20230330050530_add_address.up.sql
  └── atlas.sum

GithubにプッシュしCloud Run Jobsを再実行します。

IAPを介して再度確認をしてみます。

  db=# \d companies
 ------------+-------------------+-----------+----------+---------
  ompany_id  | uuid              |           | not null | 
   name       | character varying |           | not null | 
+ address    | character varying |           | not null |

カラムの追加も確認することができました。

まとめ

Cloud Run Jobsがpre-GAとなってから活用している事例をあまり見受けられなかったのでAlloyDBへのスキーママイグレーションに利用している模様を紹介できて少しでもCloud Run Jobs活用への糸口になれば幸いです。

個人的にはパラレルなタスクにも向いてそうなので何か別の切り口で活用できないかを模索していこうと思います。

最後まで見ていただいた方がいらっしゃいましたらありがとうございます。

脚注
  1. 作成したGCEインスタンスにはサービスアカウントを介してroles/alloydb.clientを付与することを忘れないようにしてください。 ↩︎

  2. IAPの接続元アドレスです。 ↩︎

Discussion