1. ホーム
  2. スクリプト・コラム
  3. ルビートピックス

Ruby on Railsのパフォーマンスを最適化するためのいくつかの方法についての考察

2022-02-01 02:09:05

1. Railsアプリの動作が遅くなる理由は2つしかありません。

  1. 本来RubyやRailsを使うべきでないところで使っている(苦手な作業をRubyやRailsで行っている)
  2. 過剰なメモリ消費により、ガベージコレクションに多くの時間を割かなければならない。

Railsは楽しいフレームワークであり、Rubyはクリーンでエレガントな言語です。しかし、乱用すると、かなりパフォーマンスが低下します。例えば、データベースはビッグデータ処理に適していますし、Rは統計関連の仕事に特に適しています。

メモリの問題は多くのRubyアプリケーションで速度低下の第一の原因であり、Railsのパフォーマンス最適化の80-20の法則は次のようになります。高速化の80%はメモリの最適化によってもたらされ、残りの20%は他の要因によるものです。なぜメモリ消費がそれほど重要なのか?なぜなら、メモリを多く確保すればするほど、Ruby GC(Rubyのガベージコレクション機構)の仕事が増えるからです。Railsはすでに多くのメモリを消費しており、平均すると、各アプリケーションは起動直後に100M近くのメモリを消費しています。メモリを抑えておかないと、アプリケーションが1G以上増えてしまうことも十分あり得ます。これだけのメモリを再利用するのですから、プログラムの実行時間のほとんどがGCに取られてしまうのも無理はありません。

2 Railsアプリを高速に動作させるには?

アプリを高速化する方法には、スケーリング、キャッシュ、コードの最適化の3つがあります。

スケーリングは今日、簡単に実装できます。Herokuは基本的にこれをやってくれますし、Hirefireはそのプロセスをより自動化します。他のホスティング環境でも同様のソリューションが提供されています。要するに、可能であれば使うのです。しかし、スケーリングはパフォーマンスを向上させる銀の弾丸ではないことを心に留めておいてください。アプリケーションが5分以内にリクエストに応答する必要がある場合、スケーリングはあまり効果がないでしょう。また、HerokuとHirefireの組み合わせでは、銀行口座の残高を超過することがほとんどです。Hirefireが私のアプリを36エンティティまでスケールさせたのを見たことがありますが、それには3100ドルもかかりました。

Railsのキャッシュは実装も簡単です。Rails 4のブロックキャッシュは非常に優れており、Railsのドキュメントはキャッシュの知識を得るための優れたリソースです。しかし、キャッシュはスケーリングとは異なり、パフォーマンス問題の究極の解決策ではありません。コードが最適に実行されないと、キャッシュにますます多くのリソースを費やすことになり、速度向上が見込めなくなります。

Railsアプリを高速化するための唯一の確実な方法は、コードの最適化です。Railsのシナリオでは、これはメモリの最適化です。そして、私のアドバイスに従ってRailsの設計機能以外を使用しないようにすれば、最適化するコードが少なくなるのは当然のことです。

2.1 メモリを大量に消費するRailsの機能を避ける

Railsの機能の中には、ガベージコレクションの追加につながる、大量のメモリを消費するものがあります。その一覧は以下のとおりです。

2.1.1 プログラムのシリアライズ

シリアライザは、データベースから読み込んだ文字列をRubyのデータ型として表現するための実用的な方法です。

class Smth < ActiveRecord::Base
 serialize :data, JSON
end
Smth.find(...) .data
Smth.find(...) .data = { ... }


効果的にシリアライズするためには、より多くのメモリが必要です、ご自分の目で確かめてください。

class Smth < ActiveRecord::Base
 def data
 JSON.parse(read_attribute(:data))
 end
 def data=(value)
 write_attribute(:data, value.to_json)
 end
end


これならメモリのオーバーヘッドが2倍で済みます。私を含め、RailsのJSONシリアライザーで、1リクエストあたりのデータ量の10%程度のメモリリークが発生した人がいます。この理由はよくわかりません。また、再現可能な事例があるのかどうかもわかりません。経験者の方、メモリを減らす方法をご存知の方、ぜひ教えてください。

2.1.2 アクティビティロギング

