gRPC in Cookpad

岩間雄太氏:お願いします。クックパッドのgRPCの話をします。

「自己紹介いるか?」と思いましたが、一応書いておきました。

岩間雄太といいます。よろしくお願いします。ふだんは「@ganmacs」でやっています。今日いる人はみなさんそうですが、技術部開発基盤グループというところにいます。2017年度に新卒として入社しました。

目次ですが、今日は「これまでクックパッドでサービス間通信をどうやっていたか」「結局gRPCを入れることになってどうしているか」「RubyでgRPCどうしているか」という話をします。

これまでのクックパッドは、「マイクロサービスやっていくぞ」とやっていました。社内では「GarageとGarageClient」というRESTfulなAPIをいい感じに作れるやつでAPIを共通化しています。Garage を使用したアプリケーションもだいぶ増えてきたところです。。ドキュメンテーションはAutodocでやっていて、これはGarageのAPIがどういう値を返すかみたいなことを書いておけるやつですね。

あとは、PactでCDCテスティングもやっています。このへんに参考があるので良ければ見てください。

「結局つらいよね」となったのがこれです。

「IDLとスキーマが欲しい」。Autodocを使っているときの問題としては、誰かががんばって(ドキュメントを)書いてくれるといいのですが、誰かががんばらないと書かれないという問題があります。

「結局型は何なんだ」という問題があって、各フィールドの型を定義して参照したいっていうのがまず1個あります。また、さっき「RESTfulなAPIを(Garageで)作れる」って言ったんですけど、RESTなエンドポイントへのマッピングが困難なケースが増えてきているんですね。

これはあるリソースに対してGETやPOSTではなく「全部POSTでバッとやったほうがこのAPIに適してない?」というのが増えてきたということです。

それと多言語化です。先ほど言った通りCookpad ではほとんどRailsアプリですが、Goも増えてきていて、「Garage使えんやんけ」みたいなことも問題にありました。

gRPCの概要

それでgRPCはおそらくみなさんもそこそこ知っていると思いますが、軽く説明しておきます。

オープンソースのRPCのフレームワークで、IDLとしてProtocol Buffersをデフォルトで使えます。JSONも使えるらしいんですけど、JSONは使ったことないので一切知らないです。

Protocol Buffersで定義を書いて、いい感じにジェネレートするコマンドがあって、クライアント、サーバーのコードがこういうふうにできて、サーバーの実装を書くと動くという感じですね。

C++、Ruby、Java、Goとかさまざまな言語で実装があって、クライアントとサーバーのコード生成ができるっていうフレームワークですね。

もう少し話すと、これはHTTP2上で動いていて、ここに書いてあるリンクにgRPCの仕様が書いてあります。4種類のRPCがあって、Unary RPCがいわゆる普通のHTTP1みたいな「リクエストしたらレスポンスが返ってくる」というただそれだけのRPCです。

Server streaming RPCというのは、1回リクエストを送ったらサーバーからレスポンスをいっぱい返ってくるってやつです。Client streaming RPCは逆で、クライアントがいっぱいリクエスト送ったあとにサーバーがレスポンスを1つだけ返すというRPC。Bidirectional streaming RPCは、クライアントもサーバーもいっぱい送れる、チャットみたいなことが実現できる RPC ですね。

この4種類がサポートされています。Interceptorがあって、「ログ、認証、エラー処理をいい感じにやってくれや」というのを差し込めるレイヤーがあります。

これが実際のProtocol Buffersの定義になっていて、これを元にコードジェネレートされる感じですね。これはHelloServiceっていうサービスで、RPCの名前はSayHelloです。HelloRequestっていう型を受けて、HelloResponseっていうのを返すと。

HelloRequestがどんなのかっていうと、stringのgreetingっていうフィールドを持った型で、messageになっていると。Responseも同じような感じです。

gRPCの運用

gRPCの運用をどうやっているかというと、基本的には「hako」というAmazon ECS上で動いています。例外もあって、cookpad.comというのは EC2 インスタンス上で動いていて、これは gRPC クライアントのみが動いている感じです。

