Slide 1

Slide 1 text

Ruby と Rust と私 Kohei Suzuki (@eagletmt)

Slide 2

Slide 2 text

自己紹介 ● Kohei Suzuki (@eagletmt) ● クックパッド レシピ事業部 バックエンド基盤グループ (SRE) ● 好きなプログラミング言語は Ruby と Rust

Slide 3

Slide 3 text

アウトライン Ruby と Rust を比較しながら個人の考えや感想を述べていく ● CLI を作る上での比較 ○ Ruby で実装した CLI の例: hako ○ Rust で実装した CLI の例: ecs-logs-merger ● ISUCON での言語移植 ○ エラーハンドリング ○ リソース開放 ○ Web フレームワーク ● まとめ

Slide 4

Slide 4 text

Ruby: hako

Slide 5

Slide 5 text

Ruby で実装した CLI の例: hako ● hako https://github.com/eagletmt/hako ○ Amazon ECS で Web アプリケーションをデプロイしたりバッチジョブを起動し たりするためのツール ○ Jsonnet という設定言語を採用し、Jsonnet 上で共通化しながら ECS の設定 が可能 ○ script という機能を持ち、デプロイの前後などに処理を追加できる ■ script はユーザ側で拡張可能にしたい ■ 仕事で作ったものを GitHub で公開するためのテクニックという側面もあ る (社内固有の事情は拡張として社内に持つ)

Slide 6

Slide 6 text

hako script

Slide 7

Slide 7 text

hako script デプロイ開始時に log driver が awslogs だったら CloudWatch ロググループを作成

Slide 8

Slide 8 text

hako script

Slide 9

Slide 9 text

hako script の実装 ● Jsonnet 上で type に指定された文字列を使って require する ○ scripts: [{ type: 'foo_bar' }] であれば require 'hako/scripts/foo_bar' する ● その require によって期待するクラスが定義されたと仮定して const_get して new する ○ scripts: [{ type: 'foo_bar', hoge: 'fuga' }] であれば Hako::Scripts.const_get('FooBar').new({ hoge: 'fuga' }) する ● この慣習に従うよう Hako::Scripts::FooBar を実装して gem にする ことで hako script を拡張できる

Slide 10

Slide 10 text

hako script を Rust で? ● Ruby では動的に require や const_get ができるので、プラグイン機構を 作るのが非常に簡単で、拡張性を確保しやすい ● 静的型付けの性質が強い言語では同様のプラグイン機構を作るのが難 しい ● I/O などを許可しなければ WebAssembly で拡張させるという手法が良さ そうに思う ○ 例: Envoy proxy ● しかし hako script のように I/O 含めて任意の処理を許可したいことも多 い ○ 個人的には Terraform や Packer などで採用されている hashicorp/go-plugin の手法が答えかなと思っている

Slide 11

Slide 11 text

hashicorp/go-plugin の機構 ● gRPC サーバを起動する実行可能ファイルとしてプラグインを配布 する ● メインプロセスはサブプロセスとしてプラグインを起動して stdout 経 由で接続先 (ポート番号や UDS パス) を受け取り、gRPC の呼び 出しでプラグインに処理を移譲する ● プラグインは環境変数経由でメインプロセスから証明書を受け取り、 mTLS で通信をセキュアにする ● hashicorp/go-plugin は Go 専用だが、この機構は幅広い言語で採 用可能

Slide 12

Slide 12 text

とはいえ…… ● 個人的には拡張可能な CLI を作るなら Ruby を選びそう ● hashicorp/go-plugin 方式だとプラグインの配布や配置をどうする かという課題がある ○ Terraform の場合は https://registry.terraform.io/ でプラグイン (実行可能ファ イル) をホストし、そこから配布している ○ Ruby なら通常の gem として配布できる

Slide 13

Slide 13 text

Rust: ecs-logs-merger

Slide 14

Slide 14 text

Rust で実装した CLI の例: ecs-logs-merger ● クックパッドでは ECS で動かしたアプリのシステムログを Fluentd 経由で Amazon S3 に保存している ○ システムログ: Rails.logger で出力するようなログを指し、ユーザの行動ログ のようなログではない ● 速報性のため、短いインターバルで S3 にログを保存している ● ログは2年間保持する ● ある程度古いログは参照されることが稀なので、Glacier という容量 単価の安いストレージに移動してコストを抑えたい

Slide 15

Slide 15 text

Rust で実装した CLI の例: ecs-logs-merger ● Glacier に送るときにはオブジェクト数に応じた料金が発生する ○ 容量に対する料金と比べて、このオブジェクト数に対する料金がかなり割高 ● 前述のように短いインターバルで S3 にログを置くと、オブジェクト数 が膨大になる ● したがって、細かいオブジェクトをマージして1つのオブジェクトにまと めることができれば全体のコストを抑えられる ● => ecs-logs-merger

Slide 16

Slide 16 text

