💎

Cloud Spannerの無料トライアルがGAしたので、Rails on Cloud Run X Cloud Spannerを構築した話

2022/09/22に公開

はじめに

先日、Cloud Spannerの無料トライアルが来ました。

個人的にCloud Spannerについて気になってはいたのですが、個人でお試しで使うには少々高価なので、気が引けたのですが無料となれば話は別。さっそく使ってみました。

作成に使用したコード等は下記リポジトリに公開しておりますので、どなたでも試すことは可能です。
(あくまで試用のため、比較的単純なデータ構造をとっています)

rails-cloud-spanner

※あらかじめGCPのプロジェクトやgcloud cliの準備は済ませておいてください。
また、使用するGCPのAPIは下記のとおりです。あらかじめ有効化をお願いします。

  • Cloud Spanner
  • Cloud Run
  • Cloud Build
  • Secret Manager
  • Cloud Key Mangement

本記事で取り扱うこと

  • アプリが稼働するCloud Run、Cloud Spannerに関する簡単な説明
  • 無料トライアル枠のSpannerを構築し、Cloud Run上で稼働するRailsと連携する方法
  • Spannerを用いるRailsをローカルで開発する方法

取り扱わないこと

  • RailsやSpanner、Cloud Run、Terraformなど使用技術の細かい仕様
  • Spannerの性能回り
  • SpannerやCloud Runのパフォーマンスチューニング
  • Railsのコードの内容やRailsでの開発の流れ

使用技術について

Cloud Spanner

Cloud Spanner (以下、Spanner) は優れたスケーラビリティ、強整合性、99.999%の可用性、自動シャーディング機能を備えたフルマネージド New SQLサービスです。

AWS の RDS やAurora、 GCP の Cloud SQL などのマネージド RDB サービスを使うとき、サイジング、フェイルオーバー、レプリケーション、リードレプリカなどの設計が必要になります。
Spanner はノード数とリージョンを設定するだけで構築でき、ノード数に関しても性能が足りなくなればダウンタイムなしで追加できます。
そのため従来の RDB サービスに比べてインフラとしての構築・運用コストは遥かに少なく済みます。
(ダウンタイムなしのスケーリングができるRDBサービスに関しては、2022年4月にAWSがAurora Serverless v2を一般提供しています)

ただしSpannerはその性能要件のため、通常のRDBサービスと比べても高価です。

本記事で作成するのは、無料トライアル枠での利用となりますので、スケーラビリティや可用性、シャーディングなどのSpannerの強みを最大限に活用できないことはあらかじめご承知ください。

※今回作成するSpannerインスタンスは90日経過後自動的に終了します。
その他トライアル枠の制約については下記をご参照ください。
https://cloud.google.com/spanner/docs/free-trial-instance

Cloud Run

Cloud Runはコンテナを実行するサーバーレスプラットフォームです。
コンテナイメージさえ事前に準備していれば、HTTPSかつ下記の要件を満たしてサービスを公開できます。

  • オートスケール
  • リビジョン管理
  • Blue/Greenデプロイ
  • ロギング
  • モニタリング

(AWSだとApp Runnerが近いです。Cloud Runは機密管理サービス(Secret Manager)で管理している機密情報を環境変数として使用できるなど、微妙に機能や課金体系が異なりますが)

また、2022年5月に通常のWebサービスだけではなく、バッチJobを実行できるCloud Run JobがPreview版として提供されるようになりました。

今回は使用するコンテナイメージは共通に、
Rails AppをCloud Run上で稼働し、migrateをCloud Run Jobで実行します。

構築

初期設定

GCPを用いるので、環境変数としてGCP周りの設定に関する値をセットします。
注意点としては、2022年9月22日現在、Spannerの無料トライアル枠をサポートしているリージョンは、
us-east5, europe-west3, asia-south2, asia-southeast2のみ
となっております。
どのリージョンでも構いませんが、例ではus-east5で構築しています。

# for terraforn and gcloud cli
export GOOGLE_APPLICATION_CREDENTIALS=YOUR_CREDENTIAL
export GOOGLE_PROJECT=YOUR_PROJECT
export PROJECT_ID=$GOOGLE_PROJECT
# spanner free-trial instance support region
# us-east5, europe-west3, asia-south2, asia-southeast2
export REGION=us-east5
export RAILS_MASTER_KEY=`cat config/master.key | tr -d \n `

(都度exportするのは手間なので、direnvで登録することをお勧めします)

バージョン情報