ActiveRecordでデータを操作するのは簡単です。しかし、ActiveRecordは基本的にデータを包んでしまいます。1gのテーブルデータがあれば、ActiveRecordは2g、場合によってはそれ以上のコストがかかるということです。そう、9割のケースで利便性が向上するのです。しかし、必要ない場合もあります。たとえば、一括更新でActiveRecordのオーバーヘッドを減らすことができる場合です。以下は、モデルをインスタンス化せず、バリデーションやコールバックを実行しないコードです。

Book.where('title LIKE ?' , '%Rails%').update_all(author: 'David')
後者のシナリオでは、単にSQLのupdate文を実行するだけです。

update books
 set author = 'David'
 where title LIKE '%Rails%'
Another example is iteration over a large dataset. sometimes you need only the data. no typecasting, no updates. this snippet just runs the query and This snippet just runs the query and avoids ActiveRecord altogether:
result = ActiveRecord::Base.execute 'select * from books'
result.each do |row|
 # do something with row.values_at('col1', 'col2')
end


2.1.3 文字列コールバック

Railsのコールバックは、before/after saveやbefore/after actionと同じで、使い方がたくさんあります。しかし、その書き方によってパフォーマンスに影響が出る場合があります。ここでは3つの書き方を紹介します。例えば、callback before saveです。

before_save :update_status
before_save do |model|
model.update_status
end
before_save "self.update_status"



最初の2つはうまくいくのですが、3つ目はうまくいきません。なぜか?Railsのコールバックを実行するには、実行コンテキスト(変数、定数、グローバルインスタンスなど)をコールバックの時点で保存しておく必要があるからです。大規模なアプリケーションを使用している場合、メモリ内に大量のデータをコピーすることになります。コールバックはいつでも実行できるため、アプリケーションの終了までメモリはリサイクルできません。

コールバックで1リクエストあたり0.6秒短縮できるという記号があるのですが。

2.2 より少ないRubyの書き方

これは私が一番好きなステップです。私の大学のコンピューターサイエンスの教授は、「最高のコードは存在しない」と言うのが好きでした。目の前のタスクをうまくこなすには、他のツールが必要なこともあるのです。最もよく使われるのはデータベースです。なぜか?Rubyは大きなデータセットを扱うのが苦手だからです。とても、とても苦手です。Rubyが大量のメモリを消費することを忘れないでください。だから、たとえば1Gのデータを処理するためには3G以上のメモリが必要になるかもしれない。良いデータベースなら、そのデータを一瞬で処理できます。いくつか例を挙げてみよう。

2.2.1 プロパティプリローディング

アンチ正規化モデルのプロパティが他のデータベースからフェッチされることがあります。例えば、タスクを含むTODOのリストを構築している場合を想像してください。各タスクは、1つまたは複数のタグを持つことができます。正規化されたデータモデルは次のようになります。

  • タスク
  •  イド
  •  名前
  • タグ
  •  イド
  •  名前
  • タスク_タグ
  •  タグID
  •  タスクID

タスクとそのRailsタグを読み込むには、次のようにします。

このコードは、タグごとにオブジェクトを作成し、多くのメモリを消費してしまうという問題があります。オプションの解決策としては、データベースでタグを事前にロードしておくことです。

tasks = Task.select <<-END
  *,
  array(
  select tags.name from tags inner join tasks_tags on (tags.id = tasks_tags.tag_id)
  where tasks_tags.task_id=tasks.id
  ) as tag_names
 END
 > 0.018 sec


これは、ラベルの配列を持つ1つの余分な列を格納するためのメモリしか必要としません。3倍速くなるのも当然です。

2.2.2 データコレクション

データコレクションと呼ぶのは、データを要約したり分析したりするコードです。これらの操作は単純な要約であったり、より複雑なものであったりする。グループランキングの例を見てみよう。従業員、部署、給料のデータセットがあり、ある部署での従業員の給料のランキングを計算したいとする。

SELECT * FROM empsalary;


 depname | empno | salary
-----------+-------+-------
 develop | 6 | 6000
 develop | 7 | 4500
 develop | 5 | 4200
 personnel | 2 | 3900
 personnel | 4 | 3500
 sales | 1 | 5000
 sales | 3 | 4800


