はじめに
こんにちは、ライブコミュニティ事業本部 Pococha 事業部バーティカルシステム部第一グループの菅野です。
スレッド数を増やせば速くなる ——そう考えて、バックグラウンドジョブシステム Sidekiq の同時実行スレッド数を 50 に設定していましたが、10 に減らしたところ、むしろパフォーマンスが改善しました。 本記事では、その背景にある GVL の仕組みと、大量のランキング集計を効率化したチューニング事例を紹介します。
最近、ライブストリーミングアプリケーション Pococha に 「ぽこランキング」 という機能のべータ版が追加されました。 この機能は、ユーザの「応援」をランキングとして可視化するもので、100万種類以上のランキングを30分おきに集計します。
この機能には以下の要件がありました。
- 100万種類以上のランキングデータの加算・保存をする
- 30分おきの集計は可能な限り素早く終わらせる
本記事においては、大量のランキング集計の検証段階で遭遇したパフォーマンス悪化を、「あえてスレッド数を下げる」「worker を分割する」ことで改善した事例を詳しく記述します。
Pococha について
Pococha はライブストリーミングのアプリケーションです。 ライバーが配信を行い、複数のリスナーがその配信を視聴します。視聴中は、コメントをしたり、アイテムを使用したり、といった行動ができます。
100万種類以上のランキング
技術的な話に入る前に「なぜ100万種類ものランキングの加算・保存をするのか」、を仕様の観点から記述します。 この機能では、配信中のアイテム利用や、視聴時間などのユーザの応援行動を、ユーザ個別の「属性」を組み合わせたランキング に加算します。組み合わせであるため、属性の種類がたくさんあればあるほど、ランキングの種類が爆発的に増えることになります。
シンプルな例として、ライバーにおける、「ハート」アイテムの使用個数のランキングを考えてみます。 ライバーには「地方」と「ランク」という 2つの “属性” があるとします。 ここで、以下のような属性のライバー2人を考えてみます。
- ユーザA:「中部」地方の「S1」ランク
- ユーザB:「関東」地方の「B2」ランク
これらのユーザのランキングは以下のようにして決定されます。
このように、”属性” の組み合わせの数だけ、ランキングに加算されることになります。 この例において属性は2つで、「地方」が 7種類、ランクが 18 種類あるとすると、 それぞれの属性は「全て」という種類を含むため、このハート「アイテム」のランキングは、(7 + 1) * (18 + 1) = 152 種類 存在します。
上記はシンプルな例でしたが、実際は、地方やランクの属性に加えて、1時間のランキングか1日のランキングか、など、さまざまな分割方法があります。
またアイテムも「ハート」だけではなくさまざまなアイテムがあり、アイテムだけではなく視聴時間やコアファンの獲得数などもユーザの応援行動が加算の対象になります。
これらの大量の属性の組み合わせごとにランキングが存在するため、100万種類以上のランキングが生成されることになります。
そして、この加算されたランキングを、30分に1回集計します。 実際に集計してユーザに表示する画面は次のようになります。 赤枠部分の通り、集計によって2種類のランキングがピックアップされています。
ランキング集計時のパフォーマンス悪化問題
上記の仕様は、ざっくり以下のようなシステム構成で実現されています。
ここで、赤線で囲ってある Sidekiq による 30分に1回の集計部分に関して問題が起こりました。 前提として、Pococha のインフラは EC2 をオーケストレーションする自社基盤を用いて運用されています。( 参考 ) 今回のケースでは、集計を可能な限り早く終わらせるために、限界まで並列・並行処理を行い CPU をできるだけ活用する必要があります。
問題の概要
具体的に、問題は2つありました。
まず、CPU が効率良く使えていない、という問題です。 Sidekiq のスレッド数の上限まで job を並行処理しても CPU 使用率が 50% 以上伸びないという事態が起こりました。
実際のメトリクスを見てみます。以下は集計サーバの1コアあたりの CPU 使用率のメトリクスです。
台形のような形になっている箇所が、集計処理をしている期間です。
上昇した CPU 使用率が 50 % 付近に張り付いています。
次に2つ目の問題は、「集計処理とは関係のない別の model の Active Record メソッドのレイテンシが、集計処理中に悪化する」という問題です。 ここでいう Active Record のレイテンシとは、IO のレイテンシではなく、オブジェクトの生成などの Ruby の処理のレイテンシを指しています。
当該 Active Record model のレイテンシをみてみます。
ちょうどランキングの集計期間と同じタイミングでレイテンシが大きく悪化していることがわかります。
結論から言うと、この問題はいずれも Ruby の GVL(Global VM Lock) によるものでした。
解決策の方針
解決策は2つでした。
- 同一 queue の「worker(プロセス)を増やす」ことで、GVL の制約上 1 プロセスでは使い切れない CPU コアを活用し、CPU 使用率を改善
- 「スレッド数(concurrency) を下げる」ことで、1 プロセス内でのスレッド間 CPU 獲得待ちを減らし、Active Record のレイテンシを改善
以下、詳細を説明します。
GVL という制約
まずは GVL について軽く説明します。
Ruby(CRuby)には、「同時にRubyスレッドはたかだか1つしか動かない」という制約があります。 これが GVL(CRuby 内部では thread_sched として実装されている仕組み) です。 導入された背景は こちら で Ruby コミッターの方が解説してくださっています。
これにより、Sidekiq のスレッド数 (concurrency) をたくさん増やしても、利用できる CPU リソースは 1コア分であるため、処理がスケールしにくくなります。
当初は、
- コア数: 2
- worker数: 1
- concurrency: 50
という環境でリリース前の検証を行っていたのですが、これでは 2コアのうち 1コアしか活用できず、さらに 50 個のスレッドが 1コア分の CPU を奪い合う非効率な状態となっていました。
この「GVL により CPU を使い切れていない」状況を単純化した図で考えてみます。
CPU が2コアのサーバで、Sidekiq worker 1つのみで、concurrency 4 の処理を実行し、全スレッドが実行中の状況を図示すると、以下のようになります。
この状況では、1つの worker (プロセス) において同時に CPU リソースを獲得できるスレッドは1つであるため、どこかのスレッドが処理を実行している間、他のスレッドは CPU の獲得を待つことになります。
CPU の利用効率としても、色つきブロックの間に「アイドル時間」の隙間が空いており、両方のコアが 50%、つまり合計すると 50 + 50 = 100% で、1コア分の CPU しか使えていないことがわかります。
Sidekiq で Active Record の処理を実行している最中に、CPU 獲得待ちが発生するため、当然処理は遅延します。これがレイテンシ悪化の原因でした。
つまり、以下のことが予想できます。
- concurrency を下げることで、CPU 獲得待ちの時間が減るはず
- worker を分割することで、余剰 CPU リソースを別プロセスに割り当て、CPU を実際に使えるスレッドの総数を増やせるはず
試しに、先程の図に、
- concurrency: 4 -> 2
- worker 数: 1 -> 2
という変更を加えて考えてみます。
この図においては、
- 各スレッドの CPU 獲得待ち時間が減少し(すぐに「自分の番」が回ってくる)、これにより job 1つあたりの実行時間が短くなる
- コアを100%使い切る
ということが可能になっています。
なお、1コアのコンテナ環境などでは、1 worker でその 1コアを専有できるため、worker 分割によるメリットは限定的です。 worker 分割はマルチコア環境において余剰リソースを使い切るための手段と言えます。 一方で、concurrency の調整は、1つの worker 内で各ジョブがいかに効率よく CPU タイムを獲得できるかを最適化するために行います。
IO バウンドか CPU バウンドかで concurrency を決める
concurrency は、job のうち IO バウンドな処理が多いか、CPU バウンドな処理が多いかによって調整します。 この関係を図で表すと以下のようになります。
IO バウンドな処理を行っている間は GVL が解放され、他のスレッドが CPU を利用できます。そのため、IO が多い job であれば concurrency を上げることでスループットが向上します。
一方で、CPU バウンドな処理が多い job の場合、様々なスレッドが CPU を奪い合うことになります。このとき、concurrency を下げると 1 job が CPU を獲得できる頻度が高まるため、1 job あたりの処理時間削減につながります。 ただし、同一 worker 数のまま concurrency を下げるだけでは、同時に処理できる job 数が減るため、全 job の合計処理時間は変わりません。合計処理時間を短縮するには、worker 数を増やして CPU コアをフルに活用する必要があります。
今回のケースに関しては、「Active Record のレイテンシ」という問題があったためこれを調整の指標にしました。 これといって調整の指標がない場合は、 こちら で紹介されているような GVL の計測ツールを使うのも有効と思われます。
チューニングの実施と結果
実施方法に関しては、シンプルに concurrency を少し下げてはレイテンシを確認する、ということを繰り返しました。 調整前は concurrency を Sidekiq の公式ドキュメント で推奨されている上限値の 50 に設定していました。 50 -> 40 -> 30 と下げていくと、徐々に Active Record のレイテンシ悪化は解消していきました。これは想定していた通り、各 job の CPU の獲得待ちの時間が減ったためです。
最終的にレイテンシが落ち着いた concurrency は 10 でした。
さらにその後インフラチーム側で改善を行っていただき、vCPU が8コアの専用の EC2 インスタンスを用意し、1つの Sidekiq queue に対して worker を 8つ実行するようになりました。
実現方法は、bundle exec sidekiq -C sidekiq.yml を独立した daemon プロセスとして必要な数だけ起動するというものです。
これで、それぞれの daemon プロセスは、sidekiq.yml に記載されている queue から job を dequeue するようになります。
「queue から job が重複して dequeue されてしまうのでは?」と思うかもしれませんが、これは Redis の仕組み上起こらないことがわかりました。 Redis はメインスレッドがシングルスレッドになっており、Sidekiq は BRPOP(ブロッキング付きでリストの末尾から要素を取得・削除する Redis コマンド)を用いて queue から job をアトミックに取り出すためです。
https://github.com/sidekiq/sidekiq/wiki/Reliability#using-super_fetch
Sidekiq uses BRPOP to fetch a job from the queue in Redis.
これにより、並行して、重複することなく job を実行できます。 2コア以上の CPU を備えたインスタンスで Sidekiq を運用する場合に有効な方法です。
改善前後の構成をまとめると、以下のようになります。
| 改善前 | 改善後 | |
|---|---|---|
| vCPU 数 | 2 | 8 |
| worker 数 | 1 | 8 |
| concurrency | 50 | 10 |
| 最大 CPU 使用率 | 50% | 100% |
最終的にチューニングを行った後のメトリクスは以下です。
CPU が 100% 近く使い切って CPU 利用効率が上がっており、Active Record のレイテンシ悪化もなくなっています。
さきほどの concurrency が 50 のときのメトリクスと比べてみると一目瞭然です。
| 改善前 | 改善後 |
|---|---|
|
|
|
|
まとめ
これらの各種検証とチューニングの結果、大量のランキングを効率的に集計できるようになりました。 この問題に遭遇するまでは、「Sidekiq の concurrency は多ければ多いほど良い」と考えてしまっていました。 並行処理において重要なのは、「いかにして CPU を活用できているスレッドを増やすか」であると学びました。 そのために worker の分割やインスタンス増加によってプロセス数を増やし、concurrency を調整して各プロセス中のスレッドの CPU 獲得待ちを減らします。
Sidekiq を利用する場合は、
- IO バウンド / CPU バウンドな処理はどれくらい存在するか、コードから推測する
- top などで定常的に実行されている thread 数を確認する
- 監視ツールで Sidekiq の busy(実行中) なスレッドが増えたときのレイテンシを確認する
- 実際に concurrency を調整してみて、最適な値を探る
などの調査・検証を行い、最適な concurrency に調整することで、効率的にリソースを使用でき、スループットの向上を図れます。
なお、ぽこランキング機能はまだβ版なので、これからも継続的にアップデートを重ねていく予定です。
最後に、調査や対応時にアドバイスをくださった SRE チームの皆さん、複数 worker 構成を構築してくださり、また安定運用に向けて尽力してくださった IT 基盤部のインフラチームの皆さん、ありがとうございました。
この記事が Ruby や Rails を用いた開発・運用や、その他のプロダクト開発の参考になれば幸いです。
最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。