各言語・ライブラリ・ツール等のバージョンは以下の通りです。

  • Ruby 3.1.2
  • Rails 7.0.4
  • Terraform 1.2.9
  • google provider 4.36.0
  • Cloud SDK 401.0.0

ローカル環境について

ローカルの環境はdocker-composeで管理・構築しています。

docker-compse.ymlの中身を見てみましょう。

version: "3.9"
services:
  spanner:
    image: gcr.io/cloud-spanner-emulator/emulator:1.2.0
    ports:
      - "9010:9010"
      - "9020:9020"

  create_instance:
    image: gcr.io/google.com/cloudsdktool/cloud-sdk:332.0.0-slim
    command: >
      bash -c 'gcloud config configurations create emulator &&
              gcloud config set auth/disable_credentials true &&
              gcloud config set project $${PROJECT_ID} &&
              gcloud config set api_endpoint_overrides/spanner $${SPANNER_EMULATOR_URL} &&
              gcloud spanner instances create $${INSTANCE_NAME} --config=emulator-config --description=Emulator --nodes=1'
    environment:
      PROJECT_ID: project-dev
      SPANNER_EMULATOR_URL: http://spanner:9020/
      INSTANCE_NAME: test-instance

  web_app:
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/web_app
    ports:
      - "3000:3000"
    depends_on:
      - spanner
    environment:
      SPANNER_HOST: spanner:9010
      SPANNER_INSTANCE: test-instance
      PROJECT_ID: project-dev
      RAILS_ENV: development

spanner, create_instance, web_appの3つのserviceで構成されていますね。
それぞれを見てみましょう

spanner(emulator)

https://cloud.google.com/spanner/docs/emulator?hl=ja#docker
docker-compse.yml(一部抜粋)

  spanner:
    image: gcr.io/cloud-spanner-emulator/emulator:1.2.0
    ports:
      - "9010:9010"
      - "9020:9020"

Local上でspannerのように稼働するエミュレータです。
Rails(web_app)と通信を行います。

create_instance(spanner emulater上でspannerインスタンスを作成する)

https://cloud.google.com/spanner/docs/emulator?hl=ja#using_the_with_the_emulator
docker-compse.yml(一部抜粋)

  create_instance:
    image: gcr.io/google.com/cloudsdktool/cloud-sdk:332.0.0-slim
    command: >
      bash -c 'gcloud config configurations create emulator &&
              gcloud config set auth/disable_credentials true &&
              gcloud config set project $${PROJECT_ID} &&
              gcloud config set api_endpoint_overrides/spanner $${SPANNER_EMULATOR_URL} &&
              gcloud config set auth/disable_credentials true &&
              gcloud spanner instances create $${INSTANCE_NAME} --config=emulator-config --description=Emulator --nodes=1'
    environment:
      PROJECT_ID: project-dev
      SPANNER_EMULATOR_URL: http://spanner:9020/
      INSTANCE_NAME: test-instance

エミュレータ用のgcloud configを設定し、設定をもとにspanner emulater上にインスタンスを作成します。
注意点としては、spanner emulaterは起動中にメモリ上にデータを保存する仕様なので、一度でもエミュレータを落とすと、その時点で保管していたデータ(DB、テーブルごと)が初期化されます

web_app(Rails App)

docker-compse.yml(一部抜粋)

  web_app:
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/web_app
    ports:
      - "3000:3000"
    depends_on:
      - spanner
    environment:
      SPANNER_HOST: spanner:9010
      SPANNER_INSTANCE: test-instance
      PROJECT_ID: project-dev
      RAILS_ENV: development

コンテナ上に配置されたRails Appを立ち上げます。
環境変数として、SPANNER_HOST,SPANNER_INSTANCE,PROJECT_IDを渡しています。
これがspanner emulatorに対してruby-spanner-activerecordがアクセスするのに必要な情報になります。

https://github.com/googleapis/ruby-spanner-activerecord#database-connection
https://cloud.google.com/spanner/docs/emulator#client-libraries

config/database.yml

default: &default
  adapter: spanner
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  project: <%= ENV.fetch("PROJECT_ID") { "" } %>
  instance: <%= ENV.fetch("SPANNER_INSTANCE") { "" } %>
  timeout: 5000

development:
  <<: *default
  emulator_host: <%= ENV.fetch("SPANNER_HOST") { "" } %>
  database: spanner_test_dev

production:
  <<: *default
  database: spanner_test

※developmentはemulatorなのでauthを無効化し、productionはservice accountに権限を持たせているのでcredentialsの指定は不要

ローカル環境構築

ローカルでの環境構築には以下のコマンドから実行できます

make init

中身を見てみましょう。

