🐡

設定だけで Ruby アプリを高速化する

2022/01/24に公開

新年あけましておめでとうございます。 @rosylilly です。

この前 SRE 養成講座の体験授業配信 をしたんですが、その時にハンズオンの一環で Ruby 製アプリケーションをいじって高速化してみる。という内容を実施しました。

コンセプトとしては、『ちゃんと設定ファイルを書くだけでも高速化する』という感じです。作業としてはアプリケーションをいじらずに、設定だけいじって高速化する、ということなんですが、結局何がどれくらい効くのか、というのを事前に参考値レベルで弾いておかないとぶっつけ本番になっちゃうな、ということで何がどれくらい効くのかを試してからにしようということで、事前に調査したときのベンチテストと何を変えたかの記録になります。

ベンチマークの前準備

今回はほぼ何もしない Sinatra アプリケーションを作って、その性能差から測ります。ということで用意した性能測定用何もしない Sinatra がこちらです。

app.rb
require 'sinatra/base'

class App < Sinatra::Base
  get '/' do
    ({ ok: true }).to_json
  end
end
config.ru
require_relative './app'

run App.new

本当に to_json で JSON を返すだけのほぼ何もしないアプリケーションです。アプリケーションレベルでこれをより早くするなら to_json の結果をキャッシュして毎回固定の文字列を返すとかすればいいんですが、そこまでやりすぎるとあまりにも『何もしない』すぎてベンチマークテストがおかしくなりそうだったので、今回は一旦そのままにしました。

ベンチマーク環境

ベンチマークに使った環境は MacBook Air (M1, 2020) メモリ 16 GB 版です。8コアあるんですがローカル通信内でテストするし……ということで、負荷をかける wrk 側4コア、アプリケーションに裂くのは4コア、みたいな感覚で調整しました。本当は負荷かける側は別マシンとかにしたほうがいいのですが、今回は何をいじるとどれくらい変わるかなーというテストがしたかったのであえてローカルでやっています。

ベンチマークコマンド

基本的にトップページに負荷をかけたいだけなので、トップページにどこどこ負荷がかかるようにします。

$ wrk -t 4 -c 40 --latency http://localhost:8080/

Puma の設定をいじる

最近の Rails は rails new するとデフォルトで Puma を使ったサーバーが立ち上がります。なので、とりあえずまずは Puma で試してみることにします。

Puma の設定は以下の通り。環境変数をいじりつつベンチマークを回していきます。

workers(ENV.fetch('PUMA_WORKERS', 1).to_i)

threads_min = ENV.fetch('PUMA_THREADS_MIN', 1).to_i
threads_max = ENV.fetch('PUMA_THREADS_MAX', threads_min).to_i
threads(threads_min, threads_max)

まずは何もいじらず(つまり workers 0 / threads 1,1)で最小値の状態でベンチマークを回した結果が以下のとおりです。

Running 10s test @ http://localhost:8080/
  4 threads and 40 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    10.19ms   12.12ms  84.06ms   77.15%
    Req/Sec     2.84k   247.32     3.03k    89.25%
  Latency Distribution
     50%    4.93ms
     75%   19.67ms
     90%   30.01ms
     99%   36.03ms
  113451 requests in 10.05s, 20.11MB read
Requests/sec:  11285.14
Transfer/sec:      2.00MB

何もしないアプリケーションなので早いかと思いきや、最大90ms近く待たされたりしており、何もしてないアプリケーションにしてはおそすぎです。

workers

workers とは Puma のワーカープロセスの起動数を指定するオプションです。単純にここの数が増えれば受け付けているプロセスが増えるわけですが、多ければ多いほどいい、というものでもありません。

試しに2の倍数で増やしていった結果の Requests/sec(rps) が以下の通りです。

workers Requests/sec
1 11285.14
2 19293.58
4 31105.32
8 26778.92
16 28062.22

ベンチマーク試験環境にもよるでしょうが、基本的に CPU コア数の 1.5 くらいがいいよ、と公式でもお達しが出ています。今回のケースだと wrk 側が 4 threads なのもあって4以上から伸びが悪くなります。

また、 workers を増やすのは rps だけでなくメモリにも影響します。今回の何もしないアプリケーションくらいだとあまり問題になりませんが、あまりにプロセス数を増やしても次はメモリが足りない……などになってしまえば大問題です。OOM Killer にプロセスを強制終了される恐怖は現代でも現役です。

ここで注意が必要なのが、この設定値は『CPU コア数とメモリに応じて柔軟に変更したほうがよい』という点です。最近はコンテナ環境やサーバーレスみたいな環境が流行ったのも相まって、自分がデプロイしている先の CPU コア数いくつだっけ?みたいな状況になったりします(特に、 ECS などのコンテナデプロイ環境だといくつ割り当てたっけ?みたいなことになりがち)。環境変数で柔軟に設定できるようにしておくのも手ですが、Puma の設定ファイルは幸い Ruby の DSL なので、自分の CPU 数を見て 1.5 倍くらい〜みたいなことを書くのも簡単です。

require 'etc'
workers (Etc.nprocessors * 1.5).floor

メモリを取得して n MB あたり1 worker みたいな決まりにしてもいいでしょう。少なくとも、1 worker と 4 workers では倍以上性能が違うわけですから、設定しておいて損のない項目です。

threads

