ISUCON での言語移植 (Rust)

ISUCON10 に続き今回の ISUCON11 でも初期実装の Rust 移植を担当したのでそのへんの話を書いてみます。

ISUCON とのかかわり

ISUCON4 から ISUCON7 までは選手として参加していて、何度か本選にも出場しました。ISUCON8 以降に参加しなくなった理由はいくつかあるんですが、Web のインフラやバックエンド界隈を盛り上げて学生の興味を惹く素晴らしいイベントだと思っています。そんなわけで ISUCON からはしばらく離れていたんですが、ISUCON10 では同僚が作問するということと新たな試みとして初期実装に Rust を加えると聞いて、Rust 移植担当に応募して採用されました。そして今回の ISUCON11 でも Rust か Ruby の移植に応募し Rust の移植を担当しました。

言語移植を担当するモチベーション

自分の場合は ISUCON が盛り上がってほしいことに加えて、自分の好きな言語が盛り上がってほしいというのが主なモチベーションです。自分は Rust や Ruby が好きなんですが近年の ISUCON は Go に一極集中していて、元々プログラミング言語自体が好きな自分にとって1つの言語にあまりに偏りすぎる状況はつまらないように感じます。ISUCON11 ではこの傾向が更に加速してしまって、本選出場者のうち9割が Go でそれ以外は Rust と Node.js (TypeScript) のみという結果でした。

isucon.net

Rust に限って数字を見ると ISUCON10 と比較して予選での利用者が大きく増えていて本選通過も2チームに増えたので、Go 一極集中になんとか対抗していきたいところです。

あとは移植作業を通じて色々な言語の差を感じられるというのも楽しみの1つです。Go ではこういう書き方をする・できるんだなぁというのを見ながら、Rust に移植するならどう書くか考えたり、また自分のあまり詳しくない TypeScript や Python ではこう書くんだなというのを眺めるのは楽しいです。

ISUCON での移植作業

問題のテーマが決まってオリジナルである Go の初期実装の原型があるくらいの段階から参加して、Rust に移植する上で十分なライブラリがあるか等の移植する上での問題点のチェックから始まります。この時点で気になった点は作問者にフィードバックして初期実装をどうするか決めてもらいます。ISUCON10 本選では gRPC を扱えるかというのがありましたし、ISUCON11 予選では JWT、本選では UUID *1 や zip の扱いをチェックしました。ある程度初期実装が固まってきたら Rust 版を書き始めて、同時に開発が進んでいるベンチマーカーを手元で実行して通るか確認したりブラウザ上での表示を確認したりしながら移植を進めていきます。その間にも問題の改定が進むので Go 版の初期実装に変更が入ったらそれに追従したり、ベンチマーカーの実装が進んでチェックが厳密になったら失敗するようになった箇所を直したりといった作業が続きます。ISUCON11 はかなり穏やかに進みましたが、ISUCON10 本選のときは https://github.com/isucon/isucon10-final/issues/136 ということがあってだいぶ緊張感がありました。 あとはコードレビューを受けたり、本番環境で Rust 版に対してベンチマークを実行して完走するかチェックしたり初期スコアを確認したりすればだいたい終わりです。

これは厳密には移植担当の仕事ではないんですが、ISUCON11 では事前の試し解きにも参加して、実際に動くポータルの環境とマシン1台を用意してもらってその時点での問題を解き、こういう改善を入れてこれくらいスコアが伸びたみたいなメモを残したり問題に対するフィードバックを書いたりして作問に少しだけ協力していました。自分の場合は移植の動作確認も兼ねて Rust に移植した状態で問題を解いてました。