Makefile(一部抜粋)

up: migrate
	docker compose up -d web_app

init: create_instance create_db build up

migrate:
	docker compose run --rm web_app bundle exec rails db:migrate

create_instance:
	docker compose up -d spanner
	docker compose run --rm create_instance

create_db:
	docker compose run --rm web_app bundle exec rails db:create

build:
	docker compose build

create_instance > create_db > build > up (migrate > web_appの立ち上げ)
の順に実行しています。
GCP上での環境構築も基本的にこの流れとなります。

前述の通り、spanner emulatorは終了の度にデータが初期化されるので、一度でもspanner emulatorを落とした場合はinitを実行する必要があります。

(スキップ可)ローカルでアプリを動かすことで、Spannerの振るまいを確認する

http://localhost:3000にアクセスすると下記のような画面が表示されます。
トップ画面

New Articleを押して、記事作成画面を開きます。
記事新規作成

項目を適当に埋めて記事を作成します。
画面の例では、

  • Titleに記事1
  • Bodyに1234567890
  • StatusにPublic

をセットしています。
作成後、記事詳細画面に遷移します。
記事詳細画面

記事のIDにあたる部分(画面URLの赤線部分)を見ると、最初に作成された記事であるのにもかかわらず、1以外の値が入っていることがわかります。
これはSpannerの特徴で、MySQLなどほかのDBであればidはインクリメントに採番されますが、Spannerの主キーにおいて連番はアンチパターンとされており、Active Record の Spanner アダプターは主キーにUUIDを利用しています。

インフラ構成について

上述の通り、Spannerに加え、RailsはCloud Run、migrateにCloud Run Jobを使用しています。加えて、以下のGCPサービスを利用します。

  • Cloud Build: コンテナイメージのbuild & pushする
  • Secret Manager: Railsのmaster keyを保管しアプリ、マイグレーションに提供する
  • Cloud Key Mangement: Terraformでmaster keyをSecret Managerのsecretとして提供するために暗号化する(Secret Managerをgcloudで作成する場合は不要)

また、Cloud RunのService Agent(以下、SA)にSpannerのdatabaseAdmin権限と、Secret ManagerのsecretAccessor権限を付与します。
(本来は必要最小限の権限にとどめておくことが推奨されています。アプリからマイグレーションは実行しないので、アプリケーション用にユーザー権限、マイグレーションJob用にDBAdmin権限など細かく分けることを推奨します)

構成図としては下記の通りになります。

インフラ構成

Cloud Spanner(無料トライアル枠)の構築

Cloud Spannerの無料トライアル枠は下記のコマンドから構築できます。

make create_instance_production

中身を見てみましょう。

create_instance_production:
	gcloud spanner instances create trial-1 --config=regional-${REGION} --instance-type=free-instance --description="trial-1"

gcloudのspanner instances createを実行しています。
重要なのはここです。

--config=regional-${REGION} --instance-type=free-instance

このREGIONがSpannerの無料枠を提供しているリージョンである必要があり、
また、instance-typeにfree-instanceを指定することで作成できます。
今回はtrial-1という名前でインスタンスを作成します。

Secret Manger、Cloud Run以外のリソース作成

TerraformでGCPのリソースを作成します。上述の通り、本記事ではTerraform の細かい内容については説明しません。

まずはinfraディレクトリに移動し、terraform initを実行します。

cd infra && terraform init

Secret ManagerのsecretにRAILS_MASTER_KEYの中身を直接書いてコミットするのは避ける必要があるので、kmsでいったん暗号化する必要があります。
そのため、まずは暗号化用のキーを作成し、gcloud kms encryptコマンドで暗号化後にSecret Mangerのsecretに指定する必要があります。

(余談ですが、作成するkmsリソースはアプリでは使用しないので、asia-northeast1で作成しています。)

そのため、まずはSecret Manger以外のリソースを作成します。
下記のブロックをコメントアウトします。
それぞれ、

  • kmsで暗号化されたRAILS_MASTER_KEY
  • RAILS_MASTER_KEYを保管するSecretとそのVersion

となります。

data "google_kms_secret" "rails_master_key" {
  crypto_key = google_kms_crypto_key.crypto_key.id
  # Must Replace!: echo -n $RAILS_MASTER_KEY | gcloud kms encrypt --location asia-northeast1 --keyring key-ring --key crypto-key --plaintext-file - --ciphertext-file - | base64
  ciphertext = "CiQAZ4zH06xt5lU6j2Q4QRsojbdH1RCwg9KJLJt3blR+2noYcbYSSQDLeR9jDCTyztjOnaxTLsvcBjP82GLLCIRWfK5RtzAYt/x4IySg6Awot82dFLuOrYi3/zEk6W8rR+iEnrddxhPQDbJAlqAa3uU="
}