こちらは Puma のワーカースレッド数を指定するオプションです。基本的に受け付けられる同時リクエスト数、と解釈してよいでしょう。こちらも試しに2の倍数ずつ設定してみます。

threads Requests/sec
1 11361.52
2 10681.53
4 9760.14
8 11180.89
16 10279.41

期待したほど効果が出ません……多少差異はあれど、ほぼ誤差のように見えます。

threads は同時に受け付けられるリクエスト数を変化させますが、この場合効果があるのは MySQL など外部との通信や IO を利用した時です。なので、今回の何もしないアプリケーションだとあまり恩恵がありません。今回のようなマイクロベンチマークだと恩恵が見えにくいので設定項目として軽くみられがちがオプションですが、もちろん設定する意味がない、ということはなく Rails などの DB 操作のおおいようなアプリケーションではここに適切な数字を入れておくことで、性能向上が期待できます。

ただし、このスレッド数は同時に DB への同時接続数にもなってくる点に注意です。特に PostgreSQL を利用する環境などでは、コネクション数がはちゃめちゃに多いとそれだけで DB への負荷になったりします。もちろん、前段に pgpool などを導入することでアプリケーションはあまり気にせず同時接続数を増やしても良い、なんて場合もありますが、それらの設定がちゃんと環境に導入されているかの確認は必要です。

また、threads は何も指定しない場合 5 が設定されており、この数字でほぼ問題ないことが多いです(アプリケーション次第では10くらいまではあげてもよい)。workers と違ってアプリケーションの特性次第なところがあるので、あまり没頭したり自動化したりせず、アプリケーションにとっていい具合の数字を制御してあげるのがおすすめな項目です。

その他の設定項目

アプリケーションを worker 起動時に事前にメモリ上にロードしておく preload_app やそのプリロードしたメモリを削減するための nakayoshi_fork など、他にも設定しておくとメモリ削減やパフォーマンス改善につながる設定項目は多々あります。また、注意しなくてはならないこととして、これらの設定項目は見落とされやすく、あるいはアプリケーションリリース時に当時のエンジニアが秘伝技的に設定したままメンテされておらず……みたいな状態になって、現在の設定が妥当かが検証されにくい類の項目でもあります。

昔は EC2 上にデプロイしてたんだけど今は Docker になったんだよね〜という場合だったりするとこれらの設定をきちんと環境にあったものに変更するだけでいきなり性能改善したりする場合もあり、案外冗談抜きで侮れない項目です。

こういうパタメータチューニングは程よい設定値を探すことが難しいのですが、いろいろ参考になるドキュメントが世の中に転がっています。もちろん公式のドキュメントも大事です。僕が Puma の設定をいじる時に参考にするサイトで大変助かるところでいうと Heroku の Rails サポートページ が異常に情報がまとまっていて便利です。

その他、Ruby アプリケーションの挙動を変える環境変数

Rails を使っている時は気にしないけど、案外速度の変わる環境変数として有名所では RACK_ENV があります。設定できる項目は無指定(development)か、deployment あるいは none (前記2つ以外もこれ)です。自動的に読み込まれるミドルウェアが変わり、特に development の時読み込まれる ShowException などは begin ... rescue ... end で例外を捕まえるためか、なくしてみると結構性能が変わります。

ref: https://github.com/rack/rack/blob/1760292adeb6900c54270dfdd9490d1592a318fd/lib/rack/server.rb#L264-L279

また、Ruby そのものに与える環境変数でも速度が変わったりします。特に Ruby の GC を制御する環境変数などは難解複雑ですが、うまくハマれば性能改善も期待できます(書いておいてなんですが、僕は流石にこの辺はいじっていません。どれがどう作用してどうなるのか読み解けなかった……)。

ref: https://docs.ruby-lang.org/ja/latest/class/GC.html#tuning_gc

その昔は一時的に GC を止めることで Ruby アプリケーションの性能がよくなる……みたいなこともあったのですが、現代の Ruby でそこまでやっているケースはあまり見ません。

どちらかというと Ruby のビルド時に jemalloc を使うことでメモリの使いすぎを防止する、みたいな話のほうがよく見ます。メモリの断片化を防ぐことで長時間起動した際に増加していくメモリを抑制してくれるのですが、メモリが断片化しないことでアプリケーションのパフォーマンスそのものにも影響するようです。

まとめ

Ruby だけ(かつめっちゃ触りの部分だけ)に絞ってもこの分量になってしまう程度には、単なる Web アプリケーションを動かすだけでも設定項目は膨大です。これに追加して nginx や別言語での実装、JVM のパラメータチューニングなどしはじめるととてもじゃないけど追いつかない……んだけど、ちゃんとすればきちんと効果の出てくれるものでもあります。

記事冒頭でも書いたとおり、最近は SRE みんなでやろうぜ!という講座も開いたりしています。特にこういう設定値などは(threads のあたりでもわかる通り)手元の環境でちょっと実験しただけではわからない部分もおおく、実感を得るための『環境』そのものを作るのが難しい側面があるので、もしこういう話に興味があって実験する環境がほしければ、ぜひウチの講座もご検討いただけると幸いです。

https://tech-consiglie.com/burst.html

興味はあるけどよくわからんしどういうこと実際にするの?という場合などはお気軽に @rosylilly までご連絡いただければお答えできます。

それではみなさん、よいチューニングライフを!

Discussion