Go から Rust に移植する上で大変だなと思うのは、ライブラリの細かい挙動の差はもちろんあるんですが、Go から Rust 特有の話としてはエラーの扱い、nil やゼロ値の扱い、ポインタの扱いあたりでしょうか。Go の場合どんなエラーが返ってくるのかの詳細が型の上でもドキュメントでも分からないことが多く、Rust のように細かく分類されたエラーのうちどれを拾えばいいのか分かりにくく感じることがあります。これと似た話で Go では失敗したり存在しなかったりするときにゼロ値を使うことがあり、Rust に移植するときに Go 実装を注意深く読んだりベンチマーカーが投げるリクエストを調べたりしないと Option<T> にすべきか単なる T にすべきか悩みます。また Rust は GC が無くライフタイムの概念のある言語なので、Go で自由奔放にスライスを扱ってるような実装を Rust でどう書くか悩んだりしました。具体例としては ISUCON11 予選問題のこのへんを見比べてみてください。

ISUCON で初期実装として Rust が提供されるようになったのは ISUCON10 が初であり、その移植を担当する上でどのフレームワークやライブラリを選択するかも悩みました。Web フレームワークとしては2回とも Actix Web を選んでいます。

actix.rs

他にも warp も検討したんですが、RubySinatra くらいの機能が標準で提供されているものとなると Actix Web かなぁということもあって決めました。ISUCON11 では Actix Web の beta 版が使われていることが気になった人ももしかしたらいたかもしれませんが、今更 Tokio v1 以前に揃えるのもなぁということで beta 版を選びました。

MySQL に接続するライブラリとしては ISUCON10 では mysql を選びましたが ISUCON11 では sqlx を選びました。ISUCON10 のときも sqlx は検討対象でしたが、まだよく枯れてない印象だったので避けました。ただ今回の ISUCON11 では使っても大丈夫そうかなと考えを変え、async/await に対応していて Tokio v1 への対応も終わってるということで sqlx にしました。Actix Web も sqlx も、ISUCON で利用する上で重厚すぎず、またデファクトスタンダードとして広く使われているものという基準で選んでいます。まだ始めてから2回目なので、もしライブラリ選定に疑問があれば声を上げてもらえればと思います。

sqlx の不具合

ISUCON11 本選の移植作業中に sqlx の MySQL 向け実装固有の不具合の影響を受けていることが発覚し、sqlx の実装を読みつつワークアラウンドを入れて不具合を回避するという対応をしました。実は予選のときにも同じ不具合が発生していたんですが見落としていました。競技中のスコアには影響しないと思いますが、競技中に panic のログが出ていて戸惑ったチームがいたら申し訳無いです。何が起きていたかについては本選では https://github.com/isucon/isucon11-final/blob/0bd78572393513f0b6534365f378c34595e2463e/webapp/rust/src/db.rs に説明を書きました。過去問として利用されるときに混乱させないよう、予選のほうにも同様の修正を後日入れておきました https://github.com/isucon/isucon11-qualify/pull/1454

おそらくこうすれば直るだろうというパッチを sqlx に出しています https://github.com/launchbadge/sqlx/pull/1439 が、本当にこの修正でいいのか現時点では不明です。

まとめ

ここ2回の ISUCON での言語移植について書いてみました。移植作業には移植作業の楽しさがあります。今後も ISUCON が続くことを願っていますし、選手として参加する気持ちが復活しない限りまた Rust か Ruby の言語移植に応募しようと思っています。

*1:最終的には ULID に変わりました

unnest を使って楽に bulk insert する

プライベートでよく PostgreSQL を使っている人の MySQL 不便シリーズ。

ActiveRecord のような高機能な ORM あるいはクエリビルダを使っていると bulk insert するクエリをライブラリがいいかんじに組み立ててくれるが、そんな高級なライブラリを使わずにもっとシンプルなもので済ませたいことがある。 そんなときに bulk insert をしたくなった場合、クエリを組み立てて変数を bind して…… というのを自分でやるのは結構面倒である。

PostgreSQL だと配列型があり unnest() で配列を行に変換できるので、これと insert into select を組み合わせると bulk insert するクエリを簡単に組み立てられる。

