たごもりすメモ

コードとかその他の話とか。

#isucon を支えた技術: ベンチマークmaster/agentの構造とnode.jsの話

支えた技術ってほど大層なものでもないんだけど、なんとなくカッコいいのでシリーズ名にしてみようと思った。

で、表題の件。 #isucon のベンチマークツールは1台のmasterと複数台のagentという構造になっていて、agentはベンチマークツールの負荷を複数台のサーバに分散させるために存在する。各agentは交換可能で、仮にagent用サーバが1台壊れたら、別のagentに負荷を振り向けるよう設定を書き換えるだけでいい。役割としてはだいたいこんな感じ。

master
各チームのスコア表示、およびベンチマークの起動・停止操作の提供、agentへのベンチマーク実行状況の問合せ、agentへのベンチマーク起動・停止指示、ベンチマーク実行結果の受け取りと保存
agent
ベンチマーク処理の起動・停止、ベンチマーク実行状況の提供 (すべてJSON RPC)

masterとagentは両方とも node.js と express で書かれたWebアプリケーション。なんでnodeなのよってのは後述。

なお master へのベンチマーク結果(スコアやチェック実施結果)の保存はベンチマークスクリプト(bench.js)が直接 master のJSON RPCを叩いて行っている。このため agent は自分が起動した bench.js の実行結果を見張らなくてよくなり、状態管理がシンプルになる。またagentはベンチマーク用の各サーバに立っていて、サーバ1台あたり(つまりagentひとつあたり)通常は3つのチームが割り振られる。つまり最大3つのベンチマークが同時に走ることを想定している*1。関係を図にすると下図のようになる。

基本的それぞれの関係はすべてHTTPで、agentがbenchを起動するところだけforkしてコマンド実行となっている。また bench は単一のスクリプトで、コマンドラインから対象チームを指定して起動すれば動く。*2

master.js

フレームワークにexpressを使いnode.jsで書かれたWebアプリケーション。データはMySQLに保存する。表示するWebページはスコアの一覧と各チームのベンチマーク起動・停止操作用を兼ねており、21チーム用としてずらっと出すとこんな感じ。

画面自体は静的なhtmlとして作ってあって*3、表示の更新はすべてjavascriptでやっている。JSON RPCでチームごとに状況を master に問合せ、結果が返ってきたら該当チームのセルの表示を更新。これを各チームに対して行う。のを、表示されている数だけ行う。表示の更新間隔を短くするとリクエスト数が割と増えてくるのであまり油断できない。*4

またagentへのRPCがとにかく多い。チームの状況問合せが1度来ると、LATEST SCOREおよびHIGHSCOREは手元のMySQLにクエリした結果を返せばいいが、ベンチマークの走行状態は問合せのたびにagentにRPCで状況確認のリクエストを送り、並列実行したMySQLのクエリ結果とステータス確認RPCの結果が揃ったところでブラウザに応答を返すようになっている。ベンチマークの起動・停止ももちろんagentに対するRPCを伴う。

agent.js

フレームワークにexpress以下略。agent自身はデータストアを持たない。またブラウザから見ることを想定した機能は一切持っておらず、JSON RPCでの通信専用のWebアプリケーション。

ベンチマークを起動するたびにそのpidを覚えておいて、走行状態の問合せが来たら ps -p pid の結果を見て返す。ベンチマーク停止は該当pidをkill。ベンチマークの起動時はベンチマークスクリプトを外部コマンドとして起動している。どの処理もforkを伴う。

bench.js

