📝

Pumaのアーキテクチャ及びスレッド・ワーカーの設定値の考え方

2024/03/09に公開

はじめに

業務でPumaを用いてWebサーバーを立てることになりました。Pumaでは、ワーカー(プロセス)数とスレッド数を設定することが可能です。これらの設定値を決めるにあたって、Pumaの理解が浅かったのでキャッチアップを行いました。以前、Pumaのソースコードを読んだので、その内容も踏まえています。

https://zenn.dev/stmn_inc/articles/27315965dd909c

アーキテクチャ

まずはアーキテクチャについて整理します。
私が理解したPumaのアーキテクチャを以下の図にしました。

よりイメージしやすいように、ワーカ内のスレッドの状態を確認しておきます。

# top -p 1 -H
1  root      20   0  848580 120956  32280 S   0.0   2.4   0:09.18 ruby
19 root      20   0  848580 120956  32280 S   0.0   2.4   0:00.03 DEBUGGER__::SES
22 root      20   0  848580 120956  32280 S   0.0   2.4   0:00.00 puma plgn bg 0
23 root      20   0  848580 120956  32280 S   0.0   2.4   0:00.00 puma srv tp 001 👈 Worker Thread
24 root      20   0  848580 120956  32280 S   0.0   2.4   0:00.00 puma srv tp 002 👈 Worker Thread
25 root      20   0  848580 120956  32280 S   0.0   2.4   0:00.00 puma reactor 👈 Reactor Thread
26 root      20   0  848580 120956  32280 S   0.0   2.4   0:00.00 puma srv thread
27 root      20   0  848580 120956  32280 S   0.0   2.4   0:00.00 puma srv thread
28 root      20   0  848580 120956  32280 S   0.0   2.4   0:00.00 puma srv 👈 Receive Thread

レシーブスレッド

このスレッドの最も重要なメソッドは、Server#handle_serversです。このメソッドは、ソケットのbacklogをIO.selectで監視しています。※ソケットのbacklogとは、listen(2)で接続待ちの状態になったソケットのキューを指しています。
IO.selectは、select(2)を実行するメソッドです。つまり、I/Oの多重化です。複数のファイルディスクリプタを監視して、accept(2)可能なものを返却します。
そしてコネクションが確率されたら、Clientクラスのインスタンスを生成してスレッドプールのTODOに積みます。

https://github.com/puma/puma/blob/609d62b7362cdda084d86617957c3cbf7d7f4275/lib/puma/server.rb

Server#handle_serversでは、以下の2つの処理が実行されていることを補足しておきます。これらはスレッドの設定値を考える上で重要です。

  1. ThreadPool#wait_until_not_full
    ワーカー内の全てのワーカスレッドが使用されている場合は、レシーブスレッドはワーカースレッドに空きが発生するまで処理を待機するという処理も設けられています。[1]
  2. ThreadPool#wait_for_less_busy_worker
    複数のワーカーが起動している場合、全てのワーカーは同じソケットを監視します。その為、どのワーカーがリクエストを処理するかはランダムです。そこでPumaでは、ビジーなワーカーがリクエストを受け取る確率を下げるために、5ミリ秒の遅延を設けるという対策を取っています。[2]

ワーカースレッド

スレッドプールのTODOに格納されたClientインスタンスを取り出して、ノンブロックングI/Oでリクエストデータの読み取りを試みます。そこでEAGAINが発生、つまりファイルディスクリプタの準備が完了していなければ、ClientインスタンスをReactorのキューに積みます。読み取りが完了した場合においては、Rackアプリケーションの起動に移ります。PumaはRackベースのWebサーバーです..!

ワーカースレッドは、設定値に基づいてスケールアウトします。TODOに格納されたリクエストよりも、起動しているワーカースレッドが少なければ、スレッドが複製されます。ワーカースレッドの設定値をどのように決めるべきか、は本記事の目的の1つであり、これについては後述することにしましょう。

https://github.com/puma/puma/blob/609d62b7362cdda084d86617957c3cbf7d7f4275/lib/puma/thread_pool.rb

リアクタースレッド

Reactorのキューに積まれたリクエストを監視するのがリアクタースレッドです。ファイルディスクリプタの準備が完了していれば、再度スレッドプールのTODOに格納することで、ワーカースレッドの処理対象になります。

https://github.com/puma/puma/blob/609d62b7362cdda084d86617957c3cbf7d7f4275/lib/puma/reactor.rb

少し余談ですが、リアクタースレッドではnio4rが使用されています。このgemは、クロスプラットフォームに非同期I/Oを提供します。レシーブスレッドでは、select(2)を用いてファイルディスクリプタを監視していました。select(2)は監視対象のファイルディスクリプタn個をループの中で1つずつ状態確認します。その為計算量的にはO(n)になるので、ファイルディスクリプタに比例してコストが上昇するという課題がありました。
一方でnio4rを使用しているリアクタースレッドでは、MRI上で動くRubyの場合、Linuxならepollを実行することが可能です。

