Railsで重いページをなんとかする取り組みの一事例
こんにちは。坂ノ途中の片山です。最近はコーヒー受発注まわりのシステムを Ruby on Rails で開発しています。最近発生した、社内システムの特定のページでのページ表示速度改善の取り組みを紹介します。
Webアプリケーションで、表示まで時間がかかるとユーザに苦痛をもたらしますが、その原因はさまざまです。状況や現象によって対処方法は異なりますが、Google検索などではあまりよい方法論が出なかったためひとつの初歩的な事例として共有します。
Rails上級者の方でよりよい方法があればお知らせください!
前提
バックエンドをRails、フロントエンドはHotwireで構成された業務システムです。フロントエンド側の要因よりもバックエンドの比重が大きいサービスです。利用者から重くて困っている、と相談があったという経緯、と思ってください。
例として、今回は「仕入れ商品一覧」ページを考えます。仕入れ商品はコーヒー生豆で、例えば「インドネシア クリンチマウンテン どこどこ村 ナチュラル 24年クロップ」のように、産地や精製方法ごとに分かれていて、さらに、麻袋に入った状態で、麻袋の状態ごとに在庫が管理されています。
サービス開始当初はシンプルだったのですが、利用者からの要望で表示項目がどんどん増えていって重くなっています。たとえば、各仕入れ商品に対して、あとどれくらい販売契約をつけることができるか、というのは単純に在庫をみるだけではなく、それぞれの商品の仕入れ可能量から、販売契約をつけた数や、出荷済みの商品などから動的に計算する必要がありややこしいことになっていました。
測る
まずは計測です。改善ごとにこまめに計測できるよう、開発環境で準備します。 大事なのは、クレームのあったページで必要なデータをある程度本番環境と同じように揃えておくことです。このデータが少ないと、開発環境では問題ないと気軽にリリースしても本番で大きなパフォーマンスの問題になる場合があります。データ量次第ですが、匿名化しローカルに持ってくる場合もあれば、検証用DBを用意してつなぎにいくもありです。
さっそく確認してみます。
Completed 200 OK in 6176ms (Views: 2037.3ms | ActiveRecord: 4027.9ms | Allocations: 1473011)
げ・・・かなり重くてストレスですね。 原因はViewの表示で2秒、ActiveRecordの4秒、Allocationsも迫力ある。 あと
原因を探して修正していく サーバーログを眺めると、たくさんSQLを打っています。 定石的にはコレクションの表示ごとにSQLを実行するN+1問題をなんとかしたいのですが、単純なpreloadでは解決しなさそう(要素ごとに、集計したりなど・・・)。
これらが重さにつながっているのはありますが、もうちょっと俯瞰してみます。
さらに細かい影響をみるには rack mini profiler が便利です。
https://rubygems.org/gems/rack-mini-profiler
https://github.com/MiniProfiler/rack-mini-profiler
Gemfileに追記し bundle install してください。
group :development do gem 'rack-mini-profiler', require: false end
初期設定します
$ bundle exec rails g rack_mini_profiler:install
create config/initializers/rack_mini_profiler.rb
ブラウザでページを表示したときに、左上にタブが出てめくるともうちょっと細かいデータがでてきます。
※ モザイクかけなくてもいいか・・・
こんなSQL打ってるのか・・・。 このSQLを表示すると重いクエリが赤くなっていたり視覚的にわかります。
まず、合計点数を表示するために countをしているSQLが遅い! これは mysql8 で count が遅い問題があることと関連がありそうです。調べてみると 8.0.37 では解消しているという話もあります。
参考) MySQL8.0でSELECT COUNT(*)が低速になる動作は8.0.37で解消されていた! - CyberAgent SRG #ca_srg
しかし、使っている mysql のバージョンは 8.0.40 なのでこれと同じ問題ではなさそう。 重要な集計ではないので、業務チームと相談し、検索結果数を表示しないこととしました。
はい。ちょっとは改善
Completed 200 OK in 4778ms (Views: 1853.0ms | ActiveRecord: 2844.6ms | Allocations: 1339987)
でもまだまだ重いですね。 次はページネーションで利用しているpagyを呼んでいるところが重い。 https://github.com/ddnexus/pagy
pagy自体はシンプルでふつうに便利なのですがたくさんある項目のページネーションはつらい。 ただ、ここもページ数とかは利用者はあまり関心がないので、ページ数を正確に表示する仕様を妥協し、 pagy_countlessとしてみました。 https://ddnexus.github.io/pagy/docs/extras/countless/
Completed 200 OK in 3599ms (Views: 1870.0ms | ActiveRecord: 1527.9ms | Allocations: 1329822)
さらに改善。でもまだまだ。
さて、各仕入れ商品ごとに、売約済み数とか、キャンセルされた数とか、別の処理した数を出すところについて、安直にコードを書くと、N+1問題的にコードが発行されてしまう問題がありました。 これは表示する項目から、リレーションをたどっていって計算しているためです。
最初は悩んでこれらをキャッシュしておくか?という悪魔の声も聞こえてきますが、更新サイクルとかは悩ましい。
「キャッシュは麻薬」という標語からの脱却 - id:onk のはてなブログ
ここで発想を転換して、画面に表示する要素のIDをもとに、リレーション先のモデルを起点に集計してハッシュに保持し、Viewではそのハッシュから要素のIDで値を取り出すこととしてみました。
ちょっと説明してもわかりにくいと思うのでコード例はこちらです。
モデルにクラスメソッドとしてこういうコードを実装します
# 画面一覧に表示する項目を事前にまとめて計算し、idをキーとしたハッシュを返すメソッド def self.prepare_stats(purchase_products:) shippable_product_ids = ShippableProduct.where(purchase_product_id: purchase_products.pluck(:id)).pluck(:id) shipped_quantity_hash = OrderProductAssign.where(shippable_product_id: shippable_product_ids) .eager_load(:shippable_product) .なんかスコープ .group(:purchase_product_id).sum(:quantity) # こんな計算をいくつかしておく purchase_products.each_with_object({}) { |pp, hash| hash[pp.id] = { shipped_quantity: shipped_quantity_hash[pp.id], ほかの項目: ~, } } end
うちのコード例なのでわかりにくいところあるかと思いますがご笑覧を・・・
結果こうなりました。かなり高速化されますね!ActiveRecordは30msと問題ないレベルまでいきました。
Completed 200 OK in 888ms (Views: 621.2ms | ActiveRecord: 30.9ms | Allocations: 447587)
ここでの注意点は、このハッシュを用いるテンプレート(erbファイルとか)がshowアクションとindexアクションのみで利用されるのであれば、コントローラだけでよいのですが、このテンプレートがほかの箇所で Hotwire などで置き換えられることがあります。 それらを呼ぶ可能性のあるコントローラすべてで事前にこのハッシュを事前計算するのは少し手間です。
そこで、ややダーティな感はありますがテンプレート最上部でこのように変数チェックをしておきます。少人数チームで開発している分では許容範囲かと。
<% # turbo_stream などで置換する際にデータがなければ用意する unless defined?(@purchase_product_stats) @purchase_product_stats = PurchaseProduct.prepare_stats(purchase_products: [purchase_product]) end %>
ほかこまごま改修し、最終的にこんな感じとなりました。 特に影響があったのは、不要な eager_load を削除したこと。 N+1問題解消のためにと includes や eager_load すると、Allocations が大きくなるのでご注意ください。
Completed 200 OK in 507ms (Views: 279.7ms | ActiveRecord: 40.1ms | Allocations: 330966)
爆速というわけではないですが、10分の1以下まで速度改善でき、開発環境でも本番環境でもストレスはありません。ほかこまかいチューニングでよりよくできるとは思いますがひとまず利用者のストレスがないレベルまできました。 振り返り 最初のめちゃ重状態の数字も出して恥ずかしいところではあるのですが、業務システムで素直につくっているとそういうこともあります(よね?)。 ただ、Railsでバックエンドもフロントエンドも通して開発できることで、パフォーマンスチューニングはやりやすいなあ、とも感じます。もちろん、これは少人数で利用者とも近い環境でわいわい開発している業務システムだからできることではあるのですが。
そんな感じで、日々、コーヒーエキスパートたちとともに、いろんな商習慣のあるコーヒーの流通システムを開発しています。まだまだやりたいことやおもしろ案件がありまして、一緒にやっていくソフトウェアエンジニア募集中です!お気軽にお声掛けください。
参考)より高いレベルでチューニングする際
こんな電子書籍もでていることに気付きました。
脱 Paperclip2023. ActiveStorage への移行アイデアいくつか
はい。2023年の記事です。多くの方々はとっくの昔に対処済みで、2023年にもなっていまさらという記事かと思いますが、さまざまな事情でいまだ需要がある気配を感じています。そこで、恥ずかしながら取り組んだことを共有します*1。
まとめ
- Ruby on Rails のファイルアップロードとその表示を便利にする Gem である Paperclip は2018年にメンテナンス停止し非推奨になりました
- 移行先は ActiveStorage が順当
- 公式のドキュメントでは migration で移行する方法が案内されているけれど元のファイルパスを変えずに登録する手順のため少しわかりにくい
- ファイル数が少ない見込みであれば全部移行してもいいかもしれない
- PaperClip と ActiveStorage 同時に(別名で)ファイルをもつようにすると非同期でできて便利
前提:PaperClip について
2007年から提供されている、画像やファイルアップロードを扱う有名 Gem でお世話になった方も多いと思います。
Rails5.2で ActiveStorage が登場したこともあって2018年に deprecated になりました。移行しましょう。 とはいえ、小規模なあまり手がかけられていないアプリケーションではそうなっていない場合もあると思います。
事前準備:ActiveStorage の動作と移行の検証
公式の手順では S3 上のファイルを動かさずに ActiveStorage が把握するファイルの先を更新するためにわかりにくくなっています。ActiveStorage の動作について簡単なコードでファイルを移動させる例をあげてみます。
たとえば、Attachment クラスにて以下のように Paperclip を利用しているとします。
has_attached_file :document
このとき以下のように ActiveStorage をもつよう移行したい状況を考えます。
has_one_attached :active_document
以下のように attach メソッドで paperclip で管理していたリソースをActiveStorageに移せます(※ただし、このやり方だとクラウドストレージは倍かかるし実ファイルを移行することになり時間もかかるので ActiveStorage の理解のための動作確認とお考え下さい)。
こうして試してみてから active_storage_attachments と active_storage_blobs にできたレコードをみると、公式で案内する手順を理解しやすくなると思います。
document_url = attachment.document&.url(:original) uri = open(document_url) attachment.active_document.attach( io: open(document_url), filename: attachment.document_file_name, content_type: attachment.document_content_type )
ファイル数が少ないなどでしたらこの方式で公式の migration スクリプトを書き替えて実行してもよいかもしれません。
作戦
以下のように、Paperclip と ActiveStorage を両方有効にし、追加・更新時に両方に保存するようにすると移行のダウンタイムを小さくできます。そして表示時には、 attached? メソッドで ActiveStorage にリソースがある場合はそちらを表示するようにする。
has_attached_file :document has_one_attached :active_document
あとはテストをしながら愚直に書き換えていくだけです。画像利用クラスや箇所が多いとたいへんです。いくつかはまりどころがあったので触れておきます。
ActiveStorage でのはまりどころ
1.画像を変更できるかどうか
これまでは、事前にクラスにサイズを指定しておくと変換したものを用意してくれて image.url(:thumb) などで簡単に表示できましたが、そうすることはできませんし、ファイルによって変更できるかどうかで分岐させる必要があります。
たとえば画像を
has_one_attached :active_image
として保存している場合、以下のように variable かどうかをみる必要がある。
def image_icon_size if active_image&.attached? && active_image&.variable? active_image.variant(resize_to_fill: [100, 100]) else active_image end end
2.公開URLは Rails6.1 までない
ActiveStorageではデフォルトではファイルへのURLはrailsを経由し、有効期間が短い。 このままではCDNがキャッシュしにくいので公開して問題ない場合はURLを公開するとよい。
ただ、この設定はRails6.1からなので移行時にバージョンを確認しておいてください。
Rails 6.1 つまみ食い② : ActiveStorage の永続的なURL - インゲージ開発者ブログ
3.PDFで画像を使う場合、テストで気付きにくい
wicked-pdf でPDFを生成し、そこでActiveStorageの画像を用いる場合、background-image で設定すると表示されないようです(謎)。 素直に image タグを使って解決させました。 PDFでの表示内容、自動テストで確認しにくく見逃してしまっていました・・・。
4.ファイルのバリデーション
公式ドキュメントで探しにくかったのですが、下記の記事が参考になりました。感謝
まとめ
そんなこんなで 脱PaperclipしてRailsのバージョンも7まであげてすっきりしました。 振り返ってみると、Paperclipはファイルアップロードをシンプルに使えてたいへん便利だったことがわかります。ほんとありがとうございました。
生産者さまがデータを活かした営農をできるようにするファーモを開発している片山がお届けいたしました。ご質問やコメントあればお気軽にお知らせください。
参考URL
公式の脱出ガイド https://github.com/thoughtbot/paperclip/blob/master/MIGRATING.md
日本語訳(感謝!)
Rails: PaperclipからActiveStorageへの移行ガイド by thoughtbot(翻訳)|TechRacho by BPS株式会社
新登録されたファイルをどちらにも登録することでダウンタイムゼロを目指す戦略
From Paperclip to Active Storage: An incremental, zero-downtime approach | TokyoDev
ActiveStorage の仕組みとコードリーディングについてはこちらの記事がたいへん参考になりました感謝。
[Rails5.2]ActiveStorageの仕組み(図あり)と使ってみてわかったこと - Qiita
コーポレートサイトとECサイトの一部をNext.jsでJAMStack化し高速化した話
三行まとめ
- 坂ノ途中の技術ブログはじまります
- WordpressのヘッドレスCMS化とNext.jsによるJAMstack化を採用しました
- ヘッドレスCMSは速度改善でき、これからのCMS運用の標準になるかもしれません
こんにちは。「100年先も続く、農業を。」というビジョンを掲げ、環境負荷の小さい農産物の流通やコーヒーの輸入・加工などに取り組んでいる坂ノ途中の片山です。機運が高まってきたので技術ブログをはじめます。
会社紹介
ぼくたちは、環境負荷の小さい農業を広げるために、少量だったり不安定だったり、けれど美味しいお野菜を流通させる仕組み、多様性を排除しない流通の仕組みをつくることをめざし、生産者さまからお野菜を仕入れて個人のお客さまや飲食店さまや小売店さま向けに野菜を販売しています。 そのためにさまざまなシステムを開発していますが、そのなかでもECサイトは個人のお客さまとの接点であり重要なプロダクトです。 世の中のECとの違いとして、定期宅配をサポートし、スキップや日程変更などを柔軟にできるようにしつつ、単品の商品も定期便に同梱できるようにしているのが特徴でパッケージなどを使わず自前で開発しています。