これはただのコマンドラインから起動するようなスクリプト。チーム名(とオプションとして動作モード)を与えると、そのチームのサーバに対して一連のベンチマークおよび動作チェックを実行する。実行内容は以下のもの。

  1. 準備処理
    1. ページのトップ(パス /)を取得して最新の記事IDを取得
    2. 記事を新規にひとつ投稿
    3. 投稿後の記事を正常に取得できることを確認
    4. http_load に読み込ませるURLリストファイルを作成
  2. ベンチマーク&動作チェック
    • http_loadの起動(停止したら次の処理、結果の出力を実行)
    • 一定時間ごとのコメントのPOST、およびPOSTしたコメントの結果が反映されているかどうかの確認(POSTの1秒後)
      • コメント結果反映: コメント対象記事のページをGETし、コメント欄に投稿内容が存在するかどうか
      • コメント結果反映: トップページをGETし、サイドバーに対象記事があるかどうかどうか
      • コメント結果反映: ランダムに1記事を選択してGETし、そのサイドバーに対象記事があるかどうか
    • ランダムでページを取得してのDOM構造のチェック
  3. 結果出力
    1. 結果をまとめたJSONをひとつ作成
    2. ローカルファイルに書き出す
    3. (standaloneの場合) 標準出力に書き出して終了
    4. masterに対してJSON RPCで結果を送信
    5. 終了 (もしくはprepareに戻る)

上述の処理内容で、番号つきの項目は順序を守って実行され、そうでないものはすべて並列に走る。つまりベンチマークと動作チェックについてはすべて並列に走る。

これは、動作チェック自体も負荷走行中にやるのが当然(負荷がかかっているときに動作がおかしくなるようなものはチェック失敗にするべき)ということと、負荷走行によるHTTPアクセスとチェッカによるHTTPアクセスを同時に走らせることで識別を難しくし、チートを行いにくくする、というふたつの理由による。

なんでnodeなの

で、これらのツールを記述するのになんでnodeなの、ということ。

masterの処理性能

まず第一に node + express で作成したWebアプリケーションの性能が良い、という点。特に、重くはないけど大量のHTTP要求をさばく、という点で実に良い。なにを隠そうISUCONの参考実装アプリもデフォルトだとnode実装が一番性能がいいのだ。CPUをゴリゴリ働かせるようなケースだとまた色々あるけど、軽量なリクエスト&レスポンスを大量のコネクションに対して処理するのには向いている。

master.js の項に書いたがインターネットに露出した状態で稼動していたため、不測の事態として、いつどんなアクセスが来るかわからない状況にはあった。実際、参加者のご家族が自宅から眺めていたということもあったようだ、とあとで聞いた。
一人がブラウザで画面を開くと21チーム分の状況確認のリクエストが常にやってくることとなる。もし大量にクリックされるような場所にURLが貼られてしまったら、と思うと、やはりそれなりの負荷対策をしておく必要がある。*5

大量の非同期処理

これまでに書いてきた通り master/agent/bench のどれでも大量の非同期処理が走る。各構成ノード間でのRPCもだし、コマンド実行のかたわらで別途さらにHTTPリクエストをいくつも並列で送出し、レスポンスに対する処理はそれぞれ別個のもの、など、普通に書くとかなり頭が痛くなりそう。何度 fork しなきゃいけないか考えたくもない。

で、nodeだと非同期処理こんなにラクだよってコード例を書こうかと思ったがめんどくさくなったので省略。とにかくラクなので「ユーザからアクセスが来たらHTTPリクエストをみっつ投げてその結果を処理したいんだけどそれとは別にDBからもデータを引っ張ってきて最終的には全部まとめたあとDBに突っ込みたいんだけど、ユーザに返すレスポンスってべつにDBに突っ込むのを待つ必要ないんだよね」みたいなこと考えてる人はnode.jsを試すといいんじゃないかなーと思う。もしくは「スクリプト起動されたら10くらいのURLを別個に叩いて結果を持ってきた上で何かしないといけないんだけど結果によっては更にどれかのURLに関連した別のURLも叩かないといけなくて、しかも2分くらい結果をなかなか返さない奴とかもいるから手で順序制御とか書いてもいられないし」みたいな用途にも超便利だと思う。*6

JSON RPCが超ラク

今回は最初に master がブラウザ/agent/benchとの全通信をRPCでやると決めてしまってたので、その周辺の記述のしやすさというのも大事。node.jsでは(当たり前だけど)オブジェクトがすべてjavascriptオブジェクトで、expressがJSONでレスポンスを返す簡便な記法をサポートしているので、このあたりが超ラク。

