こんにちは。サイバーエージェント AI 事業本部 Dynalyst にて、ソフトウェアエンジニアをしている豊田(@helloyuki_)です。また、Rust 領域における Next Experts も務めています。

先日、CA BASE NEXT という CyberAgent が開催する20代が中心のカンファレンス[^1]に、私も一応20代ということで登壇させていただきました。

内容は Web アプリケーション開発を Rust で行った体験談を語るというものでした。実際に2018年〜2020年頃に Rust をとあるチームのアプリケーションに導入し、引き継ぎのために何をしたかという内容をお話させていただきました。

セッション自体は25分しかなく、またコード例をスライドからかなり削るなど、CG スタジオ仕様への対応が必要でした。というわけで、十分に伝えたいことを盛り込めたわけではありませんでした。この記事はその補足説明です。

Rust とは

Rust のロゴ

Rust は2015年に最初のリリースを迎えたプログラミング言語です。

Rust が得意とする領域は、いわゆる低レイヤーと呼ばれる領域です。OS を作ったり、コンパイラを作ったりといったことが得意な言語です。この領域では C や C++ といった言語が大きなシェアを持っていますが、Rust はこれらの言語に次ぐ第3の選択肢として今注目を集める言語です。

Rust は基本的に高速な言語です。具体的には C や C++ 並のスピードが出ます。Go や Java より一段速いプログラミング言語です。Debian プロジェクトが定期的に更新する Benchmark Games という、プログラミング言語の速度をベンチマーキングするサイトがあります。そのサイトをご覧いただくとわかるように、 C や C++ と遜色ないスピードが出ています。

Rust が特徴的なのは安全性を追求したメモリ管理の方法です。コンパイラが、所有権、借用、ライフタイムという独特な概念を使ってメモリを安全に管理します。この仕組を借用チェッカー(Borrow Checker)と呼びます。use after free や data race のような脆弱性のもととなるような操作を含むコードは、Borrow Checker によってコンパイルエラーになります。安全でないコードは、コンパイルすらされないのです。

Rust には GC (Garbage Collection)が搭載されていません。従来のプログラミング言語では、GC がメモリ安全を実質的に保証していた節がありました。またこれが、メモリのことをほぼ意識しなくてよくなるという開発者体験の向上につながっていました。しかし、GC はランタイムを肥大化させたり、あるいはごみの解放時に処理そのものにオーバーヘッドを発生させるなど、安全性を担保できる代わりに速度やバイナリのサイズ等をある程度諦める必要がありました。

Rust の扱う低レイヤーの領域では、GC は残念ながら、速度面の問題などいくつかの理由から搭載することができません。しかしだからといって、安全性を担保する代わりに速度を犠牲にするという選択肢はとりたくありません。

GC を採用しない場合、従来の C 言語のとった手法のように手動でメモリを確保/解放するといった方法が考えられます。あるいは C++ のようにデストラクタやムーブセマンティクスを導入して、リソースの管理を多少自動化することもできるでしょう。しかしこうした手法では結局のところ、そのコードが安全かどうかはコードを実行してみるまではわからないうえに、人間が実装するとミスをどうしても防げないという問題がありました。

速度と安全性のトレードオフを解消する仕組みが、Rust が今回導入した所有権、借用、ライフタイムの仕組みだったわけです。この独自の仕組みにより、Rust はコンパイル時に人間の手を介することなく、自動で安全性のチェックをできるようになりました。安全性と速度の両方のトレードオフを打破した言語のひとつの例になりました。

Rust の高速性の秘密は、実は他にもあります。もし興味を持たれた方は、The Rust Programming Language という公式ガイドがあるので、そちらをぜひご覧ください。

Rust は、最近では AWS の Firecracker を実装する際に使用されたりKubernetes 上で WebAssembly を動かすための Krustlet というソフトウェアに使用されたりしています。また直近では、Linux の一部に Rust を導入するかしないかという議論がメーリングリストでなされたことが話題になりました。じわじわと利用者を増やしているプログラミング言語として、今後注目をしばらく集めることになりそうです。