eagletmt=> select unnest(array[1,2,3]) as x, unnest(array[4,5,6]) as y;
 x | y
---+---
 1 | 4
 2 | 5
 3 | 6
(3 rows)

PostgreSQL だとこんなかんじで行に展開できるので、あとは insert into bulk_inserts (x, y) unnest($1), unnest($2) みたいなクエリに配列型の値を bind すれば bulk insert になる。たとえば Rust の sqlx だとこれだけ。

#[derive(Debug)]
struct Record {
    x: i32,
    y: i32,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let records: Vec<_> = (0..1000)
        .map(|i| Record {
            x: 2 * i,
            y: 2 * i + 1,
        })
        .collect();

    let pool = sqlx::PgPool::connect("postgres://...").await?;

    let mut xs = Vec::with_capacity(records.len());
    let mut ys = Vec::with_capacity(records.len());
    for r in records {
        xs.push(r.x);
        ys.push(r.y);
    }
    sqlx::query("insert into bulk_inserts (x, y) select unnest($1), unnest($2)")
        .bind(&xs)
        .bind(&ys)
        .execute(&pool)
        .await?;

    Ok(())
}

この方法だと動的に SQL を組み立てる必要がなくて楽。なお大量のレコードをロードするなら copy を使ったほうがいいと思う。

tokio::runtime::Handle でスレッドを跨ぐ

tokio::runtime::Handle を最近まで知らなくて無駄なコードを書いてたのでメモ。

Handle とは意味的には Runtime への参照になっている。
https://docs.rs/tokio/1.7.1/tokio/runtime/struct.Handle.html

Handle のドキュメントにもあるように、これを使うと現在のスレッドの Runtime への参照を取り出して別スレッドで同じ Runtime を使い回すことができる。

#[tokio::main]
async fn main() {
    let handle = tokio::runtime::Handle::current();
    std::thread::spawn(move || handle.block_on(run_in_child_thread()))
        .join()
        .unwrap();
}

async fn run_in_child_thread() {
    todo!()
}

同様に tokio の非同期なコードと rayon のようなスレッドによるデータ並列なコードを組み合わせるのも Handle を使えば簡単にできる。

use rayon::prelude::*;

#[tokio::main]
async fn main() {
    let handle = tokio::runtime::Handle::current();

    [1, 2, 3].into_par_iter().for_each(|i| {
        handle.block_on(async {
            cpu_intensive_func1(i);
            tokio::time::sleep(tokio::time::Duration::from_secs(i)).await;
            cpu_intensive_func2(i);
            println!("Finished {}", i);
        });
    });
}

パスワードマネージャーをストレージとした envchain みたいなやつ

envchain とは macOS の keychain または LinuxD-Bus secret service に API キーのような秘密の情報を安全に保存し、環境変数として取り出せる CLI ツールである。
https://github.com/sorah/envchain
僕は envchain の大ファンで、当初 macOS 向けに書かれていた envchain に Linux サポートを加えたりしていた。

そんな envchain をずっと愛用していたのだけど、Windows を使うようになってから WSL2 内で使いにくいことが気になっていた。 Linux 向けには D-Bus secret service (ほぼ実質 gnome-keyring) を使っているため、WSL2 内で D-Bus を起動したり X サーバを起動したりする必要がある。 無理ではないんだけどやりにくい。

そこでパスワードマネージャをストレージとした envchain のようなものを書いて使ってみている。 具体的には Bitwarden (私用) と 1Password (社用) にそれぞれ書いた。どちらのサービスも保存されたデータにアクセスできる CLI を提供しているので、それをラップしたようなツールになっている。

Bitwarden

bw という CLI を提供しているので、これをラップした envwarden というのを書いて使っている。
https://github.com/eagletmt/misc/tree/master/rust/envwarden
envwarden は Bitwarden に Secure Notes として保存された値を bw を使って取り出してそれを環境変数としてセットしているだけである。

