🦀

RustでWebバックエンドを書き始めてから1年くらい経った

2023/12/31に公開

はじめに

僕はDeno Land Inc.でDenoを利用したサーバレスエッジホスティングサービスのDeno Deployを開発するチームに所属しています。OSSのほうのDenoのメイン言語はRustで、Deno Deployのバックエンドも同様にRustで書かれています。

今年のアドベントカレンダーで一休さんから以下の記事が公開されましたが、日本でもRustをWebバックエンドの言語として採用する企業がじわじわと増えてきている印象があります。

https://user-first.ikyu.co.jp/entry/2023/12/25/132215

Deno DeployのバックエンドをRustで開発してきて、RustでWebバックエンドを書くことのメリットやデメリットをいくつか感じたので、この記事で紹介したいと思います。

https://x.com/yusuktan/status/1739609658619552217

Deno Deployの構成

まず、ざっくりとDeno Deployのバックエンドの構成を紹介します。

多くのコンポーネントがありますが、ここではどのようにRustを利用しているのかイメージをつかんでいただくのが目的なので、主要なコンポーネント3つに絞って説明していきます。

コントロールプレーン

まず、ユーザーがアクセスするダッシュボードであるdash.deno.comやそのCLIであるdeployctlが利用するAPIをHTTP経由で提供するコンポーネントがあります。HTTPリクエストを受け付けて、データベースを参照・更新したり、他のコンポーネントを呼び出したり、といったような役割を担います。Deno Deployのコントロールプレーンの中核ですが、一般的なWebサービスのバックエンドとやっていることは似ていると思います。

proxy

次にproxyコンポーネントです。これは、デプロイされたユーザーのコードがアクセスされたときに最初に処理が行われるコンポーネントです。たとえば、preactベースのWebフロントエンドフレームワークfreshの公式ページ:
https://fresh.deno.dev
はDeno Deployでホスティングされていますが、このURLにアクセスするときのリクエストはこのproxyコンポーネントを通過します。

proxyの最も大きな仕事は、fresh.deno.devというドメインから、対応するデプロイのIDを特定し、マッピングすることです。ちょうどネットワークにおいて、ルーターがIPアドレスをもとにルーティングテーブルをlookupするのと似たような感じです。また、TLS終端もこのコンポーネントで行われますが、より高速にリクエストに応答するため、TLSコネクションがまだ確立しきっていない段階からV8 isolate(詳しくは後述します)のbootを開始する(prewarmと呼んでいます)ことも行います。TLSコネクションの確立プロセスに割り込む必要があるので、Rust製のTLSライブラリであるrustlsを使った細かい制御を行っています。ここは通常のWebバックエンドよりレイヤーが低いところになるかなと思います。

manager

最後にV8 isolateの管理を行うコンポーネントです。とりあえずここではmanagerと呼ぶことにします。V8 isolateはユーザーがデプロイしたJavaScript/TypeScriptのコードが実行されるところで、isolate同士は隔離されていてセキュアに保たれています。さきほどのproxyのところでprewarmを紹介しましたが、prewarmリクエストを受けたmanagerは、少しあとで来るリクエストを処理するためのV8 isolateの立ち上げを開始します。外部からネットワーク経由でJavaScript/TypeScriptコードをとってくる必要があるので、ちょっと時間がかかります。少しでも時間を無駄にしないためにprewarmということでちょっと早めに立ち上げを開始しているということです。

立ち上がったV8 isolateに対して、死活管理、CPU・メモリ・ネットワーク利用状況などの監視とメトリクス収集、アプリケーションログ(console.logで吐かれたものなど)の収集などを行うのがmanagerの重要な仕事です。さきほどのproxyと同様、一般的なWebバックエンドサーバーがやるような仕事よりはレイヤーが低いかなと思います。

利用しているcrate