Rust の Web 関連エコシステムを整理する

エコシステムのイメージ

Rust の Web アプリケーション開発に関連するクレート(ライブラリ)のエコシステムは、まだまだ発展途上です。

Rust で Web アプリケーションを扱うためには、下記について知る必要があると思います:

  • 非同期処理ランタイム: 現代の Web アプリケーションは、クライアントの数が非常に多く同時アクセス数が膨大な数になるという特徴があります。そうした状況下では、少ないリソースでより多くの処理を行える必要があります。それを可能にする計算方法として並行処理と呼ばれる手法がありますが、Rust で Web アプリケーションを扱うためには、この並行処理を扱う基盤(非同期処理ランタイム)について知る必要があります。
  • Web アプリケーションフレームワーク: Rust は Go 言語のように、標準ライブラリで Web アプリケーションを作り上げられてしまうということはありません。もちろん自分で実装すれば問題はないわけですが、可能ならば巨人の肩、つまり Web アプリケーションフレームワークやライブラリに乗りたいですよね。Rust ではいくつか種類があり、それらについて紹介します。この際、どの非同期処理ランタイムの上に構築されている Web アプリケーションフレームワークかを知っておくのは非常に重要です。
  • 周辺エコシステム: 現代の Web アプリケーションは、JSON のパースや GraphQL、gRPC などさまざまな周辺技術とともに成立しています。それらを組み合わせてアプリケーションを実装していくことになります。どのようなクレートがあるのかという知識が必要になります。

非同期処理ランタイムとは何か?

2018年に Rust に async/await という文法が入ることになりました。それまでの Rust はずっと非同期処理関連の実装で議論が続いており、安定化しないという状況がありました。それが、async/await の導入によりようやく安定したのです。

これに伴って、従来から存在はしていたものの、非同期処理周りが安定化するのを待っていたクレートの開発が急速に進み、エコシステムが充実し始めました。非同期処理関連のエコシステムが充実すると、Web アプリケーションを作るためのライブラリも作りやすくなります。

Rust の async/await は、JavaScript や C# 、Python などで導入されているその記法とほぼ同じような動きをします。async を使って非同期処理を行う関数を定義し、await をどこかにつけることによって、その関数の処理待ちをしながら次の処理に進んでいくという基本的なコンセプトは、そのまま Rust でもいかされています。

実際、Rust における非同期処理を定義するコードは下記のように書けます。

async fn hello_world() {
    println!("hello, world!");
}

ただ、このままでは Rust は async/await の書かれたコードを実行できません。というか、コンパイルが通らないはずです。async の関数に対して await して yield すれば動くのでは…?と思うかもしれませんが、下記はコンパイルが通りません。

async fn hello_world() {
    println!("hello, world!");
}

// 下記はコンパイルエラーになる。
async fn main() {
    hello_world().await;
}

main 関数は通常、非同期化できない(asyncfn の前につけることができない)からです。それを可能にするために、サードパーティ製のクレートの助けを借りる必要[^2]があります。

それを手助けするのが、非同期処理ランタイムと呼ばれるクレート群です。これらのランタイムがやっていることは大雑把には、async 定義された関数の実行スケジューリングと実際に発火して実行すること、非同期 I/O やネットワーキングを行うための API を追加で提供することです。

なぜ Rust は、非同期処理ランタイムというある種重要な機能を標準機能や標準ライブラリに含めず、プラグインのように付け替える形にしているのでしょうか?Rust が使われるフィールドが広範であることが大きな理由だと思います。Rust は組み込み開発から Web 開発まで、非常に広範な領域で利用される言語です。非同期処理ランタイムが不要なケースに不要なモジュールが付随してしまうことを避けたいわけです。

これは結果、モジュールサイズの低減につながります。たとえば組み込み開発などで使う際にモジュールサイズが肥大化することは避けたいわけですが、非同期処理ランタイムは実装が大きくなりがちなため、モジュールサイズを大きくすることに寄与してしまいがちなわけです。