例えばブラウザ向けにJSONを返すハンドラはこんな。最新の結果、ベストスコア時の結果、およびベンチマーク走行状態を並列で取得して、全部完了したらレスポンスをJSONで返している。*7

app.get('/status/:teamid', function(req, res){
  var latest = null, highest = null, status = null;
  async.parallel([
    function(cb){ getLatestResult(err, data){ latest = data; cb(null); } },
    function(cb){ getHighestResult(err, data){ highest = data; cb(null); } },
    function(cb){ getStatus(err, data){ status = data; cb(null); } }
  ], function(err, results){
    res.send({latest: latest, highest: highest, status: status});
  });
});

それに対応するブラウザ側のjsのコード(jQuery)。

$.get('/status/' + teamid, function(data){
  if (data.latest) {
    /* DOM update and ... */
  }
});

どちらのどこにもJSONエンコード/デコードとかめんどくさい話がない。jQuery側でも受け取ったらすぐにjavascriptオブジェクトとして扱える。また言語がサーバサイドとクライアントサイドで同じだから、シリアライズ/デシリアライズの境界で発生する値の変換などについてのめんどくさい問題も起きない*8

とにかくあちこちでRPCを記述しないといけなかったので、あんまり時間がなかったのもあるし、この記述性の高さは特筆モノの採用理由になったといってもいい。

なおexpressはレスポンスを返すときのJSONは適切に処理してくれるんだけど、現状ではリクエストとしてJSONがPOST/PUTされてきたときにはそれをどうにかしてくれたりしないので、自分で JSON.parse() にかけてやる必要があったりしてちょっと残念。その点、フォーム形式だと app.use(express.bodyParser()); しておけばオブジェクトから取り出せるので便利でなんとなく納得のいかないものを感じる。

またnode.jsとjQueryから縦横にHTTPリクエストを発行していると、たまにどっちのコードを書いているか混乱することがある。特にhttpリクエストのAPIは微妙に似ているが決定的に違う部分もいくつかあって混乱時にハマると抜け出すのに時間がかかった。 method: 'POST' として指定する場合と type: 'POST' として指定するのなんて、どっちがどっちとか覚えられないよ orz

結論

ISUCONでは管理系やベンチマークツールとしてもそれなりの量の処理を実装したけど、自分としてはかなり効率よく実装できたし、本番時にもこれらのツールのパフォーマンスが問題になることは皆無で、まあまあいい感じだったんじゃないかと思う。(コード品質には特にbench.jsにおいて問題が多々ある。それはまた次のエントリで。)

で、非同期処理や相互のRPCが絡む処理をさくさく書けたのはnode.jsのおかげでした。割とよかったよ。という話でした。

*1:サーバ障害とかが起きて割り振りを変えればもっと多くなる可能性はあったが、当日そのようなトラブルは起きなかった。基本的

*2:masterが上がってなければ結果を記録しに行くところで失敗するが、当日後に追加した standalone オプションを指定すると master に行くかわりに標準出力にダンプして終了する。またどちらにしろローカルファイルに結果を保存している。

*3:jadeテンプレートから生成してるけど、一度表示したら画面遷移は全く無し

*4:しかも当日はグローバルIPで稼動しててインターネットに露出してたので、誰かが大々的にアドレスを公開して大量アクセスが、みたいな状況もいちおう想定していた。

*5:参加者以外に見えないようにアクセス制限しとけばいいじゃん、という話はあるのだが、面倒だったし、遠隔地からスコアボードを眺めるという楽しみかたもこっそり想定はしていた。あんまり禁止したくなかったんだよね。

*6:なお、大規模なクローラの類になるとこのテの欠点はすべてクローラの数という要素に押し流されて最終的にHTTPアクセスおよびレスポンス解釈のパフォーマンスとバックエンドのDB等の負荷耐性あたりの問題になるようだけど詳しくないので省略。そこまで行くとnode.jsで書く意味はたぶん全く無い。

*7:実際のコードよりかなり綺麗に、例として、書いてあるけど……。

*8:正確に言うと全く起きないわけではない、と思う。いくつかのマイナーケースが記憶にある。