HerokuからGCPへのインフラ移行 〜ダブルライト検証編〜

アイキャッチ

こんにちは、Plex Job 開発チームの池川です。

Plex Job では従来、バックエンドのデプロイ先として Heroku を使用していましたが、2024年1月に GCP に移行しました。 移行にあたって、移行後しばらくはいつでも旧環境に切り戻せるようにしておく必要があったほか、切り戻し時に発生するサービスの休止時間もなるべく抑えたかったため、新旧 DB にダブルライトする方法が取れないか検証しました。 結論としてはダブルライトは不採用としたのですが、不採用とした経緯も含め検証して得た知見を今回はまとめたいと思います。

検証環境

設計

ダブルライト実行の大まかな流れは下記です。

  1. 新環境 DB に該当レコードの書き込みを行う
  2. 書き込み結果のレコードを取得する
  3. 非同期タスクのジョブとしてキューを作成する
  4. Worker によりジョブが実行され、旧環境の DB に 2.が書き込まれる

ダブルライトの実行対象は /graphql エンドポイントへの Mutation クエリとしました。 また、新環境への影響を考慮して旧環境 DB への書き込みは非同期タスクとし、非同期タスクのジョブとしてキューを登録するタイミングは ApplicationRecord の after_commitコールバック時としました。 なお、非同期タスクの実行にあたっては Delayed Job を使いました。

設計時に考慮した点がいつくかあったので紹介します。

1. コールバックは順番が保証されているか

同一のトランザクションの各操作に対するコールバックの順番が保証されているかどうか検証しました。 例えば下記のように新規登録したデータを元に別のモデルのデータを更新するケースです。

ActiveRecord.transaction do
  user = User.create!(name: params[:new_user_name]) # ①

  product = Product.find(params[:product_id])
  product.update!(price: params[:new_product_price], updated_by_user_id: user.id) # ②
end

after_commit使用して旧環境 DB への書き込みした際にも ① → ② の順番が正しく担保されるか検証した結果、順番通りに実行されており問題ありませんでした。 ただし、同一のトランザクション内で同じレコードに対して書き込みを行うと、後続のレコード更新時にコールバックが実行されない問題があるため、場合によっては下記の記事で紹介されているような対応が必要となります。

zenn.dev

2. コールバックがスキップされる ActiveRecord のメソッドがある

ActiveRecord のメソッドの中にはコールバックがスキップされる、つまりafter_commitが呼ばれないメソッドがあります。 例えば下記のようなメソッドです。(一覧はこちらにまとめられているので参照ください)

  • insert_all
  • update_all
  • delete_all
  • update_column
  • update_columns

上記のメソッドを使用している箇所はそれぞれ下記の対応を行いました。

  • insert_all などを使用しているところで扱う件数が少ない箇所
    • create, save, update を使って一件ずつ登録・更新するように変更してafter_commitを使用する
  • insert_all などを使ってバルクインサートした方が良い箇所
    • after_commitは使用せず、バルクインサートした後に個別に非同期タスクのキューの登録処理を実装する
  • update_column などを使っていてコールバックを敢えて使用したくない箇所
    • after_commitは使用せず、update_columnした後に個別に非同期タスクのキューの登録処理を実装する

3. Active Storage に対応可能か

Active Storage にファイルを保存する際、active_storage_attachments テーブルと active_storage_blobs テーブルにデータが書き込まれていたため、対応する必要がありました。 それぞれのテーブルの役割は下記を参照ください。

railsguides.jp

upload_file = ActiveStorage::Blob.create_and_upload!(
  # 省略
)

user = User.find(params[:id])
user.file.attach(upload_file)

attachment_attributes = user.file.attachment.attributes # active_storage_attachments テーブルの値
blob_attributes = user.file.blob.attributes # active_storage_blobs テーブルの値

# キュー登録の処理を実装する

ActiveStorage の一連のアップロード処理で作成されるレコードは上記のような形で取得できるため、項目 2 で紹介したバルクインサートのケースと同様に、ファイル登録処理のタイミングで個別にキューを作成するようにしました。

次にどのような実装を行なったかコードを元に説明していきます。

実装

1. 設定の追加

旧環境 DB への接続情報を database.yml に追加します。 今回は replica_db という名前としましたが任意の名前を設定可能です。

development:
  db:
    <<: *default
    url: <%= Settings.db.url %>
  replica_db: # 以下、追加
    <<: *default
    url: <%= Settings.replica_db.url %>

