業務でRustを採用してみた
Specteeでエンジニアをしている和山です。
最近あるプロジェクトでRustを採用してみました。
今回はRustの採用経緯や良かった点、課題点を紹介します。
今後業務で採用しようかと悩んでいる方の参考になれば幸いです。
✨️ 採用経緯
採用経緯は2点です。
- パフォーマンスを向上する手段として利用できるか検証したかった
- 技術的挑戦をしたかった
経緯1. パフォーマンスを向上する手段として利用できるか検証したかった
弊社が提供しているプロダクトの裏側では、さまざまな形式のデータを分析・処理するためのバッチ処理が稼働しています。
データは不定期・定期に入電し、形式も多岐にわたります。中には画像処理や大容量データの処理を行うものもあります。
バッチ処理には次の入電タイミングまでに処理を完了させることが求められるだけでなく、ユーザーへの迅速な価値提供のため、処理時間をできる限り短縮することも重要です。
弊社では、Pythonで記述されたバッチ処理の中に、次の入電タイミングギリギリで処理が完了するものや、処理時間に課題を抱えているものが複数存在します。
これらの処理をRustに置き換えることで、パフォーマンス向上を図れないかと考えました。
パフォーマンスが向上すれば、Lambdaの実行時間が短縮され、ユーザーへの価値提供が迅速化されるだけでなく、コスト削減にもつながります。
ただし、Rustは学習コストが比較的高いとの情報があり、保守運用を担当できるメンバーを確保する必要があります。
置き換えによるメリットがこれらの課題を上回るかどうか、保守運用に関する課題が実際にはどのようなものかを検証したいと考えていたところ、今回の検証に適したプロジェクトが立ち上がりました。
経緯2. 技術的挑戦をしたかった
私たちのチームでは、プロダクトの基盤となるサービスを提供するプロジェクトを推進しています。
その最初のフェーズとして、既存のデータフロー改善を行いました。
このフェーズでは、いわゆる技術的負債と向き合う時間が予想以上に多く、短期間であれば良いものの、
長期間にわたって向き合い続けることは開発者のモチベーション低下につながると考えられました。
そこで、メンバーと話し合い、開発に対するモチベーションを維持するために何が必要かを検討しました。
その結果、「今までやったことのない、技術的な挑戦があればチェイサーになって嬉しい」 という意見が出ました。
本プロジェクトでは、期間限定で稼働するバッチ処理を作成する必要がありましたが、最終的には不要となる処理であるため、技術的な挑戦がしやすい環境だと判断しました。
1つ目の理由も考慮し、技術的な挑戦としてRustを採用するという流れになりました。
😀 検証することを整理
最終的にCTOとも相談し、魅力や懸念点を整理したうえで採用する方向になりました。
その際、Rustを採用することで検証したいことも整理しました。
主に検証したいことは2点です。
- 学習コスト
- AWS Lambdaとの相性
学習コストは初期ハードルと実装後の保守運用まで考えたコストを検証します。
AWS Lambdaとの相性は弊社のバッチ処理が主にLambdaで動いているので重要です。
ここでいう相性は主にRustを採用したことで発生する独自のメリット・デメリットがないかの確認となります。
✏️ 採用〜実践までにやったこと
実践までにやったことはシンプルで、「公式ドキュメントを読む」 です。
Rustは公式サイトにて「The Rust Programming Language」と呼ばれる学習用のドキュメントが公開されています。
非公式ではありますが、日本語の翻訳版も提供されているので安心して読み進められます。
ここで基本文法を学習することにしました。
実践までのキャッチアップを、公式ドキュメントだけに抑えた理由は2点です。
- 最低限のキャッチアップによって発生する、つまづきポイントが把握しやすくなる。他のチームやメンバーで採用するときのナレッジになる。
- GitHub Copilotがよしなに支援してくれる(と期待している)ので、細かい箇所までのキャッチアップは不要と判断。
実践中はモブプロやペアプロを定期的に入れる ことで、お互いどういった点に難しさを感じているか、逆に良かった点を整理しながら実装を進めました。
🔍️ 採用結果の検証
ここまでの流れを一度整理します。
採用経緯として
- パフォーマンスを向上する手段として利用できるか検証したい
- 技術的挑戦をしたい
採用後の検証項目として
- 学習コスト
- AWS Lambdaとの相性
採用までにやったこととして
- 公式ドキュメントを読む
実践中のプラクティスとして
- モブプロやペアプロを定期的にいれる
を挙げました。
ここからは、検証項目で挙げていた2点について実際どうだったかをお話します。
🤔 学習コストはそれなりに高い
学習コストは それなりに高いという印象を受けました。
Copilotはよしなに頑張ってくれる。問題は判断する人間
Copilotによるコーディング支援はほぼ期待どおりでした。
PythonやTypeScriptと同じくらいの期待値でコーディング支援をしてくれます。
単体テストもしっかり書いてくれます。
(他言語でもそうですが、Copilotによる支援で一番嬉しいです。)
そのため、最初は公式ドキュメントでの最低限キャッチアップで大丈夫だったなと思いました。
一方で、生成されたコードでエラーになった際にどう解消すればいいかが分からない場面もありました。
コンパイルエラーはRustのコンパイラが優秀なので何が悪くて、どう直せばいいかまで教えてくれます。ここは安心して直せました。
問題なのはCopilotがunwrap
を書いていて、コンパイルは通るが実際には動かせないパターンです。
unwrap
はOption型やResult型が返却された場合に、中の値がSomeやOKであることを前提にして値を取り出します。
NoneやErrの場合は、パニックとなりプログラムをクラッシュさせて終了します。
fn get_result(flag: bool) -> Result<i32, &'static str> {
if flag {
Ok(42)
} else {
Err("Something went wrong")
}
}
fn main() {
// 成功する場合
let value = get_result(true).unwrap();
println!("Success: {}", value);
// 失敗する場合(パニック発生)
let error_value = get_result(false).unwrap();
println!("This will not be printed: {}", error_value);
}
エラーの内容が本当にクラッシュすべき箇所であれば良いのですが、こちらの入力値や前提処理が誤っていた場合はその前段での修正が必要になります。こういった エラーの本質的な原因を特定するのに実践初期は苦労しました。
事象の特定は、RustでかかれたコードをPythonやTypeScriptに一旦書き直してみることでおかしい箇所を整理したり、ライブラリのドキュメントを再度読み直すことで一つずつ整理していきました。
この事象は最終的にunwrap
ではなく?
でResult型を早期に返すようにして、ある程度安全なコーディングをするようにしました。
我々人間側がCopilotにどれだけ寄り添えるかがポイントとなりそうです。そのために継続的なキャッチアップが必要だなと感じました。
🏗️ AWS Lambdaとの相性は悪くない。つまづき・課題点は2つ
AWS Lambdaとの相性ですが、実行時間が短いRustとの相性は良いかなと思いました。
採用経緯で挙げていた 「パフォーマンスが求められるバッチ処理への採用」は割と現実的 かなと感じます。
また、AWS SDKも一通り提供されているので実装中に困ることは少ないです。
Python→RustのSDKの使い方の違いから一瞬戸惑う
とはいえ、書き方は最初戸惑います。
例えばRustで別Lambdaを起動する時は以下のようなコードを書きます。
use aws_config;
use aws_sdk_lambda::Client;
use aws_sdk_lambda::types::InvocationType
use tokio;
#[tokio::main]
async fn main() -> Result<(), aws_sdk_lambda::Error> {
// AWSの設定をロード
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let client = Client::new(&config);
// 呼び出すLambda関数名
let function_name = "my_lambda_function";
// Lambdaを同期的に起動
let response = client
.invoke()
.function_name(function_name)
.invocation_type(InvocationType::RequestResponse)
.send()
.await?;
println!("Lambda invoked: {:?}", response);
Ok(())
}
一方でPythonだと以下のようなコードになります。
import boto3
def main() -> None:
# AWS Lambda クライアントを作成
lambda_client = boto3.client("lambda")
# 呼び出すLambda関数名
function_name = "my_lambda_function"
# Lambdaを同期的に起動
response = lambda_client.invoke(
FunctionName=function_name,
InvocationType="RequestResponse"
)
print("Lambda invoked:", response)
Pythonは 必要パラメータを引数で渡すイメージに対して、
Rustは 必要なパラメータを自分で組み立てて渡すイメージです。
Rustの下記コード
let response = client
.invoke()
.function_name(function_name)
.invocation_type(InvocationType::RequestResponse)
.send()
.await?;
invoke()
関数によって返ってくるのはInvokeFluentBuilder
という構造体になります。
Builderの名前通り、この構造体で持っている関数を自分で呼び出してパラメータを投入していきます。
最後のsend()
を呼び出すことでResult型のInvokeOutput
が返ってくる仕組みです。
今までPythonをメインに使っていたので、最初はちょっとだけ違和感がありました。
(なんでinvoke()
して返ってくるのがBuilderなんだ?みたいな違和感)
これは慣れてしまえば楽。むしろこういう実装パターンも実務で使えるなと勉強になりました。
CI/CD環境のビルド時間の長さに悩まされる
ビルド時間が長い。今回Rustを採用した中で一番の課題になりました。
Rustの情報収集をしていたときも「ビルドが長い」という記事はいくつか見ました。
当時の所感は「自分たちのプロジェクトは関数小さいし大丈夫だろう」と思っていました。
結果、やっぱビルド時間長かったです。
私のチームではCI/CD環境にGitHub Actionsを採用しています。環境のスペックがデフォルトだとビルドするのに5~10分くらいかかります。
ビルド対象はAWS Lambda。関数サイズは比較的小さめなので、PythonやTypeScriptと比較するとだいぶ待たされるなという気持ちになりました。
正確に比較はしていませんが、過去の経験から
同じ処理をPython、TypeScriptで書いた場合、3分程度でデプロイまで終わるところ
Rustだと10分くらいかかる感じです。
お金があればスペック増し増しの脳筋手法が良いと思うのですが、別方法が無いか模索中です…!
ローカル環境編であれば下記記事が参考になるなと感じています。
CI/CD編を楽しみにしつつ、色々調べてみようかなと思います。
👍️ 技術的な挑戦は楽しい
最後に、今回の採用経緯として開発者のモチベ維持も挙げていたのでここはどうだったか述べて終わりにしたいと思います。
結論 「やってよかった」 と思いました。
今回、プロジェクトを技術負債の向き合いと交互に入れながら進めることも良かったのか、
進捗は比較的一定のペースで安定していました。
新たな言語なのでもちろん学習コストは低くはありませんし、今までの経験には無かった課題にぶつかることがあります。
そこをモブやペアプロで協力しながら解決したり、Rustという言語性質に向き合うことで新たな知見を得ることができました。
得られた知見として、前述したunwrap
やBuilderの他にも例えばオブジェクト志向デザインパターンが挙げられます。
ドキュメント内でステートの持ち方についてRustならではの実装方法について語られています。
ここで語られている実装はRustだけではなく、他言語でも参考になるなと思いました。
今回のRust採用の経験を通じて、各メンバーが興味の幅を広げたり、深めるきっかけを貰うことができたと感じています。
今後は運用保守も発生するので、継続的な勉強をして、知見を共有していきながら保守できるメンバーを増やしていきたいと思います!
Discussion