rustlsを直接利用していることを紹介しましたが、他に利用しているcrateをいくつか書きます。

  • tokio 非同期ランタイム
  • hyper httpライブラリ
  • routerify hyperの上に薄いルーティングとミドルウェアレイヤーを提供するライブラリ
  • sqlx SQLライブラリ。ORMは使わず生でSQLを書いている
  • futures Futureに関する十徳ナイフ。Future, Streamをこねくりまわしたいときはまずこのcreateのドキュメントを読み漁る
  • chrono 日付・時刻ライブラリ
  • tracing 構造化ロギングライブラリ。tracing-opentelemetryなどの関連ライブラリと組み合わせてOpenTelemetry準拠のログ・トレースを実現
  • moka 並行キャッシュライブラリ。パフォーマンスクリティカルな部分でのキャッシュに活用している
  • anyhow 安心と安全のdtolnay氏製ライブラリ
  • thiserror 安心と安全のdtolnay氏製ライブラリ
  • serde 安心と安全のdtolnay氏製ライブラリ

Rustで開発していてつらいところ

よかったところの前につらいポイントを先に書きます。

コンパイル時間がかかる

まず真っ先に思いつくのは、コンパイルが遅いことと開発中にマシンリソースをかなり食うところです。まず前提として、Deno Deployの各コンポーネントはモノレポで管理されていて、基本的にはコンポーネント単位でsub crateに分割されています。そのため、ある1つのコンポーネントのみに変更を加えた場合には、コンパイル時間はさほどかかりません。

しかし、いろいろなコンポーネントから利用される関数やトレイトなどが定義されているsharedというcrateがあり、ここに変更を加えたときのコンパイル時間が悪夢になります。コンパイルのたびに毎回コーヒーを淹れにいくことができるようになります。sharedに依存する他のcrateすべてが再コンパイルされてしまい、実質フルビルドみたいな感じになるためです。

これに関しては、いろいろなものをsharedに詰め込むのではなく、適切な粒度でのcrate分割をするようにすれば緩和されるとは思うのですが、なかなかそれをする時間がとれていないという状況です。

開発中もrust-analyzerががんばってコード解析をしてくれています。ありがたいですが、いつのまにかメモリを4GBくらい消費していたりします。僕は普段M2 MacBook Airで開発していて、メモリが24GBしかないので結構カツカツです。メモリもりもりのM3 Maxがほしい。

Async Rustが難しい

あとは、Async Rustが難しいです。さすがに1年も仕事で書いているとよくあるケースで困ることはなくなりましたが、最近出くわしたのは、tokio::test を使って書いているテストで、特定のケースで意図した通りのリソース管理をすることができない、という問題でした。少し細かい話になりますが、ややこしい話であるということをお伝えするため詳細を以下に書きます。(雰囲気だけ掴んでいただければOKです)

テスト用に EphemeralDatabase という構造体を定義していて、生成のタイミングで一時的なデータベースを生成し、 Drop のときにそのデータベースを削除するようにしています。

struct EphemeralDatabase { /* ... */ }

impl Drop for EphemeralDatabase {
  fn drop(&mut self) {
    // データベースを削除する
  }
}

データベース削除を行うためにsqlx::Executor::executeを呼び出しますが、これはFutureを返してくるので、処理を開始・完了させるためにはawaitする必要があります。しかしDropはasync関数ではないので直接的にはawaitすることができません。そこでワークアラウンドとして、以下のように別スレッドを立ち上げ、そこで新しいtokio runtimeを立ち上げ、その中でsqlx::Executor::executeを呼び出してawaitするということをしています。

impl Drop for EphemeralDatabase {
  fn drop(&mut self) {
    std::thread::scope(|s| {
      let handle = s.spawn(|| {
        tokio::runtime::Builder::new_current_thread()
          .enable_all()
          .build()
          .unwrap()
          .block_on(async move {
            let mut conn = sqlx::postgres::PgConnection::connect(&ENV.database_url)
    .await
            .unwrap();
            conn.execute("DROP DATABASE <db_name>").await.unwrap();
          })
      });

      handle.join().unwrap();
    });
  }
}

これは基本的には問題なく動き、またテストでしか使わないものなので、強引なワークアラウンドでも問題なしとしていました。