非同期処理ランタイムは現在いくつか種類があります。ただ、お互いの互換性は今のところないようです。代表的なものは下記2つで、現状も下記2つを基盤とするクレートが多く作られています。

  • tokio: 非同期処理ランタイムの事実上のデファクトスタンダードになっている。
  • async-std: すべての標準ライブラリを同じ API を保ったまま非同期化することを目的としたクレート。println! などのマクロまで非同期化されているところがおもしろいポイント。

以降紹介するクレートについては、どちらの非同期処理ランタイムを基盤とするものかについて明記するようにします。

Web アプリケーションフレームワーク・ライブラリの紹介

各非同期処理ランタイムごとにいくつか種類があります。今回は私が知っている限りで紹介をします。

tokio ベースのもの

actix-web

https://github.com/actix/actix-web

近年では Rust での Web アプリケーション開発ではデファクトスタンダード感のあるクレートです。ミドルウェアとの接続や HTTP/2 対応、WebSocket 対応などがされており、actix-web を使えば一通りやりたいことは実現できるのではないかと思います。

このクレートの特徴は内部でアクターモデルを使用していることでしょうか。裏側の非同期処理ランタイムは tokio ですが、その上にアクターモデルを使用した層が載っているのが特徴的だと思います。たとえば、自身でアクターを実装して独自の状態管理をさせることができます。

また、速度面も非常に優秀なクレートで、一時期は全言語1位のスピードを誇る Web アプリケーションフレームワークでした。現在でもベンチマークでは上位に食い込んでいます。相変わらず爆速なフレームワークであることに変わりはないでしょう。

書き心地は次に紹介する Rocket に似ているかもしれません。私が初めて触った数年前と比べると、かなりデザインが変わって今の形になったという印象があります。

下記は、バージョン 3.3.2 で書いたサンプルコードです。

use actix_web::{get, App, HttpResponse, HttpServer, Responder};

#[get("/hc")]
async fn hc() -> impl Responder {
    HttpResponse::Ok()
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().service(hc))
        .bind("127.0.0.1:9000")?
        .run()
        .await
}

Rocket

https://github.com/SergioBenitez/Rocket

私が Rust を触り始めた 2017 年ごろからあったと思われる(たしか)クレートで、ルーティング周りの書き心地が Java の Spring Boot に似ているなと当時思っていたクレートです。

このクレートは、2018年の Rust の非同期関係のエコシステムの変化への追従に時間がかかり、これまで同期処理しか行えないクレートでした。ハイパフォーマンス性が求められるプロダクトでの利用はあまり推奨できなかったというのが実情でした。

ただ当時としては非常に書き心地がよかったのと、Rocket は「このクレートさえ入れておけば Web アプリケーションが作れる」という安心感のあるクレートだなと思っていました。なので、個人的にはとても好きでした。ちなみに今は actix-web も全部のせ感があります。

現在は 0.4.10 と次の 0.5 系との間の時期で、Rocket は 0.4 系では非同期処理を行うことができないという注意点があります。0.5 系を使用すると非同期処理ランタイムをかませることができるようになります。その際のベースは tokio です。

下記は、バージョン 0.5.0-rc.1 で書いた同期処理を用いた実装です。

#[macro_use]
extern crate rocket;

use rocket::{http::Status, routes, Config};

#[get("/")]
async fn hc() -> Status {
    Status::Ok
}

#[launch]
fn rocket() -> _ {
    let config = Config {
        port: 9000,
        ..Config::debug_default()
    };
    rocket::custom(&config).mount("/hc", routes![hc])
}

非同期化すると下記のように書くことができます。こちらのほうが、今後使用することが多くなるでしょう。

#[macro_use]
extern crate rocket;

use rocket::{http::Status, routes, Config};

#[get("/")]
async fn hc() -> Status {
    Status::Ok
}