bw は一度ログインすると暗号化された状態でデータがローカルファイルシステムに保存された状態になり、bw unlock で BW_SESSION を得てその BW_SESSION を使うと暗号化されたデータを読み出せるようになる、というかんじになっている。 なので envwarden を起動するたびに毎回ネットワークアクセスが発生するわけではない。 したがって後述の 1Password 版と比べて高速に起動できるようになっている。 bw 自体が Node.js を埋め込んだ? バイナリになっているため envchain と比べると起動はやや遅くなってしまっているが、ストレスを感じるほどではない。

1Password

op という CLI を提供しているので、これをラップした envop というのを書いて使っている。
https://github.com/eagletmt/envop
envwarden 同様、1Password の Secure Notes として保存された値を op を使って取り出してそれを環境変数としてセットしている。

bw と大きく違うのは、一度認証して得たセッションが一定時間で切れてしまう点である。op signin ${account} すると OP_SESSION_${account} を得てその OP_SESSION_${account} を使うと 1Password からデータを読み出せるのだが、30分経つと再度 op signin して OP_SESSION_${account} を更新しなければならない。 このため envwarden と同じような素朴なラッパーにすると非常に使いづらいため、ssh-agent のように裏側でずっと起動しているデーモン envop-agent を立てておき、envop コマンドは envop-agent と通信してデータを取り出すようにすることで、OP_SESSION_${account} の更新を envop-agent に任せるような形にした。 先日 ssh-agent の実装を調べていた のはそのような envop-agent を書くためだった。 これにより tmux 等で複数のシェルを起動していても頻繁にサインインし直すことを抑えている。 しかし op がデータを取り出すのが非常に遅くストレスを感じるレベルなので、WSL2 のような特殊な環境でない限りイマイチかもしれない……

そんな envop を書いていたら 1Password から Secrets Automation なるものが発表されていた。
https://support.1password.com/secrets-automation/
まだよく理解できてないけど envop のような用途で使えるものではない気がする……? 気にはなるので後日ちゃんと読んでおきたい。

ssh-agent のしくみ

ssh-agent のように daemon として起動し秘密の情報を保持しつつ別プロセスと通信するようなプログラムを書きたくて、ssh-agent はどう実装しているのかざっくり調べた。
https://github.com/openssh/openssh-portable

通信方法

これは普通に ssh-agent を使っていてもすぐ気付くことだけど、ssh-agent は UNIX domain socket を使って通信している。 eval $(ssh-agent) のように実行すると SSH_AUTH_SOCK と SSH_AGENT_PID の2つの環境変数がセットされ、SSH_AUTH_SOCK は UNIX domain socket のパスを、SSH_AGENT_PID は daemon 化した ssh-agent の pid を指している。 SSH_AUTH_SOCK は /tmp/ssh-*/agent.${parent_pid} というパスになっている。parent_pid は daemon 化する前の pid。/tmp/ssh-* も /tmp/ssh-*/agent.${parent_pid} もオーナー以外アクセスできないようなパーミッションになっている。 このへんは /tmp 以下にできるだけ安全にプライベートなファイルを作るときの一般的な方法をそのままやっているかんじ。
https://github.com/openssh/openssh-portable/blob/V_8_5_P1/ssh-agent.c#L1517-L1543

SSH_AGENT_PID は ssh-agent -k のときに利用される。 sshSSH_AUTH_SOCK が定義されていればそれを使って ssh-agent に通信しにいって、通信内容のプロトコルは独自。この部分は各プログラムが独自に決めればよいことなのであまりよく読んでない。
https://github.com/openssh/openssh-portable/blob/V_8_5_P1/ssh-agent.c#L961-L1061

daemon

daemon 化はだいたい一般的な流れ。fork して setsid して chdir して dup2 で stdin、stdout、stderr を /dev/null に向ける。
https://github.com/openssh/openssh-portable/blob/V_8_5_P1/ssh-agent.c#L1560-L1596

秘密の情報を漏らしにくく

