見出し画像

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アプリケーションとDBとの関連図(抜粋)

おおまかな構成は図に示したとおり、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で違うため注意が必要です。

PostgreSQL
https://cloud.google.com/sql/docs/postgres/flags(max_connections部分)
MySQL
https://cloud.google.com/sql/docs/quotas

当初想定していたデプロイフロー

次に、当初想定したデプロイフローを図示します。


db:migrate後に各種サーバーをデプロイ

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停止やmigration待機などをステップに盛り込んだ最終形

先述通り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を止めているだけです。

参考サイト: https://n8.hatenablog.com/entry/2015/05/01/100424

  • Sidekiq::ProcessSet.newして現在のSidekiqプロセスを取得

  • sidekiqのプロセスをeachで回し quiet! を実行

ps = Sidekiq::ProcessSet.new
ps.each do |process|
  process.quiet!
end

次に deployment:migrate については、Redisのロックに加えてマイグレーションが必要な場合はdb:migrateを実行します。

参考サイト: https://zenn.dev/koron/articles/b62ed42d79ed39

  • 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とカジュアルにお話しませんか?


この記事が気に入ったらサポートをしてみませんか?