#[rocket::main]
async fn main() -> Result<(), rocket::error::Error> {
    let config = Config {
        port: 9000,
        ..Config::debug_default()
    };
    rocket::custom(&config)
        .mount("/hc", routes![hc])
        .launch()
        .await
}

現時点では先ほど紹介した actix-web がデファクト感がありますが、非同期処理対応版がリリースされたあとはもしかすると、Rocket の利用事例が増えていくかもしれません。

warp

https://github.com/seanmonstar/warp

こちらも tokio をベースとするフレームワークです。他のフレームワークと違うのはまず、 FilterHandlerという概念があり、それを実装しながらエンドポイントを作り上げていくという点でしょう。エンドポイントに対する型付けが少々他のフレームワークと比較すると強めかもしれないとも言えます。

Filter はいくつか組み合わせながら使用することができます。実際にこのサンプルプログラムでは、いくつかの Filter を個々に定義しておいて、最終的にそれらをつなぎ合わせるという実装が行われています。これを見ると、Scala のフレームワークである Finch の Endpoint という概念を思い出します。

加えて、比較的 HTTP の低いレイヤーを触る際に使用する hyper というクレートをベースに作られているのは特徴的かもしれません。

サンプルでは、まずリクエストがあった際に標準出力に Hello! と出力する Handler ( hello 関数) を実装しています。そしてそれを Filter ( endpoints 関数) 内で warp のルーティングに登録しています。

下記は、バージョン 0.3.1 で書いたサンプルコードです。

use std::convert::Infallible;

use warp::{hyper::StatusCode, Filter, Rejection, Reply};

fn endpoints() -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
    warp::path!("hc").and(warp::get()).and_then(hc)
}

async fn hc() -> Result<impl Reply, Infallible> {
    Ok(StatusCode::OK)
}

#[tokio::main]
async fn main() {
    let api = endpoints();
    warp::serve(api).run(([127, 0, 0, 1], 9000)).await;
}

warp によるアプリケーションの実装に関しては、下記の記事にかなり詳しく書かれています。そちらも参考になりますので、ぜひご覧ください。

Axum

https://github.com/tokio-rs/axum

先日 tokio チームから発表があったクレートです。

tokio が作っているということもあり、tokio エコシステムとのインテグレーションが非常に優れています。Axum は、内部では tower という tokio チームが作るクライアント-サーバーを実装する際に使用できる抽象化レイヤーや、先ほども紹介した Hyper というクレートなどを組み合わせて作られています。

また、マクロを使用しない設計を重視しているようで、書き心地も非常に直感的で罠が少ないように感じています。エルゴノミックなデザインであることを売りにしているようです。

examples ディレクトリにあるサンプルコードを見る限り、Web アプリケーション開発で必要な機能はすでに実装されているようです。multipart/form-data への対応や、静的ファイルのサービング、WebSocket など一通り揃っています。リリースされたてなので導入事例はまだないかもしれませんが、プロダクションで使用してもとくに問題ない仕上がりだと思います。tokio との統合の安心感から、今後このクレートが Web アプリケーション開発ではデファクトスタンダードになっていくかもしれません。

下記はバージョン 0.1.1 で書いたサンプルコードです。actix-web や Rocket などと比べると、とにかくコード上にマクロがないのが印象的です。

use std::net::SocketAddr;

use axum::{prelude::*, response::IntoResponse, route};
use hyper::{Server, StatusCode};

