動機
重いバックグラウンド処理(画像処理)を並列化するために、マルチコアのマシン上でsidekiqのプロセスを複数立ち上げて対処していましたがサーバ費用がかさんでしまうので、マルチプロセスではなくマルチスレッドで並列化したいと考えました。
メインのアプリケーションプロセスはMRIのままにしておきたい理由は、一度JRubyで動かしてみたところ自分の環境だとPumaのレスポンスが5倍くらい遅くなってしまったからです。
いまの自分では原因を見つけることは難しいと判断したので、アプリケーション(Puma)はMRIで、ジョブ用のプロセス(Sidekiq)はJRubyで動かしてみようと思いました。MRIはシングルスレッド前提での実装になっている分、基本的には速いらしいです。
現在、僕個人のプロジェクトでいわゆるオンラインストレージサービスを作っています。
ドロップボックスのようなサービスを想像してみてください。
数メガの画像を一度に1000枚近くアップロードされ、それをリアルタイムに加工しながら、処理が終わったものからウェブソケットでブラウザにプッシュしてサムネイルを表示していきます。
5MB程度のオリジナルの画像をもとに、表示用にサムネイル画像と、プレビュー用の画像を作成してそれぞれ保存するとして、僕の環境では一枚あたり3秒ほど掛かります。
1000枚なら3000秒なので、ユーザーは完了を待つ間に白骨化してホネホネロックを踊ることになります。
画像処理を並列化できれば、ネットワークさえ快適な環境であればほとんど気にならない速さでサムネイルが表示できるようになる、ということで現在は冒頭に書いた通りマルチプロセスで並列化しています。
ですが、単に並列化にコアの数だけが必要な状況(CPUやメモリはさほど重要でなく、とにかく「数」が欲しい状況)で、ジョブサーバについてはメモリもCPUもだぶだぶに余っていてなんとも悲しい話です。
いまは4コアのマシンを2台ジョブサーバとして使っているんですが、常時起動する場合およそ月に$93程度(1台あたり)掛かってしまうので、趣味のプロジェクトとしてはちょっと厳しいです。
さらにアプリケーション用のインスタンス、キャッシュ用のインスタンス、DBインスタンスの料金、それからファイルストレージの料金が掛かっています。全部自腹運用なので少しでも費用を下げたいです。もしくはあふれんばかりのお金が欲しいです。
マルチスレッドで処理できれば、コアは4つもいらなくなるし、だいぶ費用を抑えられるはずです。
MRIとGIL
普段Rubyを使っていない人向けにRubyのスレッドについて少し説明をしておきます。
RubyはC言語で書かれたMRIと呼ばれる実装、Java実装のJRuby、Rubinius(これについては僕もよくわかっていないので説明できません)などがあります。
MRIは安定していて、バグを引き当てることもあまりなく、RailsにしろSinatraにしろウェブ以外にしろ、これを使っている限りおかしな問題を踏むことはあまりなく、Rubyというと一般的にこのMRIのことを指し、これ以外を選択するのはよほどの理由があってのことだと思います。
ただ、MRIのスレッドにはGIL(Global Interpreter Lock)という概念というか制限があって、これはWikipediaからの引用だと
グローバルインタプリタロック(英: Global Interpreter Lock, GIL)とは、プログラミング言語のインタプリタのスレッドによって保持されるスレッドセーフでないコードを、他のスレッドと共有してしまうことを防ぐための排他 ロックである。
となります。
これはわかりやすくいうと同時に実行できるスレッドは一つに限られるということで、 さらに言い換えると「MRIではマルチスレッドで処理を並列化することはできない」ということです。
じゃあスレッド意味ないじゃん?というのがGILの話ではお決まりの流れなんですが、パフォーマンスを目的に考えた場合でも意味がないなんてことはなく、例えばIOを待っている間なんかは別のスレッドを動かすことでトータルの処理時間を短縮することができます。
RailsというかWeb ApplicationはDBへの問い合わせや外部へのHttpリクエストなどIOを待つことが多く、たとえGILで制限されるとしても複数スレッドを使うことは十分に速度改善に寄与してくれます(Puma速い!)。
なので、大抵のプロジェクトにおいてはGILがーー!GILがーー!とか騒ぐような必要はないんじゃないかと思います。
実際いままで仕事でかかわってきたプロジェクトのほとんどすべてはRuby(MRI)でしたが、GILを問題にしていたものはひとつもありませんでした。
ただ、今回のようにIOではなくRubyの処理(画像処理)自体がとても重く、しかも大量に処理する必要がある場合はやはりスレッドを同時に動かせないと厳しいです(でなければマルチプロセス、即ちマルチコア、即ちお金で解決しなければいけません)
※追記: これは僕の勘違いで、実際にはIO(convertコマンド)で時間掛かっていました
JRubyはJVM上で動作するのでマルチスレッドが普通に動く
↓
これを使って画像処理を並列化しよう
↓
でもなんかわからんけどウェブサーバはMRIのほうが速いぞ?
↓
じゃあウェブサーバはMRIで、ジョブプロセスはJRubyで動かしてみよう
というのがやりたいことです
やっていく
まずはGemfile
gem 'mysql2', '>= 0.4.4', '< 0.6.0', platforms: [:mri] # mysql2はjruby環境では使わないのでplatformの指定が必要 gem 'activerecord-jdbcmysql-adapter', :github => 'jruby/activerecord-jdbc-adapter', platform: :jruby # この記事を書いている時点では rails 6.0.0 はmaster branchを使う必要がありました
あ、いまさらですがmysql使ってます。別のDB使ってる人は適宜いい感じに読み替えてください。
$ bundle install $ jruby -S bundle install
で、
$ bundle exec rails s
$ jruby -S bundle exec sidekiq
これだけで問題なく動くはずです。
よく考えたら当たり前か、という感じですが、DBに接続するためのアダプターを切り替える必要があることに気づかずbundle installでハマりました。
JDBCが必要です
JDBCとはJava Database Connectivityの略で、Javaアプリケーションからデータベースを操作するAPIのことです。
あと注意点として、rubyとjrubyをコマンドで切り分けるので、普段rbenvなどでRubyを使っている人は、jrubyはrbenvなどではなく別にインストールしたほうがやりやすいと思います(さもないとrubyってコマンド打つとjruby起動される)
※追記(悲報)
全然速くなってる感じがしなくて嫌な予感を感じながらtopで様子を見ていたら、ImageMagickのconvertというコマンドが画像ごとに呼ばれてプロセスが複数ポコポコ立ち上がっていることに気づきました。
つまり今回の変更ではconvertを呼ぶ処理がマルチスレッドで並列化できただけで、肝心の画像処理部分はコアを増やさないとまったく速くなりませんでしたというオチです。リバートしました。つらぽよ