ssh-agent は秘密鍵の情報をメモリ上に持っておく必要があるため、できるだけメモリ情報が漏れないようにする工夫が見られた。

自分で同じように秘密の情報を保持するプログラムを書くときはこのへんを真似しておけばよさそう。 通信のプロトコルを gRPC にして実装したサンプルコード https://github.com/eagletmt/misc/tree/master/rust/agent-proto

ドーナドーナ いっしょにわるいことをしよう 感想 (ネタバレなし)

「稼いだ金を力に変えるハルウラレ系RPG」だそうです https://www.alicesoft.com/dohnadohna/ 。エロゲです。 一企業に支配されるディストピア的な街で主人公たちがヒトカリしたりハルウリしたりしながらその企業を倒そうとする物語。 たまたまこれをプレイしている配信を見て、絵が好みだし話は面白そうだしゲームとしても面白いかもと思って買った。 実際ハルウリパートもヒトカリパートも面白くてとても満足でした。人におすすめしたいレベル。

ユニークヒロインのイベント全部回収、全メインヒロインのフィーリングレベル10達成まではやった。エンディングの分岐は一部だけ。

ストーリー

全体的にテンポが良くてよかった。まぁゲーム紹介を見ての通りすごく感動するようなタイプの話ではないけど、常にバカゲー的な楽しさがあったし、その上で全体的なストーリーラインはしっかりしていて違和感はそんなに無かったと思う。

全部がエロシーンなわけではないけど、6人のメインヒロインに対して各フィーリングレベルにイベントを用意してあるの単純にボリュームがすごいと思う。魚介先生の仕事量もすごい。 ただ、エンディングの分岐はヒロイン毎に差分が小さいわりに周回が面倒 (フィーリング値の調整はいいんだけど、何度もラスボスマップ + ラスボス戦やるのが……) だったので、一部しかやってない。 ポルノ、菊千代、キラキラが好きです。

ヒトカリパー

さすがにペルソナや世界樹のような RPG と比べると雑だけど、RPG の楽しさがしっかり抑えられていて RPG の好みが二分しやすい自分にとっても十分楽しい側に入る出来だった。 ただのポチポチゲーではないけど、すごく考えて進めなければならないわけでもない、気軽にやるにはちょうどいいかんじ。 近接は主人公と菊千代がド安定、遠距離からアンテナがバフと全体攻撃、残り1枠はポルノでデバフかけたりキラキラでデバフ + 範囲攻撃だったりメディコで回復させたり。 アリスは使い所が難しいけど時々役に立つ。ザッパは優秀な壁。虎太郎とジョーカーは……

バフ・デバフの影響がわりと強い調整に感じた。とくにボス戦ではアイテムでバフ・デバフを入れると楽。TEC の高さの効果はよく分からん。 MP を使いきっても MP 値をマイナスにしながらスキルを使えて、でも一定以上マイナスになると段階的にデバフがかかっていくシステムは面白かったと思う。まぁ結局雑魚戦相手では MP がマイナスになっても大した影響はなく、ボス戦前には回復させておくというかんじで安定してしまったけど。

ハルウリパート

序盤はヒトカリで手に入るジンザイがしょぼいので初期キャラのメンタルをアイテムで維持していかに長持ちさせるかというゲームだったけど、アイテムやジンザイ枠が揃ってくる中盤以降は「有利な属性を手に入れてほぼメンタル変動しないエース枠」と「不利な属性付与相手にぶつけたり適当に使い潰したりする使い捨て枠」にはっきり分けるのが正解だと思った。 有利属性無しで全員のメンタルを維持するのつらいし、ピルを手に入れるのが地味に面倒 or 運ゲーだし…… こう言うと倫理観が終わってるけど、このゲームには倫理観を崩壊させて向き合うべき (?)。 最終的なエース枠はこんなかんじになった。とくに一番左はハルウリさせてもルックスもメンタルも一切下がらないスーパーエースとなった…… なのでメンタルをカンストさせてるのは完全におまけ。

