Cloud Run JobでCloud Spannerのマイグレーションを実行する

この投稿は「[グレンジ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/grenge)」の3日目の記事です。

こんにちは、[株式会社グレンジ](https://www.grenge.co.jp/)でサーバーサイドエンジニアをしています。
島田（[@konnyaku256](https://x.com/konnyaku256)）です。

先日、Google Cloudを用いたクラウドインフラ構築業務を行いました。
その中で、アプリケーションのデータを管理するデータベースに対するスキーママイグレーション機構を構築しました。
データベースとしては、Cloud SpannerやCloud SQLを使用しました。

この投稿では、特にCloud Spannerに対するスキーママイグレーションに焦点を当てて、構築内容や動作例をまとめます。

### 背景

今回のマイグレーション対象はCloud SpannerとCloud SQLの2つでした。
Cloud Spannerについては、すでにCloud Buildによるマイグレーションが実装されていました。
また、アプリケーションはCloud Runで実行しており、Cloud SQLにはDirect VPC Egressを使用して接続していました。
この構成の場合、Cloud SQLについては、Cloud Buildによるマイグレーションを実装するよりも、アプリケーションと同じ仕組みで接続したほうがメンテナンスしやすいと考えました。
そうなると、Cloud SpannerについてもCloud SQLと同様の構成にすることでメンテナンスがさらに容易になると判断しました。

### 技術選定

そこで、マイグレーションの実行環境としてCloud Run Jobを選択しました。
Cloud Runには一般的に使用されているServiceとは別に、Jobという実行環境があり、これが今回の用途に適していると考えました。
また、実行環境と適用するスキーマのデータは分離しておきたかったため、Cloud Runのボリュームマウントを活用することにしました。
ボリュームマウントとしては、いくつかの選択肢がありましたが、Cloud Storageが最も手軽に利用できそうだったため選択しました。

https://cloud.google.com/run/docs/create-jobs?hl=ja

https://cloud.google.com/run/docs/configuring/jobs/cloud-storage-volume-mounts?hl=ja

:::note warn
本記事執筆時点でCloud Storageのボリュームマウントのリリースステータスは「pre-GA」であることに注意してください。
:::

### マイグレーション機構の全体構成

上述の背景と技術選定の結果、以下の図に示すような構成で構築しました。

![マイグレーション機構の全体構成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/942071/04a00d44-a7d4-a199-432c-51038a4fecf0.png)

1. マイグレーションファイルのアップロード
2. Cloud Run Jobの起動
    a. 起動時にCloud Storageをボリュームマウント
3. Cloud Spannerに対してマイグレーション実行

### マイグレーション機構の構築手順

※Google Cloudプロジェクトの設定・Consoleの操作、Terraformの設定、Cloud Storageのバケット作成、Cloud Spannerのインスタンス作成については、本筋でないため割愛します。

#### 1. コンテナイメージの作成

Cloud Run Jobでマイグレーション実行するためのコンテナイメージを作成します。
イメージはCloud Buildでビルドし、Artifact Registryにpushしておきます。

実際のマイグレーション処理には[golang-migrate/migrate](https://github.com/golang-migrate/migrate)を使用しています。
migrate.shは指定したデータベースが未作成なら作成し、golang-migrate/migrateコマンドでマイグレーションを実行します。

```sh:ディレクトリ構成
images
└── migrate-spanner
    ├── Dockerfile
    ├── cloud-build.yaml
    └── migrate.sh
```

```cloud-build.yaml
steps:
  # build image
  - name: 'gcr.io/cloud-builders/docker'
    args: ['build', '--build-arg', 'GO_BASE_IMAGE=${_GAR_REGION}/${PROJECT_ID}/${_REPOSITORY_NAME}/${_GO_BASE_IMAGE}', '--build-arg', 'BASE_IMAGE=${_GAR_REGION}/${PROJECT_ID}/${_REPOSITORY_NAME}/${_BASE_IMAGE}', '-f', 'build/${_REPOSITORY_NAME}/${_IMAGE_NAME}/Dockerfile', '-t', '${_GAR_REGION}/${PROJECT_ID}/${_REPOSITORY_NAME}/${_IMAGE_NAME}:${_IMAGE_TAG}', '.']
  # tagging
  - name: 'gcr.io/cloud-builders/docker'
    args: ['tag', '${_GAR_REGION}/${PROJECT_ID}/${_REPOSITORY_NAME}/${_IMAGE_NAME}:${_IMAGE_TAG}', '${_GAR_REGION}/${PROJECT_ID}/${_REPOSITORY_NAME}/${_IMAGE_NAME}:${_IMAGE_TAG}']
# saving image
images:
  - '${_GAR_REGION}/${PROJECT_ID}/${_REPOSITORY_NAME}/${_IMAGE_NAME}'
substitutions:
  # GAR(Google Artifact Registry) region name to push image
  _GAR_REGION: asia-northeast1-docker.pkg.dev
  # Image name
  _REPOSITORY_NAME: sample
  # Image name
  _IMAGE_NAME: migrate-spanner
  # Image tag
  _IMAGE_TAG: 0.0.1
  # Go Base Image
  _GO_BASE_IMAGE: golang:1.23.0-bookworm
  # Base Image
  _BASE_IMAGE: google/cloud-sdk:496.0.0-alpine
```

```Dockerfile:Dockerfile
ARG GO_BASE_IMAGE
ARG BASE_IMAGE

FROM ${GO_BASE_IMAGE} as builder

# https://github.com/golang-migrate/migrate/tree/master/cmd/migrate
RUN apt-get update && apt-get install -y lsb-release && \
    curl -L https://packagecloud.io/golang-migrate/migrate/gpgkey | apt-key add - && \
    echo "deb https://packagecloud.io/golang-migrate/migrate/debian/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/migrate.list && \
    apt-get update -y && \
    apt-get install -y migrate

###############################################################################

FROM ${BASE_IMAGE}

COPY --from=builder /usr/bin/migrate /usr/bin

COPY ./images/migrate-spanner/migrate.sh /migrate.sh
RUN chmod 755 /migrate.sh

CMD /migrate.sh
```

```migrate.sh
#!/bin/bash

set -eo pipefail

count=$(gcloud spanner databases list --instance ${SPANNER_INSTANCE_ID} --filter "name=projects/${PROJECT_ID}/instances/${SPANNER_INSTANCE_ID}/databases/${SPANNER_DATABASE_ID}" --format "value(name)" | wc -l)
if [ $count -eq 0 ]; then
  gcloud spanner databases create ${SPANNER_DATABASE_ID} --instance ${SPANNER_INSTANCE_ID}
fi

migrate -path ${SPANNER_MIGRATIONS_PATH} -database "spanner://projects/${PROJECT_ID}/instances/${SPANNER_INSTANCE_ID}/databases/${SPANNER_DATABASE_ID}?x-clean-statements=True" -verbose up
```

Cloud Buildでコンテナイメージをビルドし、Artifact Registryにpushするコマンド
```
$ gcloud builds submit --config=images/migrate-spanner/cloud-build.yaml \
      --substitutions=_IMAGE_TAG="0.0.1"
```

#### 2. Cloud Run Jobの作成

手順1で作成したコンテナイメージを使ってマイグレーションを実行するCloud Run Jobを作成します。
Cloud StorageはReadOnlyでボリュームマウントします。
また、Cloud Run Jobを実行するサービスアカウントにCloud Storageのオブジェクト読み取りとCloud Spannerへのマイグレーション実行に必要な権限のみを付与したカスタムIAMロールを設定します。

```sh:ディレクトリ構成
terraform
└── modules
    ├── run-job
    │   └── main.tf
    └── iam
        ├── service-account
        │   └── main.tf
        └── custom-role
            └── main.tf
```

```terraform/modules/run-job/main.tf
variable "name" {
  type = string
}

variable "location" {
  type = string
}

variable "run_job" {
  type = object({
    image              = string
    memory             = string
    cpu                = string
    timeout            = string
    max_retries        = number
    parallelism        = number
    task_count         = number
    volume_bucket_name = string
    volume_mount_path  = string
    service_account    = string
  })
}

resource "google_cloud_run_v2_job" "this" {
  name     = var.name
  location = var.location

  template {
    template {
      volumes {
        name = var.run_job.volume_bucket_name
        gcs {
          bucket    = var.run_job.volume_bucket_name
          read_only = true
        }
      }

      containers {
        image = var.run_job.image

        resources {
          limits = {
            memory = var.run_job.memory
            cpu    = var.run_job.cpu
          }
        }

        volume_mounts {
          name       = var.run_job.volume_bucket_name
          mount_path = var.run_job.volume_mount_path
        }
      }

      execution_environment = "EXECUTION_ENVIRONMENT_GEN2"
      timeout               = var.run_job.timeout
      max_retries           = var.run_job.max_retries

      service_account = var.run_job.service_account
    }

    parallelism = var.run_job.parallelism
    task_count  = var.run_job.task_count
  }
}
```

```tf:terraform/modules/run-job/main.tfのinputs例
inputs = {
  name     = "migrate-spanner"
  location = asia-northeast1
  run_job = {
    image              = "asia-northeast1-docker.pkg.dev/project-id-999999/sample/migrate-spanner:0.0.1"
    memory             = 521Mi
    cpu                = 1
    timeout            = 600s
    max_retries        = 1
    parallelism        = 1
    task_count         = 1
    volume_bucket_name = project-id-999999-migrate
    volume_mount_path  = "/mnt/bucket"
    service_account    = migrate-spanner@project-id-999999.iam.gserviceaccount.com
  }
}
```

```terraform/modules/iam/service-account/main.tf
variable "project" {
  type    = string
  default = null
}

variable "service_account_name" {
  type    = string
  default = null
}

resource "google_service_account" "this" {
  count = var.service_account_name != null ? 1 : 0

  account_id   = var.service_account_name
  display_name = "${var.service_account_name} SA"
}

variable "service_account_id" {
  type    = string
  default = null
}

data "google_service_account" "this" {
  count = var.service_account_id != null ? 1 : 0

  project    = var.project
  account_id = var.service_account_id
}

variable "service_account_email" {
  type    = string
  default = null
}

locals {
  service_account_email = var.service_account_email != null ? var.service_account_email : try(google_service_account.this[0].email, try(data.google_service_account.this[0].email, null))
}

variable "google_project_iam_role_list" {
  type = list(object({
    name = string
  }))
  default = []
}

resource "google_project_iam_member" "this" {
  for_each = { for role in var.google_project_iam_role_list : role.name => role }

  project = var.project
  role    = each.value.name
  member  = "serviceAccount:${local.service_account_email}"
}

variable "google_spanner_instance_iam_role_list" {
  type = list(object({
    name          = string
    instance_name = string
  }))
  default = []
}

resource "google_spanner_instance_iam_member" "this" {
  for_each = { for role in var.google_spanner_instance_iam_role_list : "${role.name}_${role.instance_name}" => role }

  instance = each.value.instance_name
  role     = each.value.name
  member   = "serviceAccount:${local.service_account_email}"
}

variable "google_storage_bucket_iam_member_list" {
  type = list(object({
    role        = string
    bucket_name = string
  }))
  default = []
}

resource "google_storage_bucket_iam_member" "this" {
  for_each = { for role in var.google_storage_bucket_iam_member_list : "${role.role}_${role.bucket_name}" => role }

  bucket = each.value.bucket_name
  role   = each.value.role
  member = "serviceAccount:${local.service_account_email}"
}
```

```hcl:terraform/modules/iam/service-account/main.tfのinputs例
inputs = {
  project              = project-id-999999
  service_account_name = "migrate-spanner"

  google_project_iam_role_list = [
    {
      name = migrate-spanner-project
    },
  ]

  google_spanner_instance_iam_role_list = [
    {
      name          = migrate-spanner
      instance_name = sample
    },
  ]

  google_storage_bucket_iam_member_list = [
    {
      role        = migrate-spanner
      bucket_name = project-id-999999-migrate
    },
  ]
}
```

```terraform/modules/iam/custom-role/main.tf
variable "project" {
  type = object({
    id = string
  })
}
variable "name" {}

variable "google_project_iam_custom_role" {
  type = object({
    permissions    = list(string)
    suffix_version = number
  })
}

resource "google_project_iam_custom_role" "this" {
  role_id     = replace("${var.name}${var.google_project_iam_custom_role.suffix_version}", "-", "_")
  title       = "${var.name} role"
  description = "${var.name} role"
  permissions = var.google_project_iam_custom_role.permissions
}
```

```tf:terraform/modules/iam/custom-role/main.tfのinputs例その1
inputs = {
  project = project-id-999999
  name    = "migrate-spanner"

  google_project_iam_custom_role = {
    permissions = [
      # Spanner
      "spanner.sessions.create",
      "spanner.sessions.get",
      "spanner.sessions.delete",
      "spanner.sessions.list",
      "spanner.databaseOperations.get",
      "spanner.databases.create",
      "spanner.databases.list",
      "spanner.databases.updateDdl",
      "spanner.databases.beginReadOnlyTransaction",
      "spanner.databases.beginOrRollbackReadWriteTransaction",
      "spanner.databases.read",
      "spanner.databases.select",
      "spanner.databases.write",
      # Storage
      "storage.buckets.get",
      "storage.objects.get",
      "storage.objects.list",
    ]
    suffix_version = 1
  }
}
```

```tf:terraform/modules/iam/custom-role/main.tfのinputs例その2
inputs = {
  project = project-id-999999
  name    = "migrate-spanner-project"

  google_project_iam_custom_role = {
    permissions = [
      "artifactregistry.repositories.downloadArtifacts",
      "logging.logEntries.create",
    ]
    suffix_version = 1
  }
}
```

#### 3. マイグレーションファイルのアップロード

Cloud Run JobにマウントするCloud Storageのバケットにマイグレーションファイルをアップロードします。
マイグレーションファイルの例を下記に示します。

```:ディレクトリ構成
migrations
├── 20241106143440000_CreateTableUser.down.sql
└── 20241106143440000_CreateTableUser.up.sql
```

```20241106143440000_CreateTableUser.down.sql
DROP TABLE User
```

```20241106143440000_CreateTableUser.up.sql
CREATE TABLE User (
    UserID STRING(36) NOT NULL,
    CreatedAt TIMESTAMP NOT NULL OPTIONS(allow_commit_timestamp = true),
    UpdatedAt TIMESTAMP NOT NULL OPTIONS(allow_commit_timestamp = true)
) PRIMARY KEY (
    UserID
)
```

※手順3は実際には一連のデプロイ処理の中に組み込むと良いです。

ここまでで、マイグレーション機構の構築は完了です。

### マイグレーション機構の実行

ConsoleやgcloudコマンドなどでCloud Run Jobを実行します。
実行時に下記の環境変数を設定します。

```:env
env:
  - name: PROJECT_ID
    value: project-id-999999
  - name: SPANNER_INSTANCE_ID
    value: sample
  - name: SPANNER_DATABASE_ID
    value: sample
  - name: SPANNER_MIGRATIONS_PATH
    value: "/mnt/bucket/migrations"
```

実行すると下記のようなログがCloud Loggingに出力されます。
Cloud Spannerに対してマイグレーションが実行されていることが確認できます。

```:Cloud Logging
2024-11-14 17:06:12.212 JST
Cloud Storage FUSE will be mounted to an internal path not accessible from customer containers
2024-11-14 17:06:12.270 JST
time="14/11/2024 08:06:12.269109" severity=INFO message="Start gcsfuse/2.5.1 (Go version go1.23.0) for app \"serverless\" using mount point: /var/lib/volumes/gcs/project-id-999999-migrate\n"
2024-11-14 17:06:12.270 JST
time="14/11/2024 08:06:12.269965" severity=INFO message="GCSFuse config" config="&{AppName:serverless CacheDir: Debug:{ExitOnInvariantViolation:false Fuse:false Gcs:false LogMutex:false} EnableHns:true FileCache:{CacheFileForRangeRead:false DownloadChunkSizeMb:50 EnableCrc:false EnableODirect:false EnableParallelDownloads:false MaxParallelDownloads:16 MaxSizeMb:-1 ParallelDownloadsPerFile:16 WriteBufferSize:4194304} FileSystem:{DirMode:511 DisableParallelDirops:false FileMode:438 FuseOptions:[ro allow_other] Gid:-1 IgnoreInterrupts:true KernelListCacheTtlSecs:0 RenameDirLimit:0 TempDir: Uid:-1} Foreground:true GcsAuth:{AnonymousAccess:false KeyFile: ReuseTokenFromUrl:true TokenUrl:} GcsConnection:{BillingProject: ClientProtocol:http1 CustomEndpoint: ExperimentalEnableJsonRead:false GrpcConnPoolSize:1 HttpClientTimeout:0s LimitBytesPerSec:-1 LimitOpsPerSec:-1 MaxConnsPerHost:0 MaxIdleConnsPerHost:100 SequentialReadSizeMb:200} GcsRetries:{MaxRetryAttempts:0 MaxRetrySleep:30s Multiplier:2} ImplicitDirs:true List:{EnableEmptyManagedFolders:false} Logging:{FilePath:/mnt/logging/gcs/system Format:text LogRotate:{BackupFileCount:10 Compress:true MaxFileSizeMb:512} Severity:INFO} MetadataCache:{DeprecatedStatCacheCapacity:20460 DeprecatedStatCacheTtl:1m0s DeprecatedTypeCacheTtl:1m0s EnableNonexistentTypeCache:false ExperimentalMetadataPrefetchOnMount:disabled StatCacheMaxSizeMb:32 TtlSecs:60 TypeCacheMaxSizeMb:4} Metrics:{PrometheusPort:0 StackdriverExportInterval:0s} Monitoring:{ExperimentalOpentelemetryCollectorAddress: ExperimentalTracingMode: ExperimentalTracingSamplingRatio:0} OnlyDir: Write:{CreateEmptyFile:false}}"
2024-11-14 17:06:12.271 JST
time="14/11/2024 08:06:12.270301" severity=INFO message="Creating Storage handle..."
2024-11-14 17:06:12.271 JST
time="14/11/2024 08:06:12.270518" severity=INFO message="UserAgent = gcsfuse/2.5.1 (Go version go1.23.0) (GPN:gcsfuse-serverless) (Cfg:0:0:0)\n"
2024-11-14 17:06:12.277 JST
time="14/11/2024 08:06:12.276928" severity=INFO message="WARNING: DirectPath is misconfigured. Please set the EnableDirectPath option along with the EnableDirectPathXds option."
2024-11-14 17:06:12.278 JST
time="14/11/2024 08:06:12.278514" severity=INFO message="Creating a mount at \"/var/lib/volumes/gcs/project-id-999999-migrate\"\n"
2024-11-14 17:06:12.280 JST
time="14/11/2024 08:06:12.278878" severity=INFO message="Creating a new server...\n"
2024-11-14 17:06:12.281 JST
time="14/11/2024 08:06:12.279256" severity=INFO message="Set up root directory for bucket project-id-999999-migrate"
2024-11-14 17:06:12.486 JST
time="14/11/2024 08:06:12.486300" severity=INFO message="Mounting file system \"project-id-999999-migrate\"..."
2024-11-14 17:06:12.487 JST
time="14/11/2024 08:06:12.487485" severity=INFO message="File system has been successfully mounted."
2024-11-14 17:06:18.908 JST
2024/11/14 08:06:18 Start buffering 20241106143440000/u CreateTableUser
2024-11-14 17:06:18.955 JST
2024/11/14 08:06:18 Read and execute 20241106143440000/u CreateTableUser
2024-11-14 17:06:55.421 JST
2024/11/14 08:06:55 Finished 20241106143440000/u CreateTableUser (read 54.640496ms, ran 36.459576958s)
2024-11-14 17:06:55.421 JST
2024/11/14 08:06:55 Finished after 36.550477553s
2024-11-14 17:06:55.421 JST
2024/11/14 08:06:55 Closing source and database
2024-11-14 17:06:55.449 JST
Container called exit(0).
```

### まとめ

タイトルの通り、Cloud Run JobでCloud Spannerのマイグレーションを実行することができました。
Cloud Run Jobはバッチジョブ的な実行が可能なため、マイグレーション用途でも十分活用できそうです。
また、Cloud Run Jobから何かしらのファイルを参照したい場合は、Cloud Storageのボリュームマウントを使うことでファイルシステムの一部のように扱えてお手軽でした。

データベースのマイグレーション機構を構築する際の参考になれば幸いです。

明日は、[@harashima318](https://qiita.com/harashima318)さんの記事が投稿される予定です。お楽しみに！