ここで新しく、データベース削除処理のあとに、あるtokio taskが終了したということを待ち受けるようにしたいという要件が出てきました。「あるtokio task」はデータベースに関する処理を行っていて、データベースが削除されたらconnection closedということで終了するようになっているものです。

このtaskの終了を待ち受けるためには、taskを生成するときに呼び出すtokio::spawnが返すJoinHandleを保持しておき、それに対してawaitすれば良いのですが、やはりDropはasync関数ではないので直接的にはawaitすることができません。そこでさきほどのワークアラウンドと同様に、別スレッド内で立ち上げたtokio runtimeの中でawaitをすればいいのでは、と一瞬思いますが、あるruntimeに紐づいたtaskを別runtimeで扱うことはご法度です。

他の方法として、別スレッド・別runtimeを立ち上げるのではなく、tokio::runtime::Handle::currentを使って現在のruntimeに対するハンドルを取得し、block_onメソッドを使うというやり方が思いつきました。この方法であれば、別のruntimeを立ち上げることなく、現在のruntime上でFutureを待ち受けることができるようになります。

しかしここでtokio::testマクロの「デフォルトではcurrent_thread runtimeが立ち上がる」という仕様が絡んできます。block_onのドキュメントにも記載されているように、current_thread runtimeを利用している場合、block_onをしてもIOドライバーとtimerドライバーを進めることができません。

これに対処するためには、tokio::testの代わりにtokio::test(flavor = "multi_thread")を使えば良いのですが、EphemeralDatabaseを利用しているテストケースが大量にあり、その全てを置き換えるのは少し負けた気になる(?)ため、断念しました。
最終的にはtokio taskの終了を待ち受ける必要がなくなるように、他の場所を修正することで対応しました。

長くなりましたが、つまりAsync Rustはこのようにややこしい状況になることがある、ということが言いたかったです。

Rustで開発していてよかったところ

つらいポイントを長く書いてしまいましたが、RustでのWebバックエンド開発について、個人的にはかなり満足しています。

まずよく言われる「Rustは学習コストがかかる」という点ですが、これはDeno自体がRustで開発されていて、Rustが書ける、書きたいというメンバーがもともと揃っているということから、僕たちの場合は特に問題になっていません。一般的にはRustは学習が大変で、普通のWebバックエンド開発をするにはオーバーキルなのではないかという意見も根強くありますが、個人的にはRustがもつ様々な利点を考慮すると、十分採用を検討する価値があると思っています。

Resultによるエラーハンドリング

まず、Rustでは失敗する可能性のある処理はResult型で表すということが標準ライブラリ、コミュニティのcrateを通して一貫しています。さらに、言語機能として、エラーの場合にそこで処理を中断して呼び出し元にエラーを返すことのできる?演算子が提供されており、流れるようにストレスなくエラーハンドリングを行うことができます。

代数的データ型による強力な型の表現力

enumによるデータ構造の表現力はビジネスロジックを表現する上で非常に強力な助けになります。今日では代数的データ型の有用性は広く知られていて、対応しているプログラミング言語も増えてきているとは思いますが、とはいえまだまだ「AまたはBのいずれかの値をとる」ということを型にエンコードすることが不得手な言語も多くあるかと思います。ビジネス上の要件を正確に型に落とし込むことができ、さらにコンパイラによる強力な網羅性検査などの静的検査を受けることができるのは、Rustの大きな強みだと言えます。

時間に関する型表現の堅牢さ

時間の「幅」を表現する型としてのstd::time::Duration、時間の「点」を表現する型としてのchrono::DateTimeが地味ですが強力です。

もし時間の「幅」が 42 という単なる数値で管理されていたら、これって秒なの?分なの?といった暗黙の文脈が必要です。あらゆる「幅」を統一的にstd::time::Durationで表現することで、単位を意識することなく利用することが可能になっています。