#[tokio::main]
async fn main() {
    let app = route("/hc", get(hc));
    let addr = SocketAddr::from(([127, 0, 0, 1], 9000));
    Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn hc() -> impl IntoResponse {
    StatusCode::OK
}

async-std ベースのもの

tide

https://github.com/http-rs/tide

async-std ベースでは、この tide というクレートが選択の第一候補になるかなと思います。周辺のエコシステムについても、Auth やミドルウェア、GraphQL との接続など、実装時に必要になる基本的な構成要素は揃いつつあるようです。私がこのクレートを知った2年ほど前は、まだまだ発展途上というイメージがありました。が、今見直してみるとかなりできあがってきていますね。

書き心地は README にあるコードを見ると下記のようになっています。actix-web などとは違い、アトリビュートでエンドポイントを登録する形式ではなく、 at という関数で明示的に登録するのが特徴的だと思います。

下記は、バージョン 0.16.0 で書いたサンプルコードです。

use tide::prelude::*;
use tide::Request;
use tide::Response;
use tide::Result;
use tide::StatusCode;

async fn hc(_req: Request<()>) -> Result<Response> {
    Ok(Response::new(StatusCode::Ok))
}

#[async_std::main]
async fn main() -> Result<()> {
    let mut app = tide::new();
    app.at("/hc").get(hc);
    app.listen("127.0.0.1:9000").await?;
    Ok(())
}

2年前に実は記事を書いたことがあります。今はかなりデザインが変わってしまっていて役に立たないかもしれませんが、根本の思想は大きくは変わっていないと思います。こちらの記事もあわせてどうぞ。

風景画

分野別でクレートをいくつか紹介

箇条書きにはなりますが、いくつか私が使ったことがあるものや、使えるであろうものを紹介します。紹介に留めます。

  • RDBMS, KVS との接続系
  • JSON
  • gRPC
  • GraphQL
  • HTTP Client
  • Cloud SDK
    • AWS SDK
      • rusoto: メンテナンスモードになっている
      • aws-sdk-rust: まだα版だが、今後はこちらが普及しそう
    • GCP SDK

小さめの Web アプリケーション開発で必要なライブラリは一通り揃い始めている印象です。ただ、まだまだツールによっては Rust サポートがあったりなかったりといった状態なのは事実です。Rust による開発を始める前に、まずご自身が利用しようとしているツールやミドルウェアの Rust サポートが存在するかを確かめることをおすすめしておきます。

Rust で Web アプリケーションを書いてみた経験

先日 Rust で Web アプリケーションを作った話で登壇しました

5月に CA BASE NEXT にて下記スライドを使って登壇しました。

先日の登壇では、私がプロダクトで Rust を使用してみた経験についてお話をしました。具体的には、局所的に高パフォーマンスが求められる箇所に Rust を使用したというものです。実行基盤は AWS Lambda です。注意点としては、とくに他の言語からのリプレースというわけではなく、最初から導入してみた話だということです。したがって、これから示すことは他の言語でも達成できる可能性があります。

Rust を使用した結果、私が観測した限りでは

  • メモリフットプリントを最小で済ませられた。Lambda はメモリサイズと CPU サイズは比例するので、つまり小さなコンピューティングリソースで十分性能を出せた。
  • 起動が速い(vs JVM)

などのメリットがあったように感じられました。

今日は、とくに Lambda と Rust の相性や、Rust をチーム開発に投入した際に課題に感じたことなどについてフォーカスしたいと思います。

AWS Lambda x Rust は比較的簡単に試せるはず

2018年、AWS Lambda に「カスタムランタイム」という機能が導入されました。それ以来、AWS Lambda では Rust が実質的にサポートされています。

AWS Lambda 上で Rust を動かすには、aws-lambda-rust-runtime というクレートを用います。Rust のアプリケーションが Lambda 上で起動できるようになります。

https://github.com/awslabs/aws-lambda-rust-runtime

導入自体はそこまで難しくなく、簡単なアプリケーションであれば、たとえば README にある下記のコードを書いてモジュールを Lambda にアップロードするだけで実行できます。なお、このサンプルコードには、シリアライザ/デシリアライザに関係するクレートでは実質的にデファクトスタンダードになっている serde というクレートも同時に使用されています。

use lambda_runtime::{handler_fn, Context, Error};
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> Result<(), Error> {
    let func = handler_fn(func);
    lambda_runtime::run(func).await?;
    Ok(())
}

async fn func(event: Value, _: Context) -> Result<Value, Error> {
    let first_name = event["firstName"].as_str().unwrap_or("world");

    Ok(json!({ "message": format!("Hello, {}!", first_name) }))
}

AWS Lambda のランタイムは tokio をベースとしています。使用するその他のライブラリも tokio ベースのものを選ぶ必要がある点に注意が必要です。もっとも、現在 Rust でエコシステムがもっとも充実しているランタイムは tokio ですので、対応するクレートは多いはずです。

最近 tokio はメジャーバージョンが上がり 1.0 系になりました。もちろん、AWS Lambda ランタイムは 1.0 系の tokio にも対応しています。今から新規でプロジェクトを始める場合には、tokio 1.0 系を安心して利用することができるはずです。

自分が Rust を教える必要がある

教師のイメージ

今回私は、Scala を普段書いているチームメンバーに Rust を使ってもらうというチャレンジを同時にしました。

基本的なメンタルモデルと言うか、コードの書き方は Scala と Rust とではかなり似通ったところがあります。なので、コードを書いている限りではそれほど大きく困っている様子は見受けられませんでした。

それより大変そうだったのは Rust の標準ライブラリの使い方やエコシステムのキャッチアップでした。JVM 系のエコシステムをいかせる言語ではないのでゼロからのキャッチアップが必要であったことと、エコシステムが成熟しているわけではないため、日夜状況が変わるというのが大きな苦労ポイントでした。

Scala は JVM 上で動作する言語であり、Java の既存の資産をいかしながらアプリケーションを構築できるのですが、Rust はそれらとはまったく違うエコシステムをもちます。まず、この点がキャッチアップが必要なポイントでした。

Rust はまだまだエコシステムが成熟している言語とは言えません。したがって、使用中のクレートにある日突然大きな変更が入り、クレートのバージョンを上げると不意にコンパイルエラーを吐き出すようになるということはままあります。このあたりのキャッチアップもまた、苦労したポイントだったようです。

Rust をチームに導入するためには、やはり自分がすべてを教えていく気概が必要だと思いました。文法の質問もそうですし、こうしたエコシステムに関連する質問にも答えられる必要があります。このあたりは、私自身が Next Experts に就任したタイミングで、社内でハンズオンを開くなどして対応しました。

Rust を導入したい場合には

Rust を使用するとよさそうなユースケースがあり、チームで Rust を導入したいけれど、どうすればよいかわからないという方は多いかもしれません。2つご紹介できることがあるので、紹介させていただきます。

まず最初のとっかかりとしての入門資料です。実はここ数年で Rust に入門するための資料はかなり充実してきました。そうした資料がどういう特徴をもっているかについて以前まとめた記事があります。そちらをご覧いただくとよいかもしれません。

また、先日のことにはなりますが、エウレカ社にご招待いただき Rust のハンズオンを実施させていただきました。Rust というプログラミング言語を軽く紹介し、文法を軽く知ったあとに実際にアプリケーションを作ってみるという内容を3時間で行いました。

もしご要望があれば、今後もこのようなハンズオンを行うことは可能です。ご興味があればぜひお問い合わせください。

まとめ

Rust で Web アプリケーション開発をする際の現状について紹介しました。みなさんのお役に立てば幸いです。


  • [^1]: CA BASE NEXT は、サイバーエージェント社内の若手エンジニア/クリエイターの登壇を目的としたカンファレンスです。登壇するのも若手、準備するのも若手というくくりで開催されたカンファレンスでした。サイバーエージェントには若手社員の活躍に強くフォーカスする文化があり、このカンファレンスもその一環として行われました。
  • [^2]: 実はもうひとつ手があります。main 関数を async 化せずとも、自身で非同期処理ランタイムに近い実装を行ってしまえば問題は解決できたりします。具体的にはスケジューラを実装し、非同期 API を提供して…といった形が考えられます。が、非同期処理ランタイムは実装が非常に難しく、巨人の肩に乗るのがもっともよい戦略だとは思います。