Rails+PostgreSQL+Apartmentでたった1行の変更がレスポンスタイムを3倍速にした話

こんにちは!

Tech KAYAC Advent Calendar 2023 11日目を担当する荒賀(@ken39arg)です。

在籍期間15年と弊社の中でかなり古参になってしまった私ですが、アドベントカレンダーを年に2本書くのは初めてです。1

今回の内容は、今年7月に無事に成功したお仕事の話で、完了したらブログに書くように言われていたものです。 が、どうしても筆が進まず、のらりくらりと逃げていたのですが、良い年越しをするためにいい加減書いておくか〜という内容です。

TL;DR

Rails + PostgreSQL + Apartment という構成のサービスで下記の1行の設定変更コミットを取り込んだ結果・・・

$ git log -n1 -p 64b39f258e9adde2e55752e4f1d5b6be12bcb216

commit 64b39f258e9adde2e55752e4f1d5b6be12bcb216
Author: Kensaku Araga <ken39arg@gmail.com>
Date:   Mon May 8 19:08:49 2023 +0900

    config.use_schemas = false を消してdefaultのtrueを有効にする

diff --git a/config/initializers/apartment.rb b/config/initializers/apartment.rb
index 7862ec5793..2a465294ff 100644
--- a/config/initializers/apartment.rb
+++ b/config/initializers/apartment.rb
@@ -47,7 +47,7 @@ Apartment.configure do |config|
   #
   # The default behaviour is true.
   #
-  config.use_schemas = false
+  # config.use_schemas = false

   #
   # ==> PostgreSQL only options

handlename先生にお褒めいただいた

平均レスポンスタイムが3分の1に大改善した。

というお話です。

「まちのコイン」の技術選択の背景

カヤックが運営する「まちのコイン」のパフォーマンス改善の話ですが、なぜそのような選択をしたのか、その経緯に軽く触れておきたいと思います。

coin.machino.co

※ 私は初期メンバーではありませんので、これらの内容には一部私の想像が含まれていることをご了承ください。

「まちのコイン」の技術選択の意思決定に大きな影響を与えた要件は下記4つです。

  1. 「まちのコイン」は、実証実験から始まった。
  2. 開発時のエンジニアメンバーは元々クライアントワーク(現・面白プロデュース事業)に在籍していたメンバーで構成された。
  3. 一般ユーザーは無料かつ広告もなく、導入する地域(団体)からお金を頂くビジネスモデルである。(2023年時点)
  4. 1つのアプリで地域を切替えて利用するマルチテナントアプリケーションを採用

実証実験というスタートだったため、メンテナンス性やハイパフォーマンス性などの他の要素に比べて開発スピードが圧倒的に優先されました。

クライアントワークでは、リリーススピードと開発コストの優先度が運用コストに比べて高くなることが多いためRailsが採用されることがとても多く2、「まちのコイン」の初期開発メンバーはクライアントワーク出身者が多くRailsでの開発に慣れた人たちだったため、Railsが採用されました。

直接の顧客となる地域が公共団体であることが多いため、個別のセキュリティ要件や個別の機能を求められる可能性や特定の地域が高負荷になる可能性を考慮して、 他の地域の影響を受けづらいことと費用を天秤にかけて共通のアプリケーションサーバーに対してDBを論理的に分割するという判断がなされました。

そしてDBを論理的に分割するに当たり、当時比較的多くの採用情報があった Apartment という gem ( ros-apartment (旧 influitive/apartment) ) が採用されました。

また、Apartment導入時に試行錯誤の結果、使い慣れたMySQLでなくPostgreSQLに変更されました。この変更に関しては、スピード重視での開発の結果、MySQL だとうまく動かなかったが PostgreSQL にしたら動いたというのが経緯のようです。

このように、極めて妥当な判断で「まちのコイン」のサーバーアプリは Rails + PostgreSQL + Apartment という構成で開発されました。

問題の発覚

2019年11月から実証実験を開始した「まちのコイン」は2020年2月に正式リリースとなり、2021年に全国拡大を目指すことが決定しました。3 2021年5月頃からはSREチームが関わるようになり運用体制が強化されました。4

SREチームの介入により、mackerel によるサービスメトリクスの可視化や Sentry の導入などが進むこととなりました。 特に2022年3月頃にSentryの Performance Monitoring を有効化したことで、チーム内でもエンドポイント毎の改善の機運が高まりました。

明らかに全体的なパフォーマンスの劣化を引き起こしているボトルネックがありそうだと言うことでApartmentを疑いモンキーパッチを当てperformanceを計測するためのspanを埋め込みまくりました。その結果 Apartment::Tenant.switch! というデータベースの接続先切替え処理がボトルネックになっていそうだという結論に達しました。