あとはService Discovery Service(SDS)を自前で実装していて、先ほどTaikiさんが言っていたEnvoyを使って、Client side Loadbalancingでサービス間の通信をしています。これは便利なリンクがあるので見てください。

あと、Envoyのload_balancing_weightという機能があります。それでSlow Startみたいなことができます。サービスインしたときにいきなり全部のリクエストを流すのではなく、サービスインしたサービスに対してちょっとずつリクエストを増やしていくみたいなのもサポートしています。

今はリクエスト送る側の話だったんですけど、受ける時も違うEnvoy経由でリクエストを受けています。あと開発環境からstaging環境へのアクセスもできるようになっています。

これがサービスインの時の図です。Service Discovery Serviceがいて、それがサービス名とエンドポイントとweightを持っていて、ここがhako appで起動してきたアプリケーションです。

このアプリケーションはappというコンテナで、これが実際のアプリケーションのコンテナですね。appコンテナと、あとfront-envoyコンテナとenvoyコンテナ。この2つのコンテナは別々のコンテナにしてあります。

あとregistratorというコンテナもいて、このコンテナはサービスAが立ち上がってきたときにService Discovery Serviceに対して「起動したよ」と教えてあげる。教えるときは、front-envoyのエンドポイントをService Discovery Serviceに教えてあげる。ここだと、「サービスAは10.0.0.0:7777にあるよ」と教えてあげます。

Slow Startは、先ほどのweightの部分が2からスタートしているだけです。そのweightの部分をClient side LoadbalancingするときにEnvoyが見てくれていて、「2だったらloadは2くらいからスタートして最後は100までいく」みたいな感じになっています。

起動中はregistratorがappコンテナに対して定期的に「生きてる?」とヘルスチェックをして、生きていたらSDSに対して「生きてるよ」と教えてあげる流れになっています。

gRPCの動作環境

先ほど口頭で言いましたが、ややこしいので図にしました。

サービスBからサービスAに対してアクセスをするときに、サービスBのenvoyコンテナがService Discovery Serviceに対して「サービスAの情報をくれ」と教えてもらいにいきます。サービスAのエンドポイントを教えてもらって、appがenvoyコンテナに対してリクエストを送ると。

それで、envoyコンテナがサービスAのfront-envoyコンテナに対してリクエストを送る。front-envoyコンテナがappに対してリクエストをそのまま流す。逆も一緒です。これがリクエストの流れになっています。

サービスアウトするときは、Service Discovery Serviceに対してサービスAのregistratorが「死ぬらしいよ」と教えてあげる、するとSDSが「じゃあ消しとくね」と言ってテーブルからエンドポイントのデータを消すという流れになっています。

これはクックパッドでProtocol Buffersをどういうふうに管理しているかです。サービスは複数あるんですけど、基本的にProtocol Buffersの定義は1つのリポジトリにまとめています。

サービスごとにディレクトリを切るようになっていて、「serviceAだったら/serviceA以下に、そのサービスが使うProtocol Buffersの定義を全部書いてください」という運用にしています。

使用したいアプリケーションは、このProtocol BuffersのリポジトリをそのままGitのサブモジュールで引っ張ってきて、それをもとにコードをジェネレートして、サーバーを書いたりクライアント書いたりする流れになっています。

Protocol Buffersの書き方もバラバラだと困るのでLintを入れています。あとProtocol Buffersの定義を元にドキュメントを自動で作ってくれるやつがあるので、それを使ってドキュメントを作るようにしてます。そのドキュメントのリンクをhako-consoleというアプリケーションのコンソールアプリに出すようにしています。

例えばサービスAに対してリクエストを送りたい人がいたら、サービスAのhako-consoleのページを見にいきます。それで、このProtocol Buffersの定義を見て、「あぁ型こういうのね」と書く流れになっています。

メトリクスについて

これはメトリクスについてです。

先ほど言った2つの「front-envoy」と「ふつうのenvoy」です。「ふつうの」という言い方が正しいのかわかんないですけど、この2つでメトリクスをとっています。Envoy statsというEnvoyに付いているstatsの機能と、アクセスログの2つを使っています。

メトリクスとアクセスログは、Prometheusにがんばって入れてGrafanaで見ることになっています。このアクセスログをそのままGrafanaに入れることができないので、mtailを使ってPrometheusで読めるかたちにしてPrometheusに入れています。

