REQLY開発ブログ

プロの料理レシピ専門検索エンジンREQLY(レクリー)の開発ブログです。

ISUCON8 本戦参加記

10/20(土)に行われたISUCON8(Iikanjini Speed Up CONtest)というWebサービスの高速化を図るコンテストに、REQLYチームで本戦初参加してきました。ありがたいことにREQLYチームとしては初めてオンサイトのコンテストに参加させて頂き、そして30チーム20位(学生では16チーム中10位)という順位で清々しく惨敗しました! そこで、本戦当日にどんなことをしたのか、あるいはどんなことをすれば点数が伸びたと考えられるかを振り返ってみました。

メンバー

予選と同様、プログラミング言語としてpythonを選択した。

本戦参加記

10:00〜11:00 環境構築

  • git, htop, vim, dstatなどをインストールする。
  • gitで、webappのコードをトラッキング。自動でデプロイしてslack通知してくれるシェルスクリプトを導入。
  • alpでnginxのログを見る。
  • ベンチマークを走らせながら、ログなどを瞥見する。
  • コードリーディングを行う。

11:00〜12:00 開始1時間で確認したこと

dstat で現状の分析

----system---- -------cpu0-usage--------------cpu1-usage------ ---load-avg--- ------memory-usage----- -dsk/total- --io/total- -net/total- ----swap---
     time     |usr sys idl wai hiq siq:usr sys idl wai hiq siq| 1m   5m  15m | used  buff  cach  free| read  writ| read  writ| recv  send| used  free
20-10 11:04:12|  3   0  97   0   0   0:  3   0  97   0   0   0|0.46 0.51 0.75| 562M 10.1M  268M  152M|  88k   25k|2.34  0.88 |   0     0 |  98M 1950M
20-10 11:04:22| 18   1  81   0   0   0: 27   1  71   0   0   0|0.39 0.49 0.74| 571M 10.2M  268M  143M|   0    18k|   0  0.60 |4160k 4160k|  98M 1950M
20-10 11:04:32| 70   3  26   0   0   1: 59   3  35   3   0   1|0.79 0.57 0.77| 593M 5276k  220M  174M|7468k  368k| 227  23.1 |7777k 7652k|  99M 1949M
20-10 11:04:42| 76   2  21   0   0   1: 73   2  24   0   0   0|1.29 0.69 0.80| 629M 3276k  176M  184M| 423k  328k|14.0  22.5 |  11M   11M| 100M 1948M
20-10 11:04:52| 98   2   0   0   0   0: 98   2   0   0   0   0|1.93 0.85 0.85| 704M 3724k  179M  106M| 225k  283k|4.30  22.6 |  11M   11M| 100M 1948M
20-10 11:05:02| 97   2   0   0   0   0: 97   2   0   0   0   0|2.85 1.08 0.93| 656M 3692k  129M  204M|  60k  405k|5.10  32.4 |  14M   14M| 100M 1948M
20-10 11:05:07| 97   3   0   0   0   0: 98   2   0   0   0   0|3.18 1.18 0.96| 766M 4012k  131M 92.0M| 266k  366k|7.20  28.8 |  16M   16M| 100M 1948M
20-10 11:05:08| 97   3   0   0   0   0: 98   2   0   0   0   0|3.18 1.18 0.96| 683M 4068k  131M  175M| 234k  361k|6.67  28.2 |  18M   18M| 100M 1948M
20-10 11:05:09| 97   3   0   0   0   0: 98   2   0   0   0   0|3.18 1.18 0.96| 664M 4076k  131M  193M| 201k  321k|5.71  24.7 |  17M   17M| 100M 1948M

server1の2台のCPUが最大のボトルネックになっている。メモリは1GBのなかで800MB近くまで使っているが、swapは特に起きていない。このことから、

isucon実装改変アイディア

11時の時点でインフラを整える中で考えていたのは以下の改良であった。

  1. dockerをはがす(dockerがあると通信のボトルネックになるという例が、過去のisuconであった)
  2. mysqlを別サーバーに逃がして、pythonサーバ3台とmysql1台としたときにどうなるか? -> 14:00に改善
  3. nginxで静的ファイルをキャッシュする(あまりスコアには貢献しないかもしれなさそう)-> 13:30に改善
  4. /orders が重いのをなんとかできないだろうか?
    • ベンチマーカのログで、error: POST /orders request failed: this user give up browsing because response time is too long. [10.01874 s] となって落ちている。

2,3の改善を行い、最終的には4台のサーバーは以下のような構成とした*1

13:00〜 プロファイリング

alpによる計測

+-------+-------+--------+----------+-------+-------+-------+--------+--------+------------+------------+--------------+------------+--------+-------------------------------+
| COUNT |  MIN  |  MAX   |   SUM    |  AVG  |  P1   |  P50  |  P99   | STDDEV | MIN(BODY)  | MAX(BODY)  |  SUM(BODY)   | AVG(BODY)  | METHOD |              URI              |
+-------+-------+--------+----------+-------+-------+-------+--------+--------+------------+------------+--------------+------------+--------+-------------------------------+
|   218 | 0.000 |  0.000 |    0.000 | 0.000 | 0.000 | 0.000 |  0.000 |  0.000 |  51679.000 |  51679.000 |  8216961.000 |  37692.482 | GET    | /js/moment.min.js             |
|   218 | 0.000 |  0.000 |    0.000 | 0.000 | 0.000 | 0.000 |  0.000 |  0.000 |    886.000 |    886.000 |   140874.000 |    646.211 | GET    | /                             |
|   218 | 0.000 |  0.000 |    0.000 | 0.000 | 0.000 | 0.000 |  0.000 |  0.000 |  11992.000 |  11992.000 |  1906728.000 |   8746.459 | GET    | /css/app.033eaee3.css         |
|   218 | 0.000 |  0.000 |    0.000 | 0.000 | 0.000 | 0.000 |  0.000 |  0.000 |    894.000 |    894.000 |   142146.000 |    652.046 | GET    | /favicon.ico                  |
|   218 | 0.000 |  0.000 |    0.000 | 0.000 | 0.000 | 0.000 |  0.000 |  0.000 |   8988.000 |   8988.000 |  1429092.000 |   6555.468 | GET    | /img/isucoin_logo.png         |
|   218 | 0.000 |  0.000 |    0.000 | 0.000 | 0.000 | 0.000 |  0.000 |  0.000 |  14403.000 |  14403.000 |  2290077.000 |  10504.940 | GET    | /js/Chart.Financial.js        |
|   218 | 0.000 |  0.000 |    0.000 | 0.000 | 0.000 | 0.000 |  0.000 |  0.000 |  19425.000 |  19425.000 |  3088575.000 |  14167.775 | GET    | /js/app.2be81752.js           |
|   218 | 0.000 |  0.002 |    0.091 | 0.000 | 0.000 | 0.000 |  0.001 |  0.001 | 139427.000 | 139427.000 | 22168893.000 | 101692.170 | GET    | /js/chunk-vendors.3f054da5.js |
|   218 | 0.001 |  0.003 |    0.173 | 0.001 | 0.000 | 0.001 |  0.003 |  0.001 | 159638.000 | 159638.000 | 25382442.000 | 116433.220 | GET    | /js/Chart.min.js              |
|     1 | 0.052 |  0.052 |    0.052 | 0.052 | 0.052 | 0.052 |  0.052 |  0.000 |      2.000 |      2.000 |        2.000 |      2.000 | POST   | /initialize                   |
|     1 | 0.138 |  0.138 |    0.138 | 0.138 | 0.138 | 0.138 |  0.138 |  0.000 |     13.000 |     13.000 |       13.000 |     13.000 | DELETE | /order/719388                 |
|   170 | 0.025 | 10.295 |  685.267 | 4.031 | 0.025 | 3.725 |  9.900 |  2.806 |      0.000 |     59.000 |     6374.000 |     37.494 | POST   | /signin                       |
|   249 | 0.007 | 10.535 |  989.574 | 3.974 | 0.008 | 3.971 | 10.052 |  2.750 |      0.000 | 141746.000 |   227333.000 |    912.984 | GET    | /orders                       |
|   122 | 0.195 | 10.569 |  510.996 | 4.188 | 0.195 | 4.369 |  9.471 |  2.587 |      0.000 |     40.000 |      287.000 |      2.352 | POST   | /signup                       |
|   637 | 0.019 | 10.845 | 2860.303 | 4.490 | 0.025 | 4.367 | 10.457 |  2.645 |      0.000 | 183587.000 |  9204806.000 |  14450.245 | GET    | /info                         |
|   159 | 0.142 | 12.329 |  833.227 | 5.240 | 0.142 | 5.192 | 11.633 |  3.053 |      0.000 |     87.000 |     1855.000 |     11.667 | POST   | /orders                       |
+-------+-------+--------+----------+-------+-------+-------+--------+--------+------------+------------+--------------+------------+--------+-------------------------------+