また、時刻を表現するchrono::DateTimeは型変数としてchrono::offset::TimeZoneをとるようになっていて、chrono::DateTime<Utc> のようにタイムゾーン情報を型レベルに含んだ時刻を表現することができます。これは直接serdeによるシリアライズ・デシリアライズをすることもできる(RFC3339形式の文字列にシリアライズされます)し、sqlxによるサポートもされているので、chrono::DateTime<Utc>のまま直接データベースとの出し入れをすることもできます。タイムゾーンの取り違えが起こりようがなく、とても安心です。

Shared XOR Mutable

RustのShared XOR Mutable、すなわち「不変参照と可変参照は同時に存在することはできない」という原則は、極めて有用なものです。Deno Deployの構成を紹介する際に軽く触れたmanagerコンポーネントでは、とても多くの非同期タスクが共有データ構造にアクセスしながら全体の役割を遂行していきます。Rustコンパイラの強力な支援によって、万が一にでも共有データ構造に対して複数のタスクから同時にアクセスをしてデータ競合が発生するようなコードを書いてしまった場合には、コンパイラがそれを検知してくれます。Webアプリのバックエンドで考えると、例えばユーザーからの複数のリクエストに対して共有されるようなデータ構造があったとして、リクエストハンドラ内でそのデータ構造に対して何か書き換え操作を行うようなコードをうっかり書いてしまったら、Rustであればコンパイル時点で弾いてくれます。

ドキュメントが充実

さらに、Rustはcrateのドキュメントが極めて読みやすいのも良いポイントです。基本的にそれなりに利用されているcrateはドキュメントが充実しており、exampleも多く書かれていることが多いように思います。例えば複数のFutureに対してPollしてどれか1つがReadyになったら先に進む、というようなケースで便利なtokio::selectマクロのドキュメントを見てみると、「どのFutureからpollするかは公平性のためにランダムに選択されるよ」とか「どれか1つがReadyになった時点で他のFutureはキャンセルされるから、キャンセルしても大丈夫なFutureを渡すようにしてね」などといった細かい挙動に関する注意が丁寧に書かれています。ドキュメントがソースコードと紐づいていて、サンプルコードもコンパイラによってチェックされることで、常に最新の情報が提供されているというのもGoodポイントです。

パフォーマンス・メモリ効率

Rustの強みとしてよく語られるパフォーマンスについては、Deno Deployは最初からRustで書かれているので他の言語で書いた場合と比較できないためなんとも言えません。しかしある程度雑に書いてもそれなりのスピードで動いてくれるだろう、という安心感はあります。

また、Deno Deployの構成で紹介したproxyコンポーネントでは、デプロイされているすべてのドメインとデプロイIDのマッピングを保持しておく必要があり、マッピング情報のデータは可能な限りコンパクトにしたいという要請があります。そこで、デプロイIDを保持するためのデータ構造として、標準のStringではなく、デプロイIDのデータ特性を考慮に入れたよりメモリ効率の良いデータ構造を自前で用意しています。このように、ケースバイケースでカリカリにチューニングした型・実装を用意するということがかんたんにできるのもRustの良さだと思います。

RAIIによるリソース管理、メモリリークの防止

あとは所有権、RAIIによるリソース管理のおかげで、メモリリークも他のプログラミング言語に比べると引き起こしにくいのではないかと感じています。ただ、tokio::spawnによって開始されたtaskは、そのtaskに対するJoinHandleがドロップされたとしても、task自体が完了するまでは生き残り続けるというところには注意する必要があります。うっかり寿命が長い(またはずっと終わらない)taskをリクエストごとに立ち上げるなどしてしまうと、メモリリークにつながります。

まとめ

Deno Deployのバックエンド開発チームでRustを1年以上使ってきて感じたRustのメリット・デメリットを紹介しました。

もともとRustが大好きだったというのもあり贔屓目が入っている可能性は否定できませんが、仕事でRustを書くようになってからさらにRustが手に馴染むようになり、いくつかのつらいポイントはありつつそれを上回る大きなメリットがあると感じています。今後日本でもRustの利用がますます広がっていくことを期待しています。

Discussion