これもhako-consoleに対してGrafanaのリンクを貼ってあるので、サービスAを見ているサービスチームの人はサービスAのhako-consoleのページに行って、「メトリクス見たいな」と思ったらそのメトリクスのリンクを押せばすぐ見れるというふうになっています。

それで、EnvoyのアクセスログをどうやってPrometheusに入れるようにしているかですね。ここまでの話のほとんどは、サービス開発者が何もしなくてよくて、1行ぐらい定義をシュッと書けばだいたい動く感じです。

どういう動作かというと、もう1個mtail用のコンテナが動いています。mtailのコンテナをfront-envoyがマウントするようなかたちになっていて、mtailがEnvoyのアクセスログを順次読んでいって、Prometheusが読める形式に出力するようになっています。MVPを雑に作ったやつがあるので、興味ある人は見てください。

Prometheusというのはプル型のメトリクスをとるアプリなのでmtailに対してPrometheusからアクセスができないといけないのですが、サービスAというのはECS上で動いているんです。つまり、EC2の上でDockerが動いていて、そのDockerの1つのコンテナがmtailで、そのコンテナに対してPrometheusからアクセスを通す必要があります。

先ほどのSDSとは違う「ECS用のSDS」を自前で書いていて、そのSDSに対してアクセスしに行って、Prometheusの設定を毎回アップデートします。Prometheusはその設定を見てmtailを見に行くという流れになっています。

メトリクスの例

これはメトリクスの例で、ちっちゃいな(笑)。こちらがリクエストカウントで、ふつうにとれますよという話です。そちらがレスポンスタイムで、このあたりがgRPCのパスごとのリクエストカウントやリクエストタイムです。こういうのもとれます。

これはサービスメッシュ感がありますが、「あるサービスからどれくらいリクエストが来たか」をとれるようになっています。

こちらはホストごとのリクエスト数やレスポンスタイムをとることができて、どこかのコンテナにリクエストが集中してないかを見たくて作りました。

このあたりはコンテナが受け取ったリクエストカウント、むこうがレスポンスタイム。こちらはそれにgRPCのパスごとをプラスしたものです。むこうもリクエスト、レスポンスタイムという感じです。

Ruby (on Rails)でgRPC

RubyでgRPCをどうやっているかという話をします。grpc gemについてまずしゃべりますが、拡張ライブラリとしてCとC++で書かれているやつをRubyの拡張ライブラリという形で使って作られています。

grpc/grpcの下にRubyの実装や、ほかにもC++やPythonとかいろんな言語があって、それらがCとC++で作られたでっかいgRPC coreをそれぞれ拡張ライブラリとして使うという作られ方をしています。

grpc gemはマルチスレッドなんですが、シングルプロセスでなかなか厳しいです(笑)。

RubyにはGILがあるのでシングルプロセスだとマルチスレッドなアプリケーションでも並列に動きません。一応、IOは動きますけどCPUは並列に動かないので厳しいです。

マルチプロセス化する予定がないかというのをがんばって探したんですが「直近のロードマップにはないよ」というのが GitHub の Issue のに書いてあったので多分しばらくはなさそうですね。

どういうふうに使うかというと、grpc-tool gemを使うとサーバーとクライアントのコードがいい感じに作れます。先ほどのこれを書いてgrpc-toolを使うとクラスができます。

このHelloworld::Greeter::Serviceが自動生成でできます。先ほどにはなかったsay_hello_againもありますけど、say_helloの実装を書きます。そうするとこれで動くみたいなけっこうお手軽なやつです。

クックパッドにおけるRubyでgRPC

RubyでgRPCをクックパッドでどうしているかと言うと、普通に公式のgrpc gemを使っています。

「グラフ」と読むのかよくわかってないですけど、grufというフレームワークも見つけましたが、これは「そんなでもないな」と思ったので使いませんでした(笑)。

(会場笑)

失礼しました(笑)。先ほど、Interceptorの話が出ましたが、アプリケーションレイヤーでアクセスログとか「どんなメッセージ送られてきたか」みたいなのがとれないので、それ用のInterceptorを自作しました。それと、grpc gemはシグナルを受けると死んでしまう作りなので、トラップしていい感じにストップするやつも作ったりしています。