第一回として、このWebショップ(以下ECサイト)のアーキテクチャ変更についてお話しします。 今回のプロジェクトは、レンタル移籍という仕組みでさる大企業からやってきた植木快さんによる取り組みによるものです。かれは昨年4月から10月までこのプロジェクトを立ち上げて離陸までさせましたが、みなに惜しまれつつ涙の帰任を果たしたので代わりに紹介させていただきます。
旧環境とその課題
まず、ぼくたちのECサイトはこれまで、トップページや読み物などのあるいわゆるコーポレートサイト部分をWordPressで構築し、お届け先選択やマイページなどのカート部分はRuby on Railsで開発していました。デザインを共通にしているため、お客さまはどこからRailsかは気にしないことでシームレスに読み物を読んだりお買い物をすることができます*1。

WordPressというとレガシーでは?いまどきまだ使ってるの?というお声もあるかもしれませんが、2位のShopify以下に大差をつけて世界でもっとも使われているCMSです*2。ホワイトハウスでも2017年末から利用されており*3、多数のpluginが提供され慣れている編集者やライターや開発会社も多く普及しているプロダクトです。ぼくたちも仲の良い開発会社さんにデザインから構築までお願いしていました。
しかし、WordPressはページの表示速度やバージョン管理やデプロイに課題があったり、攻撃対象にも選ばれやすいほか、テーマをさくっとつくるとすぐにコードのViewとロジックが深く結合してしまうなどメンテナンス性や改善速度に課題があります。
特にページ表示速度はUIの重要な要素です。 ページ生成までの処理が全体的に重いことが課題となっており、このTTFB短縮がパフォーマンス改善における最重要課題でした。 しかし、オンラインショップは商品の在庫数、販売可否、ログイン状態やカート状態に応じて表示を出しわける必要があり、すぐにCDNを導入することが難しい状況でした。
JAMstackアーキテクチャの採用
これらの状況ではコーポレートサイトをWordPressからリプレースするなどの方法もありますが、移行コストなどを考慮し、今回は、Wordpressを残したままフロントエンドをNext.jsでSSGしていくことで解決をはかりました。 WordpressからGraphQLで記事情報を取り出す、いわゆるヘッドレスCMSとこれをもとにするJAMstackアーキテクチャにしていく、名付けて「坂ノ途中JAMプロジェクト」です*4。
JAMstackについてはさまざまな記事が執筆されていますが、この記事がたいへん参考になりました。
各状態に応じた表示出し分けについては、クライアントサイドにてswrを用いる方針とし、cssについては特段手を入れず*5、グローバルcssを全ページで読み込む方式として、テンプレートや表示用データ取得のロジックをNext.jsに移植していきました。
下記がシステム全体のざっくり構成図です(※ いろいろ省略されています)。

