Rails × Cloud Runでデプロイ時のDBマイグレーションによるダウンタイムを極限まで減らしてみた
はじめに
開発の竹内(@kenta_714)です。
弊社では、6/12に漢方・サプリのサブスクリプションサービスであるYOJOのシステム基盤をHerokuからGCPへとリプレースしました。
その際に、IaC化としてTerraformを導入し、GCPをTerraformで実装する際のノウハウを複数回に分けて記事にて共有しています。
今回は、Cloud RunでRailsアプリケーションをデプロイするときに発生するDBマイグレーションについて、サービスのダウンタイムを減らすための施策を共有したいと思います。
このあたりの知見はあまりWebサイトでみかけないため、ぜひ参考にしていただき、フィードバックいただけますと幸いです。
簡単なアーキテクチャの説明
本題に入る前に、まずは弊社のDBとWebアプリケーションまわりを中心にアーキテクチャの説明します。
おおまかな構成は図に示したとおり、Cloud Runで動いている各WebアプリケーションからVPCを通してCloud SQLに通信をしています。
今回関わってくるサーバーは「APIサーバー」「Workerサーバー」「ScheduleJobAPIサーバー」「ScheduleJobWorkerサーバー」「Cloud SQL for PostgreSQL」になります。
ScheduleJob系の2つのサーバーは、GCP環境で定期バッチを実行するために用意されたサーバーです。Cloud Scheduler・Cloud Pub/Subと連携しています。
ちなみにCloud Runのコンテナは、オートスケールで増減します。そのためDBへのコネクション数については、負荷テストから導いた各コンテナの最大コンテナ想定数を考慮したうえで定義しています。
DB側の最大接続数については以下をご参照ください。MySQLとPostgreSQLで違うため注意が必要です。
当初想定していたデプロイフロー
次に、当初想定したデプロイフローを図示します。
cloudbuild.ymlの紹介
弊社では、ビルド〜デプロイまでの工程にGCPのCloud Buildを利用しています。GitHub Actionsも利用していますが、こちらは単体テスト〜静的解析(ライブラリの脆弱性検知や秘匿情報漏れの検知など)をやっています。
以下に最初に実装したcloudbuild.ymlを記載します。
steps:
# ------------------------------------------------------------------------------
# Pull the container image
# 先にプルしておくことでキャッシを有効化する
# ------------------------------------------------------------------------------
- id: 'pull-app'
name: 'gcr.io/cloud-builders/docker'
entrypoint: 'bash'
args: ['-c', 'docker pull ${_IMAGE_NAME_APP}:latest || exit 0']
waitFor:
- '-'
- id: 'pull-worker'
name: 'gcr.io/cloud-builders/docker'
entrypoint: 'sh'
args: ['-c', 'docker pull ${_IMAGE_NAME_WORKER}:latest || exit 0']
waitFor:
- '-'
- id: 'pull-gcloud'
name: 'gcr.io/cloud-builders/docker'
entrypoint: 'sh'
args: ['-c', 'docker pull ${_IMAGE_NAME_GCLOUD} || exit 0']
waitFor:
- '-'
# ------------------------------------------------------------------------------
# Build the container image
# ------------------------------------------------------------------------------
- id: 'build-app'
name: 'gcr.io/cloud-builders/docker'
entrypoint: 'sh'
args: [
'-c',
'docker build
--target=product
--build-arg PORT="3000"
-f ./docker/web/Dockerfile.${_ENV}
--cache-from ${_IMAGE_NAME_APP}:latest
-t ${_IMAGE_NAME_APP}:$COMMIT_SHA
-t ${_IMAGE_NAME_APP}:latest
.',
]
waitFor:
- 'pull-app'
- id: 'build-worker'
name: 'gcr.io/cloud-builders/docker'
entrypoint: 'sh'
args: [
'-c',
'docker build
--target=product
--build-arg PORT="7433"
-f ./docker/web/Dockerfile.${_ENV}
--cache-from ${_IMAGE_NAME_WORKER}:latest
-t ${_IMAGE_NAME_WORKER}:$COMMIT_SHA
-t ${_IMAGE_NAME_WORKER}:latest
.',
]
waitFor:
- 'pull-worker'
# ------------------------------------------------------------------------------
# Push the container image
# ------------------------------------------------------------------------------
- id: 'push-app'
name: 'gcr.io/cloud-builders/docker'
args: ['push', '${_IMAGE_NAME_APP}']
waitFor:
- 'build-app'
- id: 'push-worker'
name: 'gcr.io/cloud-builders/docker'
args: ['push', '${_IMAGE_NAME_WORKER}']
waitFor:
- 'build-worker'
# ------------------------------------------------------------------------------
# Deploy with run db:migrate
# ------------------------------------------------------------------------------
- id: 'db-migrate-deploy'
name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args: [
'run',
'deploy',
'${_SERVICE_NAME_APP}',
'--image=${_IMAGE_NAME_APP}:latest',
'--region=asia-northeast1',
'--platform=managed',
'--labels',
'build-id=$BUILD_ID,migration-mode=true',
'--update-env-vars=WARMUP_DEPLOY=true',
]
waitFor:
- 'pull-gcloud'
- 'push-app'
- 'push-worker'
# ------------------------------------------------------------------------------
# Deploy the mainstream version
# ------------------------------------------------------------------------------
# APサーバー
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args: [
'run',
'deploy',
'${_SERVICE_NAME_APP}',
'--image=${_IMAGE_NAME_APP}:latest',
'--region=asia-northeast1',
'--platform=managed',
'--labels',
'build-id=$BUILD_ID',
'--update-env-vars=WARMUP_DEPLOY=false',
]
waitFor:
- 'db-migrate-deploy'
# Workerサーバー
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args: [
'run',
'deploy',
'${_SERVICE_NAME_WORKER}',
'--image=${_IMAGE_NAME_WORKER}:latest',
'--region=asia-northeast1',
'--platform=managed',
'--labels',
'build-id=$BUILD_ID',
'--update-env-vars=WARMUP_DEPLOY=false',
]
waitFor:
- 'db-migrate-deploy'
# スケジュールジョブサーバー
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args: [
'run',
'deploy',
'${_SERVICE_NAME_SCHEDULE_JOB}',
'--image=${_IMAGE_NAME_APP}:latest',
'--region=asia-northeast1',
'--platform=managed',
'--labels',
'build-id=$BUILD_ID',
'--update-env-vars=WARMUP_DEPLOY=false',
]
waitFor:
- 'db-migrate-deploy'
# スケジュールジョブワーカーサーバー
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args: [
'run',
'deploy',
'${_SERVICE_NAME_SCHEDULE_JOB_WORKER}',
'--image=${_IMAGE_NAME_WORKER}:latest',
'--region=asia-northeast1',
'--platform=managed',
'--labels',
'build-id=$BUILD_ID',
'--update-env-vars=WARMUP_DEPLOY=false',
]
waitFor:
- 'db-migrate-deploy'
timeout: 3600s
substitutions:
_BASE_IMAGE_NAME: asia-northeast1-docker.pkg.dev/${PROJECT_ID}/yojo/linebot-service
_IMAGE_NAME_APP: ${_BASE_IMAGE_NAME}-app
_IMAGE_NAME_WORKER: ${_BASE_IMAGE_NAME}-worker
_IMAGE_NAME_GCLOUD: gcr.io/google.com/cloudsdktool/cloud-sdk
_SERVICE_NAME_APP: ${PROJECT_ID}-app
_SERVICE_NAME_WORKER: ${PROJECT_ID}-worker
_SERVICE_NAME_SCHEDULE_JOB: ${PROJECT_ID}-schedule-job
_SERVICE_NAME_SCHEDULE_JOB_WORKER: ${PROJECT_ID}-schedule-job-worker
images:
- ${_IMAGE_NAME_APP}:latest
- ${_IMAGE_NAME_APP}:$COMMIT_SHA
- ${_IMAGE_NAME_WORKER}:latest
- ${_IMAGE_NAME_WORKER}:$COMMIT_SHA
options:
dynamic_substitutions: true
logging: CLOUD_LOGGING_ONLY
pool:
name: projects/$PROJECT_ID/locations/asia-northeast1/workerPools/${PROJECT_ID}-pool
ちょっと記述量が多いのですが、軽く説明します。
Pull the container image
GCPのArtifact Registryにアップロードされている既存コンテナイメージをPullします。このステップを最初に持ってくることで、ビルドするコンテナイメージとの差分についてキャッシュを利用して取得してくれます。それによりビルド時間の短縮が望めます。ビルド時間は課金やデリバリー速度にも関わるのでとても重要です。
Build the container image
実際にコンテナイメージをビルドするステップです。Dockerfileに基づいてコンテナイメージをビルドします。
Dockerfileの中身では、 assets:precompile、db:migrateを実施しています。
Push the container image
ビルドに成功したコンテナイメージをArtifact Registryにプッシュしています。
Deploy with run db:migrate
デプロイ前にビルド後のイメージを使ってdb:migrateを実行します。 `migration-mode` や `WARMUP_DEPLOY` という変数を使ってデプロイフローがどのような状態なのかを判断できるようにし、Dockerfile内でこのフラグを用いてdb:migrateを実行するか判断します。(実際はDockerfileから呼び出したentrypoint.sh内で処理)
Deploy the mainstream version
最後にCloud Runへデプロイします。
各ステップで「waitFor」を使っていますが、これはその名の通りここに指定したステップが終わるまで当該ステップの実行を待つということができる設定です。ステップを並列にしたくないときや並列化した前ステップを同期する場合に利用します。
問題点
イケているようにみえるCloud BuildとDockerfileとの関係性ですが、これには大きな問題がありました。それが「DBマイグレーション実施中にSidekiqジョブが実行されつづける」というものです。
Ruby on Railsで非同期ジョブを利用している方はご存じかと思いますが、Ruby on RailsではSidekiqやDelayedJobという仕組みを利用することで特定の処理の実行を遅延させつつ並列処理させることができます。
例えば、HTTCSV出力や画像アップロードなどのデータ通信の多い処理や特定データの一括変更などトランザクションが重めの処理が対象になるでしょう。
Sidekiqの場合、Ruby on Railsから非同期処理の内容をRedisに登録し、それをSidekiqが取得しにいくことで非同期処理実現しています。
問題点の話に戻ると、デプロイのタイミングとユーザーのアクセスのタイミングによっては、古い(デプロイが完了していない)SidekiqサーバーがRedisに処理を取得しにいくことがあります。
そして、デプロイがDBのカラムの追加・削除のようなDBマイグレーションを含んでいた場合、旧Sidekiqサーバーにはそれらの変更が反映されません。そのため、非同期処理実行時のエラーや正しいデータ処理ができないという状況が発生します。
これをなんとかするため、なるべくシステム停止時間が短くなるような仕組みで解決できないか社内で議論し、デプロイフローを見直すことになりました。
最終形
検討の結果、このようなフローに変わりました。
先述通りSidekiqが新たにジョブを受け取ろうとするのを防ぐために、Sidekiqを止めてしまう処理(sidekiq:quiet)や、APIサーバー側から非同期処理をRedisに追加させないようにする処理(deployment:migrate)という2重の策により、古い状態の非同期処理が生成されないように対応。db:migrate自体もdeployment:migrateにて実行しています。これらの詳細の説明は後述します。
cloudbuild.ymlの紹介
Sidekiqを止めるステップを追加したので以下のように変わりました。追記修正が発生している部分だけ抜粋して記載します。
# ------------------------------------------------------------------------------
# Before quiet sidekiq. No traffic deploy.
# ------------------------------------------------------------------------------
- id: 'quiet-sidekiq'
name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args: [
'run',
'deploy',
'${_SERVICE_NAME_APP}',
'--image=${_IMAGE_NAME_APP}:latest',
'--region=asia-northeast1',
'--platform=managed',
'--no-traffic',
'--labels',
'build-id=$BUILD_ID',
'--update-env-vars=QUIET_SIDEKIQ=true',
]
waitFor:
- 'pull-gcloud'
- 'push-app'
- 'push-worker'
# ------------------------------------------------------------------------------
# Deploy the mainstream version
# ------------------------------------------------------------------------------
# APサーバー
- id: 'deploy-ap'
name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args: [
'run',
'deploy',
'${_SERVICE_NAME_APP}',
'--image=${_IMAGE_NAME_APP}:latest',
'--region=asia-northeast1',
'--platform=managed',
'--labels',
'build-id=$BUILD_ID',
'--update-env-vars=MIGRATION_MODE=true,QUIET_SIDEKIQ=false',
]
waitFor:
- 'quiet-sidekiq'
(略)
まず db-migrate-deploy という元々のステップを quiet-sidekiq というステップに変更しました。これは上図にある「Sidekiq停止」に該当するステップです。
次にWARMUP_DEPLOY というフラグではなく、 MIGRATION_MODE、QUIET_SIDEKIQの2つを新たに追加。このフラグの値によってDBマイグレーションしたりSidekiq止めたりするかどうかをentrypoint.sh(Dockerfile内で呼ばれるShellScript)で判断しています。
工夫した点
一番の工夫点は、お手製のrakeタスク「sidekiq:quiet」と「deplyoment:migrate」です。
sidekiq:quietはフローで示したとおりSidekiqを止めているだけです。
Sidekiq::ProcessSet.newして現在のSidekiqプロセスを取得
sidekiqのプロセスをeachで回し quiet! を実行
ps = Sidekiq::ProcessSet.new
ps.each do |process|
process.quiet!
end
次に deployment:migrate については、Redisのロックに加えてマイグレーションが必要な場合はdb:migrateを実行します。
Redlockを使いRedisのプロセスを取得後ロック
ロック中にDBマイグレーションが必要な場合(MIGRATION_MODE=true) db:migrate を実行
APIサーバー、Workerサーバー、ScheduleJobAPIサーバーのデプロイのうち早いもの勝ちでdb:migrateを実行し、他の2台は完了するまでデプロイを待機
db:migrateが終わったらデプロイを再開
デプロイ後新しいSidekiqサーバーが立ち上がり止まっていた非同期処理を再開
コードの例は以下のような感じです。実際に実行するとマイグレーションでデッドロックするかもしれないので、ところどころで `needs_migration`や `waiting_for_some_containers` フラグを通じてabortする条件式を入れたほうが良いかと思います。また、「一意なキー」についても考慮が必要です。どのmigrateが実行されているかがわかればよいので、実行されているマイグレーションのコンテキスト(ctx)をハッシュ化するみたいなことを弊社ではやっています。
client = Redlock::Client.new([ENV.fetch('REDIS_URL', 'redis://localhost:6379')])
ttl = 1800000 # 30分
interval = 300
ctx = ActiveRecord::Base.connection.migration_context
waiting_for_some_containers = false
# マイグレーションが不要になるまで繰り返す
while ctx.needs_migration?
client.lock("一意なキー", ttl) do |lock_info|
if lock_info
if waiting_for_some_containers
abort "他のコンテナでmigrationされてるよ"
else
# マイグレーション実行
Rake::Task['db:migrate'].invoke
end
else
waiting_for_some_containers = true
sleep(1) # 他コンテナでマイグレーションされてるっぽいので自分は待つ
end
end
end
以上の仕組み化によってSidekiqが停止後、db:migrate中のダウンタイムは発生するものの、非同期処理の一意性は担保できるようになりました。もちろん、db:migrateの処理が重すぎるもの(例えば億のデータに対するインデックスの貼り直し)だった場合は、別途処理を考える必要があるかもしれません。
医療体験をアップデートするための仕組みづくりに力を貸してくれるエンジニアメンバーを募集!
弊社エンジニアチームでは、今後も技術面で医療体験のアップデートを促進するための仕組みづくりに励んでいきます。絶賛仲間募集中です!
詳しくは採用情報をご覧ください。
まずは弊社CTOとカジュアルにお話しませんか?