ecs-logs-merger のイメージ S3 S3 ecs-logs-merger Glacier へ

Slide 17

Slide 17 text

なぜ Rust? ● ecs-logs-merger の役割は gzip 圧縮された JSON Lines ファイル を S3 からダウンロードしてマージするだけで、Ruby で実装するの も難しくはない ● それでも Rust を選んだ理由: ○ 大量のログを高速に処理する必要があった ○ Serde が便利だった ○ (仕事でも使ってみたかった)

Slide 18

Slide 18 text

並列処理 ● ログはとにかくたくさんある ● 試運転してみると gzip の inflate/deflate にかなり CPU を持ってい かれることが分かった ○ 細かい大量のオブジェクトのダウンロード・アップロードの I/O もあるが、CPU もマルチコアを活かして効率的に処理したい ● Ruby で CPU-intensive な処理を並列化するのは難しい ○ たとえば Fluentd ではマルチプロセス化や gzip を外部コマンドとして実行す るオプションがある https://docs.fluentd.org/deployment/performance-tuning-single-process

Slide 19

Slide 19 text

並列処理 ● Rust ではスレッドを起動すればマルチコアで動く ○ マルチスレッドプログラミングでミスしやすい箇所の多くは型チェックで防ぐこと ができる ● Ruby だと工夫が必要なところが、Rust では普通に書くだけで達成 できる ○ 個人的に、「がんばれば速くなる」と「普通に書けば速い」の間の距離は結構大 きいと思っている ○ Ractor に期待

Slide 20

Slide 20 text

Serde ● 何らかのフォーマットで表現されたデータと Rust のデータ型の間を 変換するためのフレームワーク (SERialization/DEserialization) ● たとえば JSON <-> データ型の変換が簡単かつ柔軟にできる ● Derive マクロという機能を活用しており、アノテーションをつけて コード生成する ○ この手の Derive マクロは広く利用されており、コマンドライン引数をデータ型 にマッピングするライブラリ (clap) などもある

Slide 21

Slide 21 text

Serde の例

Slide 22

Slide 22 text

Serde の例 Serde でデシリアライズす るためのマクロ time フィールドを時刻型にデシ リアライズ JSON のフィールド名をリ ネームしてマッピング

Slide 23

Slide 23 text

Ruby では? ● JSON.parse では Hash が返ってくる ○ Hash のままだとどんなキーが存在するのかコード上から分からないので、 Data や Struct に入れ直したい ● Hash を Data や Struct に入れるのはやや面倒 ○ 型チェックや存在チェックをしないといけない ○ ネストを自分で扱わないといけない ● Ruby でやや大きめの JSON を扱うときは、自分は Protocol Buffers (protobuf) を利用することが多い

Slide 24

Slide 24 text

例: Terraform の tfstate を読む

Slide 25

Slide 25 text

とはいえ…… ● protobuf である程度カバーできるとはいえ、Rust の Serde の使い やすさには負ける ○ たとえば柔軟なリネームができない ○ protobuf の性質上、存在すべきフィールドを表現できない (Go のゼロ値のよ うな値にデシリアライズされる) ■ 型が違う場合は decode_json 時に例外 ○ protoc を拡張してこのへんをがんばることもできるが…… 「がんばればでき る」と「普通に書ける」の距離は大きい

Slide 26

Slide 26 text

使い分け ● 1つのことを上手くやる CLI では、やることに応じて言語を選ぶこと が多い ● Ruby を優先したいとき ○ プラグインで拡張させたい ○ 実装が十分短い ● Rust を優先したいとき ○ 並列処理でパフォーマンスを優先したい ○ 難しい JSON を読みたい

Slide 27

Slide 27 text

ISUCON での言語移植

Slide 28

Slide 28 text

ISUCON ISUCONとはLINEヤフー株式会社が運営窓口となって開催している、お題となるWeb サービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバト ルです https://isucon.net/ ● 複数の言語でお題となる Web アプリが提供され、それを高速化するコンテスト (競 技) ● 複数の言語で提供するために、言語移植が必要になる ○ ここ4回ほど、Ruby や Rust への言語移植を自分が担当している

Slide 29

Slide 29 text

ISUCON での言語移植 ● 近年の ISUCON においては、お題となる Web アプリが出題チー ムにより最初に Go で実装される ● 運営のお手伝いとして言語移植担当が呼ばれ、Go から各言語に 移植する ● なるべく Go 実装に近い形での移植が望まれる ○ できる限り言語間で有利・不利が出ないようにする

Slide 30

Slide 30 text

エラーハンドリング: Go ● Go: error + if ○ output, err := F(input); if err != nil { return ... } の形のエラーハンドリングが 多い https://github.com/isucon/isucon14 より引用

Slide 31

Slide 31 text

エラーハンドリング: Ruby ● Ruby: 例外 ○ エラーを例外で扱って output = f(input) の形にする ○ エラーの中身を調べるときは begin/rescue

