TypeScriptのコードをRustで書き直した話
モニクル Advent Calendar 2024の12日目の記事です.
はじめに
モニクルの開発組織では,TypeScriptをプロダクトを作るときの最初の選択肢として採用しており,Node.jsをランタイムとした一般的なJSの技術スタックでの開発を行っています.
そんな中でNode.jsのパフォーマンスに課題を感じ始め,一部のコードをRustで書き直すという作業を行いました.
Node.jsに感じた課題
あらゆるサービスが稼働しているだけでお金を生み出してくれると良いのですが,残念ながら全てのサービスがお金を生み出すわけではありません.サービスを稼働させるコストがかかっているのであれば,そのコストをできる限り削減したいと思うのは組織としては一般的なことだと思います.
そんな中,サービスの一部がインスタンスのメモリーリミットに引っ掛かるようになりました.
通常ウェブアプリケーションのサーバーサイド処理といえば,1リクエストに対して1レスポンスを返すといったものですが,問題となった処理はデータを一括で処理するためのバッチ処理を行っています.通常のリクエスト処理であればメモリ消費をそれほど気にしなくても良いのでですが,該当の処理は1回あたりメモリを400MB以上消費していました.
課題 メモリが解放されない
一度に400MB以上のメモリを消費するのはまだ良かったのですが,問題は確保したメモリをNode.jsのランタイムがすぐに解放してくれないというのが悩みの種でした.
該当のバッチ処理は,月に数回,ほぼ同じタイミングで実行されます.そのため,1回目の処理で確保したメモリが解放されないまま2回目の処理が実行されてしまうと追加で400MB以上確保しようとしてしまい,メモリーリミットに到達してしまうという現象が発生していました.
2回目の処理に入る前にメモリを解放して欲しいのですが,Node.jsのGCはその辺り得意じゃないようです.
インスタンスサイズを大きくすれば解決する話ではあるのですが,月数回しか行われない処理のためにインスタンスサイズを大きくしたくありません.
メモリ消費を抑えたい
メモリ消費を抑えるためにできることとして,処理のチューニングとメモリ管理が得意な言語で書き直すという2つの方法が考えられます.
メモリ管理が得意な言語で書き直すという選択
処理のチューニングは,すでにコードがそれなりにシンプルなものであるというのもあり,今あるドメインロジックを壊してテクニカルなコードに書き直す必要がありました.
また,肌感として「データ量に対してメモリを消費しすぎではないか」という疑問もあり,試験的にTypeScript以外の言語で書き直す方向に舵を切りました.
GoかRustか
「実データ量に対して確保するメモリを予測可能な言語」として,GoかRustの2つを選びました.
Goは業務で書いた経験があり,GCの挙動や実際のパフォーマンスがどうなるのかなんとなく想像できるということで有力な候補です.また,Google Cloudの公式SDKがあるのも魅力の一つで,実装する上での悩みが少なそうというのが魅力の一つでした.
課題はメモリ消費だった
一方でRustは趣味で簡単なコード書いている程度でしたが,今回の問題の発端が「GCが確保したメモリを解放してくれない」がスタートだったため「GoのGCは大丈夫だと思うけれども,本当に?」という疑問がありました.
そのため,「メモリの話ならRustで間違いないだろう」ということで今回Rustで書き直すという選択をしました.
Rustで書き直した結果
Rustで書き直して実測した結果,次のような成果を得ることができました.
- 1処理あたり400MB以上のメモリを確保していたのが,サーバー全体で40MB程度に収まった
- 1処理あたり90秒ほどかかっていた処理が,10秒以内に終わるようになった
- 100MB以上あったコンテナイメージが10MB以内に収まった
メモリ消費に関して期待以上の結果を得られただけでなく,よく言われるパフォーマンスだったりコンテナイメージサイズなども目に見えて良い結果を得られました.
大変だったこと1 Google Cloudの公式SDKが存在しない
最近,公式SDKが作られ始めているようですが,まだ使える状態ではなさそうというのもあり,今回の対応ではAPIを直接叩いたりしています.
大変だったこと2 ビルドに時間がかかる
Node.jsのビルドで一番時間がかかるのがnpmパッケージのダウンロードでしたが,Rustの場合は純粋にビルドに時間がかかります.特にCI/CDパイプラインで利用するデフォルトのインスタンスは1vCPUだったりするので,ローカル環境で30秒でビルドできていたとしてもビルドに10分かかるというのは普通に起こります.
大変だったこと3 シングルバイナリを作るのにコツがいる
今回TLS周りのライブラリ依存を解決できず,scratchではなくalpineをベースイメージに選択しました.
この点に関しては「Goならscratchをベースにして簡単にコンテナイメージを作れたのにな〜」と思いましたが,結果的に10MB未満のコンテナイメージを用意できたのでそれほど不満はありません.
まとめ
TypeScriptをRustで書き直した結果,パフォーマンスに関して申し分ない結果を得られました.
他にもRustにしたことでTSよりも型の意味が厳格になったことも嬉しいポイントです.これは「TSにも型がある」といった単純な話ではなく「その型にどう振る舞って欲しいか」というのをRustは表現しやすいなと感じています.
Rustは良いぞ〜
Discussion