その後、負荷試験を実施した結果、サービスの処理性能は目標値の20分の1以下で、特に Apartment::Tenant.switch! が処理時間全体の63%を締めているため、この改善をしないと今後の拡大は難しいという結論が出されました。

とあるエンドポイントの冒頭

また、この時点で Apartment の開発は活発ではなく、pull request を送ったとしても受け入れられるのは難しいということで、 当初は モンキーパッチ を当てるなどして微々たる改善は試みていたのですが、そもそも導入時の設定で何故か not recommended なことをしてしまっていたことに気づいてしまいました。5

Notes on PostgreSQL

PostgreSQL works slightly differently than other databases when creating a new tenant. If you are using PostgreSQL, Apartment by default will set up a new schema and migrate into there. This provides better performance, and allows Apartment to work on systems like Heroku, which would not allow a full new database to be created.

https://github.com/rails-on-services/apartment#notes-on-postgresql

※ ちなみに、なぜPostgreSQLでdatabaseモードが遅いかというとMySQLでいうところの USE DATABASE foo がなく、PostgreSQLでは同一コネクションでのデータベース間の参照や切り替えが出来ないためデータベースの切替え=コネクションの貼り直しとなるためです。PostgreSQLで参照や切り替えをしたいなら、schemaを利用しSET search_path = 'foo' すれば良いです。これはPostgreSQLがMySQLより勝る部分と言えます。

やったこと

Apartmentの利用を継続したままuse_schemas=trueにするということに決まりました。

この決断をするに当たって、PostgreSQLをdatabase単位で分けて扱うことをやめることはまず決定しましたが、その方法についてはApartmentをやめてRailsのシャーディング機能を利用することや、 PostgreSQLのRow-Level Security を利用することなども各種議論しました。また、SmartHR さんの事例 を見て「うちは大丈夫なのか??」と心配されたりもしました。

しかし、どの方法をとってもApartmentをやめることはテーブルのスキーマの修正など大量かつ大きな変更が必要であり、またSmartHRさんのように数千テナントになることはかなり飛躍的な希望的観測でありましたので、Apartmentを採用したままschemaでの分割にするという決断をしました。

step1. リファクタリングと諸問題を解決し、まともにテストできる状態にする

まず、Apartmentの導入時にスピード重視で導入したことから発生していた諸問題を解決しました。これにより、テストでのDB分割だけでなく、パフォーマンス改善にも寄与しました。

  1. テスト実行時はswitch!を無視するパッチを当て、共通DBも各地域のDBも全て同じDBを使うようにしていたのですが、これではDBの違いによる不具合を見つけることが出来ないためまずこの問題を修正しました。 これにより、テストでは問題ないが本番環境で結果が空になるといったようなバグが発生するリスクが解消されました。
  2. 一部のテーブルが共通DBでも各地域DBにも存在しどちらも実際に利用しているという部分があったのですが、これは今後の方針によっては大きな問題になるためすべて共通DBに統合しました。
  3. Apartmentのexcluded_models の設定により共通DBに管理するモデルをすべて列挙していたため、それらが全て異なるDBコネクションを確立していたため、1プロセスが40以上のDBコネクションを握っていた問題があり、共通DBと地域DBでそれぞれ親となる抽象クラスを持つことでDB毎に唯一のコネクションを貼るようにし40コネクション→2コネクションにしました。

step2. Apartmentの設定を use_schema=true にしてテストを通す

冒頭のコミットです。

この変更でテストがパスしない限りstep1が不足していると考えてリファクタリングと改善、テストの追加等を繰り返しました。

step3. データベースからスキーマへのデータマイグレーションスクリプトを作る

当日やテスト環境作成のためのスクリプトを作成しました。

最初は下記のようなスクリプトでした。

# 1. 新DBを作成する
psql -h ${APP_DST_DATABASE_HOST} -c "CREATE DATABASE new_db;"

for $tenant_name in $tenants ; do
    # 2. 旧地域DBのpublicスキーマをすべてexport
    pg_dump -h ${APP_SRC_DATABASE_HOST} -Fc -n public ${tenant_name} > tmp/${tenant_name}.dump

    # 3. 新DBのpublicとして取り込む
    pg_restore -h ${APP_DST_DATABASE_HOST} -d new_db tmp/${tenant_name}.dump

    # 4. 新DBのschema名を変更
    psql -h ${APP_DST_DATABASE_HOST} -d ${TEMP_DATABASE_NAME} -c "ALTER SCHEMA public RENAME TO ${tenant_name}"
