📚

Goで作ったシステムをRubyでリプレイスすることを検討してみた

2024/04/30に公開
2

はじめに

弊社にはGoで作ったシステムが存在しますが、作られてから数年が経過して、メンテナンスも十分にできていない状況でした。
そこで、このシステムをリファクタリングして生産性を上げようという結論になりました。

リファクタリングにあたり、Goのままで行くのか、弊社でよく使われているRubyで行くのかを検討してみましたので、その過程を紹介したいと思います。

Rubyでリプレイスしようと思った理由

Goで動いてて言語やライブラリのバージョンアップなどメンテナンスがされてない部分はありますが、
そこを解消すればGoのままで行った方が良いのでは?と思うかもしれません。
しかし、あえてRubyでリプレイスしようと思うに至ったのは以下の点があります。

  • Rubyの方が開発速度があがりそう
  • Goのリファクタリングをするのに時間がかかりそう
  • Goのリファクタリングと機能追加でコード修正箇所が被るとスケジュール調整が大変
  • 主に処理時間が掛かっているのは外部APIとの連携部分とDBの処理部分なのでRubyでも実行時間はそれほど変わらないのでは

今回のシステムではこれから機能追加・改善を多数入れていく必要がありそうで、
Goのままこれらを行うとなると、リファクタリングをしてからでないと、より多くの時間がかかってしまいそうと予想されます。
このリファクタリングはシステムの大部分を改修することになりそうなので、機能追加を行いながらやるとなると難易度が高そうで、
全体として機能追加とリファクタリングでコードの修正箇所が被るとスケジュール調整が難しそうと思いました。

ただGoで書かれた部分のソースはコード量は多いですが、全体機能としてはシンプルなものなので、
Rubyで書き直すにはそれほど時間がかからないため、スケジュール調整の部分では大きく影響が無く進められるのではないかと予想しました。

Pros&Consを検討する

Goで行くかRubyにするかにあたって、プロコンをまとめてどこがネックになりそうかを絞っていくことにしました。

Goのまま

  • pros
    • リファクタリングさえ完了すれば、機能追加・改修が楽になる
    • 処理速度が早い
    • Goの知見ができる
  • cons
    • Goの定期的なメンテナンスが必要
    • メタプログラミングしたいケースがあったが、Goでやると難易度が高かった
    • 社内にGoのコードレビューできる人が少ない

Rubyでリプレイスする

  • pros
    • 社内に知見が十分あるので、それが活かせ
    • 開発速度は早くなりそう
    • リプレイス難易度がそこまで高くない
    • コードレビューできる人が多い
  • cons
    • 処理速度やサーバリソース使用量はGoより大きい
    • 大量の処理を行うシステムなので、将来的にGoの方が恩恵が大きそう

検討結果

今回は開発速度を早くしたいという目的があるので、Rubyのconsを何とかできればリプレイスしても良さそうという結論になりました。
一方でGoの処理速度やエコシステムは大量処理を行うこのシステムにはマッチしているので、今後Goでないと困る場面が出てきた場合は、あらためてその部分だけGoで実装できれば良さそうと思っています。

しかし、このままRubyでリプレイスが完了してから処理時間の面で問題があると、困ってしまうので、先に仮組みでRubyで作ってみてから問題がないことを確かめることにしました。

Goで処理時間を計測

まずは今使われている処理にどれくらい時間が掛かっているのか計測してみます。
これは timeコマンド使うと便利です。
vオプションを使うと詳細情報を取得してくれるのでCPU使用率などその他参考になる情報も一緒に知ることができます。

# time -v ./bin/run
User time (seconds): 0.54
System time (seconds): 0.49
Percent of CPU this job got: 5%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0m 17.67s
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 60272
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 117
Minor (reclaiming a frame) page faults: 2964
Voluntary context switches: 9642
Involuntary context switches: 26
Swaps: 0
File system inputs: 0
File system outputs: 0
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0

Rubyで処理時間を計測

Rubyで処理時間を計測するにあたって、Goで書かれている内容をRubyで実装しました。
ただ、実装に大きく時間を使いたくないので、変数名やメソッド名、クラスなどは適当なものにし、最低限動くものを実装しました。
この実装に掛かった時間は1日程度で、Goのコード量は1,000行を超えるものでしたが、Rubyでは300行ほどになり、短時間で実装まで行けたかと思います。