また、ほかのInterceptorも作っています。例えば、例外処理のInterceptorも作って動いているし、あとNew Relicを使いたいんだけど、サポートがないので、それ用のInterceptorも作りました。

Goだとgo-grpc-middlewareみたいなものがありますが、Rubyにはないので作らないといけませんでした。

Ruby on RailsでgRPCをどうしているか。Ruby単体で動いているアプリはあんまりなくて、基本的にはRailsで動いているんでこの話はディレクトリをどうやって置いているかというただそれだけのことなんですけど。

先ほどの実装をapp以下のgrpcやserviceというディレクトリに置きます。lib以下に自動生成のコードを配置して、config/initializersにgRPC用の設定のファイルを1個作って、そこでポートとか、Interceptorはどういうの使うのかとか、どういうサービスをハンドルするかみたいなのを書いています。

どうやって起動させているかというと、rails serverみたいなコマンドがないので、rails runnerで起動しています。最初はrake taskを作りましたが、それで起動すると勝手にeager_loadがオフになってハマリまくったのでrails runnerに変えました。

あとActiveRecordのconnection poolとの相性があって、それについてはこの次でしゃべりますが、先ほど言ったみたいにマルチスレッドなんですね。マルチスレッドで ActiveRecord を使う場合、with_connectionで囲ってあげて、connection poolからconnectionを取ってきて、終わったら返すというのをやらないといけないんですね。

例えばgrpc gemのWorkerの数を30にして、connection poolの数を5にすると、律速になるのは5のほうです。connection poolから5個取ってきたら6個目からブロックするようになって、「なんかぜんぜんパフォーマンスでないっスね」「やっぱシングルプロセス、ダメですわ」みたいに笑っていたら、そうじゃなくて。ふつうにconnection poolで死んでたみたいなのがあって、こういう地味なところにハマってたりもしました。

grpc gemの問題点

「grpc gem、つらくない?」ということで、CPUがそもそも使いきれないんですね。

先ほどの問題が解消されたところで、ほかのアプリに比べてオートスケールがぜんぜんうまくいかなくて。スケールアウトする前に、gRPCのResourceExhaustedという「Workerが完売したよ」というエラーが返ってきてしまいます。

あとGraceful Shutdownがないです。先ほどの「シグナルを受けるとると死ぬ」ですが、まぁふつうにシグナルをうけると死ぬんですよ。シグナルを受けて、ストップ用のメソッドがあって、「ストップ」を押してもGracefulに死んでくれなくて、リクエストを受けているときに勝手に死にます。

いまだとEnvoyを使って、RubyのgRPCアプリケーションをいったんサービスアウトして止めるというふうにしています。当然ストリーム系のGraceful Shutdownもないので、Server streaming で1回リクエストを送って、サーバーがリクエストを返しているときでも急に死ぬっていう感じです。

ここに書いてあるだけじゃなくて、けっこう厳しいことがいっぱいあって、「つらいね」となります。それで、新しくgRPCのgemを作り始めました。nghttp2を使うとH2のレイヤーをいい感じにやってくれているので、それを使ってgriffinを作っています。grpc gemとの互換性を気にして作っていて、Protocol Buffersでgrpc toolsから生成されるコードをそのまま使用できるようにして、grpc gemが改善されたらすぐに戻りたいという気持ちで作っています。

社内アプリではもう動き始めていて、現在移行を進めています。ベンチマークをとったら実はgriffinのほうがちょっと速かったんです。

まとめとしては、gRPCでサービス間通信をクックパッドでもするようになってきました。Envoyを活用してgRPCのサービス基盤を構成しています。RailsとgRPCを組み合わせて使っていて、つらかったので新しいgrpc gemを作って、ただいま検証中です。

今後はEnvoyの機能を使ったカナリアリリースとか、あとプロダクションに入ってないのでgriffinをプロダクションに入れるのと、gRPCゲートウェイみたいなのもやっていこうかなと思ってます。今日の資料はこちらに公開しております。以上です。

司会者:ありがとうございました。

(会場拍手)