done
# 最後に共通DBのpublicを新DBにコピー
# 略...

psql -h ${APP_DST_DATABASE_HOST} -d new_db -c "ANALYZE VERBOSE"

これをRDS環境で実行した結果、RDSではPostgresのpublic schemaはpostgresユーザー(role)ではなくrdsadminが権限を持つため、ALTER SCHEMAが出来なかったため下記のように改善しました。

# 1. 新DBを作成する
psql -h ${APP_DST_DATABASE_HOST} -c "CREATE DATABASE new_db;"

for $tenant_name in $tenants ; do
    # 2. 旧地域DBのpublicスキーマをすべてexportしschema名を強制変更
    #    `"public"."` が万が一データに含まれていたら変わってしまうがしょうがない
    pg_dump -h ${APP_SRC_DATABASE_HOST} --no-owner --quote-all-identifier -n public $tenant_name \
        | sed -e "s/\"public\".\"/\"${tenant_name}\".\"/g" \
        | sed -e "s/CREATE SCHEMA \"public\"/CREATE SCHEMA \"${tenant_name}\"/" \
        | sed -e "s/COMMENT ON SCHEMA \"public\"/-- COMMENT ON SCHEMA \"public\"/" \
        > tmp/${tenant_name}.sql

    # 3. 新DBに取り込む
    psql -d new_db -h ${APP_DST_DATABASE_HOST} < tmp/${tenant_name}.sql
done
# 最後に共通DBのpublicを新DBにコピー
# 略...

psql -h ${APP_DST_DATABASE_HOST} -d new_db -c "ANALYZE VERBOSE"

万が一DBのテキストフィールドに"public" という文字列があったら壊れますが、それがあったら後でなんとかすればいいかという気持ちでダンプしたファイル内の"public"をあたらしいschema名に置換しました。(無事"public" というコメントはなかったようです...)

step4. use_schema=true なテスト環境でのテスト及び負荷試験

スキーマによるデータ分割環境で動作する環境を用意しQAを実施しました。 また、並行して負荷試験を実施しパフォーマンスが改善することを確認しました。

ここで問題が発生するとかなり厳しかったのですが、幸いここでの問題は特に発生しませんでした。

step5. メンテナンスナンス当日のリハーサルの実施

メンテナンス当日に予定外の問題が発生しないように、手順書の作成とコードレビュー、本番同等環境でのリハーサルを実施しました。 問題は発生しましたが、重大なものではなく予定の変更が必要なものではありませんでした。 大規模なメンテナンスの際は、可能な限りリハーサルを行うべきだと感じました。

step6. サービスの停止を伴うメンテナンスを行う

メンテナンス当日には、事前に用意可能なものをすべて整え、深夜にメンテナンスを実施しました。予定通りに進行し、サービスの停止を含むメンテナンス作業は無事完了しました。

その他

サービスの停止を伴うメンテナンスを行うため、gemのアップデートやRubyのアップデート(3.0系 → 3.2系)なども同時に行い、結果としてpg gemのアップデートによるパフォーマンス改善が見られました。

結果と学び

完了後は上述の通り、プロジェクトの動き全体を通してレスポンスタイムは3分の1に改善されました。

リファクタリングによってApartmentに依存した部分がコード上、局所化されたため今後サービスが大ブレイクしてApartmentキツイとなったときも対応がしやすくなったと思います。

もしリリース前に負荷試験が出来ていたら、もしもう少しちゃんとドキュメントを読んでいたら、等、、、もしかしたら避けることが出来たかもしれない事案ではありましたが、 問題を可視化したことから始まり課題を明確にすることが出来たため、工数の見積もりなどはある程度正確に見積もることができ、非エンジニアメンバーも納得の上で取り組むことが出来たことは非常に良かったと思います。また、結果としてリファクタリングが進んだことやモジュールが最新化されたことなど良い効果もたくさんありました。

「まちのコイン」の成長に期待したいです。

お疲れ様でした。


  1. 前回の記事は https://techblog.kayac.com/nit-ekiden-2023
  2. カヤックでは主な自社サービスはパフォーマンス性やコンテナ(ECS等)での扱いやすさからgolangが採用されています
  3. https://prtimes.jp/main/html/rd/p/000000424.000014685.html
  4. https://techblog.kayac.com/migrate-machino-coin-from-eks-to-ecs
  5. 意図があったら困るため、なぜこの設定をしたのか調査しましたが理由はわかりませんでした。おそらく試行錯誤の際にいじったのでは無いか?と推測しています。