gRPCを使ってPairsのchatを高速にしたお話

こんにちは、2匹の猫様に尽くす事を生きがいにしているSREチーム エンジニアのmarnieです。

最近はdatadogという監視とヴィジュアライズが得意な便利なお犬様と戯れるのを趣味にして、メトリクスと監視項目と向き合っている日々です。
どうせなら犬じゃなくて猫なら、なお良かったのですが 😺

さて今回は、pairsのchat機能にgRPCの双方向ストリーミングを利用して
速度を改善したお話をしようと思います。

解決したかった事

現状のpairsのメッセージ交換は定期ポーリング(数秒間隔)+Push通知をトリガーにしたMySQLDatabaseからのFetchで実現していたので、メッセージが実際にユーザーに届くのに遅延が大きい/外部のPush通知サービスの遅延の影響をそのまま受けるという課題がありました。

恋は秒単位で冷めてしまう物なので、メッセージはできるだけ早く届けなければいけません。そういった意味でこの速度改善は人類にとってプラスという事にもなります。(たぶん

why gRPC?

プロジェクトの開始にあたり、最初に頭を悩やませたのは技術選定でした。
gRPC以外にも、この手のリアルタイム性の実現や遅延をできるだけなくすような技術モデルは結構前からあります。
例えば、facebookMessageで採用されていた(今は不明ですが)らしいMQTTや、ストリーム通信といえばWebSocketも古くからありますし、大掛かりな事をやめてロングポーリングで実現する事も可能だよなぁ、とか
AWS AppSync,Firebase RealTimeDatabaseのようなフルマネージドな物から色々と遊びながら悩みに悩みましたが
最終的に以下の点からgRPCを選択しました。

  • go/java/swiftといった各プラットフォームの主要言語においてIDLによるコード/Documentの自動生成のメリットを受けられる
  • HTTP/2の新機能であるstream型の通信についてもサンプルコードやインターフェースが十分に用意されているので、そこまで通信層の処理を意識せず実装できる。
  • interceptor周りの開発パターンも枯れてきてるので実装も容易。
  • 概ねはgrpc-middlewareの実装例やコードを見れば使える。
  • そのうちgrpc-webもGA適用されるだろう ( されました:)

丁度困っていたAPIDocumentの整備問題の解決や、社内の技術スタック(エンジニアのメイン言語がgo)を満たしているなど、今回実現したかった目的にたいする実現までの敷居が低かった事が大きかったと言えます。

マネージドサービスを使うかという悩み

インフラ管理を自社でしなくて済むという点では、フルマネージド型のサービスは結構諦めたくなかったので、AWS AppSync,Firebase RealTimeDatabaseなどは細かく調査・プロトタイプを組んだりもしました。

結論として

  • Pairsの仕様と両サービスの仕様に伴う認可周りの制約を考えるとデータ構造再設計も必要になりそうだった
  • キャンペーン単位で細かい修正もよく入る部分でもあるので、サービスのコア部分である機能のコントローラビリティは今と同レベルを保っておきたい

移行コストとか色々考える方が大変そう..という事で今回は除外しました。

gRPCでの双方向通信の実装方法

proto上でresponseとrequestにstreamをつけるだけで双方向通信用のinterfaceがclientに生成されます。

rpc Chat(stream ChatRequest) returns (stream MessageResponse);

中身の実装方法や接続方法は各言語で異なるのでチュートリアルを参照してください。

速度改善のアプローチ

今まで、メッセージ受信側は以下のようなプロセスをメッセージ取得時に踏む必要がありました。

今回、gRPCの双方向通信の導入と共に、以下のようなシーケンスに変更します。

受信側の差分としては、以下の通りです。

  • 既に接続済みなのでHTTP接続コストが掛からない。
  • MySQLへの接続・Fetchといった処理を省略
  • Pooling間隔に依存しない。

これによって、pooling間隔/Push通知の遅延の影響を受けず、かつ従来よりも処理を省略してメッセージを受信する事が可能になります。

システム全体像

システムの全体像を簡単に図にしてみました。

高速な受信・送信が期待できるRedisPubsubを各Server(新・旧)のコミュニケーションバスとして利用する事でユーザー間のメッセージの送受信を実現しています。

  • 折角の双方向通信なのにReadだけ?

当初、Writeも一緒に対応してしまおうかなーと思ったのですが、一番恩恵を受けるReadの高速化を一番最初にリリースしたかった+ Writeのロジックを新・旧で二重に管理したくなかった、というのもあります。

  • 結局ポーリング(従来のfetch)もするの?

後述しますが、二重化の意味合いもあり、従来のfetchも活用しています。

安全なリリースのために

メッセージ機能はpairsを利用する方がメインで用いる大事な機能ですので、不具合が起きた時の影響が大きいです。

影響範囲が大きい事によって発生する必要以上の確認作業や検討開発速度を損なう事になりがちなので、切り戻し方法や退路・検知方法をあらかじめ確保し、心理的安全性を担保する必要があります。

テストコードやE2Eテストはもちろんですが、例えば、今回は全処理を全デバイス一斉に書き換えるビッグバンリリースを避けて、各デバイス別にリリースして恩恵を受けられるように、新系統と旧系統を並行するような設計を採用する事で、リリースの影響範囲を限定的にし、価値提供を段階的に行える恩恵も受けられています。実際に今回はまずAndroid端末のみをリリースしています。

また、それ以外にも以下のように色々な工夫をしています。

  1. firebase remote configによる機能のon/off
  2. 従来のポーリングと併用する事による二重化(重複処理されているので問題なし)
  3. ワーストシナリオ(例えば誤送信が起きていないか)がおきているかなどの検知機構
  4. 段階リリース

protoの管理方法とドキュメント/コードの自動生成の活用

  • レポジトリを分ける

今回、protoファイルはandroid/ios/serversideの各プラットフォームのリポジトリに属さず、独立したリポジトリで管理し、git submoduleなどでそれぞれプラットフォームごとに従えて、各Platformのリポジトリ側でコード生成を行う形をとっています。(schemaはplatformから独立させたかった)

  • ドキュメントとコードの自動生成

gRPCの強みであるprotocを使った自動生成でコード作成とDocument生成については、Makeコマンドを簡単に書いて実行できるようにしています。

gen-doc:
docker run --rm -v $(CURRENT_DIR):$(CURRENT_DIR) -w $(CURRENT_DIR) xxxx/protoc --doc_out=markdown,README.md:./ -I. *.proto

以下は、protoのサンプルと生成されたDocumentのサンプルです。

message ChatRequest {
string message_body = 1; //[message本文]
string sticker_id = 2; //[スタンプのID]
string user_message_partner_id = 3; //[送信先ユーザーのpartner_id]
}

便利すぐる :kami:

パフォーマンス計測と速度改善の結果

実際にプロトを動かしてみた時点で、十分に高速化できたというのは実感を持てたのですが、いかんせん実際に数値として計測していなかったので、以下のような悩みを抱えていました。

  • 例えば接続数やピーク時間帯においても劣化していないか?という不安
  • 数値がわからないとパフォーマンス監視の閾値や性能比較がしづらい

動作は問題なくしているものの、このままでは健全に稼働し続けていると断言もできないので、どうしようかなぁと頭を悩ましていました。
通常の1リクエスト1レスポンスのHTTP通信であれば、nginxのログからlatencyの平均値を見るだけで事足りるのですが、1リクエストに対してレスポンスがいつ、何回帰ってくるかわからないストリーム型では、データがいつ発生し、いつ届いたかを起点にしないと正しくパフォーマンスが計測できません。

そこで、以下3つの指標値をStackDriver&BigQueryへ集積し、パフォーマンスチェックに用いる事にしました。

1. 送信側のデータベースへのデータ保存時刻(μs)
2. gRPC Server側への通知時刻(μs)
3. gRPC側からの受信側クライアント側への送信完了時刻(μs)

概ね1~3の間が0.2秒以下で実現できている事がわかったので、今回の速度改善は十分に達成できたと安心して言えるようになりました。
また指標値を大きく超えた場合にアラートを流すなどの運用をする事で性能面での監視/計測・アラートが可能になりました。

AccessLogや監視設計とインフラ面の制約

機能を実現する上での実装や設計面では、実はそこまで悩んだり、ハマったりする事はなかったのですが、監視・運用の面では色々考えたり、ハマった事がありましたので、何点かつらつらと、書き並べていきます。

RESTfulで設計されている従来のPairsのAPIと比べてgRPCの監視面は色々と項目を再検討したり、マッピングし直す必要がありました。

  • 4xx,5xx系 のELB上でのRateBase監視 → grpc codeを用いた各gRPCServiceごとのRate監視
  • Stream通信におけるLatencyの計測はデータ(イベント)発生日時/データ到着日時/データ更新日時(上述) の3つの差分を使って表現した。
  • Stream通信のReceive,SendのLogging は特にサポートしていなかったので、ServerStreamのWrapperを自前で書いたりする必要があった。
  • BackendTargetとの通信がAWS ALBはHTTP2未対応、AWS ELBのTCPModeはALPN未対応と、色々メリットが減っていたので、暖機運転の不要なNetworkLoadBalancerを利用。伴ってELBの監視項目→ NLBの監視項目に変更が必要でした。

まぁ考えてみれば当たり前のものが多いので、デメリットと言うほどでもないかな、と思います。

今回本番に適用してみて思った事

実際に手間がかかったところの概ねは上に挙げたような監視の設計の部分であったり、LoadBalancerでのSSLTerminateが未対応だったなどの細かいインフラ部分の問題が主で、双方向通信部分やgRPCならではの部分はそこまで学習コストも高くなく、サーバーサイド+インフラサイドの構築は正味、1人/月程度の開発で大体まとめられました。

当初の目的だった速度改善だけでなく、protocのgenerateで足回りのメンテが非常に楽 & doc/client/request/response のメンテナンスから
解放された点や、社内的にバックエンドで一番使われている言語がgoな事もあり、参入障壁や技術エントロピーをむやみに増やさなくて済んだ点も
とても良かったなぁと思っています。

フルスタックフレームワークの代わりにはなりませんが、マイクロフレームワーク使ってAPIを作るようなケース、HTTP2のストリーム通信系の用途としてはgRPCは導入しやすく、開発しやすいと思うので是非試してみてくださればと思います :)