Slide 32

Slide 32 text

エラーハンドリング: Rust ● Rust: Result + ? ○ Rust では Result 型でエラーを扱うことが多く、? という演算子で if err != nil { return ... } 部分を省略し output = f(input)? の形で書ける

Slide 33

Slide 33 text

エラーハンドリング: Rust ● Result 方式のエラーハンドリングだとエラーの型をどうするかが問 題になりがちだが、最近の Rust では anyhow または thiserror と いうライブラリを使うことが多いと思う ○ anyhow: Go の error のように、すべてのエラーを1つの型の値にラップする。 アプリ向き ○ thiserror: enum で細かくエラーの種類を分けて扱うのをやりやすくする。ライ ブラリ向き ● ? 演算子を使ったときにエラー型の変換が自動で行われる (From trait)

Slide 34

Slide 34 text

エラーハンドリング ● 個人的には、Ruby 方式と Rust 方式の間に大きな好みの差は無い ● 一般には Result 型だと面倒になりがちだが、? 演算子と anyhow/thiserror でかなり記述を省略できる

Slide 35

Slide 35 text

リソース開放: Go ● Go: defer ○ file, _ := os.Open(...); defer file.Close() とすると関数を抜けるときにファイル クローズのようなリソース開放を漏れなく実行できる https://github.com/isucon/isucon14 より引用

Slide 36

Slide 36 text

リソース開放: Ruby ● Ruby: ブロック (ensure) ○ File.open(...) { ... } と書けるよう にして、ブロックを抜けるときに漏 れなくリソースを開放できるよう にしている API が多い ○ File.open の中では ensure を 使って漏れなくリソースを開放す るよう実装する

Slide 37

Slide 37 text

リソース開放: Rust ● Rust: Drop (RAII) ○ let file = std::fs::File::open(...)? と書くと、file 変数のスコープを抜けるときに リソース開放を漏れなく実行できる ○ これは std::fs::File が Drop trait を実装していて、そこでファイルをクローズす るよう実装されているためである

Slide 38

Slide 38 text

リソース開放: Rust スコープを抜けるときに rollback https://github.com/launchbadge/sqlx より引用

Slide 39

Slide 39 text

リソース開放 ● 個人的には、Ruby 方式と Rust 方式の間に大きな好みの差は無い ● どのリソース開放が行われそうか分かりやすくなる点では Ruby 方 式のほうが好き ● ネストが深くならない点では Rust 方式のほうが好き

Slide 40

Slide 40 text

Web フレームワーク: Ruby ● Ruby では Sinatra + mysql2 が基本 ● かなり記述量が抑えられる点が好き ○ おおよそ Go 実装の半分くらいのコード量で済む ○ struct を定義しない (しづらい) ことで減っている分もあるけど…… ● GET リクエストのクエリ文字列や POST リクエストの JSON をデシ リアライズするときが若干扱いづらい ○ とくに JSON の中でネストがあると存在チェックや型チェックが面倒

Slide 41

Slide 41 text

リクエストボディを Data に入れる

Slide 42

Slide 42 text

リクエストボディを Data に入れる

Slide 43

Slide 43 text

Web フレームワーク: Rust ● Rust ではここ2年間は Axum + SQLx にしている ● GET リクエストのクエリ文字列や POST リクエストの JSON を Serde でデシリアライズできるのはかなり楽 ● SQLx でも Serde と同じように MySQL の1行を Rust のデータ型 にデシリアライズする Derive マクロが用意されている

Slide 44

Slide 44 text

リクエストボディを struct に入れる

Slide 45

Slide 45 text

リクエストボディを struct に入れる

Slide 46

Slide 46 text

SQLx で MySQL の1行をデータ型にマッピング

Slide 47

Slide 47 text

ISUCON では ● Ruby と Rust で書き方すごく大きな差があるようには感じていない ○ JSON や MySQL の行に型がついて Rust のほうがちょっと嬉しい、くらい ○ 言語移植の観点では、Rust のほうが凡ミスをしにくいので、最初に Rust に移 植してコード理解を深めた後に Ruby に移植するようにしている ● 8時間の競技であることを考えると、Rust のコンパイルの遅さは結 構たいへんそう ○ 本番サーバだと c5.large (2 vCPU、4 GiB memory) なのでリリースビルドは 激重

Slide 48

Slide 48 text

まとめ

Slide 49

Slide 49 text

まとめ ● Ruby と Rust は型付けの面などで正反対に語られることも多いけ ど、結構近いものがあるように感じている ○ 実行時かコンパイル時かの差はあるけど、どちらもコードを短く書くための機 能が豊富 ■ Rust 側はライフタイムが絡むとコードが長くなりがちではあるが…… ○ if や case/when・match が文ではなく式な点も共通 ● YJIT や rb-sys などで Rust が Ruby 界隈に受け入れられつつあ る? ので、用途に応じて使い分けていくのがいいと思う