ISUCON8にRustで参加するためにしたこと
背景
- ISUCONとは、与えられたWebサービスをいい感じにスピードアップするコンテストである。
- いい感じに最適化すさえすれば言語は問われない。ということは、最初に提供される参照実装の書かれた言語を使うのが得策である。
- 複数の言語で参照実装が提供されることはあるが、Rust版が提供される可能性は高くない。
まず、ISUCONではないコンテストをする覚悟を持つ
http://isucon.net/archives/52012898.html
競技の成り立ちにも由来しますが、ISUCONは参加して学ぶことではなく「勝つこと」が前提となっているイベントです。
とある。↑に書いたように参照実装でない言語を好んで使うということは割とこれに反しているし、ISUCONで本来得られる経験の一部を「IShokU CONtest」の経験に変換することになる。(オペレーション担当が必死にログを見ている傍らでひたすらコードを右から左に移植する)
「そうは言っても、私はこの言語が好きなんだから仕方ない」と思うしかない。
いずれにせよ、本来の勝利条件を捨てているので勝利条件を設定する必要はある。ハッキリと宣言したわけではないが、今回は「Rustを一部でも使って点数に貢献する」といったところが暗黙の勝利条件になっていた気がする。ここは凡そ達成されたと思う。
チームメンバー集め
社内にRustに興味があるエンジニアがいたので、その人達に組んでもらった。とはいっても、Rustでアプリケーションを書けるレベルまで練習する余裕はなかったので、一人で書くことになってしまった。
フレームワークの選定
フレームワークをくまなく漁ったわけではないが、昨今のRustではActix-Webを使うのが正解っぽい雰囲気がある。
5月にあった社内ISUCONでもRustで参加し、このときはGothamを使ったが、これはあまり良くなかった。Gotham自身の設計はよくできているが、ミドルウェアのインターフェースがあっても肝心の実装がないみたいな状況が多く、Actix-Webに比べると心許ない。
逆にActix-WebはRustでサーバーを書くにあたっての最低限(クッキーセッションミドルウェア、redisセッションミドルウェア、グレースフルシャットダウン、JSON APIのためのアダプタなど)が揃っていてよく出来ているが、個人的には以下のような不安点もある。
- Actix-Webはtokioベースの非同期処理を採用しているが、実はその上にもう1枚Actixというレイヤが挟まっている。Actixはアクターフレームワークで、正直このレイヤがどんな仕事をしているのかはあまり詳しくない。Actix-WebはActixのレイヤをそこまで露出していないが、たまに困ることがある。
- 歴史的な事情からか、ハンドラのインターフェースの決定版がなく、APIごとに少しずつ異なっている。たとえば
Responder
トレイトの他にAsyncResponder
トレイトもあるが、Responder
トレイトでも非同期処理を書けるし、現在の標準的なハンドラ挿入インターフェースであるroute
メソッドではResponder
が想定されている。
テンプレートエンジンの選定
5月の社内ISUCONではHandlebarsを使ったが、これは失敗で、コンパイルに時間がかかるのにHandlebarsまわりのミスは実行時までわからないことが多かった。(そもそもこいつはJavaScript用のテンプレートエンジンの移植なのでさもありなん)
今回はAskamaというテンプレートエンジンを採用した。これはJinja2風の記法で、Rustコードにコンパイルしてから使用する。テンプレートパラメーターも、「テンプレートごとに別々の構造体にマップしてから渡す」というRustイディオマティックな方法でできるので圧倒的に型に守られる。また、Actix-Web連携機能もあり、有効にしておくとテンプレート構造体がそのまま Responder
になってくれるので、とても便利だった。
ちょっと運が悪かった点としては、今回対象となっているHTMLがVuejs用のやつで、Jinja2記法と衝突して面倒だった。
DB・コネクションプール
まず、不本意なことにISUCONではPostgreSQLではなくMySQL(またはMariaDB)ばかり使われるそうなので(日本のWebサービスの傾向がそうらしい?)、基本的にMySQLを想定して準備する。
MySQL用のDBライブラリとしては最もポピュラーな mysql
を使い、コネクションプールにも最もポピュラーな r2d2
を用いることにした。
この組み合わせなら、さらに著名なORMの diesel
を載せることもできるが、ISUCONではORMを使わずクエリを直接書く傾向にある (参照実装もそうだし、改善する上でもクエリをいじる方が便利) ということで、使わないことにした。
この部分の選定では、ORMを使うか否かのほかに難しい選択がひとつある。それは、非同期に対応させるかどうかである。特にコネクションプールの r2d2
とORMの diesel
には非同期の代替品がない。それらの作者が非同期に対応しない理由はissueから伺い知ることができ、以下のように推測される。
- Rustの非同期まわりの基礎部分のインターフェースが確立していない。
- たとえば、
Future
のインターフェースは0.1から0.2までで大きく変わった。単にAPIが変わっただけではなく、コンテキストを陽に持ち回すようになったので、全体的な設計変更が必要になる可能性がある。 - その上、async/awaitの対応のため、(0.2をほとんど見捨てる形で0.3が導入され) 0.2から0.3まででさらに変化した。Pinという特殊な参照型が導入された上に、
Future
が標準ライブラリに移管された。 futures
は汎用非同期ライブラリだが、その上の非同期I/Oライブラリであるtokio
にも大きな変化があった。元々のランタイムであったtokio-core
が廃止され、新しいランタイムライブラリであるtokio
に移行されるようになった。特に大きな変更として、シングルスレッドからマルチスレッドへの移行がある。tokio-core
は1:Nの非同期モデルなのに対して、tokio
はN:Mの非同期モデルである。そのためtokio
への移行では関連するデータ型をスレッドセーフなものにアップグレードする必要がある。
- たとえば、
- 現状で実装しようにも、現状の非同期エコシステムではコネクション系のAPIを適切に設計する準備ができていない。
- 特に、現状の非同期エコシステムは参照との相性が悪い。しかし、コネクションで
conn: &mut Conn
を受け渡すようなパターンを回避しようとすると、Conn
を受け取って(Result, Conn)
を返すといったパターンになりがちで、これは皮肉にもRustイディオマティックではないし利便性も低い。- async/awaitの導入により非同期における参照の問題はかなり解決される見込みなので、あと少しの辛抱かもしれない。
- 特に、現状の非同期エコシステムは参照との相性が悪い。しかし、コネクションで
そもそも、RDBに貼るコネクションが同時10000接続とかそういうことはしない。 (postgresとかだと1コネクション1プロセスらしいので、それをやるとサーバー側が大変なことになりそう) たとえば10接続くらいだったら、同じ数のOSスレッドを用意してやればイベントループをブロックせずに捌けることになる。
さて、そういうとき (OSスレッドを使うことで、非同期処理の一部で同期的な処理をする) には以下の2つの選択肢がある。
futures-cpupool
。専用のCPUプール(OSスレッドのプール)を持っておき、そこに同期処理をオフロードする。今回はこちらを使った。tokio_threadpool::blocking
。tokioのランタイムが所有するスレッドプールのスレッドをロックする。別スレッドに移動せず、その場で同期処理を行うことができる。イベントループはロックされていないスレッドに移管されるので、非同期処理が間違ってブロックされることはない。- Actix-Webが
tokio-threadpool
を使っていないため、こちらは使えなかった
- Actix-Webが
ちなみにDBクライアントレベルでは非同期に対応しているものもある。たとえば mysql
ライブラリの作者は mysql-async
というライブラリも作っていて、同じ作者だけあってインターフェースも似通っている。(5月の社内ISUCONのときはこれを使った)
(もう1点、非同期とかとは関係なく困った点として、手元のMacBookに入れたmysql8のサーバーと相性が悪く接続するとpanicになるという問題があった。結局mysql5系列を入れたら治った)
redis
redisなどのオンメモリキャッシュストレージもISUCONでは頻出とのことでredisのことも考えた。基本的な方針は↑と同じだけど、オンメモリキャッシュの目的上、もっと非同期で繋いでバシバシ捌けるほうがいいのかなと思って、迷っているうちに当日が来てしまった。結果、redisが本質じゃないっぽい問題で、redisを使う機会自体が来なかったので結果オーライ。
ちなみに状況としては、事実上の標準である redis
が最近非同期接続をサポートしたので、その部分の選定では困らなさそう。ただ、非同期接続のほうは r2d2
とかには対応していないのでやはり悩ましい。自分でプーリングする?同期的に使う?それともプーリングをしないで戦う?
非同期対応のクライアントとしては他に redis-async
というのもあり、5月の社内ISUCONではこっちを使っていた。
セッションミドルウェア
ここが肝で、今回はRack::Session::Cookie
互換のクッキーセッションミドルウェアを自作した。別にわざわざRubyのMarshalをデコードしてやる義理はないので、JSONフォーマットの互換を書いた。
なぜ互換である必要があるか?それは、Rubyで書かれた参照実装から少しずつRustに移植していくためである。「一気に移植して、移植しきれなかったらドボン」みたいな怖い真似をわざわざする必要はないので、このようにした。こんな互換ミドルウェアは需要が少ない(真面目に作るとN2種類必要になる……)ので、諦めて自作するのがよい。
ISUCONではRuby実装があってSinatraで書かれているのが相場のようなので、そういう想定で互換ミドルウェアを書いた。↑にあるようにJSONフォーマットにする必要があるが、これはRuby側を1,2行修正すればすむ。
「一部だけ移植なんて甘え」という声はあるかもしれない。ちなみに「ちょっとずつ移植するなら、セッション共有の必要があるじゃん」というのは5月の社内ISUCONの当日に気付いた話で、その時はかろうじて全部移植した。(その時は2人チームで相方のagatanはRustが書ける)
過去問を解く
過去問を解くといい感じにノウハウが溜まるし、何よりもいい感じのプロジェクトテンプレートができてよい。上記のセッションミドルウェアも当然、過去問を解きながら作った。
愛と気合で移植する
どうせ、サーバーサイドのための便利関数もミドルウェアもデザパタも出揃ってないので、冗長でもとにかく書くしか勝利への道はない。
移植しながら最適化するか、移植してから最適化するかは悩ましい。今回はRustでアプリを書けるメンバーが自分しかいなかったので、「Rubyで最適化してから移植する」とか「Rubyで最適化して、それとは別にRustに移植して、Ruby側の最適化を参考にRust側も最適化する」とかになってしまったので、そこは反省が必要だった。
それはそれとしてRustで最適化した部分で点数が上がったりしたのでちょっと嬉しかった。しかし、点数がガチャであることが後に発覚してちょっと悲しかったりした。 /api/events
の内容を一通り移植してN+1を潰すのはできたので、あとはapiのつなぎ込みという感じだったが、まあそのつなぎ込みが結局大変という……
その他の話
今回のISUCONではRustの参照実装が出るかもという話があったが、「どちらかというと出ないでしょう」という仮定のもと、チーム全員書けるRubyから移植することを始めから確定させていた。結果、やっぱり参照実装は出なかった。