Reactor pattern

このアーキテクチャのおそらく重要な特徴はReactor patternに基づくということでしょう。スロークライアントをどのように扱うか、というのはWebサーバーアーキテクチャの最も大きな関心ごとの1つだと思います。Reactor patternとThread pool、ノンブロッキングI/Oを組み合わせによって、Pumaは単体でスロークライアントに対応するWebサーバーとなり得ています。

Unicornは単体ではスロークライアントに対応することが出来ないため、Nginxなどのリバースプロキシを挟む構成になりますが、Pumaにはその必要がありません。※リバースプロキシの目的は複数ある為、Pumaの前段にNginxを設けること自体はあると思います。

他にもPumaのこのようなアーキテクチャには以下のような利点があります。

  1. 1リクエスト毎に1スレッドを必要としないので、メモリを節約できる
  2. スレッドをアクティブな状態で維持できるので、CPUの利用を最大化できる
  3. 各リクエストを細かくスケジューリングできる

参考:Efficient parallel I/O on multi-core architectures

およそアーキテクチャが理解できたので、本題である設定値について考えていくことにしましょう。

スレッドの設定値

Pumaでは、ワーカースレッドの最小値・最大値の指定が可能です。ワーカースレッドが多ければ、その分アプリケーションのパフォーマンスは向上するでしょうか。答えは否であり、これはGVL[3]について考慮する必要があるからです。

殆どのRubyはMRI上で動かすと思いますが、MRIにはGVLが作用します。つまり、ワーカースレッドは並列(パラレル)ではなく、並行(コンカレント)に動作します。ワーカースレッドの数が多ければ、1リクエストのレイテンシは低下してしまいます。では単一のスレッドが望ましいのかと言うと当然そうではなくて、I/O待ちの場合は別スレッドを実行することでCPUリソースを無駄なく利用できます。ワーカースレッドの設定値は、アプリケーションのI/Oの割合を基準に決めるのが良いというのはよく聞く話です。

アムダールの法則を持ち出している意見もいくつかありました。アプリケーションI/Oが50%以下であれば、ワーカースレッドを5に設定するのはこの図からはある種合理性があるように思います。


出典;Wikipedia アムダールの法則

ところが最近(2024年)になってRailsのデフォルトの設定値が5から3に変更されました。※この記事に執筆時点は2024年3月です。
DHH氏が作成したissueでは大半のアプリケーションではデフォルトの5では多すぎるという点について議論されています。BasecampやShopifyはスレッドを1に設定しているようでこれは驚きました。
結局のところ、アプリケーション毎に適正値は異なるので、計測してパフォーマンスの違いを探るしかありません。スループットとレイテンシのどちらを重視するのか、要件にも左右されます。

推測するな計測せよ

ワーカースレッドの最大値についてはどのように考えるべきでしょうか。アーキテクチャで学んだことを思い出して、再度整理してみましょう。
レシーブスレッドは、ワーカースレッドの最大値までリクエストを受け付けます。最大値に達していれば、レシーブスレッドは処理を待機するので、そのワーカーはリクエストを処理しません。
思い出して欲しいのは、busyなワーカーのレシーブスレッドはループを5ミリ秒遅延して、リクエストを受け付ける確率を下げている処理です。しかしこの処理は、リクエストを受け付けているワーカースレッドが0のワーカと、1以上のワーカーにおいては作用しますが、1以上のワーカー同士では効果がありません。すなわち、例えばリクエストを受け付けているワーカースレッドが2のワーカと、1のワーカーではレシーブスレッドがリクエストを受け付ける確率は同等に思えます。そうであれば、ワーカースレッドをスケールアウトさせるのは、非効率な可能性があります。リソースに空きのあるワーカーが受け付ける可能性を下げてしまうからです。
全てのワーカーが逼迫していればスループットの観点でのメリットは考えられますが、サーバーの台数をスケールさせる方が現実的な対策としては私には妥当に思えます。またワーカースレッドのスケールアウトは観測し辛いこともあり、運用上も扱いにくいのではないかと思います。

余談ではありますが、Reactorのキューに積まれているリクエストは、先の最大値のカウントに該当しません。Reactorのキューにはリクエストが積まれていても、ワーカースレッドに空きがあれば、レシーブスレッドは新たにリクエスト受け付けます。Reactorのキューにそれほど多くのリクエストが積まれることはなくそれほど課題ではないのではと直感しています。しかし、これについて何か深ぼれるほどの検証は出来ていません。