resource "google_secret_manager_secret" "rails_master_key" {
  project   = data.google_project.default.project_id
  secret_id = "rails-master-key"

  replication {
    automatic = true
  }
}

resource "google_secret_manager_secret_version" "rails_master_key" {
  secret      = google_secret_manager_secret.rails_master_key.id
  secret_data = data.google_kms_secret.rails_master_key.plaintext
}

それでは作成していきます。
まずはplanで作成されるリソースを確認し、問題なければapprlyします。

terraform plan
terraform apply -auto-approve

これで下記リソースが作成されます。

  • google_kms_key_ring.key_ring: 暗号化鍵のキーリング
  • google_kms_crypto_key.crypto_key: 暗号化鍵
  • google_project_iam_member.cloud_run_sa_spanner_acces: Cloud RunのSAにSpannerへのアクセス権限を付与
  • google_project_iam_member.cloud_run_sa_secret_access: Cloud RunのSAにSecret Managerへのアクセス権限を付与
  • google_spanner_database.spanner_test:Spanner Instace上のDatabase

RAILS_MASTER_KEYを暗号化してSecret Mangerに保管する

Terraformで作成した鍵を用いてRAILS_MASTER_KEYを暗号化します。

echo -n $RAILS_MASTER_KEY | gcloud kms encrypt --location asia-northeast1 --keyring key-ring --key crypto-key --plaintext-file - --ciphertext-file - | base64 

出力された文字列をdata.google_kms_secret.rails_master_keyciphertextにセットします。

# Must Replace!: echo -n $RAILS_MASTER_KEY | gcloud kms encrypt --location asia-northeast1 --keyring key-ring --key crypto-key --plaintext-file - --ciphertext-file - | base64
  ciphertext = "CiQAZ4zH06xt5lU6j2Q4QRsojbdH1RCwg9KJLJt3blR+2noYcbYSSQDLeR9jDCTyztjOnaxTLsvcBjP82GLLCIRWfK5RtzAYt/x4IySg6Awot82dFLuOrYi3/zEk6W8rR+iEnrddxhPQDbJAlqAa3uU="

前ステップで行ったコメントアウトを解除して再度terraform applyすることで、RAILS_MASTER_KEYを保管したSecret Managerを作成します。

terraform plan
terraform apply -auto-approve

これで下記リソースが作成されます。

  • google_secret_manager_secret.rails_master_key
  • google_secret_manager_secret_version.rails_master_key

Cloud Run Jobの作成

初回デプロイする場合はその前にmigration用のCloud Run Jobを作成します。

make create_migrate_production_job

通常のCloud RunについてはすでにServiceが存在していようがいまいがdeployコマンドで展開可能ですが、
Cloud Run Jobの場合は新規作成時はcreate、すでにJobが存在する場合はupdateを実行しないとエラーになります。
そこで、初回実行時のみあらかじめJobを作成しておき、デプロイ時にそのJobを更新するようにしました。
中身を見てみましょう。

create_migrate_production_job:
	gcloud beta run jobs create rails-spanner-migrate \
	--image us-docker.pkg.dev/cloudrun/container/job:latest\
	--region ${REGION}

imageがus-docker.pkg.dev/cloudrun/container/job:latestとなっています。
Cloud Run Jobのupdateは指定したイメージのアドレスが変わっても問題ないので、ここのimageはCloud Run Jobのデフォルトを指定し、意味のないJob(rails-spanner-migrate)を作成しています。
(まだイメージがレジストリ上に存在しないので指定できないため)

アプリケーションデプロイ

いよいよアプリケーションをデプロイしていきます。

make deploy_production

中身を見てみましょう。


deploy_production: build_and_push migrate_production deploy_cloud_run

deploy_cloud_run:
	gcloud beta run deploy rails-cloud-spanner \
	--platform managed \
	--region ${REGION} \
	--image gcr.io/${PROJECT_ID}/rails-cloud-spanner \
	--set-env-vars=PROJECT_ID=${PROJECT_ID},SPANNER_INSTANCE=trial-1,RAILS_ENV=production \
	--set-secrets=RAILS_MASTER_KEY=rails-master-key:latest \
	--port 3000 \
	--concurrency=5 \
	--max-instances=10 \
	--cpu-boost \
	--allow-unauthenticated