ISUCOINのコード中に、SNS シェアを有効にしてユーザーの流入を増やすことのできるboolean optionがあり、これをTrueにするかどうかでアクセス数がかなり変化し、ベンチマークに影響を与えていた。SNS shareをTrueにした場合のalp によるログは上記であった。このことから、応答に10秒以上かかるスローリクエストが発生していることが分かった。実際にこれは、ベンチマーカーのログをみるとタイムアウトが発生してエラーとしてカウントされており、ベンチマークを完動させるにはこのようなスローリクエストの原因を潰すことが必要になると考えた。

いかにしてpython-flask serverの計測をしたか

alp を利用することで、どのリクエストが負荷の原因となっているかを調査することはできる。しかし、そのリクエストの中で、具体的にどのコードが律速となっているかを判断することは、実際のアプリケーションサーバのプロファイルをとらない限り、困難である。pythonで軽量サーバを利用するとなると選択肢が限られていることから、flaskをはじめとするWSGI互換の軽量サーバーが登場することを予想していた。予選における反省を踏まえ、直前対策ではどのようにしてpythonによるアプリケーションサーバのプロファイリングするか、ということを重点的に調査した。

まずはプロファイルを出力するため、 GitHub - pallets/werkzeug: The comprehensive WSGI web application library. をflaskに仕掛けた。

from werkzeug.contrib.profiler import ProfilerMiddleware
app.wsgi_app = ProfilerMiddleware(app.wsgi_app, profile_dir="/tmp/profile")

docker-compose.ymlで /tmp/profile をホストのprofileディレクトリでマウントするようにすれば、ホストからプロファイルファイルをみることができるようになる。リクエスト毎にバイナリファイルが1個ずつ出力されていることが見て取れるだろう。

プロファイリングの結果から

まず、1回のベンチマークに対して、その時の全てのリクエストにおける所要時間の占める割合を可視化した。