合わせて、Rails 標準で実装されている水平シャーディングを使って、レプリカ DB への接続設定を ApplicationRecord に追加します。 replica_dbの部分はdatabase.ymlで設定した名前と合わせます。

class ApplicationRecord < ActiveRecord::Base
  # 省略

  connects_to shards: {
    default: { writing: :db, reading: :db },
    shard_one: { writing: :replica_db, reading: :replica_db }
  }
end

2. モジュールを作成する

ダブルライト用のモジュールを作成します。

module DoubleWrite
  extend ActiveSupport::Concern

  included do
    after_commit :double_write, if: :on_cloud_sql? # 新環境 DB(cloudSQL)に書き込みされた時のみDoubleWriteが実行されるように設定
  end

  private

    def double_write
      operation = determine_operation
      attributes = previous_changes.transform_values(&:last) # 変更内容を取得

      DoubleWriteJob.perform_later(self.class.name, attributes, operation, id) # 非同期タスクのキューを登録
    end

    def on_cloud_sql?
      ActiveRecord::Base.connection_pool.db_config.name == 'db' # database.yml にある新環境 DBの名前を設定
    end

    def determine_operation
      return 'destroy' if destroyed?

      previous_changes.include?('id') ? 'create' : 'update' # idが含まれていれば更新処理、含まれていなければ新規登録処理
    end
end

新環境 DB に接続した場合のみ after_commit が実行されるようにします。 非同期タスクには今回は下記のような値を渡すようにしました。

名前 内容
self.class.name データの登録・更新があったモデルのクラス名
attributes 登録・更新内容
operation 処理名(create or update or destroy)
id 更新・削除の場合、対象のレコードの ID

3. ApplicationRecord でダブルライト用のモジュールを読み込む

モジュールは各モデルに継承されている ApplicationRecord でインクルードするようにします。

class ApplicationRecord < ActiveRecord::Base
  # 省略

  include DoubleWrite # ダブルライト用のモジュールの読み込み
end

4. ダブルライトを実行するジョブを作成する

非同期に実行するジョブを作成し、旧環境 DB に書き込む処理を書きます。 ActiveRecord::Base.connected_to を使って旧環境 DB にコネクションを切り替えした中で operation(create や update など)に応じた処理を行うようにします。

class DoubleWriteJob < ApplicationJob
  queue_as :double_write

  def perform(model_name, attributes, operation, record_id = nil)
    ActiveRecord::Base.connected_to(shard: :shard_one) do
      model_class = model_name.constantize

      # operation(create or update ...)ごとの処理を実装する
      # 例:create
      ActiveRecord::Base.connection.reset_pk_sequence!(model_class.table_name)
      model_class.create!(attributes)
    end
  end
end

旧環境 DB への書き込み時に注意する点として、新規登録時には ID の自動採番の不整合が生じないように reset_pk_sequence! を使ってデータを登録するようにしました。 reset_pk_sequence!を使えば該当テーブルの最大IDを取得し、次に挿入されるレコードのIDとして設定することができます。

apidock.com

主な実装は以上です。 最後に検証してみた結果をまとめます。

検証してみて

実装後、テストをしたところ画面上で会員登録したり求人に応募したりする分には問題ありませんでしたが、データの一括インポートなど高負荷な書き込み行った際にダブルライトの処理完了までに大幅に時間がかかることが分かりました。 1,000 件程度の一括アップロード処理で、旧環境 DB への書き込みが完了するのに 1 時間程度かかるといった状況です。 旧環境 DB への書き込みに時間がかかってしまうと新環境と旧環境でデータに差分が生じ、当初の目的である障害発生時にスムーズに旧環境に切り戻すことができないため、今回ダブルライトの採用は見送りました。

また、時間がかかってしまった原因ですが、Heroku は us リージョン、 GCP は asia-east1 リージョンにそれぞれデプロイしており、物理的にサーバの距離が離れているため時間がかかったのかと考えています。 なので環境によってはインフラ移行時にダブルライトを採用するのはアリかもしれません。

さいごに

今回の記事ではインフラ移行時に検証したダブルライトについて、約1ヶ月間の検証を通して得た知見をまとめてみました。 ゼロから実装するにはいくつか詰まる点もあったため、何かの参考になれば幸いです。 今回取り上げたHeroku から GCP への移行は Plex Job にとっても一大プロジェクトだったため、インフラ移行に関する記事は後日改めて公開する予定です。

最後になりますが、プレックスではソフトウェアエンジニアフロントエンドエンジニアを絶賛募集中です! 少しでも興味を持っていただけた方は業務委託や副業からでも、ぜひご応募いただけると嬉しいです。