Rubyを使ってランキングを計算することができます。

salaries = Empsalary.all
salaries.sort_by! { |s| [s.depname, s.salary] }
key, counter = nil, nil
salaries.each do |s|
 if s.depname ! = key
 key, counter = s.depname, 0
 end
 counter += 1
 s.rank = counter
end


Empsalaryテーブルの100Kデータプロシージャは、4.02秒で完了します。Postgresのクエリの代わりにwindow関数を使用すると、4倍速で1.1秒で同じ作業ができます。

SELECT depname, empno, salary, rank()
OVER (PARTITION BY depname ORDER BY salary DESC)
FROM empsalary;


 depname | empno | salary | rank 
-----------+-------+--------+------
 develop | 6 | 6000 | 1
 develop | 7 | 4500 | 2
 develop | 5 | 4200 | 3
 personnel | 2 | 3900 | 1
 personnel | 4 | 3500 | 2
 sales | 1 | 5000 | 1
 sales | 3 | 4800 | 2


4倍の加速はすでに印象的ですが、それ以上の20倍まで加速することもあります。私の経験からの一例です。600k行の3D OLAP多次元データセットを持っています。私のプログラムは、スライスと集計を行いました。Rubyでは、1Gのメモリで約90秒で完了しました。同等のSQLクエリは5で完了した。

2.3 Unicorn の最適化

Unicornを使用している場合、以下の最適化のヒントが適用されます。UnicornはRailsフレームワークの中で最も高速なWebサーバーです。しかし、もう少し速く動作させることは可能です。

2.3.1 アプリのプリロード

Unicornは新しいワーカープロセスを作成する前にRailsアプリをプリロードすることができます。これには2つの利点があります。まず、メインスレッドはコピーオンライト(Ruby 2.0+)というフレンドリーなGCメカニズムを通じて、メモリ内のデータを共有できます。ワーカーによってデータが変更された場合、OSは透過的にこのデータをコピーします。Railsのワーカープロセスは再起動するのが普通なので(これについては後で説明します)、ワーカーの再起動が速ければ速いほど、パフォーマンスが向上します。

アプリのプリロードをオンにする必要がある場合は、unicornの設定ファイルに次の行を追加するだけです。

preload_app true
2.3.2 リクエストリクエスト間のGC

GCは、アプリケーションの処理時間の最大50%を占める可能性があることに留意してください。問題はこれだけではありません。GCはしばしば予測不能で、実行させたくないときに実行の引き金となります。では、どうすればいいのでしょうか?

まず思い浮かぶのは、GCを完全に無効にしたらどうなるかということです。これはかなり悪いアイデアのように思えます。アプリが1Gのメモリをすぐに使い切ってしまい、それに気づかない可能性があります。サーバー上で複数のワーカーを同時に実行すると、たとえアプリケーションが自前のサーバーであっても、すぐにメモリ不足に陥ります。Herokuは言うまでもなく、512Mのメモリ制限しかありません。

実はもっといい方法があるんです。それから、もしGCを避けることができないなら、できるだけ決定論的なポイントで、アイドル時にGCを実行させるようにすればいいのです。例えば、2つのリクエストの間にGCを実行する、これはUnicornを設定することで簡単にできる。

Ruby2.1以前のバージョンでは、OobGCというunicornのモジュールが存在します。

require 'unicorn/oob_gc'
 use(Unicorn::OobGC, 1) # "1" means "force GC to run after 1 request"