migrate_production:
	gcloud beta run jobs update rails-spanner-migrate \
	--image gcr.io/${PROJECT_ID}/rails-cloud-spanner \
	--command=bundle,exec,rails,db:migrate \
	--region ${REGION} \
	--set-env-vars=PROJECT_ID=${PROJECT_ID},SPANNER_INSTANCE=trial-1,RAILS_ENV=production \
	--set-secrets=RAILS_MASTER_KEY=rails-master-key:latest 
	gcloud beta run jobs execute rails-spanner-migrate --region ${REGION} --wait

build_and_push:
	gcloud builds submit --config cloudbuild.yaml

build_and_push > migrate_production > deploy_cloud_run の順に実行しています。

イメージのbuild & push

build_and_pushではcloud buildを用いてコンテナイメージをビルドしてレジストリにpushしています。

steps:
  - id: "build image"
    name: "gcr.io/cloud-builders/docker"
    entrypoint: 'bash'
    args: ["-c", "docker build  -t gcr.io/${PROJECT_ID}/rails-cloud-spanner . "]

  - id: "push image"
    name: "gcr.io/cloud-builders/docker"
    args: ["push", "gcr.io/${PROJECT_ID}/rails-cloud-spanner"]

images:
  - "gcr.io/${PROJECT_ID}/rails-cloud-spanner"

Migration Jobの実行

pushの完了後、migrationを実行します。

gcloud beta run jobs update rails-spanner-migrate \
	--image gcr.io/${PROJECT_ID}/rails-cloud-spanner \
	--command=bundle,exec,rails,db:migrate \
	--region ${REGION} \
	--set-env-vars=PROJECT_ID=${PROJECT_ID},SPANNER_INSTANCE=trial-1,RAILS_ENV=production \
	--set-secrets=RAILS_MASTER_KEY=rails-master-key:latest 
gcloud beta run jobs execute rails-spanner-migrate --region ${REGION} --wait

上述の通り、migration用のJobはupdateコマンドで更新しています。
実行コマンド(bundle exec rails db:migrate)を下記で指定します。

--command=bundle,exec,rails,db:migrate

Spannerのアクセスに必要なインスタンスとプロジェクトIDを環境変数で与えています。

--set-env-vars=PROJECT_ID=${PROJECT_ID},SPANNER_INSTANCE=trial-1,RAILS_ENV=production

また、Secret Mangerで保管しているRAILS_MASTER_KEYを取得するように指示しています。

--set-secrets=RAILS_MASTER_KEY=rails-master-key:latest 

Jobの更新後、executeコマンドで実行します

gcloud beta run jobs execute rails-spanner-migrate --region ${REGION} --wait

通常Cloud Run Jobは非同期処理で実行されるので、マイグレーションの完了までアプリケーションをデプロイしないように--waitオプションを指定することで完了を待っています。

(余談ですが、ほかのRDBサービスなら一瞬で終わるような単純なマイグレーション(テーブルの作成など)でも数分かかります)
https://cloud.google.com/spanner/docs/schema-updates?hl=ja#performance

Cloud RunにDeploy

migrationの完了後、デプロイを実行します。

gcloud beta run deploy rails-cloud-spanner \
	--platform managed \
	--region ${REGION} \
	--image gcr.io/${PROJECT_ID}/rails-cloud-spanner \
	--set-env-vars=PROJECT_ID=${PROJECT_ID},SPANNER_INSTANCE=trial-1,RAILS_ENV=production \
	--set-secrets=RAILS_MASTER_KEY=rails-master-key:latest \
	--port 3000 \
	--concurrency=5 \
	--max-instances=10 \
	--cpu-boost \
	--allow-unauthenticated

Spannerへのアクセス情報やSecretの取得などはJobと同じなので特筆すべき設定はありませんが、
2022年9月に提供された--cpu-boostオプションを有効にしています。
https://cloud.google.com/run/docs/configuring/cpu#startup-boost

こちらはコンテナインスタンスの起動時にCPUを多く割り当てることで、Railsのように立ち上げに時間のかかるフレームワーク系のアプリケーションのスピンアップ高速化するオプションです。せっかくなので有効化しました。

デプロイ完了すると、GCP側がCloud Runに割り当てたURLが返ってくるので、アプリにアクセスできるようになります。

これでデプロイ完了です。

Service URL: https://~~.run.app

おわりに

Spannerを試してみたいこと、今GCPで楽にサービスを作るならどういう構成にするかと検討しているうちに本記事で紹介した構成で構築することになりました。
GCPに関してはまだまだ勉強中なので、もっとこうしたら良いという意見がございましたら、どんどんアドバイスをお願い致します。

Discussion