ページ更新処理は、mainブランチにマージされるとPipelineが実行され*6 → Next.js によるSSG →Amazon S3 へのアップロードという流れです。 この結果、最もTTFBが長かったオンラインショップトップページでは、1000ms短縮に成功しました*7。
振り返りと残課題
今回のリプレースで、各種ページパフォーマンスが大きく改善されただけでなく、データ取得ロジックのリファクタリングとReactコンポーネントの導入によって、表示ロジックの変更も容易になりました。 TypeScriptの導入やCypressを用いたE2Eテストも導入し、安全にフロントエンドの信頼性を高める取り組みもできています。 そのほか、記事更新時のキャッシュパージはSlackのbotからAWS lambdaで実行しています。
残課題としては、キャッシュパージの自動化、サイトをまたぐCSSの管理、プレビュー環境の用意などあり引き続き対応中です。 このあたりはまだまだ改善の余地はありますが、Next.jsやフロントエンドエコシステムの進化によって表示速度と開発速度の改善を感じています。
いまの時代、新しくCMSを利用したサイトをつくる際はSaaSで利用できるヘッドレスCMSが第一線の候補になるとは思いますが、既存のWordPressサイト(なにより世界のCMSの40%で使われています!)を比較的低コストで改善していく際にはWordpressのヘッドレスCMS化は十分ありだと感じています。
今後も、坂ノ途中ITチームでは、お客さまの購買体験を改善したり、生産現場のことをお伝えできるよう機能改善を行う予定です。React, Typescript, Next.jsでECサイト開発をしたいエンジニアの方はぜひお気軽にご連絡くださいませ!
*1:この構成についてはさまざまな議論の余地があると思っています。いろんな仕掛けと試行錯誤があるのでご興味ある方お知らせください!
*2:Usage Statistics and Market Share of Content Management Systems, February 2022
*3: 米ホワイトハウスがCMSとしてWordPressを採用。Drupalから変更 - Publickey
*4:プロジェクト名の由来はあくまでJAMstack。ちなみに弊社オンラインショップで販売されているジャムには、やまのあいだファームのみかんジャム 120g、べじたぶるぱーくさんの栗ジャムなどがございます
*5:html構造とid, class名を一致させることで、今のところ、運用しています
*6:CMSで記事更新時にはSlackショートカットから実行する運用としています
*7:そのほかのページはTTFBが平均約500ms短縮されました。「100ms遅延すると、売上が1%失われる」という言説もあります。