Ruby 2.1 以降では gctools (https://github.com/tmm1/gctools) を利用するのがよいでしょう.

require 'gctools/oobgc'
use(GC::OOB::UnicornMiddleware)


しかし、リクエストの間にGCを実行するには、いくつかの注意点があります。最も重要なことは、この最適化技術が知覚可能であることです。つまり、性能向上はユーザーにとって目に見えるものになります。しかし、サーバーはより多くの仕事をする必要があります。必要なときにGCを実行するのとは異なり、この手法ではサーバーが頻繁にGCを実行する必要があります。そのため、サーバーにはGCを実行するのに十分なリソースと、他のワーカーがGCを実行している間にユーザーのリクエストを処理するのに十分なワーカーがあることを確認したいものです。

2.4 限定的な成長

ここまで、1Gのメモリを消費するアプリの例をご紹介してきました。もし十分なメモリがあるのなら、このような大きなメモリを占有することは大したことではありません。しかし、RubyはそのメモリをOSに返さないことがあります。その理由を次に詳しく説明しましょう。

Rubyでは、2つのヒープを通じてメモリを確保します。RubyのすべてのオブジェクトはRuby自身のヒープに格納されます。各オブジェクトは40バイトを占有します(64ビットOSの場合)。オブジェクトがさらにメモリを必要とするときは、オペレーティングシステムのヒープにメモリを確保します。オブジェクトがガベージコレクションされて解放されるとき、OSのヒープ内のメモリはOSに戻されますが、Ruby自身のヒープ内のメモリは単にfreeとマークされてOSに戻されるわけではありません。

つまり、Rubyのヒープは増えるばかりで、減ることはないのです。1行10列のデータベースから100万行を読み込む場合を想像してください。すると、そのデータを格納するために、少なくとも1000万個のオブジェクトを割り当てる必要がある。通常、Ruby Worker は起動時に 100M のメモリを使用します。これだけのデータを格納するために、Workerはさらに400Mのメモリを必要とします(1000万個のオブジェクト、それぞれ40バイトを占有)。これらのオブジェクトが最終的に回収されるとしても、worker はまだ 500M のメモリを使用しています。

ここで注目すべきは、RubyのGCはこのヒープサイズを小さくすることができるということです。しかし、実際にこの機能を見つけたことはない。なぜなら、ヒープ削減のトリガーとなるような条件が本番環境で発生することはほとんどないからです。

Worker が大きくなるしかない場合、最も明白な解決策は、メモリを過剰に消費するたびに Worker を再起動することです。これは Heroku など、一部のマネージド サービスで行われています。

2.4.1 内部メモリ制御

Trust in God, but locked your car Trust in God, but don't forget to lock your car (モラル:外国人の多くは信心深く、神が万能であると信じているが、誰が日常生活で神の助けを期待できるだろう。信仰は信仰だが、困難があるときはやはり自分を頼るしかない) . アプリケーションを記憶の中で自己限定的にする方法は2つあります。私はそれを、KindとHardと呼んでいます。

親切なメモリ制限とは、リクエストのたびにメモリサイズを強制的に変更することです。だから私はこれを "kind"と呼んでいるのです。アプリケーションが壊れることはありません。

LinuxとMacOSではRSSメトリックス、WindowsではOS gemを使用して、プロセスのメモリサイズを取得します。この制限をUnicornの設定ファイルに実装する方法を紹介します。

class Unicorn::HttpServer
 KIND_MEMORY_LIMIT_RSS = 150 #MB
 alias process_client_orig process_client
 undef_method :process_client
 def process_client(client)
 process_client_orig(client)
 rss = `ps -o rss= -p #{Process.pid}`.chomp.to_i / 1024
 exit if rss > KIND_MEMORY_LIMIT_RSS
 end
end


ハードディスクのメモリの上限は、OSに頼んで、作業プロセスが大きくなったら殺してもらうことで設定します。Unixではsetrlimitを呼んでRSSxの制限を設定することができます。私の知る限り、これはLinuxでのみ動作します。macOSの実装は壊れています。新しい情報があればありがたいです。

このスニペットは、ユニコーンのハードディスク制限設定ファイルからのものです。

after_fork do |server, worker|
 worker.set_memory_limits
end
class Unicorn::Worker
 HARD_MEMORY_LIMIT_RSS = 600 #MB
 def set_memory_limits
 Process.setrlimit(Process::RLIMIT_AS, HARD_MEMORY_LIMIT * 1024 * 1024)
 end
end


2.4.2 外部メモリ制御

自動制御では、時折発生するOMM(メモリ不足)からあなたを救うことはできません。通常は、何らかの外部ツールを設定する必要があります。Herokuの場合、独自のモニタリングがあるので、その必要はない。しかし、セルフホスティングなら、monitやgodを使うのが良いし、その他のモニタリングソリューションもある。

2.5 RubyのGCを最適化する

RubyのGCをチューニングして性能を向上させることができる場合があります。このようなGCのチューニングはあまり重要ではなくなってきており、Ruby 2.1のデフォルトの設定がその後ほとんどの人に有利に働いていると主張します。

私のアドバイスは、何をしたいのかがはっきりしていて、どうすればパフォーマンスが向上するのか理論的な知識が十分でない限り、GCの設定を変更しない方が良いということです。これは特にRuby 2.1以降を使っている人にとって重要です。

GCの最適化が性能向上につながるケースは、私が知る限り1つだけです。それは、大量のデータを一度にロードしたいときです。以下の環境変数を変更することで、GCの実行頻度を減らすことで実現できます。RUBY_GC_HEAP_GROWTH_FACTOR、RUBY_GC_MALLOC_LIMIT、RUBY_GC_MALLOC_LIMIT_MAX、RUBY_GC_OLDMALLOC_LIMIT、および RUBY_ GC_OLDMALLOC_LIMIT...。

これらの変数は、Ruby 2.1 以降にのみ適用されることに注意してください。2.1より前のバージョンでは、変数がなかったり、変数がその名前を使用していなかったりすることがあります。

RUBY_GC_HEAP_GROWTH_FACTOR のデフォルトは 1.8 で、これは Ruby のヒープがメモリを割り当てるのに十分な領域がないときに、どの程度増やすべきかを示すために使用されます。大量のオブジェクトを使用する必要がある場合、ヒープメモリ空間はもう少し速く成長させたいものです。そのような時には、この係数を大きくする必要があります。

メモリ制限は、オペレーティングシステムのヒープに領域を要求する必要があるときにGCが起動する頻度を定義するために使用され、Ruby 2.1以降のデフォルトの制限は次のとおりです。

New generation malloc limit RUBY_GC_MALLOC_LIMIT 16M
Maximum new generation malloc limit RUBY_GC_MALLOC_LIMIT_MAX 32M
Old generation malloc limit RUBY_GC_OLDMALLOC_LIMIT 16M
Maximum old generation malloc limit RUBY_GC_OLDMALLOC_LIMIT_MAX 128M


これらの値が何を意味するのか、簡単に説明しましょう。これらの値を設定することで、Rubyは新しいオブジェクトが16Mから32Mの間で割り当てられるたびに、また古いオブジェクトが16Mから128Mの間で占有するたびにGCを実行します("古いオブジェクト"というのは、少なくとも一度はガベージコレクションで呼び出されているオブジェクトを意味します)。Rubyは、あなたのメモリパターンに基づいて、現在の制限を動的に調整します。

そのため、メモリを大量に消費するオブジェクトがいくつかある場合(非常に大きなファイルを文字列オブジェクトに読み込むなど)、その制限値を大きくしてGCが発動される頻度を減らすことができるのです。一度に4つの値で、できればそのデフォルト値の倍数で制限を増やすことを忘れないでください。

私のアドバイスは、おそらく他の方々の提案とは異なるものです。私にとっては正しいことでも、あなたにとってはそうではないかもしれません。これらの記事は、Twitterに有効なものと、Discourseに有効なものを説明しています。

2.6 プロフィール

これらの提案が万能でない場合もあります。あなたの問題が何であるかを把握する必要があります。プロファイラを使うのはそのときです。ruby-profはRubyユーザなら誰でも使うツールです。

プロファイリングについて詳しく知りたい方は、Railsでruby-profを使用するChris Healdの記事と私の記事をお読みください。また、メモリプロファイリングに関する、おそらくやや時代遅れのアドバイスもあります。

2.7 パフォーマンステストケースの作成

最後に、Railsのパフォーマンスを向上させるヒントの中で最も重要ではありませんが、コードを修正したためにアプリのパフォーマンスが再び低下することがないようにすることです。

3 まとめ テスティモニー

RubyとRailsのパフォーマンスを向上させる方法について、1つの記事ですべてを網羅することは本当に不可能です。そこで、この後は本を書くことで私の経験をまとめようと思います。もし私の提案が役に立つと思ったら、ぜひメーリングリストに登録してください。とりあえず、手を汚してRailsアプリを少しでも速く走らせましょう!