この投稿は「グレンジ Advent Calendar 2024」の3日目の記事です。
こんにちは、株式会社グレンジでサーバーサイドエンジニアをしています。
島田(@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が最も手軽に利用できそうだったため選択しました。
本記事執筆時点でCloud Storageのボリュームマウントのリリースステータスは「pre-GA」であることに注意してください。
マイグレーション機構の全体構成
上述の背景と技術選定の結果、以下の図に示すような構成で構築しました。
- マイグレーションファイルのアップロード
- Cloud Run Jobの起動
a. 起動時にCloud Storageをボリュームマウント - Cloud Spannerに対してマイグレーション実行
マイグレーション機構の構築手順
※Google Cloudプロジェクトの設定・Consoleの操作、Terraformの設定、Cloud Storageのバケット作成、Cloud Spannerのインスタンス作成については、本筋でないため割愛します。
1. コンテナイメージの作成
Cloud Run Jobでマイグレーション実行するためのコンテナイメージを作成します。
イメージはCloud Buildでビルドし、Artifact Registryにpushしておきます。
実際のマイグレーション処理にはgolang-migrate/migrateを使用しています。
migrate.shは指定したデータベースが未作成なら作成し、golang-migrate/migrateコマンドでマイグレーションを実行します。
images
└── migrate-spanner
├── Dockerfile
├── cloud-build.yaml
└── migrate.sh
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
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
#!/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ロールを設定します。
terraform
└── modules
├── run-job
│ └── main.tf
└── iam
├── service-account
│ └── main.tf
└── custom-role
└── 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
}
}
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
}
}
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}"
}
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
},
]
}
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
}
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
}
}
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
DROP TABLE User
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:
- 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に対してマイグレーションが実行されていることが確認できます。
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さんの記事が投稿される予定です。お楽しみに!