なぜRubyの会社でRustを使うのか
小林秀和氏:本日はCookpad TechConfにお越しいただき、ありがとうございます。私の発表は、今話題のRustについてです。みなさんはRustを知っていますか? あるいは書いたことがありますか? 書いたことがあるという方、挙手をお願いしてもよろしいでしょうか?
(会場挙手)
ちらほらいますね。ありがとうございます。ご覧のとおり、Rustはまだまだマイナーな言語です。そんなRustがクックパッドでなぜ・どのように活用されているのかをご紹介します。
あらためまして自己紹介いたします。本名は小林秀和で、ハンドルネームを「KOBA789」といいます。ふだんはKOBA789名義で活動をしており、社内でも「KOBA」と呼ばれています。
私は昨年、新卒として入社しました。クックパッドで仕事を始めてからおよそ1年になります。昨年の5月に、1ヶ月の研修期間のあと、今のチームに配属されました。
さて、話を始める前に、このタイトルについて解説をしましょう。クックパッドはご存じのとおり、Rubyの会社です。多くのサービスがRails、つまりRubyで書かれています。しかし、だからといってすべてのソフトウェアをRubyで書いているわけではありません。ミドルウェアには、GoやJavaといった言語で書かれているものもあります。
クックパッドには、Dockerにパッケージングしたアプリケーションを簡単にデプロイできる「Hako」という仕組みがあり、どんな言語で書いたソフトウェアでも同じようにデプロイをして運用できるようになっています。ですから、Rustも例外ではありません。たまたま私が初めてだったというだけで、Rustを選択したこと自体は特別なことではなかったのです。
プッシュ通知の配信を一手に担う基盤の改善
つまるところ、クックパッドはRubyの会社ではありますが、Rubyだけの会社ではありません。私は新卒1年目からRuby以外の言語を書いて仕事をしたわけですが、適材適所で言語を使い分けるということを、技術的にも文化的にもできる環境が整っています。
そんな中で私が配属直後に任された仕事は、プッシュ通知配信基盤を改善することでした。プッシュ通知配信基盤について説明する前に、まずクックパッドにおけるプッシュ通知について少し説明します。
クックパッドのサービスでは、毎日たくさんのプッシュ通知を配信しています。プッシュ通知とひと口に言っても、大きく分けて2つの種類があります。
1つが都度配信です。こちらはイベント発生ごとに、ユーザーにそのイベントを文字通り通知するためのプッシュ通知です。
もう1つは一斉配信です。これはなんらかのデータによってターゲティングされたユーザーに一斉に配信されるものです。例えば、最近アプリを使っていないユーザーに対してプッシュ通知を送ることで利用を促す、という用途がこちらに該当します。
このような通知の場合、ターゲティングした対象ユーザーが実際にスマートフォンを使っているユーザーかどうか、また、通知を受信する設定になっているかどうかを事前に見積もることがとても大切です。そして、これらのプッシュ通知の配信を一手に担っているのが、プッシュ通知配信基盤です。
サービスの発展にともなってシステムの改良が必要に
私がRustで書き直す前のプッシュ通知配信基盤はこのような構成でした。
アプリケーションがユーザーに通知する流れを、順を追って説明していきます。
アプリケーションはまずMySQLにある受信設定のテーブルをクエリし、受信拒否されていなければ次にARNのテーブルをクエリし、そしてメッセージをプッシュ通知基盤のS3に書き込みます。一方、プッシュ通知配信基盤はS3で書かれたメッセージを読み出してAmazon SNSに送り出します。
ちなみにARNというのは、Amazon SNSのエンドポイントのARNのことです。この発表では、単にプッシュ通知の宛先デバイスを指定するためのIDだと思ってください。
図がごちゃごちゃしているので、少しまとめましょう。プッシュ通知を配信するまでの流れは次のとおりでした。
受信設定をクエリ、ARNのクエリ、S3に書き込む、S3から読み出す、そして最後にSNSへ送信です。
しかし、S3の読み書きは本質的な処理ではないので省略します。すると、プッシュ通知に必要な処理はわずか3ステップしかないことがわかります。そのうち基盤はSNSへの送信をしているだけで、ほとんどの部分はアプリケーションが担っているのです。
これがまさにプッシュ通知配信基盤を改善しなければならない理由でした。
なぜ、基盤というにはあまりに機能が少なすぎる設計なのか? また、なぜ今になってこの設計が問題になっているのか? それには、クックパッドのソフトウェアの歴史が大きく関係しています。
マイクロサービス化でロジックが散らばってしまっていた
みなさんご存じのとおり、かつてクックパッドは、大きなMonolithic Rails Applicationでした。現在では、中央にまだまだ巨大なRailsアプリケーションがそびえ立っていることに変わりはないものの、マイクロサービス化が進んでいます。
細かな機能は巨大なRailsアプリケーションを離れ、独立したアプリケーションとして実装されるようになりました。その結果、プッシュ通知を配信するアプリケーションが1つではなくなってしまったのです。
プッシュ通知を配信するアプリケーションが1つしかなかった時代は、基盤の機能が貧弱でも問題になりませんでした。コードがすべて共有されていますから、基盤の足りない部分も1つのアプリケーションだけでカバーすることができたのです。
しかし現在は違います。さまざまなマイクロサービスがプッシュ通知の配信をしようとします。場合によっては、そのアプリケーションの実装言語はRubyではないかもしれません。実際、AWS Lambdaを用いてJavaScriptで実装されているマイクロサービスもあります。
こうなると、プッシュ通知のロジックはさまざまなところに散らばってしまいます。ましてや、受信設定やARNを管理しているデータベースを共有することは避けたい。そのため、より豊富な機能を持つプッシュ通知配信基盤が求められていました。
もう一度、基盤に必要な機能を整理するため、操作を振り返りましょう。プッシュ通知を行うときの処理は、受信設定をクエリ、ARNをクエリ、SNSに送信の3つでした。新しいプッシュ通知配信基盤では、当然これら3つの処理をすべて担います。
これは言い換えると、ARN指定ではなくuser_id指定で送信ができ、また、ユーザーの受信設定に基づいて自動的に配信先ユーザーをフィルタできるということです。
こうしてプッシュ通知に必要なすべての処理を基板で巻き取ったことで、アプリケーションはロジックをコピペすることもデータベースを共有することもなく、user_idとメッセージさえ放りこめばプッシュ通知を配信できるようになりました。
速いソフトウェアを作るのはRustではなくプログラマの仕事
しかし、これだけでは機能は足りません。このスライドを覚えているでしょうか?
一斉配信では配信対象者の見積もりが大切でした。見積もりするためだけにデータベースに直接つないでクエリを投げなければならないのでは、せっかくデータベースの共有を防いだことが台無しになってしまいます。
そこで、数百万通規模の配信でも数分で完了するほどの極めて高速な「dry-run」を実装しました。このように、性能要件の厳しいソフトウェアをRubyで実装するのは大変です。Rubyにはもっと適切な使い方があります。
一方で、Rustは速度・安全性・並行性にフォーカスしたシステムプログラミング言語です。Rubyのように派手なメタプログラミングをできる柔軟性はありません。しかし、Rubyよりも高速なソフトウェアを書きやすい言語です。
最も大きな特徴は、マルチスレッドプログラミングにおいてデータ競合の可能性のあるコードを、コンパイラがコンパイル時に怒ってくれるということです。これによって、より安全にマルチコアのパワーを活かすソフトウェアを開発することができます。
また、Rustは静的型付け言語ですが、その型システムはトレイトやジェネリクスといった強力な機能を持っています。
さて、RustはRubyに比べれば確かに多少速い言語です。ですが、Rustで書いたからといって、ソフトウェアが勝手に高速になるわけではありません。Rustはプログラマがより速いプログラムを書こうとしたとき、それをサポートしてくれるというだけです。速いソフトウェアを書くのはプログラマの仕事です。
高速化の基本は、ボトルネックを見つけて、それを改善することです。新しいプッシュ通知基盤の処理の内容をもう一度振り返っておきましょう。必要な処理は受信設定をクエリ、ARNをクエリ、SNSに送信の3つでした。このうちdry-runで必要なのは前の2つです。そして実際にボトルネックになるのもその2つです。
この2つでは、それぞれ1発ずつMySQLに向かってSELECT文を発行しますが、これにそれぞれ6ms(ミリセック)ほどかかります。仮に100万通のメッセージに対してすべてを直列に実行したとすると、これだけで3時間はかかってしまう計算になります。
当然この6msはほぼすべての時間が単なるI/O待ちです。つまり、コードを工夫して計算量を減らすような最適化ではなく、クエリの戦略を工夫して時間を削る必要があるということです。
プログラムを高速化するアイデア
そこで私が参考にしたのは、Facebookの「DataLoader」というクエリを最適化するためのライブラリでした。このライブラリはJavaScript向けのライブラリですが、アイデアは言語を問わず参考にできます。
DataLoaderの基本的なアイデアは、クエリをまとめることです。「クエリをまとめる」とは、いったいどういうことなのでしょう?
この上に書いてあるクエリのように、IDを指定してレコードを引くだけの単純なクエリはよくあります。
実際にプッシュ通知配信基盤で用いているものも同じようなつくりです。このクエリのID部分をIN演算子にまとめて放りこみ、1つにしてしまおう、というのが基本的なアイデアです。
では、いったいどれぐらいの数のクエリをまとめるのが最適なのでしょうか? ベンチマークを取り、調べてみることにしました。
このIN演算子の括弧の中に並べる値の数をバッチサイズと呼びますが、ベンチマークの結果、このバッチサイズは数千ぐらいが適切だとわかりました。たった1つのIDについて問い合わせるクエリが6msかかる一方で、IDを1,000個並べたクエリでも、わずか11msでクエリが完了します。
「クエリをまとめる」アルゴリズムの肝
次に、どのようにしてこの「クエリをまとめる」という操作を実現したのか、そのアルゴリズムをご紹介します。
基本的なアルゴリズムは、問い合わせるuser_idを一度すべてキューに入れ、クエリするための専用の別スレッドでキューにためたuser_idを一気に取り出し、それらのuser_idをIN演算子の中に展開してデータベースへクエリするというものです。キューから一気に取り出すという操作が、このアルゴリズムの肝になっています。
この、キューから取り出す動作の部分だけをライブラリとして切り出し、「Batched receive」という名前でオープンソフトウェアとして公開しています。
このライブラリを使うと、キューから1個以上3個以下の要素を一気に取得するコードは(スライドを指して)このように書くことができます。
実行すると、このコードは、キューに1つ以上の要素が追加されるまでスレッドをブロックします。そしてキューに要素が追加されると、先頭から最大3個の要素をまとめて取り出し、chunkという変数に代入します。
このライブラリの実装は非常にシンプルです。簡単に仕組みをご説明しますと、まずキューとしては、crossbeam_channelというMulti-producer Multi-consumerのI/Oキューライブラリを使っています。Multi-producer Multi-consumerというのは、書き込みも読み込みも複数のスレッドからアクセスできるということです。
このライブラリでは「crossbeam_channel::unbound()」というメソッドを呼び出すことでキューを作成することができます。キューを作成するとタプルにまとめられた2つの値を得られます。片方がSender、片方がReceiverで、それぞれが書き込み側のインターフェース、読み込み側のインターフェースです。
そしてこの読み込み側のReceiverには、その名のとおりrecvというメソッドが実装されています。このrecvは、キューの中から最初の要素を取り出すメソッドです。このメソッドはキューに要素がなければスレッドをブロックします。
また、このReceiverにはtry_iterというもう1つのメソッドがあります。このメソッドは、キューの中身をイテレータとして取得するメソッドです。
こちらのメソッドは先ほどのrecvメソッドとは違い、キューの中に要素がなくてもスレッドをブロックすることはありません。要素がキューに入っていない場合は、単に空っぽのイテレータが返ってくるだけです。
簡単な動作でクエリをまとめる仕組みとは?
また、このtry_iterの戻り値はIteratorトレイトを実装しています。このIteratorトレイトはtakeというメソッドを提供しています。このメソッドは、イテレータから先頭n個の要素を切り出すものです。
私の作ったライブラリ「Batched receive」は、これらのメソッド、recv、try_iter、takeを組み合わせて実装されています。実際のコードを見てみましょう。これはn個の要素をキューから取り出すためのコードです。
1行目でまずrecvを呼び出し、1つ目の要素がキューに書き込まれるのを待ちます。1つ目の要素を読み込めたら、2行目に移ります。2行目では後続の要素をn-1個一気に読み出します。読み出すべきn個の要素のうち、最初の1個は1行目で読み出していますので-1しています。そして3行目以降では、取り出した先頭の要素と後続の要素を連結しています。
さて、このライブラリによってキューが空なら待つ、キューに値があればn個まで一気に読み出す、という動作をできるようになりました。これだけの動作でどうしてクエリをまとめることができるようになるのか、図を使って説明しましょう。
「Batched receive」の構造
左側にあるのがメインスレッド、右側にあるのがクエリスレッド、そして中央にあるのがそれらをつなぐキューです。
メインスレッドは、キューを通してクエリスレッドにユーザーを送ることでしかデータベースに問い合わせることができません。
この①は「user_id=1」のことだと思ってください。
メインスレッドはuser_id1のレコードをデータベースに問い合わせようとしています。また、クエリスレッドはbatch_recvメソッドを呼び出し、キューにuser_idが流れてくるのを待ち受けています。なお、今回の説明では画面の大きさの都合上、バッチサイズを3に設定しています。
まずメインスレッドが、user_id1を、キューを通してクエリスレッドに送ります。user_idはキューを通してクエリスレッドに到達し、クエリスレッドがデータベースへクエリを投げます。
続いて、メインスレッドが次のクエリを投げます。しかし、最初のクエリはまだ終わっていません。クエリスレッドが忙しいときにキューに書き込まれたuser_idは、キューに積まれたまま順番を待ちます。
そろそろ最初のクエリが完了する頃合いです。クエリスレッドはクエリを完了させると、再びbatch_recvメソッドを呼び出します。すると、キューに積まれていたuser_idのうち、先頭からバッチサイズの分だけ、今回なら3件がまとめて読み出されます。この3件をIN演算子の中に展開してクエリすれば、めでたくクエリをまとめられたということになります。
しかし、ここで1つ疑問があります。どのようにして、クエリの結果をメインスレッドに返却するのでしょうか?
そこで、futuresというライブラリのoneshotという機能を使います。oneshotは、一度だけしか値を送れないキューのようなものです。先ほどのキューと同じく、SenderとReceiverという2つのインターフェースのペアで構成されています。
図中では、Senderを入口、Receiverを出口として表現しています。動作としては極めて単純で、入口に値を入れると出口から取り出せるという、ちょうどポータルのような動きをします。それでは、この入口と出口を用いて、どのようにクエリスレッドからメインスレッドに値を返却するのかを見ていきましょう。
まず、左のメインスレッドで入口と出口のペアを作ります。
そして、user_idと入口をひとまとまりとしてキューに書き込みます。クエリスレッドはキューからuser_idと入口のペアを取り出し、データベースにクエリをします。そして結果の値を入口に書き込みます。
左側のメインスレッドを見ていてください。ここで出口から結果の値を取り出すことができます。
こうしてクエリスレッドからクエリの結果を返してもらうことができました。
ライブラリを組み合わせて高速化を実現する
しかし、また次の疑問が生まれます。もしクエリがたくさんあったらどうするのでしょうか? ごく単純に実装すると、このような図になります。
メインスレッドがたくさん並んでいます。つまり、バッチサイズの分だけメインスレッドが必要になってしまうのです。
図中ではバッチサイズはたかだが3でしたが、先ほど言ったとおり、最適なバッチサイズは数千に及びます。数千のスレッドを立ち上げるのは現実的ではありません。
そこでTokioというライブラリを組み合わせます。
Tokioはイベントループの実装を提供するライブラリです。そのイベントループでは複数のFutureを1つのスレッドで実行することができます。
また、futuresはFutureトレイトを提供するライブラリです。このFutureはJSのPromiseと同様な概念で、将来的に計算結果が求まることを表す値です。
先ほど登場した入口と出口のoneshotもこのfuturesが提供する機能であり、この出口は実はFutureだったのです。つまりTokioのイベントループを使えば、1つのスレッドでたくさんの出口を待ち合わせることができるのです。
雰囲気としてはこのような図になります。
メインスレッドの数を増やさずにたくさんのクエリをまとめることができました。
このoneshotの入口をキューに入れるパターンは、キューの向こう側から1件ずつ値を返してもらいたいときに使えるパターンです。見方を変えると、ブロッキングな処理をFutureに変換しているとみなすこともできます。覚えておくと地味に便利です。
こうしてクエリをまとめることで、無事にプッシュ通知配信基盤の高速化に成功しました。
状況に応じて適切な言語を使うことは重要
これらのクエリ高速化のテクニックをまとめたライブラリも、オープンソフトウェアとして公開しています。先ほどのライブラリと紛らわしい名前ですが、こちらは「batch-loader」といいます。
このbatch-loaderは、SQLのみならず、key-valueのように、任意のクエリをまとめるために使うことができます。今回の例ではkeyがuser_idで、valueがテーブルの行でしたが、MySQLのみならず、クエリのバッチ問い合わせができる任意のデータストアに応用が可能です。
極めてレアなケースだとは思いますが、大量のクエリがボトルネックになって悩んでいるときにはぜひ思い出してみてください。きっとお役に立てると思います。
そろそろまとめに入ります。実際に実用的なソフトウェアをRustで開発してみて、Rustのいいところをいろいろと実感しました。
そんな中でも、マルチスレッドを安全に扱えるという特徴にはたいへん助けられました。ほかの言語では、データ競合をプログラマ自身で注意深く避けなければならないので、マルチスレッドとイベントループを組み合わせるという複雑なアーキテクチャは非現実的になりがちです。しかし、Rustではその点をコンパイラが検査してくれるので、安心して冒険することができます。
また「よくわからないけど、怖いから念のためロックしておこう」ということもなくなるので、より高速なソフトウェアを書きやすくなっています。さらに、型システムがかなり強力で、今回ご紹介したような汎用ライブラリも作りやすくなっています。
このようにして、クックパッドのプッシュ通知配信基盤はRustで書き直されました。クックパッドではRubyだけではなく、状況に適切な言語を使ってクールに課題を解決しています。
最後になりますが、春にRustを書くインターンシップを予定しています。講師は私が務めますので、興味のある学生さんはぜひエントリーしてください。
ご清聴ありがとうございました。
(会場拍手)