こんにちは、Wantedly の Infrastructure Team で Engineer をしている南( @south37 )です。
先日は、「gRPC Internal」というタイトルで gRPC の設計と内部実装についてブログを書きました。
この gRPC ですが、Wantedly では実際に Microservices Architecture の Service 間通信の一部に gRPC を利用しています。特に、Microservices の中でもトップクラスに High Traffic な「User Service」については、 元々 HTTP/1.1 で通信していた箇所を gRPC に移行した事で劇的なパフォーマンス改善が達成出来ました 。
User Service は、アーキテクチャを改善する過程で生まれた Microservice であり、ユーザー情報取得のために様々な Microservice から request されます。Traffic 量が多く、High Performance が求められるという特徴があります。
なお、User Service について詳細が気になる方は、昨年の Cloud Native Days Tokyo 2019 で発表した「Re-architecturing of Microservices」の中でも触れているのでこちらも参照してみてください。
今日は、User Service の gRPC 移行を通して見えてきた「gRPC の現実世界でのパフォーマンス」について Blog にまとめたいと思います。
TL;DR User Service という High Traffic な Microservice の通信方式を HTTP/1.1 から gRPC へ移行した 元々 Ruby on Rails を利用して実装された Microservice を、Ruby の gRPC server へ書き換えた gRPC へ移行した事で、User Service への request の Average Latency が 2 ~ 3 倍改善した Benchmark 結果が良かっただけではなく、実際に Production の Microservices に組み込んだ結果でも、大幅な Latency 改善が達成できた gRPC による TCP connection の効率的な利用が寄与として大きいと考えている はじめに 「gRPC Internal」という先日の blog でも書きましたが、gRPC は HTTP/2 を利用した効率的な通信が可能な RPC フレームワークとなっています。我々は実際に HTTP/1.1 から gRPC に移行する事で、 劇的なパフォーマンス改善 を行う事ができました。
User Service の gRPC 化ですが、全体としては以下の step で進めました。
gRPC server/client の PoC 実装と Benchmark 計測 gRPC server/client の本実装および gRPC への移行 それぞれの step でどういった結果が得られたのか、順を追って説明します。
1. gRPC server/client の PoC 実装と Benchmark 計測 gRPC は High Performance として知られていますが、実際に Micrtoservices Architecture に組み込んだ時にどこまでインパクトがあるのかは未知数でした。そこで、まずは最小限のコストでパフォーマンスインパクトを知るために、PoC 実装を作って Benchmark をとることにしました。
Benchmark の結果についてですが、結論を先に書くと、 gRPC は HTTP/1.1 に比べて Average Latency で 4倍高速化 (gRPC: 10ms vs HTTP/1.1: 40ms)、95%tile の Tail Latency で7倍高速化 (gRPC: 14ms vs HTTP/1.1: 100ms) という圧倒的に High Performance な結果 になりました。
[gRPC と HTTP/1.1 それぞれの Latency の分布の Benchmark 結果。縦軸が Latency、横軸が %tile。青が gRPC であり、Latency が常に低く抑えられている事が分かる]
ここで注目したいのは Tail Latency の劇的な改善 です。gRPC においては、上図のように 99%tile の Latency を一定値(今回のケースでは約 15ms)以下に抑えられます。
Tail Latencyの重要性は Jeff Dean による論文 (The Tale at Scale) などでも強調されていますが、Microservice 化を進めるほどより顕著に影響が出てきます。
Tail Latency についてイメージを掴むために、分かりやすい例を考えてみましょう。例えば、1%の確率で遅くなる Microservice 10 個と、10% の確率で遅くなる Microservice 10 個を比較してみます。仮に並列で request をしたとしても、10個のうち1つでも遅くなると全体の Latency は増えるので、前者であれば全体として遅くなる確率は 9.6% で抑えられるところが、後者では 65% の確率で遅い という極めて大きな違いになります。
0.99^10 * 100 = 90.4(90.4% の確率で遅くならない、つまり 9.6% の確率で遅い) 0.9^10 * 100 = 34.8(34.8% の確率で遅くならない、つまり 65% の確率で遅い) 特に、今回 gRPC への移行対象とした User Service は 1 つの Web Transaction 中で何度も request される Microservice であり、上記の議論がそのまま当てはまる事になります。
上記の Benchmark 結果から、gRPC 移行によるパフォーマンス改善が期待出来ると分かりました。
Benchmark について なお、実際に Benchmark をとった際の構成を詳細に書くと、以下のようなものになっています。
Kubernetes Cluster 内で計測。Client は 1 つの Pod、Server は複数 Pod 存在。別々の EC2 instance 上で Ruby Process が Docker Container として動作する状態。 HTTP/1.1 と gRPC どちらの通信においても、Istio によって Sidecar として inject された Envoy が Proxy として挟まる。Envoy は Client-Side Load Balancing を行う設定となっている。 HTTP/1.1 では Faraday を Client library として利用、gRPC では protoc によって自動生成された Client (Stub) コードを利用 Server 側では、HTTP/1.1 では Ruby on Rails、gRPC では protoc によって自動生成された Server コードを利用 Ruby on Rails では Rack Middleware として Fluentd への Access Log の Post、OpenCensus Exporter による分散トレーシング情報の Post、New Relic への Metrics の Post を行なう。gRPC では同等の機能を interceptor として実装して利用。 Benchmark としては、Client Pod から sequential に 100 回 request を送って、その Latency の分布を計測。 今回は「Network Latency の寄与」に注目したいので、User Service の提供する機能の中でも「id を返すだけ」という最も単純なものを対象に計測。 実際に利用したコードは以下となっています。User Service への通信は、Ruby コードからは UsersService
という class を通して行うようにしており、その内部実装を「Faraday を利用した通信」と「gRPC による通信」を切り替えて計測したのでコードの見た目は同じものになっています。
[1] (main)> service = UsersService.new(current_user_id: nil)
[2] (main)> h = 0.upto(99).map { s=Time.now; service.get(3454376, with: ['id']); t=Time.now-s; t }
この構成で計測した結果、上記のように「gRPC が圧倒的に High Performance」という結果になりました。
2. gRPC server/client の本実装および gRPC への移行 PoC 実装による Benchmark で手応えを掴み、そこから実際に「Production の Microservices での利用」にも着手しました。上述したように Ruby からの User Service への通信は UsersService
と呼ばれる client library を通して行なっていたので、client library の実装に手を入れて「HTTP/1.1 と gRPC という通信方式を Flag で切り替えられる」ようにして、徐々に traffic を増やしていく形で移行を行いました。なお、Benchmark をとる際に「Production に近い環境」を念頭に置いていたので、infra 周りの構成は Benchmark の時とほとんど同じになっています。
Production で利用し始めた結果についてですが、 結論としては HTTP/1.1 による通信に比べて Average Latency で 2 ~ 3 倍高速化 という劇的な改善 となりました 。
いくつか例を載せておきます。これらは New Relic で計測した結果ですが、User Service との通信部分の Average Latency が 2 ~ 3倍高速化しています。 また、おそらく Tail Latency が改善したために、時折発生していた「極端に遅くなる peak」も無くなっています。
[gRPC に移行する事で User Service との通信部分の Average Latency が 130ms->65ms に 2 倍高速化 した例]
[gRPC に移行する事で User Service との通信部分の Average Latency が 100ms->33ms に 3 倍高速化 した例]
全体としては、以下のような傾向が見られました。
User Service Server 側での処理が軽いケースでは、Latency の寄与として Network Latency が支配的であり、結果として gRPC 移行による改善が顕著に見られた 1 つの Web Transaction 中で何度も User Service へ request するケースでは、Network Latency の寄与が Round Trip の回数だけ増すので改善が顕著に見られた これらの結果から、Benchmark だけではなく 実際に Production の Microservices で利用するケースでも、gRPC は High Performance を発揮する ことが分かりました。
考察: なぜ gRPC 利用によって顕著なパフォーマンス改善が見られたのか さて、これらの計測結果から、「gRPC によって顕著なパフォーマンス改善が可能」という事が分かりました。ここで、「なぜ gRPC 利用によってこれ程までに顕著なパフォーマンス改善が見られたのか」についても考えてみたいと思います。
まず大前提として、今回の移行では「Protocol Buffers over HTTP/1.1 から gRPC への移行」をしているため、HTTP Message Body 部分は変わっていません。つまり、元々 Protocol Buffers の Binary Format を利用していたため、HTTP Message Body サイズの改善は特に行われていないはずです。そのため、それ以外の部分が主要な寄与となったと考えられます。
gRPC Internal という Blog Post でも書いたように、gRPC は HTTP/2 を transport として活用する事で効率的な通信を実現しています。そのため、HTTP/2 の特性がこのパフォーマンス改善には寄与していると考えられます。HTTP/2 にはいくつか優れた特性がありますが、その中でも「多重化」と「persistent な TCP connection の利用」がパフォーマンス改善に対する主要な寄与だと考えています。
TCP connection 確立には以下の step が必要です。その Overhead は低く見積もってしまいがちですが、実際には無視できないものです。
DNS server への名前解決 request 我々の Kubernetes Cluster では CoreDNS を利用 TCP connection 確立の 3-Way Handshake 現状、「TCP connection 確立」の上記 step のうちどれが Tail Latency を生み出していたのか正確には分かっていませんが、「TCP connection 確立」自体が Tail Latency を生み出していた可能性は高いと考えています。実際、今回の結果を受けてチームメイトの @munisystem が「HTTP/1.1 での Keep Alive 利用」の Benchmark も計測してくれましたが、 Keep Alive がない場合に比べて Latency が改善される 結果となりました。
[Keep Alive の有無による Latency の違いの Benchmark 結果。縦軸が Latency、横軸が %tile。青が Keep Alive を有効化しており、Latency が改善]
gRPC における TCP connection の効率的利用についてもう少し掘り下げてみます。 gRPC on HTTP/2 Engineering a Robust, High Performance Protocol - gRPC Blog というドキュメントを元に gRPC における TCP connection の利用について概要をまとめると、以下のようなものになっています。
接続先 Microservice ごとに 1 つの TCP connection を確立し、その上で channel と呼ばれる「仮想的な connection」を複数作成。HTTP/2 の多重化によって複数 request/response を同時にやり取り可能。 注: gRPC library を利用して Client-Side Load Balancing を行う場合には、resolver が解決した複数 address に対して TCP connection を確立し、Load Balance する事が出来る。ただし、今回の我々の環境では Envoy に Load Balancing を行わせており、gRPC client の Container からは「Envoy との間の TCP connection」しか必要としないので「1 TCP connection 上での複数 channel の利用」と表現している。 gRPC が TCP connection の状態を check し、切断された際には re-connect を行う。TCP connection が healthy かどうかを check するために、 HTTP/2 の PING frames を利用。 上記の概要から、gRPC が TCP connection を効率的に活用している事が分かるかと思います。特に、「多重化」や「切断された TCP connection の re-connect」などは「HTTP/1.1 + Keep Alive」と比較した場合にも優れている点です。多重化があるために、多数の thread から同時に request をする場合でも 1 TCP connection 確立の Overhead だけで済みます。また、TCP connection 切断時の re-connect を gRPC が内部で行うので、利用者側が気にする必要が無いというのも優れた点です。
TCP connection の効率的な利用以外では、HTTP/2 の Header 圧縮なども効いていた可能性があります。ただし、Header 圧縮は「送信データのサイズを削減する」ことで Average Latency 減少への寄与は期待できるものの、「同一内容の request での Tail Latency」への効果は薄いのでは無いかと考えています(Benchmark の際は、Tail Latency は最初の request 以外の request で確率的に発生している様に見えました)。
その他、grpc gem による gRPC server 実装が優れていて、Ruby on Rails の「Routing + Controller の Action 実行」よりも High Performance になっているという可能性もあります。 gRPC Internal という Blog Post でも書いたように、Ruby の grpc gem は C-core の wrapper gem であるため、C-core の優れた実装の恩恵を受けられている可能性は考えられるでしょう。
まとめ 以上、User Service の通信方式を HTTP/1.1 から gRPC に移行する事で達成できた 劇的なパフォーマンス改善 についてまとめました。
gRPC のパフォーマンスは当初の期待以上であり、この結果は社内でも大きな反響がありました。特に、Tail Latency は Microservices の Architecture を考える上で避けられない制約であり、Tail Latency が改善出来ると分かったことで Architecture の選択肢が広がりました。これは、将来の技術的意思決定に対して大きなインパクトになったと考えています。
今回の結果を受けて、gRPC に対して興味を持っていただければ幸いです。
Photo Credit Photo by JESHOOTS.COM on Unsplash https://unsplash.com/photos/XzoSKULTDWI