Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

「RailsアプリはIO-boundである」という神話について考える(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。
IO-boundは英ママとしました。

「RailsアプリはIO-boundである」という神話について考える(翻訳)

Shopify/pitchfork - GitHub

私がやりたいのは、Pitchfork1に関する記事を書いて、これがどんな理由でできたのか、なぜ現在のような形になったのか、そして今後どうなるのかについて説明することです。しかしその前に、いくつか解説しておく必要があります。

Railsとパフォーマンスの話題になると「データベースがボトルネックになる」という話がよく持ち上がりますが、RailsはいずれにしてもIO-boundなアプリなので、Ruby自身のパフォーマンスはさほど重要ではなく、サービスをスケールするうえで必要なのは、十分なコンカレンシーだけです。

しかし、この話は一般的にどこまで真実なのでしょうか?

🔗 スケーリングとパフォーマンスを混同してはいけない

まず、Railsアプリケーションをスケールするときに最初に遭遇するボトルネックは一般にデータベースであるというのは正しい話です。

Railsは、他の圧倒的多数の現代的なWebフレームワークと同様、ステートレスです。したがって、必要な処理を水平スケーリングによって十分行うことについては問題ではありません。サーバーを増強し続けられるうちは、Railsはスケールします。スケーリングは最も安価な技術スタックではないかもしれませんが、他のステートレスWebフレームワークと同様にスケールします。10倍のトラフィックをさばくにはサーバーも10倍以上増強する必要がありますが、これはシンプルなので、チームがRailsアプリケーションをスケールさせるうえで苦労するポイントではありません。

しかし、リレーショナルデータベースのスケーリングはそれよりももっと困難です。水平スケーリングは何らかの形でシャーディングを実装しない限りできませんし、データベースモデルによってはシャーディングが極めて難しくなることもあります。

そのため、リレーショナルデータベースは最初に垂直スケーリングすることでより強力なサーバーに移行するのが一般的です。垂直スケーリングにより、ほとんどのRailsユーザーが必要とするよりもずっと大きなスケールを実現できますが、コストの増加は直線的ではありません。アプリが大成功した場合の垂直スケーリングはコスト的に実現可能ではなくなるため、シャーディングするか、さもなければ別の種類のデータストアを使う必要があります。

「データベースがボトルネックになる」とはそういう意味です。データベースへのクエリが遅くなるという話ではありませんし、ましてや全体的なサービス遅延の原因として最も重要だという話でもありません。サービスの使用量が増大し続けるときに、インフラストラクチャの中で最も注意すべき部分がデータベースであるということです。

一般に見かける議論の多くがこの点を取り違えていて、スケーリングとパフォーマンスの話(厳密にはスループットとレイテンシの話)が混同されています。

スケーリング可能であるとは、サービスレベルをある程度維持しつつ、ユーザー増加に対応するためのコスト増加がほぼ線形で済むことを言うのであり、「速さ」「安さ」「高効率」のことではありません。スケーリング可能とは、ボトルネックにはまることもコストが指数的に増大することもなく、サービスが成長を維持できることなのです。

そういうわけでデータベースがボトルネックであることは確かですが、だからといってアプリケーションが処理時間の大半をIO待ちに費やしているというわけではありません。

🔗 Railsのパフォーマンス問題のほとんどはデータベースの問題である

RailsアプリケーションがIO-boundである理由を説明するのによく引き合いに出されるもう一つの事実は、Railsアプリケーションで最も一般的なパフォーマンスの問題は「データベースがインデックス化されてない」「N+1 クエリ」といったデータアクセスの問題であるという話です。

私の個人的な観察によれば、これは確かにその通りだと思いますが、しかしこれらはあくまでバグであって、システムの特性とは違います。バグであれば特定して修正する必要がありますが、インフラストラクチャをそうした特性に対応するように設計する意味はあまりありません。

データベースに適切にインデックスが付けられ、かつ過負荷状態でないと仮定すれば、ほとんどのクエリ(特に主キーによる日常的な探索)はせいぜい数ミリ秒におさまり、1ミリ秒を下回ることもしばしばあります。

アプリケーションがデータをHTMLやJSONにレンダリングするために大量に変換する場合であれば、IO待ち時間と同じか、それ以上の時間をRubyコードの実行に費やすことになるでしょう。

🔗 証拠: YJITの効果の大きさ

言うまでもありませんが、アプリケーションはみなそれぞれ違っているのですから、私が自信を持って語れるのは自分が手掛けたアプリケーションだけです。

しかしここ数年、YJITによってアプリケーションのレイテンシが15〜30%も削減されたという報告を多数目にしています。

Discourse
JIT 3.2で15.8〜19.6%高速化
Lobsters
26%高速化
BasecampとHey
26%高速化
ShopifyのStorefront Rendererアプリ
17%高速化

これらのアプリケーションが実際にほとんどの時間をIO待ちに費やしていたのだとすれば、YJITが全体的にこれほど優れたパフォーマンスを発揮するのは不可能でしょう。

IO処理を一切行わない、JITに極めて適したベンチマークであっても、YJITによる高速化は最大で「2〜3倍止まり」です。lobstersのようなより現実的なベンチマークでは、約1.7倍かそこらです。
私たちはこのことから、これらのアプリケーションはどれもIO待ちに80%もの時間を費やしているはずはないと、かなりの自信を持って推測できます。

私にとって、RailsアプリケーションのほとんどはIO-boundではないと考える根拠は、これで十分です。

世の中には明らかにIO-boundなアプリケーションも一部に存在していますし、私はそうしたアプリケーションのメンテナーたち何人かと話したこともあります。しかしそうしたアプリは言ってみればトビウオのようなもので、存在することは確かでも、その種属の大半を占めているわけではありません。

🔗 CPU枯渇はほどんどの人にはIO問題に見える

アプリが実行しているIOの量が過大評価される原因の1つは、CPUが枯渇している状態が、あたかもRubyがIO待ちしているかのように見えがちなことです。

IO期間が圧倒的な状態が、Railsのログ機能や有名なアプリケーションパフォーマンス管理サービスなどでどのように観測されるかを見てみると、一般に極めてシンプルかつ明白な方法で行われていることがわかります。

start = Time.now
database_connection.execute("SELECT ...")
query_duration = (Time.now - start) * 1000.0
puts "Query took: #{query_duration.round(2)}ms"

論理的には、このコードのログがQuery took: 20.0msだった場合、このSQLクエリの実行に20ミリ秒かかったと考えるのが妥当ということになるかもしれませんが、必ずしもそうとは言えません。

実際には、これは「クエリの実行」に加えて「スレッドの再スケジューリング」も合わせて20ミリ秒を要したということであり、それぞれの部分でどれだけの時間を要したかを知るのは不可能です(追記: John Duffのリクエストで別記事を書きました: アプリケーションが何らかの形でCPU枯渇に遭遇しているかどうかを判定する超簡易版ガイド -- 翻訳は近日公開)。

つまり、実際のクエリは1ミリ秒未満で実行され、残りの時間はすべて「GVLの取得」「GCの実行」または「OSのシステムスケジューラによるプロセスの再開の待機」に費やされたかもしれないのです。

どれがどれだったのかを見分けることが重要です。

クエリ実行にすべての時間が使われていた場合
アプリケーションのIO負荷が高まっているため、コンカレンシー(プロセス、スレッド、ファイバー)をもっと多用すればスループットをある程度稼げる可能性があります。
スケジューラにすべての時間が使われていた場合
上とまったく逆に、おそらくコンカレンシーを減らすことでレイテンシを削減するのがよいでしょう。

この問題によって、アプリケーションのIO負荷が実際よりも高いと信じ込むことがよくあります。特にたちが悪いのは、これが「自己達成的な予言」であることです。

アプリケーションがIO-boundであるという話はよく耳にするので、そのことを仮定すると、論理的には、production環境のアプリケーションをスレッド化サーバーか非同期サーバーを十分なコンカレンシー数のもとで実行することになります。そしてproduction環境のログにはIO待ち時間が長いことが表示されるので、仮定が裏付けられてしまうというわけです。

この問題は、共有ホスティングプラットフォームでより発生しやすくなります。そうしたサービス上では"仮想CPU"を常に100%使えることが保証されているわけではないからです。そうしたプラットフォーム上で動くアプリケーションは、物理CPUコアを他のアプリケーションと共有する必要があるため、他のアプリケーションの実行内容やCPU使用状況によっては、課金分よりも大幅に多くの仮想CPUを利用できることもあれば、大幅に少なくなってしまうこともあります。

この問題はRubyに限りません。IOの負荷とCPU処理の負荷が混在する状況では、「コンカレンシーを高めてレイテンシ低下のリスクを取る」か、逆に「コンカレンシーを下げてレイテンシ低下を抑える代わりに使用率が低下することを取る」かを選ばなければならなくなります。

モノリスでよくあることですが、負荷の種類が多様になればなるほど最適な妥協点を見つけにくくなります。これがマイクロサービスのメリットの1つで、負荷の均質性が高まるため、レイテンシにあまり影響を与えずにサーバーの使用率を高めやすくなります。

ただし、Rubyのデフォルト実装にはGVL(Global VM Lock)2があるため、この問題はさらに顕著になります。サーバー上のすべてのスレッドがすべてのCPUコアを共有するのではなく、複数の小さなスレッドバケットがそれぞれ1つのCPUを共有することになり、空いているコアやサーバーがあってもスレッドを再開できなくなる可能性があります。

しかし忘れてはいけないのは、Rubyに限らない一般則として、CPU-boundやIO-boundの負荷はうまく混在できないものであり、理想としては別々のシステムで処理されるべきだということです。

小規模なプロジェクトなら、すべての負荷を1つのシステムに集中配置することでレイテンシの影響を容認できるようになる可能性がありますが、規模が大きくなるにつれて、IO集中型の負荷とCPU集中型の負荷を切り離す必要性が増します。

🔗 ジョブキューは話が別

ひとつご注意いただきたい重要な点は、上で話しているのはあくまでRailsアプリケーションのWebサーバー部分についてだけであることです。
たいていのアプリはバックグラウンドジョブランナーも利用していますし(最も人気が高いのはSidekiqです)、バックグラウンドジョブは多くの場合、メール送信やAPI呼び出しといった低速なIO操作をカバーするのに使われています。

そうしたジョブランナーは一般にIO集中型であり、その分レイテンシの重要度は下がるので、多くの場合Webサーバーで処理するよりも高いコンカレンシーを実現できます。

しかしそれでも、ジョブランナーのコンカレンシーが高くなりすぎて全IO処理がかなり遅くなったように見えてしまうことがよくあります。その良い例は、Sidekiqメンテナーが、GVL競合による影響を避けるため、ラウンドトリップの遅延測定をC言語で実装する方法を私に質問してきたことです(#74)。

🔗 それが重要な理由

ここまで読んでみて、RailsアプリケーションがIO待ちに費やす時間がどうして重要なのか疑問に思うかもしれません。

平均的なRailsユーザーにとって、これは知っておくことが重要です。これはアプリケーションをデプロイするうえで最適な実行モデルを定義するものだからです。

アプリケーションが真にIO-boundである(95%以上をIO待ちに費やしている)場合
非同期実行モデルにすることでベストな結果を得られる可能性があります。
アプリケーションが完全にIO-boundとまでいかないが、それなりにIOが重い場合
プロセスごとに適切なスレッド数を備えたスレッド化サーバーを使うことで、レイテンシとスループットの間で最適なトレードオフを得られる可能性があります。
アプリケーションがIOに費やす時間が半分を大きく越えていない場合
純粋なプロセスベースのソリューションにする方が望ましい可能性があります。

平均的なRailsアプリにおけるIO負荷とCPU負荷の比率はどうなっているのかという話は、RailsのPuma設定でデフォルトのスレッド数を5から3に引き下げるきっかけにもなりました(#50450)。

しかし、「パフォーマンスはデータベースの問題だから」という理由でRubyのパフォーマンス問題を無視しないようにすることは、Rubyコミュニティ全体にとっても重要だと私は思います。
Rubyが遅いという話ではありません。Rubyは、優れたユーザーエクスペリエンスを提供するWebアプリケーションを書くのに十分な速さを備えていることは間違いありません。

しかし、コードの書きやすさを優先したいという理由だったり、メタプロが楽しいからというだけの理由だけだったり、より効率的な方法があるかどうかを誰もプロファイラでチェックしなかったといった理由で、Rubyコードが目に見えて遅くなってしまう可能性もあるのです。

production環境のRailsアプリケーションのプロファイルを精査することに相当な時間を費やしている者として、私から自信を持って言えることはこうです。
Railsや、よく使われている他のgemには、大幅に高速化できる可能性がありながら、public APIによってそれ以上の最適化が妨げられているために高速化できないままになっているものが多数あるのです。

私たちはRuby開発者として、当然ながら開発者の幸せを第1に考える傾向があります。それはもちろん素晴らしいことですが、それと同時にパフォーマンスをおろそかにしないようにする必要もあります。
使い勝手とパフォーマンスは必ずしも相反するものではありません。APIはパフォーマンスも高く便利ですが、使い勝手とパフォーマンスを両立するには、最初から両方を考慮しておく必要があります。
いったんpublic APIが定義されて広く使われるようになると、そのAPIをよりパフォーマンスの高いものに差し替える以外に、パフォーマンスのためにできることは限られてきますが、現在のコミュニティは非推奨化や破壊的変更という動きに対して以前ほど積極的的ではありません。

関連記事

Rubyのスケール時にGVLの特性を効果的に活用する(翻訳)

Rubyの(グローバル)VMロックをトレースする(翻訳)


  1. 訳注: pitchforkはbyrootさんが作ったUnicorn的なWebサーバーです(ウォッチ20230809)。 
  2. 訳注: RubyのGVLはrb_thread_schedという名前に変わりました。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。