ユニークヒロインについては高橋菜々実だけ条件を満たすのがやたら面倒だった (+ 自分の趣味とはとくに合わなかった) けど、それ以外は簡単だったのでまぁちょうどいいんじゃないだろうか。 個人的な好みは清水千晴です。


というわけでエロゲと RPG が好きな人にはおすすめです。FANZA や DLsite でダウンロード販売もあります。最後になぜかスクリーンショットをとっていた一場面を載せておきます。

f:id:eagletmt:20210119022000j:plain

API Gateway + Lambda + Rust で開発する (2021-01)

まとめ

この構成で Slack の interactive message や block kit で遊んだサンプルがこれ https://github.com/eagletmt/misc/tree/master/rust/slack-slash-command-sample

Rust 向けの Lambda Runtime

lambda-runtime という準(?)公式の crate がある https://github.com/awslabs/aws-lambda-rust-runtime が、リリースが滞っている。 現在リリースされている中での最新版では async/await の対応すら入っておらず、現在の Rust では正直使い物にならないレベルである。 master には async/await の対応が入ってるのでそれを使うという手もあるが、痺れを切らした Netlify の方が未マージの PR も一部取り込みつつ netlify_lambda という crate でリリースしている https://github.com/awslabs/aws-lambda-rust-runtime/issues/274 。 この netlify_lambda には v0.2 で Tokio v1.0 対応も入っている。 なので現時点では netlify_lambda を利用するのが一番手軽な上にコミュニティの恩恵を受けやすいと思われる。

Lambda の Docker イメージサポート

最初にこのリリースを見たときは zip が Docker イメージに変わっただけくらいの認識だったが、公式に提供されている public.ecr.aws/lambda/provided:al2 等のイメージには aws-lambda-rie というバイナリが含まれており、手元で Lambda を起動するのが手軽になっている点が非常に価値が高い。 https://docs.aws.amazon.com/lambda/latest/dg/images-create.html

これをベースイメージとして Docker イメージを作ることで、手元で docker run -p 9000:8080 my-awesome-app で起動すると curl -XPOST http://localhost:9000/2015-03-31/functions/function/invocations -d '{}' で Lambda のハンドラを動かすことができる https://docs.aws.amazon.com/lambda/latest/dg/images-test.html

これまでも Lambda にアップロードする zip を作るときにはどうせ Docker を使ってビルドしていたわけだし、開発やデプロイのことだけを考えれば Lambda の Docker イメージサポートを利用しない理由は無い気がする。 AWS CDK も既にこの機能をサポートしている。

手元での API Gateway 開発

API Gateway + Lambda で API サーバを作るときにも aws-lambda-rie のおかげで curlAPI Gatewayペイロードを流せば手元で動作確認ができる。 しかしその API Gateway は別のマイクロサービスだったりフロントエンドの JS だったりから呼ばれて使われることが多く、それらとの連携も合わせて手元で動作確認したい。 そこで普通の HTTP リクエストを API Gateway 用のペイロードに変換して aws-lambda-rie のエンドポイントに流すだけの小さいツール https://github.com/eagletmt/aws-lambda-rie-gateway を書いてみたところ、だいぶ便利になった。

cargo watch --shell 'docker build -t handler:dev . && docker run -p 9000:8080 handler:dev を起動しておけばコードを変更するたびに Docker イメージがリビルドされ、普通の HTTP リクエストでアクセスできる状態で開発できる。 普通の HTTP リクエストを処理できるので Slack アプリ開発のようなときには ngrok のプロキシ先にも指定できる。 aws-lambda-rie-gateway はとりあえず自分が必要になった範囲しかサポートしてないけど、API Gateway v1 (REST API) のサポートも入れたりしていくと結構実用的なツールになるかもしれない。 Lambda の Docker イメージサポートと aws-lambda-rie に感謝。