ワーカーの設定値

Pumaでは複数のワーカーを動作させることができます。ワーカーを複数起動することはどのような点で有効なのでしょうか。サーバーをスケールアウトするのと、1サーバーのワーカー数を増やすことは何か異なった意味を持つか、私は疑問に思いました。そしてこれは、一般的な待ち行列の理論で説明することができることが分かりました。


※①は3台のサーバーでリクエストを捌くイメージ、②は1台のサーバーで捌くが、その代わりにワーカーを3つ動かすイメージ。

①はスーパーのレジに似ています。スーパーではカウンター毎にお客は列を設けます。スーパーのレジにおいて、自分が並んだ列の前に大量の食材をカゴに入れたお客がいたらどうでしょうか。その列が進むのは非常に時間がかかってしまいます。この時間のかかるお客は、まさに"スロークライアント"です。自分よりも後に他の列に並んだお客が先に会計を済ませていることはよくあります。。現実のスーパーでは、空いている列に並び直すこともできるでしょうが、リクエストは当然そんなことはできません。

一方で②は、役所や銀行での待機に似ています。利用者は整理番号によって同じ列で待機しており、それを複数のカウンターで捌きます。たとえ時間のかかる利用者がいたとしても、全体のレイテンシが極端に悪化することはありません。
このように考えると、サーバーを増やすのではなく、ワーカー数を増やすことは理論的にはメリットがあることが分かります。

それではワーカー数はどれくらい用意するのが良いでしょうか。MRIで動かす場合の公式の推奨値は、1CPUあたりワーカー数は1~1.5です[4]。物理コア以上にプロセスを動かしても、リソースに対する競合が発生して、コンテキストスイッチも増加することが想像できます。また別プロセスである以上、スレッド増やすよりも、ワーカー数を増やすのはメモリへの考慮が必要です。

またpuma_worker_killerを利用している場合は、クラスターモードの利用が必須です。クラスターモードは単一のワーカーで起動することも可能ではあります。しかし、ワーカーのリスタートには多少なりとも時間を必要とします。複数のワーカーで運用することで、リスタートの間にも他のワーカーがリクエストを処理することが可能です。

冗長化の観点でも考えてみます。1リクエストでのメモリ消費量が大きく、メモリ不足によりコンテナ全体が終了してしまう場合はどうでしょうか。その場合は、そのコンテナ上で処理している他のリクエストも失敗してしまいます。CPUとメモリを増強して、1コンテナでより多くのリクエストを処理するよりも、サーバーの台数をスケールアウトさせる方が冗長性においては優れているかもしれません。

まとめ

スレッド数

  • MRIの場合はGVLが働くので、スレッドは並列ではなく、並行に動作する
  • スレッド数を増やすとスループットが向上するが、レイテンシにはマイナスに作用する
  • IO待ちの割合はスレッド数を考える上で1つの重要な目安になる
  • Railsのデフォルト値は3
  • 個人的にはスレッド数をオートスケールさせるのはメリットを感じない

ワーカー数

  • 待ち行列の観点では、サーバーを増設するよりも、ワーカー数を増やすほうがレイテンシは向上する
  • 推奨値はCPU数の1~1.5
  • CPUやメモリが充分であれば、複数のワーカーを動作させた方がレイテンシの観点でメリットがある

おわりに

もしPumaを適切にチューニングした場合どれくらいアプリケーションのパフォーマンスは向上するのでしょうか。今後の個人的な関心事としては、アプリケーションにおいてWebサーバーはどれくらい重要な領域なのかという点です。
技術的には非常に興味があります。また、Node.jsで立てたシングルスレッドで動作させるWebサーバーや、グリーンスレッドのようなアーキテクチャも気になるところです。

参考

All About Queueing In Rails Applications / Nate Berkopec
Rubyアプリケーションを毎分1000リクエストにスケールさせる: 初心者ガイド
Rails: Puma/Unicorn/Passengerの効率を最大化する設定(翻訳)
どれだけリクエストをさばけるのかを待ち行列理論で考えてみた

脚注
  1. こちらのプルリクもご参照ください。 ↩︎

  2. Inject small delay for busy workers to improve requests distributionというissueでこの対応について議論されています。 ↩︎

  3. Ruby3.0ではRacotrが導入されています。RubyVMがグローバルに単一のロックを持つのではなく、Racgtor事にロックを持つ仕様に変更されました。それ以降はthread_schedと呼ばれているそうです。https://github.com/ruby/ruby/pull/5814 ↩︎

  4. Use cluster mode and set the number of workers to 1.5x the number of CPU cores in the machine, starting from a minimum of 2. ↩︎

GitHubで編集を提案
株式会社スタメン

Discussion