gprof2dot -f pstats --colour-nodes-by-selftime --show-samples profile/* > profile.dot
dot -Tpng profile.dot > profile.png

上記の処理は、 ISUCON7予選1日目にチーム「ババウ」で参加して最終スコアは205148でした - Dマイナー志向 を参考にした。結果は下図である。

f:id:reqly-tokyo:20181021172045p:plain

このグラフからも、/order が実行時間の大宗を占めていることが明らかになったが、これよりもさらに細かい、関数ごとの所要時間をみてみることにしたい。 /tmp/profile に出力されるprofileファイルには、そのファイル名でそのリクエストの処理にかかった時間がミリ秒単位で記載されていることから、スローリクエストのプロファイルを対象として、SnakeViz を利用することで、取得したプロファイルを階層構造で表現することができる*2

10秒近くかかっている /order のクエリのうち、isuloggerへのrequestが数秒占めていることが明らかになった。そこで、そのコードを読んでみたところ、isuloggerにログを送りつける処理は、同期処理でPOSTのリクエストを吐いていることが明らかになった。ロガーがつまっているときにこのリクエストが遅延するのではないかと考え、非同期処理でisuloggerへのpost処理を行うようにした*3

その後、プロファイルは以下のようになった。まだスロークエリではisubankへのAPIリクエストに時間がかかっていたが、この問題については本戦期間中に有効な解決策を見いだすことはできなかった。

f:id:reqly-tokyo:20181021093856p:plain

時系列データの取得に向けて

またここで、alpベンチマークの結果をみてみると、1分の負荷走行のうち45秒ぐらい経過すると、アクセスが捌ききれなくなって、/info/orders/signup などアプリケーションサーバに対するリクエストが軒並みタイムアウトにするようになった。しかしこのタイムアウトの連鎖は、最初に何かタイムアウトを引き起こす原因となるリクエストがあり、それによって他のリクエストも巻き添えを食ってタイムアウトしたのではないかと考えられる*4。つまり、どのリクエストが原因で詰まり始めたのかや、最大でどのぐらいの同時接続数があったのかを検証することが、タイムアウトの原因究明につながったと考えられる*5

こうしたときに、 alp やプロファイラの出力だけでは、リクエストのスナップショットや、全リクエストの合計・平均は分かるかもしれないが、経時的な変化を観測することはできない。ngxtopngreptcpdump などでシェル上でログを眺めることはできるが、ログが流れてしまうという問題点がのこる。反省会では可視化して解釈するため、 GitHub - netdata/netdata: Get control of your servers. Simple. Effective. Awesome! https://my-netdata.io/ を導入しておけばよかったのではないか、というコメントが出たので付記しておく。

〜18:00 実装

今回取り組むことのできた実装は、大別すると以下であった。

  • LIMIT 1の追加・MySQLサーバの分離(スコア2,000点〜3,000点)
  • isuloggerへのpost処理を非同期化(スコアが4,000点〜5,000点)
  • アプリケーションサーバを3台に負荷分散(スコア最大で6,000点)

スコアの変遷は以下の通り。

f:id:reqly-tokyo:20181022112829p:plain

f:id:reqly-tokyo:20181022125426p:plain

反省

仮想通貨の取引や、APIサーバを内部で叩く実装は、REQLYチームの誰もなじみのないコードであったため、アプリケーションの全貌を理解するのに時間を費やしてしまったことが、まず反省としてあげられる。その後のプロファイリング・ドリブンによる計測によって、ボトルネック箇所の同定はできたが、それに対する有効な手立てが思いつかず、実装が進まぬまま時間が過ぎてしまったことが悔やまれる。

以下の点に改善を加えることができれば、点数を伸ばすことができたと考えられる。

  • isuloggerやisubankなどの外部API/venderソースコードは、深追いする必要は無いと判断してしまったが、マニュアルをよく読めば、バルクでisuloggerに投げる実装がTODOになっており、それによって点数の改善が見込めた。
  • /info を雑にキャッシュすることは、改善方法として考えていたものの、実装まで至らなかった。
  • これはアルゴリズムの改善というより気づきの問題であるが、SNSシェアの仕様について、「SNSシェアを全ての場合で許可する/許可しない」の二択だと思い込んでいたが、実際にはSNSシェアするかどうかはリクエストごとのものであるという点に発想が至れば、エラーが頻発しない程度に確率的にシェアさせることが可能になったはずである。

おわりに

運営の皆さんありがとうございました!これからもREQLYチームは精進を続けます!

*1:ロードバランシングの仕組みが再起動試験した際にうまく動かないことが分かったので、結局最終的にはSERVER1およびSERVER2しか利用しなかった

*2:この図はあたかも生物のタクソノミのヒエラルキーであるかのように見える

*3:しかしこれは、実際にはバルクでisuloggerに流す実装をしたほうがよかった

*4:大量のリクエストのうち、どれかが"ドライバー"となってタイムアウトを引き起こし、残りが"パッセンジャー"として巻き添えになってしまった、という言い方もできるだろう

*5:例えば、もしかしたらgunicornのワーカー数等の設定を変更することで、タイムアウトを抑制できたかもしれない

初出場3人でISUCON8予選を学生枠1位(?)で通過しました

ISUCON(Iikanjini Speed Up CONtest)というWebサービスの高速化を図るコンテストにREQLYで参加してきました。全員初出場でしたが、結果は30,491点でなんと学生枠1位(?)で通過!厳密には一般枠で通過していた猛者の学生が4チームいますが、学生枠での1位は1位、嬉しいものは嬉しい!

メンバー

  • yoco: 唯一インフラが分かる人。発起人。
  • m2ku: なんとなくSQLが分かる人。筆者。
  • kenbo: マグロの研究者。試合後、病床に臥す。

事前準備

事前準備としてpixiv社内ISUCONを2回に分けて解きました。1回目はslow query解析やalp等の計測ツールの基礎を導入方法から勉強し、そのあとは静的ファイルの処理などを試しました。2回目は本番の前日の土曜日に行い、様々な記事を参考に、N+1問題の解消など点数を上げていく練習をしました。

役割分担・使用言語

インフラが分かる人がyoco以外にいなかったので、アプリケーション側をm2kuとkenboの2人で担当するフォーメーション。SQLに関しては、m2kuがインデックスとJOINを嗜む程度。使用言語はm2ku, kenboが普段書き慣れているPythonを採用しました。

本番参加記

それぞれの備忘録を貼っておきます。

m2ku編

最初の1時間

まずサーバーが3台もあったので怯える。なんとなく3番目のサーバーを選択し、 こいつは自分のものだと宣言する。事前の取り決め通り、pyenvでjupyter notebookが使えるようにpython環境を整えたりbitbucketにwebアプリのコードを上げたりした。残りの担当はmysqlのslowqueryの設定だったが、こっちのサーバーにはmysqlがたってないので何もできない。yocoにmysqlのサーバー3への移籍を優先でやるようにお願いする。その間は、webサイトをいじりつつ、たぬきうどんを食べる。sheetseatのスペルミスについてkenboがドヤ顔をしているので殴る(10:48)。flaskのコードをみて基本的にシングルページで/api以下にアクセスすることで描画に必要なjsonをもらっていそうだ、ということが分かる。そうこうしているうちにサーバー3でmysqlがたつ。

slowqueryの設定

  • 練習では/etc/mysql/my.cnfをいじったがファイルが見つからなかったので怯える
  • 頑張ってググると(10:56)includedirみたいなことをやってどっかのディレクトリを読み込んでるみたいだった。
  • ディレクトリにあったファイルのうち[mysqld]という項目を持っていたファイルに予定通り以下の3行を追加
slow_query_log_file = /var/log/mariadb/slow.log
slow_query_log      = 1
long_query_time = 0
  • 練習で作っていた、restart.sh, check_mysql.shなどの便利スクリプトも仕込む。

初めてのベンチマーク

ベンチを回そうとなったが、サーバー1からサーバー3へのdbのアクセスがうまくいっていないらしく、手こずっていた。その間はdbのスキーマを確認しつつ、webサイトをいじり始める(with kenbo)。

  • /api/event/10を叩くと直接jsonが見れることを確認
  • eventにもpriceカラムがあって、sheetにもpriceがあることが分かる
    • 実際にwebサイトを見ると、2つの和になっていそうなことを確認
  • どのeventを選んでも、Sの席数などは変わらないことを確認。

この辺を確認したところで、yocoがmysqlの連携とkataribeの連携が終わったようで、ベンチマークを回して諸々の計測をする(11:54, 400点)。/login/users/eventsが遅いな、という空気感になる。/users/eventsが遅いのはクエリ的(スロークエリ、N+1)にも妥当だが、/loginが遅いのはクエリも速いし納得が行かないと主張する。誰も信じてくれないので、pythonのloggingを用いて、関数の実行時間を取得するコードを書く。実際にこの関数は数ミリ秒で終わっていることが分かる(12:30)。次回はアプリケーションの関数の速度を直に観測するツールの準備が必須だと感じる。kenboと唐揚げを買いに行く。道中/users/eventsは2時間くらいあれば自分が直せそうなので引き受けると主張する。/loginは全然わからないのでkenboに調査をお願いする。

get_event()改修

仕事を始める(12:54)。/usersの方はスロークエリの上位に入っていたのでindexを貼れば簡単に治りそうだと思い先に着手する。SQLIFNULLとかGROUP BY HAVINGとか初めて見て怯える。コードを読んでいくと、途中でget_event()を複数回呼んでいることに気づき、 /events も合わせてこれが諸悪の根源であることに気付く。方針転換して、get_event()のN+1を最初に直すのが良さそうだと考える。jupyter notebookでget_event()をリニューアルすることを始める(13:46)。最初はDBとの接続に苦労したが、yocoに聞いたら「flaskを捨てろ」といって華麗に解決してくれる。新しくsheet情報を一括で取得するクエリを作成し、結果を元のget_event()と返り値が同じになるように整える。jupyter上で%%timeで計測したら、4倍くらい早くなっていたので、良さそうだと感じる。deployされて点数が伸びる(15:00, 10000点)。ドヤ顔をして30分休憩する。totalの席数やランクごとの値段の加算がeventによって変わらないということをあらかじめ知っていたので、コードを単純化できたのも良かった。

def get_event(event_id, login_user_id=None):
    cur = dbh().cursor()
    cur.execute("SELECT * FROM events WHERE id = %s", [event_id])
    event = cur.fetchone()
    if not event: return None

    query = '''
    SELECT *
    FROM sheets
    LEFT JOIN (
        SELECT
            sheet_id,
            user_id,
            reserved_at
        FROM reservations
        WHERE event_id = {}
        AND canceled_at IS NULL
        ORDER BY sheet_id
    ) AS sub
    ON sheets.id = sub.sheet_id;
    '''.format(event_id)

    cur.execute(query)
    sheets = cur.fetchall()

    event["total"] = 1000
    event["remains"] = 0

    event["sheets"] = {}
    event["sheets"]["S"] = {"total": 50,    "remains": 0, "detail":[], "price": event["price"] + 5000}
    event["sheets"]["A"] = {"total": 150, "remains": 0, "detail":[], "price": event["price"] + 3000}
    event["sheets"]["B"] = {"total": 300, "remains": 0, "detail":[], "price": event["price"] + 1000}
    event["sheets"]["C"] = {"total": 500, "remains": 0, "detail":[], "price": event["price"] + 0}

    for sheet in sheets:
        if sheet["reserved_at"] is None:
            event["remains"] += 1
            event["sheets"][sheet["rank"]]["remains"] += 1
        else:
            sheet["reserved"] = True
            sheet["reserved_at"] = int(sheet['reserved_at'].replace(tzinfo=timezone.utc).timestamp())
            if sheet["user_id"] == login_user_id:
                sheet["mine"] = True

        event["sheets"][sheet["rank"]]["detail"].append(sheet)
        del sheet['id']
        del sheet['price']
        del sheet['rank']
        del sheet['sheet_id']
        del sheet['user_id']

    event['public'] = True if event['public_fg'] else False
    event['closed'] = True if event['closed_fg'] else False
    del event['public_fg']
    del event['closed_fg']
    return event

残り2.5h

残りの2.5hで何をするか話し合う。/users/adminの2つがネックになっているという話になる。/usersの方を取り、/adminを2人に任せる。

  • /eventsでは自分以外のsheet情報がいらないのでget_event()を呼ばずに専用の小さいクエリでアクセスするようにする
  • user_idでindexを貼る

などの細かい修正をしたが、ベンチマークがしばらく走らなくなってしまっていたこともあり、改善がどれくらいあったのかは分からない。ここに関してはmaster一本開発より、各々featureブランチを切っても良かったかもと思った。あと、indexを手動で貼っても、schemaに書いておかないと再起動後には失われてしまうということに気付いていなかったため、重いクエリを読み間違えたりしていたのが勿体無かった。また、サブクエリ内で使われると思っていたINDEXが、実際にEXPLAINで確認すると使われてなかったりして、本戦までにはSQL力を上げておきたい。reserved_atと主keyの順番は同じであると仮定して、ORDER BYしなくても良い説をなども唱えたが、これはどうなんだろうか。そもそもカラムがすでにsortされているかを確認するSQL文を知らないので、調べておく。

yoco編

最初の2時間

h2oに対してalpを仕込もうとしたが、alpはltsv形式での出力を要求している。nginxの設定ファイルをみながら、h2oのログの出力をalpがパースできる形に変換しようと考えたが、うまくいかない。そこでいろいろ調べてみると、kataribeというロギングツールがあることがわかったので、kataribeを導入することにした。kataribeは、h2oのもとのログフォーマットに、request_timeを追加するだけであったので、容易に導入することができた。

3台体制であったので、いままでの練習会で試していないパターンであって動揺するが、すぐにサーバ1とサーバ2をアプリケーションサーバ、サーバ3をdb用にすることに決めた。サーバ3にdbをつなげるのに苦労した原因は、「mysqlの初期化コード」がサーバ1に置いてあり、これは/initialize にアクセスしたときに実行されるが、その初期化コードが参照するmysqlが、デフォルトではサーバ1のものであったためである(これが俗に言う鈴木明エラーである)。このサーバがさす向きをipアドレスを直で指定してサーバ3に切り替えるという修正を行った。

お手製のpython再起動スクリプトrestart.shと、チームslackに通知するためのスクリプトslack-notifier.shを、サーバ1およびサーバ2にも導入した。これにより、private git repositoryでpythonサーバのコードを管理しておいて、アプリケーションを再起動したいときはそのコードをpullしてsystemctlでrestartする環境を整えることができた。

ここまで2時間かかってしまったことは誤算であった。

それ以後試したこと

インフラ側のチューニングを行った。今回の初期設定ではgunicornというアプリケーションサーバで、flaskのアプリはサーブされている。これは、もとの設定ではワーカ1、スレッド1で動いていたので、1コアしか利用することができなかった。

mysqlshow connections;をしたところ、1ワーカにつき1つのコネクションしか貼らないという挙動がみえた。この挙動通りだとすると、スレッド数を増やすよりワーカの数を増やしたほうが、mysqlのスロークエリがコネクションを占有することが少ないのではないかという仮説を持った。

そこで、少し変則的であるが、スロークエリが多いことからワーカー数を増やして並列化することにした。これにより、アプリケーションサーバで1コアだけでなく、2コアを使うことが可能になり、スコアが多少増加した。

h2oはnginxに比べてほとんどチューニングする余地はない。が、負荷分散のために/admin のアクセスだけを、サーバ2に送ることにした。これは、結果的には/admin以下にある巨大なCSVを吐くエンドポイントがワーカを占有することを回避できたといえる。(mysqlトランザクション分離レベルの問題か?)。

その後、kenboと、遅い箇所について検証した。

/loginが遅いのはクエリも速いし納得が行かないと主張する。誰も信じてくれないので、pythonのloggingを用いて、関数の実行時間を取得するコードを書く。

というところから、ロギングで実行時間として計測していない部分、今回の場合ではreturn文でjsonifyしているところが遅いのではないかと考えた。実際に/loginjsonを吐くところが律速になってたようで、python-rapidjsonを導入することで1リクエストあたりの所要時間が激減し、スローリクエスト順にソートされたログの上位からは姿を消した。

csvを吐くエンドポイント(@app.route('/admin/api/reports/sales'))のpythonコード側の改修を行い、ループの回数とソートの回数を1回ずる減らしたが、定数倍の高速化であることに加え、このエンドポイントはクエリ側がFOR UPDATEでロックをとっていたことが律速であったのでそれほど意味がなかった。

m2kuがget_eventのN+1クエリを華麗に解決したので、3万点に到達した。m2kuがクエリ修正をやっている間に、yocoとkenboではSQLをほとんど触れなかったので、それが今後の課題となる。

憶測

レギュレーションからみて、「予約or予約取り消し」のたびに10点、その他のリクエストは基本的にゴミなので、できるだけ予約を進めたいが、その他のクソリクエストにcsvを吐くとかの激重FOR UPDATEリクエストが混じっているせいで、点数の入るリクエストの実行が妨げられていたと思われる。そのため、いかにうまくそれらを捌くか、ということが重要であったように見える。

ベンチマーカの挙動として、3万点の壁を越える頃には、/login/ などに対するアクセスが1回につき数千アクセス発行されるようになった。これらのアクセスは、点数にはほとんど寄与しないが、全体の処理速度を遅くしてしまうという問題があった。これに対して、これ以外のエンドポイントをサーバ分けてしまうと、reservation更新/参照系のクエリに不整合が起きる(例えばサーバ1に更新クエリを送ったとき、それが処理されるまえにサーバ2で参照している?)らしいということがわかったので、色々と考えてみたが、failを完全に防ぐ切り分け方は当時は見つからなかった。(上位通過チームでも、random failに苦しんでいたようだった。)

コメント

今回の勝因は「細かい修正箇所に惑わされず、計測結果を信じて最もボトルネックとなっていたget_eventを修正したこと+/admin以下の遅いクエリを別サーバに待避させた」という点にあると思っている。 再起動確認はビビらずにさっさとやればよかった。

おわりに

運営の皆さんありがとうございました。本選頑張ります!

形態素解析とシノニム変換による検索エンジンの改善

はじめに

REQLYは、プロの料理レシピ専門の高機能検索エンジンとして、人とレシピとのより良いマッチングを実現することを目指しています。しかしながら従来のREQLYには、以下の2つの大きな問題がありました。

  1. 料理レシピ特有の単語が適切に検索できない場合がある。
    • 例えば「火鍋」で検索したときに、「火鍋」で一単語と認識できず、「火」と「鍋」を別々に含むレシピにもマッチしてしまう。
  2. 同じものをさす別の単語で検索した場合で、異なる検索結果が表示される。
    • 例えば「プチトマト」と「ミニトマト」、「インゲン」と「いんげん」など。

このように一部の検索ワードの意味解釈に失敗する場合があり、これではお世辞にも"高機能"とは呼べません。過去記事で触れたように、REQLYはバックエンドの検索エンジンとしてElasticsearchを利用しています。最新のβリリースでは上記の問題点を改善するためにElasticsearchのチューニングを行いました。ここではREQLYの検索結果がどのように改善されたかを紹介いたします。

reqly.tokyo

検索エンジンの流れ

まずは検索エンジンが一般的にどのような仕組みで動いているのかを見ておきましょう。検索エンジンは入力された検索ワードをどのように処理して検索結果を返しているのでしょうか?

1. 完全一致検索

はじめは単純に、検索ワードを全文検索して完全一致したレシピを返す場合を考えてみましょう。この方法でもある程度は上手くいきますが、求めている結果の一部しか抽出されないという問題点があります。例えば鶏むね肉を使ったレシピが知りたくて「鶏むね」と検索したとします。レシピによって、鶏むね肉の表記は「鶏胸肉」「鶏むね肉」「鶏肉(むね)」と様々ですが、「鶏むね」という一語の単語に完全一致するかどうかで検索してしまうと、「鶏むね」という一続きの文字列が含まれているレシピしかヒットしなくなってしまいます。 f:id:reqly-tokyo:20180728202405j:plain

2. 形態素解析

そこで「鶏」と「むね」が文中で離れているようなレシピに対しても、「鶏むね」でヒットするように改善したいと思います。このとき、「鶏むね」という単語を「鶏 + むね」に分割して、両方を含むようなレシピを検索すれば、単語が離れているような場合にも検索ができるようになるでしょう。

f:id:reqly-tokyo:20180728192749j:plain

このような部分単位は形態素と呼ばれ、形態素に基づいて文を分割することを形態素解析と呼びます。

3. シノニム変換

形態素解析を行ったとしても、「鶏むね」という検索ワードでは「とり胸肉」にヒットしません。この問題に対処するためには「鶏」と「とり」、「胸」と「むね」が意味上は同じものを指していることを踏まえ、それらを検索上で同一視する必要があります。こうした関係性は類義語(シノニム)と呼ぶことができます。

f:id:reqly-tokyo:20180728192759j:plain


このように、形態素解析シノニム変換の2つを正しく組み込むことで、検索ワード(= 鶏もも)に込められたユーザーの意図を理解し、適切な検索結果(= 鶏もも肉を使ったレシピ)を提供することが可能になります。Elasticsearchでは、インデックスを貼る際にプラグインを導入したり、設定ファイルを書き換えたりすることで、検索の挙動を変更することができます。以下では、行ったチューニングの内容と、それにより検索結果がどう変化したのかの具体例を紹介します。

施策

1. 食材名/一部レシピ名の辞書登録

Elasticsearchのプラグインとして、新語・固有表現に強いと言われているmecab-ipadic-neologdをベースとなる辞書として利用し、形態素解析を行っています。これに加えて、REQLY内部で独自に定義している食材オントロジーおよびマニュアル操作によって、その辞書に登録されていない単語を追加して、検索する際の分かち書きをより正確にしました。

github.com

例えば「火鍋」は中国の伝統的な鍋料理であるが、これがElasticsearchの辞書に登録されていないことによって、「火」と「鍋」に形態素解析され、「火」および「鍋」を使う多くの料理にヒットしてしまいます。これでは、火鍋に到達することができません。そこで、「火鍋」を一語の単語として新規に登録することにより、この問題を回避しました。

f:id:reqly-tokyo:20180721215846p:plain

火鍋では、誤検出を減らすことに成功しました。

2. シノニムの登録

最初に示した「プチトマト」や「ミニトマト」、その他にも「ジャガイモ」と「ポテト」のように、本来は同一のものをさす単語であっても、異なる単語であると認識されて別々にインデキシングされてしまうことにより、単語毎に検索結果が異なるという現象が起きてしまいます。

こうしたものは類義語(シノニム)の関係にあります。これらをElasticsearchに登録することで、あるシノニムに含まれる単語がインデックスされている際に、そのシノニムのどの単語をキーワードに含めた検索に対しても、共通のシノニムを含むドキュメントが検索でヒットするように変更することができます。

例えば「ミニトマト」と「プチトマト」はさす対象を同一視しても問題ないと考えられますが、今までは別々に検索されていました。そこで、その2つをシノニムにまとめることで、どちらの単語で比較しても同じ結果が返ってくるようになりました。

f:id:reqly-tokyo:20180724135510p:plain

一回の検索で、「ミニトマト または プチトマト」の結果が得られるようになったことで、どちらの単語で検索した場合でもレシピ数が増加しました。全体の件数が減っているように見えるのは、レシピ内に「ミニトマト」と「プチトマト」の表記が混在しているものがあるためです。

3. Exclude 検索の実装

形態素解析を行うことで、「カツオ節」が「カツオ+節」と分かち書きされるように、辞書に登録された単語は形態素に分解された形で索引されるようになります。ところが、「カツオ」を使った料理を検索しようとした場合、カツオ節を使った料理がたくさん表示されると興ざめしないでしょうか。これに対する対処の方法として、

  1. 料理に関する単語は、形態素解析しないようにする。
  2. 全ての単語は形態素解析して、料理に関する単語では検索結果から、除外したい単語を含むレシピを除外するようにする。

この2通りのやり方が考えられますが、1. の方法はElasticsearchのmecab-ipadicの辞書を使うことができなくなるほか、恐らくElasticsearchのプラグインとして、単語ごとの場合分けの処理をする必要があるでしょう。どちらにしても、2. の方法よりシンプルではなくなります。Elasticsearchでは、除外検索を行うことができるので、除外する単語のリストを予め連想配列のようなオブジェクトで持っておいて、ある単語が検索ワードに含まれているとき、その単語のリストを自動的に除外対象に加えるような実装を行うことで、この問題に対処できるでしょう。

{"カツオ": ["鰹節", "カツオ節"]}

上記のように、「カツオ」を検索ワードとして入力する場合、自動で鰹節やカツオ節の結果を排除するようにしました。これにより、かつおを削ったものとしてではなく、主要な食材として利用するレシピが表示されるようになりました。

f:id:reqly-tokyo:20180717234828p:plain

カツオでは、カツオ節を取り除いたことにより、検索の精度が向上しました。

4. Alternative 検索の実装

鱈(タラ)を利用したレシピを検索したときに、「たら」という文字列で検索しようとすると、「〜したら」という単語にマッチしてしまいます。(なぜならば、〜したらという単語は動詞「する」の未然形「し」と、接続助詞「たら」に形態素解析できるためです)。

今まではこうした検索ワードは除外していたので検索結果では表示されませんでしたが、REQLYで「たら」という単語を検索する際に、接続助詞を検索したいという需要はないと考えられますので、食材としての「タラ」ないし「鱈」として検索するように、内部で変換を行うようにすることで、表示されるようになりました*1

ただし、「もも」のような場合では、果物の「桃」の場合と「鶏もも肉」のように肉の部位をさす場合で利用されることから、変換を行っておりません。果物の桃を利用したレシピを検索したい場合は、キーワード「桃」をお試し下さい。

まとめ

REQLYの検索エンジンはまだまだ改善を続けていきます。皆さまの食卓を彩る手料理のお供に、REQLYをぜひご利用下さい!

付録

参考として、Elasticsearchの設定ファイルでどのように辞書を登録したのかについて記載します。

{
  "settings": {
    "index": {
      "analysis": {
        "tokenizer": {
          "ja_tokenizer": {
            "type": "reloadable_kuromoji_neologd_tokenizer",
            "mode": "search",
            "user_dictionary": "userdict_ja.txt"
          }
        },
        "filter": {
          "ja_synonym": {
            "type": "synonym",
            "synonyms_path": "synonym.csv"
          }
        },
        "analyzer": {
          "analysis": {
            "type": "custom",
            "tokenizer": "ja_tokenizer",
            "char_filter": [
              "icu_normalizer",
              "kuromoji_neologd_iteration_mark"
            ],
            "filter": [
              "kuromoji_neologd_stemmer",
              "kuromoji_neologd_part_of_speech",
              "ja_stop",
              "kuromoji_number",
              "ja_synonym"
            ]
          }
        }
      }
    }
  }
}

userdict_ja.txt, synonym.csv は前もって、elasticsearchで指定している環境変数ES_HOME 以下のディレクトリに置いておきます。

*1:ただし、この場合はレシピの文章中に食材としての「たら」が記載されている場合は、どのみち検索結果からヒットすることはできません。こうした品詞による検索の改善は、今後の課題といえるでしょう

ランダムフォレストを用いてスイーツ識別器の作成に挑む

はじめに

こんにちは。エンジニアのkenboです。レシピ検索エンジンREQLYでは、検索フィルタを活用することで検索ワードを入力せずに検索をすることも可能です。例えば、「スイーツ作りにチャレンジしたいけど何を作ろうかな・・・」という場合には、「スイーツ」と「簡単」をフィルタにセットして検索すれば、きっと良いレシピが見つかるはずです。ところで、REQLYではどのようにして各レシピが「スイーツ」であるか判断してるのでしょうか?今回はそんな機械学習の舞台裏をお見せします。

reqly.tokyo


ルールベースによる分類?

どのようにすれば、レシピがスイーツであるか判断できるでしょうか?例えば、多くのスイーツには「砂糖」と「卵」の両方が使われているように思います。まずはこの考えをもとに決定木を作ってみましょう。

f:id:reqly-tokyo:20180704004022p:plain
図1 決定木を用いた分類

残念ながらこの条件では、チョコバナナ、ゼリー、だし巻き卵のような場合にうまく分類できないことがわかります。追加で「チョコ」や「みりん」を加えようと考えるかもしれませんが、それでも上手くいかない例は無数にあるでしょう。このように、スイーツかどうかの判断には複数の材料を考慮する必要がありますが、そのような識別器を人手でルールベースで構築していくことは現実的ではありません。このような問題を解決する手法が、機械学習のRandom Forestという方法です。Random Forestを用いれば、コンピュータが自動でルールを作成してくれます。


Random Forestとは

Random Forestは、コンピュータが決定木を複数作りそれらの多数決を取ることで分類をおこなう手法です。イメージ図を見ながら、分類の流れを確認していきましょう。

f:id:reqly-tokyo:20180705111716p:plain
図2 Random Forestの概念図

まず、決定木をN本作成します。各決定木は、全データ集合から独立に生成されたN個のサブサンプルをそれぞれの教師データとして学習されたものです。この際、それぞれの決定木が使うことができる説明変数もランダムに選ばれます。従ってこれらの決定木は互いに異なり、同じ入力に対しても出力が異なる場合があります。識別の際には、N本の決定木の出力の多数決を取ることで全体としての出力を決定します。このように、独立に学習した決定木を複数用いることで全体としての汎化性能を挙げている点がRandom Forestの大きな特徴です*1。教師データと説明変数の両方をランダムに選択することで、互いに相関の低い決定木を作成し汎化性能をあげています。

他の機械学習アルゴリズムと比較したときのRandom Forestのメリットは、以下のような点が挙げられます。

  • 決定木をベースに構築されているので、特徴量に対するスケーリングが不要
  • 各決定木の学習は独立しているので、並列化が容易
  • モデルに対する特徴量の重要度がわかるので、特徴量選択を行いやすい


Random Forestによるスイーツ識別器

では、実際にRandom Forestを用いてスイーツ識別器を作成してみましょう。

教師データとして、正解ラベルが付与された計3305件(正例と負例がおよそ半分ずつ)のレシピデータを用意しました。それぞれのレシピデータは、各食材がそのレシピに含まれているかどうかの480次元のバイナリ特徴ベクトルに変換できます(図3)。

f:id:reqly-tokyo:20180704122811p:plain
図3 レシピデータを特徴ベクトルに変換

これらのデータに対し、Python機械学習ライブラリであるscikit-learnを用いてスイーツ識別器を作成しましょう。 まずはデフォルトのパラメタで推定してみます。

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

# データセット(X: 特徴ベクトル, y: 正解ラベル)をトレーニング用とテスト用に分割
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

# RandomForestのモデル宣言と学習
forest = RandomForestClassifier()
forest.fit(X_train, y_train)

# テストデータを用いて精度評価
print("training accuracy: {}".format(forest.score(X_test, y_test)))

結果Accuracyは95.81%ほどであり、この時点でほとんどのレシピを正しく分類できていることがわかります。

精度はほぼ十分なのですが、念のためもう少し粘ってみます。RandomForestにはユーザーが設定できるパラメタがいくつかあるのですが、これらの組み合わせのうち最も精度が高いものを全探索する方法として、 Grid Searchと呼ばれるものがあります。 Grid Searchを実際に試してみましょう。探索パラメタの候補は、それぞれ以下のように設定しました。

params = {
    'n_estimators': [1, 10, 100, 500],  
    'max_features': [1, 'auto', None],  
    'max_depth': [1, 5, 10, None],  
    'min_samples_leaf': [1, 2, 4,]
    }  

それぞれのパラメータは以下のようになります、

  • n_estimators: 作成する決定木の数
  • max_features: 各ノードで最適な分割を探す際に考慮する数(int: 個数、float: パーセント、auto: sqrt(n_features)、None: n_features)
  • max_depth: 決定木の深さの最大値 (int: 深さ、None: 全ての葉がmin_features以下になるまで分割を続ける)
  • min_samples_leaf: 各葉が持つサンプルの最小値

Grid Searchも、先ほどのscikit-learnを用いることで手軽におこなえます。

from sklearn.grid_search import GridSearchCV

# GridSearch
model = RandomForestClassifier()
cv = GridSearchCV(model, params, cv=10, scoring="accuracy")
cv.fit(X, y)

# 最適なパラメタを出力
print("Best parameters: {}".format(cv.beast_params_))

その結果、最適だと判断されたパラメタと精度は以下のようになりました。

best_param = {
    'n_estimators': 500,
    'max_features':1,
    'max_depth': None,
    'min_samples_leaf': 1
    }

GridSearch Accuracy: 96.93%

このように、Grid Searchを用いることで1%ではありますが精度の改善が見られました。問題設定によっては大幅な改善が見られることもあります。

最後に、どの特徴量がスイーツ識別に重要であるのか上位10件を見てみましょう。 

f:id:reqly-tokyo:20180704133738p:plain
図4 特徴量重要度Top10

予想通り、砂糖が圧倒的に重要な要素となっており、次いでチョコレートや卵などが上位に現れていることが分かります。同時に、醤油やコショウのようにスイーツではないということを判断するのであろう情報も上位に現れているのが興味深いですね。

まとめ

REQLYでは、この他にも様々な機械学習の手法を用いることで、高機能な料理レシピ検索を実現しています。ぜひ一度使ってみてください。 reqly.tokyo

*1:このように複数の弱識別器の結果を統合する方法をアンサンブル学習と言います。

codeFlyer本選参加記

こんにちは。エンジニアのm2kuです。今回は6/30(土)に行われたbitFlyerさん主催の競技プログラミングコンテスト「codeFlyer」の参加記です。ありがたいことに今回初めてオンサイトのコンテストに参加させて頂き、そして清々しく惨敗させて頂きました。

bitflyer.com


競技プログラミングとは?

競技プログラミングとは、「〇〇を実現するコードを書いてください」という課題がいくつか与えられ、時間内にプログラムを組んで提出し、その正確さや提出の速さを競うものです。与えられる課題は、ただ単純な実装を行うだけでは実行制限時間を超過するようになっていることも多く、その課題に適したアルゴリズムやデータ構造を上手く用いてコードを高速化する必要があります。実装を工夫して計算量を落とせた時に感じる、パズルが解けた時のような気持ち良さが魅力となっています。


いざ六本木

決戦の地は六本木にある東京ミッドタウンタワーでした。 f:id:reqly-tokyo:20180701012514j:plain

お洒落の最先端を行く東京ミッドタウンにおいて、我々100人のオタクたちが缶詰でプログラミングバトルに興じていることを、どこのマダムが想像しうるのだろうか...


会場で受付を済ませるとbitFlyerさんのロゴの入ったTシャツを頂きました。 f:id:reqly-tokyo:20180701012548j:plain おそらく会場までの交通費がTシャツの形で現物支給されるのでしょう。私は関東圏内からの参戦だったのでSサイズを1枚頂きました。中には遠方から新幹線でいらっしゃった方もいたようです。


コンテスト

本選は計3時間。のたうち回った3時間を時系列でお話ししましょう。

まず圧倒されたのが、開始10秒としないうちに響き渡る100デシベル近いタイピング音。こいつらいつ問題読んだんだよ。心を整えるため、Google Play Musicを立ち上げ平井堅を再生するところから私の戦いは始まります。

頑張ってA問題をこなし、次のB問題。ここから数学的な工夫が要求されてきそうです。問題文を5分ほど睨みましたが、クエリを独立に処理して単純に実装すると計算量がO(NQ)になってしまい、2秒の実行制限時間を超過してしまいます。一旦保留にします。

vs C問題

C問題構文解析のような問題です。個人的に競技プログラミングの問題としては初めての分野ですが、コーディング面接では何度か直面してきました。心をくくり、この問題と向き合うことにします。

とりあえず計算時間は無視して、正しい値を返すようなプログラムを考えてみることにします。30分ほど紙と向き合ってうんうん唸り続け、この問題はまずは次のようなサブ問題に分割できそうだと気づきます。

f:id:reqly-tokyo:20180701012846j:plain

入力文字列を上のように「括弧の対応が取れている文字列」(以下Good-String)に分割するのはスタックを使えばできそうです。あとはそれぞれのGood-Stringを独立に受け取って、それが合計何種類のGood-Stringを部分文字列として持つのかを計算する次のような関数を定義すれば良さそうです。

f:id:reqly-tokyo:20180701022513j:plain

これは再帰を使ってどんどんサブGood-Stringに分割して行くことで、なんとか計算できそうです。

上のアイディアに基づき、Pythonで実装を行い、提出。残念TLEです!(実行制限時間を超過すること)。この時点で開始からおよそ90分が経過。トイレへと向かい、怒りを放尿します。

ただ、上の提出でTLEにならなかったサンプルケースは全てAC(Accepted)でプログラムの動作としては信頼しても良さそうです。そこでC++でプログラムを書き換えることで高速化を図ることにしました。

(ここでそもそもの計算量を疑えなかったのは大きな反省点です。冷静に考えれば、上のサブGood-Stringへの分割は毎回文字列長スキャンするうえ、再帰の深さは最悪の場合文字列長の1/2になるのでO(N2)であり、C++にしても意味の無い書き換えであることに気付けたはずです。)

30分かけてC++に書き直し提出。当然TLEです!雲行きが怪しくなってきました。プレイリストをサザンオールスターズに変更し巻き返しを計ります。

vs B問題

Cは諦め、先ほど保留したB問題に戻ってきました。どうにかしてクエリ間で共通する処理をくくり出したいです。またしても10分ほど図を書きながら唸っていると、以下のような図が出来上がってきます。これは累積和か...?

f:id:reqly-tokyo:20180701012953j:plain

上の図で線として表現されている部分の累積和をO(N)で最初に計算しておくと、その引き算で基準点の左側の人々のコストが求まることに気付きます(図の丸で囲った部分)。累積和の関数cumは、最初に全てのXiについて値を求めておいて、cum(C)を求める時はCから最も近いXiのcum(Xi)に適宜必要な分だけ足し算すれば求まりそうです。最も近いXiは二分探索で求めることができるので、それぞれのクエリはO(logN)となり、無事に制限時間をクリアできそうです。

ここから頑張ってC++で実装を開始しますが、ギリギリタイムアップ。悔しい試合となりました。


懇親会

オタクプログラミングバトルも終わり、お洒落な六本木の街に平穏が戻ってきました。 f:id:reqly-tokyo:20180701013421j:plain


おわりに

B, C共に知っている人には典型問題だったようで、経験不足を痛感しました。しっかり復習し今回で自分のものとしたいです。結果自体は残念でしたが、久々に頭を長時間フル回転させる楽しい経験になりました。bitFlyerさん及びにAtcoderさんに改めてお礼申し上げます。

REQLY料理日記 〜いろいろな料理を検索して作ってみた編〜

背景

ソフトウェアエンジニアがプロの料理レシピ検索サービス REQLY を使っていくつか料理を作ってみた。REQLYを使って検索するとこんな様々なメニューを発見することができる。ここではどんな料理を作ったかをざっと紹介して、その時に使った検索パラメータをあわせて紹介したい。

これをもとに、今日のメニューを考えていただければ幸いである。

reqly.tokyo

二色丼

二色丼

二色丼のバリアントが2つあったときに、どちらを選択すべきかをすぐに決定することは難しいが、逆に言えばそれらを比較検討して、自分好みのレシピを選択することができる。REQLYを使えばレシピを横断して検索することができるので、こうした際のレシピの比較検討も容易だ。

f:id:reqly-tokyo:20180626235007p:plain

10分300円は、二色丼としてはだいたい妥当そうだが、これは炒り卵の時間を省いている。依存関係があるときに、依存元の料理時間と合算したほうがよいのか、そうでないのかは難しいところだ。

f:id:reqly-tokyo:20180627001800j:plain

右側の黒い物体はそぼろである。

チャーハン

チャーハン

チャーハンといっても様々あるが、ここでは面白そうなチャーハンを発見したので、これを作ってみることとした。

f:id:reqly-tokyo:20180626231558p:plain

調理風景は以下。

f:id:reqly-tokyo:20180626232043j:plain

チャーハンは他のチャーハンがどれも10分であるので、そういったことも時間推定に加味してもよいのかもしれないとは感じた。

f:id:reqly-tokyo:20180626232101j:plain

卵を焼きすぎてしまったので、もう少し早く火から下ろせばよかった。それを除いては、特に不満はなかった。

リゾット風

検索フィルタをうまく活用すると、朝食にもってこいのメニューを選ぶことも容易だ。

f:id:reqly-tokyo:20180627003134p:plain

このように、「特徴」のなかから「朝」を選んでチェックボックスを入れると、朝食向きのメニューを探すことができる。

f:id:reqly-tokyo:20180627003645p:plain

朝食向きメニュー

なお、マッシュルームはなかったので使っていない。

f:id:reqly-tokyo:20180627003511j:plain

今日も1日頑張れそうだ。

スクランブルエッグ

チーズと卵が余っていたので、エッグをスクランブルすることにした。

スクランブルエッグ

f:id:reqly-tokyo:20180627003956p:plain

完成形はこちら。

f:id:reqly-tokyo:20180627004618j:plain

普通のスクランブルエッグができた。満足である。

まとめ

いかがでしたでしょうか!REQLYを使えば誰でも簡単に、こうしたプロのレシピに到達することができます。皆さんもご飯を作ろうと思い立ったらまずはREQLYにアクセスして、食材名やレシピ名で検索してみてください! また、REQLYでは公式Twitterでも様々な情報を発信しています!そちらもよろしければフォローして下さい!

twitter.com

Railsに基づくWebサービスの全体像 〜REQLYを実例に〜

はじめに

REQLYでは、料理レシピ検索サービスをWeb上で提供しています。こうしたWebサービスはどういうように作られているのでしょうか。なかなかWebサービスの全体像というのは、外部からうかがい知ることはできないものです。そこで今回の記事では、REQLYがどのように成り立っているのかという技術構成を紹介します。

reqly.tokyo


REQLYの技術基盤

REQLYでのレシピ検索の手順を簡単に説明すると、以下の手順になります。

f:id:reqly-tokyo:20180618165114p:plain



すなわちREQLYは、以下の5つのサービスから構成されていることになります。 f:id:reqly-tokyo:20180617223433p:plain

  • Ruby on Railsは、REQLYを提供する基本的なバックエンドサーバです。クライアントからのリクエストを解釈し、処理し、適切なレスポンスを行います。
  • PostgreSQLは、レシピのメタデータ(材料一覧やレシピサイト名など)を保存しておくためのデータベースです。
  • Elasticsearchは、オープンソース全文検索エンジンです。材料名やレシピ名をクエリとして検索すると、ヒットしたレシピが類似度スコアと共に返されます。
  • ランキングサーバーは、Rustで独自に実装した、検索結果のさらなる絞り込みと並び替えを行うサーバーです。Elasticsearchの類似度スコア、検索フィルタとのマッチ度、人気度など複数の指標を総合的に考慮し、最終的な検索結果とその順番を決定します。
  • フロントエンドの中で一部の動的なコンポーネントはReactで実装されています。


モノリシックなRailsサービスが、デーモンとして動いている各種サービスと適宜通信して、クライアントからのリクエストに応答しています。

Railsと各サービス間の連携

ここからは、上の図で示したそれぞれのサービスとRailsがどのようにして連携しているのか、具体的に掘り下げて見ていきましょう。

1. Elasticsearchはsearchkickを介して連携している

Elasticsearchは、Javaで実装された全文検索エンジンで、APIを通してリクエストを叩くと検索結果が返ってきます。

例えばHTTP APIでは、jsonをbodyとして渡してリクエストを発行すると、jsonが返ってくるという仕様になっています。ですから、APIの挙動を確認する最もシンプルな方法は、curlコマンドを使って手書きのリクエストを送ってみることです。例えば、Elasticsearchがlocalhostの9200番ポートに立っていて、そのデータベース名がrecipes_developmentのときに、その中でタイトルに「寿司」が含まれるものを取得するクエリを実行するには、以下のように行います。

$ curl http://localhost:9200/recipes_development/_search?pretty -H 'Content-Type: application/json' -d '{"query":{"dis_max":{"queries":[{"match":{"name.analyzed":{"query":"寿司","analyzer":"searchkick_search"}}}]}}}'

Elasticsearchに対するクエリは、基本的にはjsonでコードできるドメイン固有言語で記述する必要があります*1。複雑なクエリを構成することで、検索結果に対する重み付けや絞り込み条件を付与することができますが、複雑なクエリを手書きするのはElasticsearch側の十分な知識が必要とされるので、ここでは必要最小限のrubyコードからよしなにElasticsearchのクエリを生成してくれるようなラッパーライブラリを利用しました。

github.com

HTTPリクエスト・レスポンスをrubyのメソッド呼び出しとしてラップしているElasticsearchのRuby APIに対して、searchkick は、データベースに対応づけられたRubyのクラスとの対応付けを容易にした、より抽象度の高いラッパーライブラリです。searchkickを用いて、以下のように設定しています。

  • index時には、レシピidと、そのレシピに関連するデータベースのレコードをドキュメントとしてElasticsearchに取り込ませます。
  • 検索時には、Elasticsearchに検索対象の文字列を投げ、類似度スコアとレシピidの順序付き集合を受け取ります。

Elasticsearchが返すのは、Elasticsearchのスコアに基づいたRecipeIDの順とスコアです。この情報をもとに、下のランキングサーバにレシピIDとスコアを渡しています。

2. ランキングサーバーをHTTPプロトコルで呼び出している

ランキングサーバーは、Rustで実装した独自のものです。これはHTTPで他のプロセスと通信するようなwebサーバとして実装しました。ランキングサーバーはjsonを受け取ってjsonを返すサーバーとして実装しているので、結果を得るためにPOSTメソッドでjsonを送ると、jsonがレスポンスとして返ってくるという形になっています。 ですから、これに対しては本質的にはcurlコマンドを叩くことを行えばよいので、Railsの内部においては薄いlibcurlのラッパーを利用しています。具体的には、Ruby連想配列オブジェクトをjsonに変換してテキスト情報としてリクエストを送って、帰ってきたテキスト情報としてのjsonを再びRuby連想配列に戻すということを行っています。こうしたサーバーをHTTPプロトコルに基づくWebサーバーとして実装したのは、HTTPがハイパーテキストで表現できるような情報をやりとりする汎用プロトコルであるからにすぎません。

ランキングサーバには、Elasticsearchによって絞り込まれたレシピIDと、検索フィルタの内容を送っています。ランキングサーバ内で、オンメモリの特徴量情報からレシピIDに対して再び総合的なスコアを振り、それをもとに絞り込みと並び替えを行い、結果としてレシピIDを返します。

3. PostgreSQLActiveRecordを介して連携している

バックエンドのデータベースとして、RailsではPostgreSQLを利用しています。こうしたデータベースに入っているデータに対して、どのようにアクセスすればよいでしょうか。このときランキングサーバーのように、ほとんど生のAPIを触るようなプリミティブなラッパーを使うことをまずは考えてみましょう。

SQL.execute("INSERT INTO items VALUES #{values}")

itemsテーブルに問い合わせをするためにはこのようなsql文を書けばいいかもしれません。では、このvaluesという変数に入っている取得したい値の情報は、どのようにSQL文にマッピングすればよいでしょうか。更にいえば、SQLから返ってきたitemテーブルの要素は、どのようにruby側で処理すればよいでしょうか。

 +----+-----------+-------------+----------------------------+-------------------------+-------------------------+----------+---------+
 | id | name      | description | url                        | created_at              | updated_at              | name_ja  | name_en |
 +----+-----------+-------------+----------------------------+-------------------------+-------------------------+----------+---------+

こうしたデータベースのテーブルに対応したクラスがRuby側にあれば、DBのそれぞれのレコードが、そのクラスのインスタンスに対応するようにできると考えられます。

class Items
  attr_accessor :id, :name, :description, :url, :created_at, :updated_at, :name_ja, :name_en
end

つまり具体的には、Rubyの側にSQLのレコードに対応した、受け取る結果を入れておくためのクラスを作り、返ってきたメッセージとそれとの対応付けを行うということを自動でやってほしいのです。なぜならばこの処理は、SQL側のテーブルのスキーマを決めることができれば、それとのrubyのオブジェクトの対応づけはある程度、機械的に行うことができるからです。

  • Rubyの「クラス」と、DBの「テーブル」が対応するとする。これはExcelのシートに対応する。
  • そのクラスの「インスタンス変数」が、DBの「カラム」に対応する。Excelにおける列に対応する。
  • そのクラスのインスタンスオブジェクトが、DBの「レコード」に対応する。Excelにおける行に対応する。

こうした処理を行ってくれるライブラリのことを総称して、オブジェクトリレーショナルマッパー(ORM)と呼びます。Railsでは生のSQLラッパーよりも高機能に、RubyのオブジェクトとDBを対応づけるようなライブラリを利用しています。これはActive Recordと呼ばれるライブラリであり、SQLの1テーブルがRubyの1クラスに、SQLの1レコードがRubyの1クラスにおける1インスタンスになるように対応づけることで、あたかもRubyのオブジェクトであるかのようにデータベースの中身を扱うことができるのです。

Active Recordというgemは、{PostgreSQL, MySQL, SQLite3} などのデータベースサーバとソケット通信して、DSLによって記述されたクエリをSQL文に変換してSQLサーバにリクエストを送り、返ってきたSQLのレコードをRubyのクラスに対応づけることができます。例えば、REQLYの内部には、レシピを管理するためのRecipeクラスがあり、その中にすべてのメタデータが入っています。

[1] pry(main)> item = Recipe.where(time: 30).first
  Recipe Load (0.6ms)  SELECT  "recipes".* FROM "recipes" WHERE "recipes"."time" = $1 ORDER BY "recipes"."id" ASC LIMIT $2  [["time", 30], ["LIMIT", 1]]
[2] pry(main)> item.expense
=> 300

このように、SQL文をラップするRubyのメソッドが提供されています。こうしたクエリをメソッドチェーンでつなげていくことで、SQL文を生で書かずに、複雑なクエリを構成することができ、そのクエリの戻り値をRubyのオブジェクトとして扱うことが可能になります。

PostgreSQLはデーモンとして動くSQLサーバーです。PostgreSQLではサーバとクライアント間の通信はINET Socket(TCP/IP)やUnix Socketを介して行われ、「フロントエンド/バックエンドプロトコル」と呼ばれる、その上で独自プロトコルで通信が行われています。Active Recordを用いると、こうした通信のプロトコルや生のAPIに触れることなく、開発者はSQLを抽象化して扱うことができるのです。

PostgreSQLとは、Unix Socketで接続することもできます。たとえば手元の開発環境ではUnix Socketで接続することもできますが、REQLYではdockerを用いてそれぞれのプロセスにつき1コンテナを設定しており、その間はTCP/IP通信でデータのやりとりをするようにしています。

Active Recordを利用してレシピIDの情報からPostgreSQLを介して、そのメタデータを得ることができます。抽象的には以下のようなコードで記述されます。

def filter_by_ids(ids) # ids: array
  Recipe.where(ids).limit(10)
end

4. Railsとフロントエンドがreact-railsを介して連携している

上のようにして、Active Recordを利用してレシピに関する情報をSQLからRails側にロードした後、その情報をHTMLとして書き出します。このときにREQLYでは、一部の動的なコンポーネントはReactを用いて描画しています。

Reactをなぜ選択したか、あるいはReactをどのようにRailsに統合すべきかについて行った技術選択については措きますが、このライブラリを利用すると、ReactとRailsを1つのプロジェクトルート内で開発することができます。

github.com

Reactにおける ReactDOM.render(element, container); が、ビューの内部で呼ぶことのできる react_component(ComponentName, Props, Options)というヘルパー関数に対応します。Railsのビューをエントリーポイントとしてpropsを渡すと、以後はreactの世界でコンポーネントを描画できるという形になっています。

実際に書いたReactのコードがどのように動作するかというと、webpackを用いてクライアントサイドで動作するjavascriptにトランスパイルされます。そのjavascriptがクライアント上でレンダリングを行います。つまりクライアントに送られる情報はReactの内容がコンパクトになったjavascriptと、「どの場所にどういうPropsのReactのコードが埋め込まれるか」というHTMLデータが送られます*2

まとめ

要点は以下です。

  • Railsは、ビジネスロジックやバックエンドサービスとのやりとりを含めた、リクエストからレスポンスを返すデータフローを担っています。
  • Ruby上で実装された様々なラッパーによって、バックエンドサービスとのやりとりはカプセル化されているので、開発者は生APIをさわることや、それによって埋め込んでしまうかもしれないバグを回避することができ、抽象的なコードを書くだけで済むようになるので、開発の効率化が促進されると考えられます。

*1:6.3ではSQL風のクエリ言語がサポートされるようになりましたが

*2:サーバーサイドレンダリングする場合においてはこの限りではありません