アプリのコードの修正だけでRailsサーバーを3倍早くした話
こんにちは、バックエンドエンジニアの山口拓弥(@yamat47)です。
東京をはじめとして、またコロナがどんどん悪い意味で盛り上がってしまっていますね...
年末に友人からベンチプレスマシンなどウエイトトレーニング用のセットを手に入れた私には死角はありません、今日も充実したステイホームを満喫しています!
(寂しい一人暮らしなのでトレーニングしている様子は撮れません、すみません...)
さて本日は、昨年末に取り組んだサーバーのレスポンスタイム改善の取り組みについてお伝えします。
ありがちな最適化からアプリ特有の事情へのアプローチなど赤裸々にまとめましたので、参考にしていただけると嬉しいです!
最適化をおこなったアプリの技術的な前提
今回対象にしたのは クラッソーネ というサービスで、Ruby on Rails 製のモノリシックなアプリケーションです。
大まかにはこのような技術を組み合わせて動いています:
- Ruby は 2.7.5(最近 3.1.0 にあげました)。
- Ruby on Rails は 6.0.2.1。
- データベースは MySQL 8.0 系を Amazon RDS で利用している。
- アプリのインフラ基盤は AWS をフルに活用。本番サーバーは ECS と Fargate で運用している。
Vue.js や React は利用しておらず、フロントエンドの実装にはせいぜい jQuery や Slim、SCSS を利用しているくらいです。
Rails の敷くレールに沿った、昔ながらな構成のアプリケーションです。
またアプリは大まかに分けて「施主様が利用する画面」「工事会社様が利用する画面」「運営メンバーが利用する画面」の三つに分かれています。
しかしそのほかの機能も含めて、一つの Rails アプリで実装を完結させています。
今回はそのうち「運営メンバーが利用する画面」に絞って最適化を行いました。
何はともあれ、最適化はうまくいったの?
結論、とてもうまくいきました!
元々は「めっちゃ頑張って2倍くらい速くなればいいな」と考えて目標を立てていましたが、画面によってはその何倍も速くなるページも...
アクセス頻度で重み付けした値を元に計算すると、最終的には 272% の改善ができました!
最適化できる余地がたくさんあることはわかっていましたが、それにしてもここまでうまくいくとは思っていませんでした。
最適化といっても、具体的には何をしたの?
細かく上げるとキリがないですが、大まかには5つの取り組みをしました。
以下、実際の画面もできるだけお見せしつつ、取り組んできたことをお伝えします。
1. Datadog を使ってアプリのパフォーマンスに影響を与えているところの調査
クラッソーネでは AWS と Datadog を連携させて、アプリケーションに関する様々な情報を Datadog 上で確認できるようにしています。
今回の取り組みでは、まずはあらかじめ決めた着手する対象のページに対して Datadog APM で1つずつ確認をしていき、どこにボトルネックがあるのかを調査しました。
例えばこのような調査記録を残していました:
リストを表示しているところではきれいな N+1 クエリの要素がたくさん見られて、嬉しいような悲しいような複雑な気持ちになっていました...。
こうした画面をエンジニア何人かでにらめっこしながら「N+1 問題を解消しよう」「スロークエリをなんとかしよう」「遅延読み込みしたいね」「AWS S3 と連携しているところを切り離せないのか?」といった、実際に解消する課題の棚卸しを行いました。
振り返ってみるとこの棚卸しの活動がとても有効だったと感じています。
むやみやたらに取り組むのではなく、それぞれの施策の効果をある程度想像しながら着手順を決めて取り組んだことで、コスパの良い取り組みにできたと想像しています。
2. N+1 問題の解消
いろんなところで語られているので詳細は割愛しますが、クラッソーネでも残念ながら N+1 問題が発生してしまっていました。
Datadog でみると一目瞭然だったので、Bullet を武器に地道に一つずつ解消していきました。
解決方法もよくある話ですが、ActiveRecord::QueryMethods#preload
を使ってレコードの先読みをしていきました。
例えばこんな感じです(実際のアプリのコードから抜粋してきました):
def index
@contract_summaries = ContractSummariesFinder.new(search_conditions).find
+ .preload(
+ contract: { project: %i[accurate_estimate estate] },
+ change_contract: [],
+ project: [
+ { estate: %i[owner prefecture city] },
+ :company
+ ]
+ )
.order(contract_id: :desc, base_table_name: :desc, change_contract_id: :asc)
.page(params[:page]).per(PAGINATES_PER)
end
またついでに、ローカル開発環境で N+1 問題が発生していることにより気付きやすくしました。
もともと Bullet は導入されていたのですが、開発環境用に準備しているデータの数が少なく、N+1 が起きている扱いにならない画面がいくつかあり...
再び N+1 問題が発生したときに気付けるように、bin/setup
をしたときに作られるデータのセットをたくさん増やしました。
3. ページネーションに関する部品を遅延して読み込むようにした
いわゆる一覧画面では、「1 - 30 ( 1462 件中 )」のようなリストの件数を表示したり「次のページへ」のようなページの操作をする部品を表示したりしています。
こうした表示には Kaminari を利用していますが、APM を解析したところこのページネーションに関する部品の表示に時間がかかっている画面がありました。
しかしこれは決してライブラリにパフォーマンス的な問題があるわけではなく...件数を取得するための COUNT
すら遅いクエリが存在していた、ということでした。
包み隠さず正直にいうと、今回のこの取り組みではクエリのパフォーマンスチューニングまでは手が回りませんでした。
SQL VIEW を利用したそこそこ複雑なクエリで解決が難しそうだったのと、あまりたくさん時間をかけられるプロジェクトでもなかったので、アドホックな解決策を講じるだけにとどめています。
ページネーションに関する部品を画面が描画された後に遅延で読み込むようにして、最初の画面表示までの時間を短くしました。
↑ は画面をリロードしたときの様子です。
最初は「ページ情報を取得しています...」という固定の表示のみを出し、裏でページ情報を遅延読み込みしています。
実装ではあまり難しいことはしておらず、DOMContentLoaded
のタイミングでサーバーに Ajax でリクエストを送っているだけです。
たったこれだけの対応ですが、数秒かかっていたクエリの実行を待たずに済むようになったため一気にレスポンスタイムが改善しました。
4. SQL VIEW をむやみやたらに呼び出さないようにした
先ほど出てきた SQL VIEW ですが、内容を取得するクエリは COUNT
どころではない遅さで、遅延読み込みなどの小手先の対応だけではどうしてもレスポンスタイムを改善しきれませんでした。
そこで更なる解決策として「本当に必要になるまで SQL VIEW を呼び出さない」という対策を講じました。
先に解決策のイメージをお見せします:
# `Estate` と `EstateSummaries` は いずれも `ApplicationRecord` を直接継承したクラス。
# `Estate` はテーブルと結びついていて、`EstateSummaries` は SQL VIEW と結びついている。
module Admin
class EstatesController < ApplicationController
def index
@estates =
if sql_view_required_search_condition?
EstateSummaries.where(...)
else
Estate.where(...)
end
end
private
def sql_view_required_search_condition?
# SQL VIEW を使わないと検索できない条件かどうかを判定する。
end
end
end
SQL VIEW を利用しているのは一覧画面のリストを取得する部分で、検索の仕組みをシンプルにするために導入した仕組みでした。
しかしこれを必要とするのは一部の検索条件についてのみだったため、それ以外の検索条件のみで検索されているときには呼び出さないようにしました。
実際にはここに先ほど載せたページネーションに関する部品の遅延読み込みも絡んでくるため、#index
アクションがそこそこ複雑になってしまっています。
しかしそれを上回る効果があることが確認できたため、入念な動作確認ののちに本番環境にリリースしました。
5. 滅多に使わない外部サービスを呼び出す処理を他の画面に切り離した
工事会社様についての管理画面では、社名や所在地などの情報の他に「会社ロゴ」など関連する画像を管理することができます。
この画像のデータは AWS S3 に保存していて、Rails との連携には Shrine を利用しています。
最適化を行うまで、この工事会社様の情報を更新する画面はひとつしかなく、そこで画像を含むさまざまな情報をいっぺんに更新できるようになっていました。
しかしそのため、S3 に置いてある画像データ以外を更新したときも Shrine の処理を呼び出してしまい、無駄な外部サービス呼び出しが発生してしまっていました。
そこで新しく「画像を変更するための画面」を準備し、画像以外の RDS に直接保存している情報と分けて更新できるようにしたことで、必要なときだけ外部サービスと接続するようにしました。
重たい処理を外に切り離しただけですが、運営メンバー曰く画像を更新する頻度は高くないとのことで、小さい変更で大きな成果を得ることができました。
最終的にどうなったの?
今回のプロジェクトで最適化にチャレンジしたのは全部で 10 の画面でした。
アクセス頻度とレスポンスタイムの悪さの二つを考慮して、一番影響が大きそうな画面たちを選びました。
取り組み前後での変化を表にまとめてみると改善度合いは一目瞭然です:
最適化をおこなった画面 | 取り組み前(p95) | 取り組み後(p95) | 改善度合い |
---|---|---|---|
工事会社情報更新処理 | 2907.8 ms | 153.7 ms | 1892% |
物件一覧画面 | 8563 ms | 1255.3 ms | 682% |
工事会社情報編集画面 | 434.9 ms | 147.7 ms | 294% |
契約一覧画面 | 1027.8 ms | 452.6 ms | 227% |
TODO一覧画面 | 417.8 ms | 243.5 ms | 172% |
工事会社詳細画面 | 1444 ms | 858.4 ms | 168% |
申し込み一覧画面 | 500.2 ms | 385.7 ms | 130% |
工事会社一覧画面 | 824.8 ms | 761.4 ms | 108% |
案件一覧画面 | 461.8 ms | 520.7 ms | 89% |
物件詳細画面 | 378.1 ms | 461.8 ms | 82% |
むしろ遅くなっている画面が2つありますが、これらはこの取り組みの間にほかの機能追加があり、さらにデータをたくさん表示するようになった画面でした。
何も取り組まなかったらさらに遅くなっていただろう変更だったので、なんとかこれくらいで食い止めたということにしています。
まとめ
実際には「カウンターキャッシュの導入」「スロークエリのチューニング」「インフラ基盤のアップグレード」など検討していたことは他にもたくさんありますが、かけられる時間の都合で上記の取り組みにとどまりました。
振り返ってみるとアプリケーションの変更に終始していて、それでも大きな影響を生み出すことができてよかったです。
運営メンバーからも 「物件一覧の絞り込み、速すぎて嘘だと思って3回試した」 など嬉しい反応をたくさんいただくことができました。
チームで「運営がユーザーの検討プロセスの支援を2倍のスピードで行えるようにする」という Objective を掲げた四半期にふさわしい取り組みにすることができました。
クラッソーネでは Rails アプリの最適化が好きなエンジニアを大募集しています。
ここまで記事を読んでくださったみなさま、ぜひカジュアル面談で私とお話ししましょう!