Cloud Spannerの無料トライアルがGAしたので、Rails on Cloud Run X Cloud Spannerを構築した話
はじめに
先日、Cloud Spannerの無料トライアルが来ました。
個人的に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日経過後自動的に終了します。
その他トライアル枠の制約については下記をご参照ください。
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)
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インスタンスを作成する)
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
がアクセスするのに必要な情報になります。
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_key
のciphertext
にセットします。
# 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サービスなら一瞬で終わるような単純なマイグレーション(テーブルの作成など)でも数分かかります)
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
オプションを有効にしています。
こちらはコンテナインスタンスの起動時にCPUを多く割り当てることで、Railsのように立ち上げに時間のかかるフレームワーク系のアプリケーションのスピンアップ高速化するオプションです。せっかくなので有効化しました。
デプロイ完了すると、GCP側がCloud Runに割り当てたURLが返ってくるので、アプリにアクセスできるようになります。
これでデプロイ完了です。
Service URL: https://~~.run.app
おわりに
Spannerを試してみたいこと、今GCPで楽にサービスを作るならどういう構成にするかと検討しているうちに本記事で紹介した構成で構築することになりました。
GCPに関してはまだまだ勉強中なので、もっとこうしたら良いという意見がございましたら、どんどんアドバイスをお願い致します。
Discussion