Rubyといいつつ、Railsタスクでの実行になるので、Railsの起動時間が掛かってしまう部分は除外して計測していきます。
※本番稼働時では、RailsのプロセスはPumaなどで起動済みと想定してます
なので、Springを使ってRailsアプリケーションをプレロードしておいてから、timeコマンドを使って測定をします。
https://github.com/rails/spring

# ./bin/rake replace:run (1回目はspringが起動してなく遅くなるので、測定しないで実行する)
# time ./bin/rake replace:run
User time (seconds): 0.09
System time (seconds): 0.02
Percent of CPU this job got: 0%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0m 18.66s
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 73024
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 3738
Voluntary context switches: 31
Involuntary context switches: 6
Swaps: 0
File system inputs: 0
File system outputs: 0
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0

Springを使って、timeコマンドで計測するとCPU使用率などが正しく計測できないっぽいので、その部分が知りたい場合は、Springを止めて実行するか、RubyのBenchmarkを使うと良さそうです。

# rake replace:run
User time (seconds): 4.08
System time (seconds): 0.95
Percent of CPU this job got: 25%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0m 19.74s

(もしくは、Railsの起動時間をあらかじめ計測しておいてからその時間分を引くのが楽そうです)

処理時間の結果をまとめる

Go, Rubyの処理をそれぞれ5回実行して、平均の処理時間を算出してみました。

言語 処理時間(s) CPU使用率(%) 最大メモリ使用量(KB)
Go 19.432 5 68,512
Ruby 18.868 25 73,456
  • 処理時間はRuby, Goでそれほど差が発生していない
    • 処理時間の大半はAPIのレスポンスが返ってくるまでの時間に左右されるので、言語によって差が発生してないと予想される
  • CPU使用率はRubyの方が大きくなっているが、Railsのロード処理なども含まれるので単純に比較ができていないが、やはりリソースは大きくなってしまいそう
  • メモリも若干Rubyの方が多くなっているが、このぐらいの差でさればそれほど気になる感じではなさそう

Rubyの処理を解析してみる

処理時間の結果から、言語間でそれほど大きな差が発生しないことがわかりましたが、APIの処理時間に時間が掛かってそうという予想が正しいか確かめたいと思います。

Rblineprof を使って計測してみる

rblineprofを使うと行ごとに処理時間がわかり解析しやすいです。これ単体だと結果が見にくいので、フォーマット用のGemも追加しています。

profile = lineprof(/./) do
  sleep 1
  sum = 0
  (0..100).each do |i|
    sum += i
  end
  puts sum
  puts (0..100).sum
end
LineProf.report(profile, out: './report.txt')

これを使って処理が遅い部分を探します。
以下の様な結果になりました。

場所 実行時間(s)
A社API 14.857
B社API 10.491
C社API 10.881
DB処理 7.345
その他 0.422

やはりAPIとDBの処理時間が全体の大半を占めているとわかりました。
以上のことから、本システムの場合では、それほど複雑な処理を行っているわけではないので、処理時間に大きな差が発生してないという結果になりました。

おわりに

今回Goで作られたものをRubyでリプレイスしても大丈夫か検討してみました。
わかったことは以下になります。

  • 検証するためにRubyで同じ処理を作るのはそれほど大変じゃなかった
    • 今回もシステムの一部分で重要な箇所を選んで実装を行いました
  • 複雑な処理じゃない場合はRubyでも大丈夫そう
  • CPUのリソースは多く使いそうなので、その点は考慮する必要がありそう

Discussion

yasulabyasulab

非常に分かりやすい記事&ベンチマーク結果の共有ありがとうございます...!! 😻✨

こちらもし可能であれば、 ベンチマーク実行時に Ruby の YJIT を有効化しているかどうかというのも伺えたりしますでしょうか...? 💭

またよければ Ruby / Go のベンチマーク実行時のバージョンも伺えると嬉しいです...!! 🙏✨

Ken OkabeKen Okabe

CPU使用率25(%)というのは、結構致命的であると思いますね。
常時稼働ならローカルではCPUファン回りっぱなし、クラウドならば単純計算でコスト5倍ということ。
Ruby採用と合理性というのが要約すると「自分たちは書きなれているから」というだけなので、一般的には採用する合理性はない、と判断できます。