こんにちは、Wantedly の Infrastructure Team で Engineer をしている南( @south37 )です。
先日、gRPC 関連で2つのブログを書きました。「gRPC Internal」では、ドキュメントやコードを読み解くなかで見えてきた「gRPC の設計と内部実装」についてブログを書きました。「Real World Performance of gRPC」では、Wantedly で実際に gRPC を利用することで達成できたパフォーマンス改善についてブログを書きました。
今日は、より実践的な内容として、「Wantedly で gRPC Server/Client の開発をどう行なっているのか」についてブログを書きます。このブログを読んで、gRPC Server/Client の開発に対して少しでも興味を持っていただければ幸いです。
TL; DR Wantedly では Ruby と Go で gRPC Server/Client の開発を行っている。今回は Ruby の例を紹介する。 gRPC Server/Client 開発においては、 .proto file を集約する apis repository や servicex と呼ばれる社内 library (gem) を活用する事で、個々の開発者がロジックの実装に集中して高い生産性で開発出来るようにしている servicex の持つ機能のうち、いくつかの汎用的な機能は RubyGems で OSS として公開している はじめに Wantedly は Microservices Architecture を採用しています。社内には様々な言語(Ruby, Go, Python, Node.js, etc.)で書かれた様々な Microservice が存在していますが、その中でも主要な言語は Ruby と Go であり、gRPC の利用も Ruby と Go を中心に行なっています。Go での gRPC 開発についてはまた後日ブログにまとめる事にして、今回は Ruby での例を紹介いたします。
さて、gRPC の利用について具体的に考えてみます。gRPC をただ利用するだけであれば、 gRPC Internal の「2. gRPC の使い方」で書いた様に「protoc で自動生成したコードを利用する」だけでよく、簡単です。しかし、会社として Production の Microservices で使っていくとなると、以下の点について考える必要があります。
Monitoring, Access Logging, Error Reporting, Distributed Tracing など Observability を高める機能が組み込まれた状態を実現する必要がある 共通部分は library で隠蔽し、protoc によるコード生成などの開発フローは標準化して、個々の開発者がロジックの実装に集中して高い生産性で開発を進められる様にする Wantedly では、これらを実現する為に library の実装や開発フローの整備を行い、「gRPC Server/Client の開発環境」を整えてきました。まだまだ発展途上の段階ではありますが、現状の取り組みについてこのブログで紹介したいと思います。
ここからは、以下の3つのトピックについて、順に説明いたします。
gRPC Server 開発の進め方 gRPC Client 開発の進め方 gRPC Server/Client 開発を支える library 1. gRPC Server 開発の進め方 まずは、「gRPC Server 開発の進め方」です。 gRPC Internal の「2. gRPC の使い方」でも触れましたが、gRPC Server を開発する際は以下の step が必要です。
Service definition を Protocol Buffers IDL で .proto file に記述 protoc を利用して、Server コードを自動生成 Server の実装を書いて実行 上記の step のうち、「1. Service definition を Protocol Buffers IDL で .proto file に記述」と「2. protoc を利用して、Server コードを自動生成」については、wantedly では「apis」という名前の repository を利用しています。まずはこの部分について説明します。
1-1. apis repository の利用 apis は .proto file を集約するための repository です。apis へ PR を作るだけで、CI によって「各言語向けのコード生成」が自動で行われるようになっており、「protoc のセットアップ」などの煩雑な作業を個々の開発者がしなくても良い仕組みになっています。この仕組みは同僚の @izumin5210 が作ってくれました。
[.proto file を集約する apis repository]
[apis repository 内で定義された User Service の Service Definition]
[apis repository で自動生成された ruby コード。これを gem として配信]
.proto file からは、「gRPC Service Class」と「Protocol Buffers Message Class」のコードが自動生成されます。生成されたコードは、Ruby では gem として配信されます。各 repository では、Gemfile に apis を記述して bundle install を行い
、必要な file を require して利用するというフローになっています。
1-2. RPC として呼び出される処理を実装 さて、apis repository によって「gRPC Service Class」と「Protocol Buffers Message Class」のコードを生成して配信することができました。gRPC Server が意味のある response を返すには、これらを利用して ロジックを実装する必要があります。
一例として、User Service の ListUsers という RPC の処理を見てみましょう。以下のように、apis で生成された gRPC Service Class である「UsersPb::UserService::Service」を継承した UserServer Class で、 list_users method を実装します。この例は簡略化して書いてますが、実際には 8-9 行目の response を作る部分で「DB への問い合わせ」などをおこなって意味のあるデータを保持したオブジェクトを作成し、それを UsersPb::ListUsersResponse オブジェクトとして返します。引数や返り値は .proto file に記述したものになっています。
1 require 'wantedly/users/users_services_pb' # Load from apis
2
3 class UserServer < UsersPb::UserService::Service
4 # @param [UsersPb::ListUsersRequest] req
5 # @param [GRPC::ActiveCall::SingleReqView] call
6 # @return [UsersPb::ListUsersResponse]
7 def list_users(req, call)
8 # NOTE: create users here
9 UsersPb::ListUsersResponse.new(users: users)
10 end
11 end
これだけで、「gRPC Server として必要な RPC のロジック」は実装できました。
1-3. 必要な設定をした上で gRPC Server Process を起動 次に、gRPC Server Process を起動します。gRPC Server を起動して Request を処理させるには、「1-2. RPC として呼び出される処理を実装」で定義した「gRPC Service Class を継承した Class」を Handler として登録する必要があります。また、Wantedly では、Microservice は Kubernetes 上で Docker Container として動かしているので、Heath Check の為に gRPC Health Checking Protocol を実装した Handler の登録も必要です。その他、Observability を高めるための gRPC interceptor(gRPC の拡張機能。後述するが、Rack Middleware 相当のもの)の設定なども行う必要があります。
こういった「どの repository でも共通で必要になる処理」については、個々の開発者が気にする必要がないよう、 servicex と呼ばれる社内 library (gem) の機能として提供するようにしています。具体的には、grpc-server という名前のコマンドを用意しており、それを実行するだけで「gRPC Service Class の load および Handler としての登録、その他に共通で必要になる各種設定」が行われるようにしています。
grpc-server コマンドは以下のように利用することができます。
$ bundle exec grpc-server
handling /grpc.health.v1.Health/Check with #<Method: Grpc::Health::Checker#check>
handling /grpc.health.v1.Health/Watch with #<Method: Grpc::Health::Checker(Grpc::Health::V1::Health::Service)#watch>
handling /wantedly.users.UserService/ListUsers with #<Method: UsersGrpcService#list_users>
.
.
.
gRPC server starting...
* Listening on tcp://0.0.0.0:6046
* Environment: development
Use Ctrl-C to stop
[grpc-server コマンドの利用例]
grpc-server コマンドは grpc-server コマンドの実装 - gist のような実装になっています。これに関しては、もう少し洗練させた形で近い将来 Ruby gem として公開したいと考えています。
1-4. 「Ruby による gRPC Server 開発の進め方」まとめ 以上、gRPC Server 開発の進め方について説明しました。Wantedly では、 .proto file を集約する apis repository や servicex と呼ばれる社内 library (gem) を利用することで、個々の開発者がロジックの実装に集中して高い生産性で開発を進められる環境を作っています。これらの取り組みは、今後さらに洗練されてより良い環境になっていく予定です。
2. gRPC Client 開発の進め方 次に、gRPC Client 開発の進め方について説明します。gRPC Client を開発する際にも、gRPC Server 開発と同様に .proto file を集約する apis repository や servicex と呼ばれる社内 library (gem) を活用するようにしています。
gRPC Client としては、 apis repository で自動生成した gRPC Service Class のコードを load したうえで、その Stub Class を利用します。gRPC Client を単純に利用するだけであれば、以下のコードで動きます。
1 require 'wantedly/users/users_services_pb' # Load from apis
2
3 client = UsersPb::UserService::Stub.new(url, :this_channel_is_insecure)
ただし、Production の Microservices で利用する上では「必ずセットして欲しい gRPC interceptor(gRPC の拡張機能。後述するが、Faraday Middleware 相当のもの)」などが存在するので、個々の開発者が意識しなくてもそれらの設定が自動で行われる様に、 servicex gem の中で .stub_for という「gRPC Client 生成用のメソッド」を用意しています。
1 require 'wantedly/users/users_services_pb' # Load from apis
2
3 client = Servicex::Grpc.stub_for(UsersPb::UserService, grpc_url)
.stub_for は同僚の @izumin5210 が実装してくれたもので、 .stub_for の実装 - gist のような実装になっています。元々、HTTP/1.1 の通信では servicex gem の中で Faraday Middleware などの設定を行なった API Client を提供する様にしていました。.stub_for は同様の体験を gRPC でも提供することが意図されています。User Agent の設定などもこの中で自動で行われるようになっています。
gRPC Client については、基本的にやる事はこれだけです。「 apis と servicex を gem として load すれば、どの Microservice からでも簡単に必要な設定が行われた状態で gRPC での通信が出来る」という環境を作っています。
3. gRPC Server/Client 開発を支える library 「1. gRPC Server 開発の進め方」と「2. gRPC Client 開発の進め方」で、gRPC Server/Client をどう開発しているかについて簡単に概要を説明しました。ここでは、それらの開発を支える library について説明したいと思います。
library という観点では、以下の2つのトピックがあります。順に説明します。
gRPC interceptor Utility for Protocol Buffers 3-1. gRPC ineterceptor 「1. gRPC Server 開発の進め方」や「2. gRPC Client 開発の進め方」でも少し触れましたが、gRPC には interceptor と呼ばれる拡張機構が存在します。gRPC の Client Interceptor は gRPC Client から request を送る際に request を hook して様々な処理を差し込める機構であり、Ruby の HTTP/1.1 Client Library として有名な Faraday における Faraday Middleware 相当の機能を提供します。逆に、gRPC の Server Interceptor は gRPC Server で request を処理する部分を hook して様々な処理を差し込める機構であり、 Rack Middleware 相当の機能を提供します。
Wantedly では、元々 HTTP/1.1 の通信をする際には Client Library としては Faraday、Server Framework としては Ruby on Rails を利用しており、Monitoring, Access Logging, Error Reporting, Distributed Tracing などの機能を Faraday Middleware および Rack Middleware として servicex と呼ばれる社内 library (gem) で提供していました。gRPC Server/Client 向けには、これらの機能を gRPC interceptor として提供するようにしています。
servicex gem で提供される gRPC interceptor は多岐に渡りますが、その中でも「Wantedly に限らず汎用的に利用出来るもの」については、以下のように OSS 化して RubyGems で公開しています。
これらの gem の機能についても、簡単に説明します。
grpc_newrelic_interceptor
grpc_newrelic_interceptor は New Relic を利用する為の gRPC interceptor です。Server/Client 両方の interceptor を提供していて、これを利用することで gRPC の通信を New Relic で Monitoring 出来るようになります。実は、 Real World Performance of gRPC という Blog の結果などはこの interceptor を利用して計測しています。
grpc_opencensus_interceptor
grpc_opencensus_interceptor は OpenCensus と呼ばれる「分散トレーシングや Metrics 収集の為の標準規格および library 群」に対応した gRPC interceptor です。Server/Client 両方の interceptor を提供しており、これによって Trace Context の伝搬や Datadog/Stackdriver への分散トレーシング情報の export などが可能になります。
元々 OpenCensus は仕様と実装の両方を提供する Project で、 OpenCensus の Ruby 実装 では Faraday Middleware と Rack Middleware が提供されています。ただし、gRPC interceptor は公式には提供されていなかったため、gem として実装しています。
一応、公式 repository に対してこの gem の実装を元に PR も出しています。ただし、実は OpenCensus は OpenTracing と merge されて OpenTelemetry Project となる事が決まっていて、現在は OpenTelemetry の活動が中心であり、OpenCensus の方は security patch のみの対応にとどまっているようです。そのため、上記 PR が merge される可能性は薄いと考えています。OpenTelemetry は OpenCensus との Backward Compatibility を保つ形で進めるとアナウンスされているため、しばらく様子を見た上で、OpenTelemetry Project が落ち着いて gRPC interceptor 実装が公式から出てきたタイミングでそちらに移行する予定です。
grpc_access_logging_interceptor
grpc_access_logging_interceptor は Access Log を記録する為の gRPC interceptor です。gRPC の RPC Method、Status Code、Access Time、Response Time、Request Parameter、User Agent などを記録するようにしています。デフォルト実装では標準出力へ print するようになっていますが、logger を差し替えられるようにしており、Wantedly では「Fluentd 経由で BigQuery へ Access Log を Post する」という使い方をしています。
こういった各種 gRPC interceptor を servicex と呼ばれる社内 library (gem) で提供する事で、個々の開発者が意識しなくても「Production の Microservices で gRPC Server/Client を動かす際に必要な機能」が揃うようにしています。
3-2. Utility for Protocol Buffers gRPC では、基本的には Protocol Buffers Message Class のオブジェクトを Request / Response の Payload として利用します。 Protocol Buffers の Ruby 実装 はよく出来ており、これを利用して Protocol Buffers Message Class のオブジェクトを組み立てるのですが、しばしば面倒な実装になるケースがあるため、 the_pb という gem を利用して実装コストを減らしています。
the_pb
the_pb gem のユースケースを簡単に紹介したいと思います。例えば、以下の様な .proto file が存在して、そこから protoc によって Ruby コードが生成されているとします。
# example.proto
message Account {
int64 id = 1;
google.protobuf.Timestamp created_at = 2;
Profile profile = 3;
}
message Profile {
int64 id = 1;
google.protobuf.Timestamp created_at = 2;
}
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: example.proto
require 'google/protobuf'
require 'google/protobuf/timestamp_pb'
Google::Protobuf::DescriptorPool.generated_pool.build do
add_file("example.proto", :syntax => :proto3) do
add_message "wantedly.example.Account" do
optional :id, :int64, 1
optional :created_at, :message, 2, "google.protobuf.Timestamp"
optional :profile, :message, 3, "wantedly.example.Profile"
end
add_message "wantedly.example.Profile" do
optional :id, :int64, 1
optional :created_at, :message, 2, "google.protobuf.Timestamp"
end
end
end
Account = Google::Protobuf::DescriptorPool.generated_pool.lookup("wantedly.example.Account").msgclass
Profile = Google::Protobuf::DescriptorPool.generated_pool.lookup("wantedly.example.Profile").msgclass
この状態で Account オブジェクトを作ることを考えてみます。int64 などの「Message 型以外の型」だけを考えるのであれば、initialize の際に値を渡せば良いだけなので簡単です。
[1] pry(main)> Account.new(id: 1)
=> <Account: id: 1, created_at: nil, profile: nil>
しかし、例えば google.protobuf.Timestamp の様な Message 型が出てくると急に扱いが面倒になります。Time 型の値であれば https://github.com/protocolbuffers/protobuf/pull/5751 の対応によって google.protobuf.Timestamp 型のフィールドに直接 set が出来るようになっていますが、DateTime、Date、ActiveSupport::TimeWithZone、String などはそのまま set しようとするとエラーになってしまいます。
[2] pry(main)> Account.new(created_at: Time.new(2019, 5, 15))
=> <Account: id: 0, created_at: <Google::Protobuf::Timestamp: seconds: 1557846000, nanos: 0>, profile: nil>
[3] pry(main)> Account.new(created_at: DateTime.new(2019, 5, 15))
Google::Protobuf::TypeError: Invalid type DateTime to assign to submessage field 'created_at'.
from (pry):34:in `initialize'
[4] pry(main)> Account.new(created_at: Date.new(2019, 5, 15))
Google::Protobuf::TypeError: Invalid type Date to assign to submessage field 'created_at'.
from (pry):35:in `initialize'
[5] pry(main)> Account.new(created_at: Time.zone.parse("2019-05-15T00:00:00+09:00"))
Google::Protobuf::TypeError: Invalid type ActiveSupport::TimeWithZone to assign to submessage field 'created_at'.
from (pry):38:in `initialize'
[6] pry(main)> Account.new(created_at: "2019-05-15T00:00:00+09:00")
Google::Protobuf::TypeError: Invalid type String to assign to submessage field 'created_at'.
from (pry):36:in `initialize'
そこで、the_pb gem では Pb.to_timestamp というメソッドを用意して、google.protobuf.Timestamp への変換を簡単に出来る様にしています。
[7] pry(main)> Pb.to_timestamp(Time.new(2019, 5, 15))
=> <Google::Protobuf::Timestamp: seconds: 1557846000, nanos: 0>
[8] pry(main)> Pb.to_timestamp(DateTime.new(2019, 5, 15))
=> <Google::Protobuf::Timestamp: seconds: 1557878400, nanos: 0>
[9] pry(main)> Pb.to_timestamp(Date.new(2019, 5, 15))
=> <Google::Protobuf::Timestamp: seconds: 1557846000, nanos: 0>
[10] pry(main)> Pb.to_timestamp(Time.zone.parse("2019-05-15T00:00:00+09:00"))
=> <Google::Protobuf::Timestamp: seconds: 1557846000, nanos: 0>
[11] pry(main)> Pb.to_timestamp("2019-05-15T00:00:00+09:00")
=> <Google::Protobuf::Timestamp: seconds: 1557846000, nanos: 0>
さらに、Pb.to_proto というメソッドを用意して、Message 型のフィールドの型をチェックして自動で変換して set を行う機能も用意しています。
[12] pry(main)> Pb.to_proto(Account, { created_at: "2019-05-15T00:00:00+09:00" })
=> <Account: id: 0, created_at: <Google::Protobuf::Timestamp: seconds: 1557846000, nanos: 0>, profile: nil>
Pb.to_proto は Message 型の値がネストしたケースでもちゃんと動作する様になっています。
[13] pry(main)> Pb.to_proto(Account, { id: 1, created_at: "2019-05-15T00:00:00+09:00", profile: { id: 2, created_at: "2019-05-15T00:00:00+09:00" } })
=> <Account: id: 1, created_at: <Google::Protobuf::Timestamp: seconds: 1557846000, nanos: 0>, profile: <Profile: id: 2, created_at: <Google::Protobuf::Timestamp: seconds: 1557846000, nanos: 0>>>
このように「Protocol Buffers Message Class オブジェクトの構築」を簡単に行いたいケースで the_pb gem は利用されています。
なお、今回は google.protobuf.Timestamp を中心に説明しましたが、the_pb gem はこれ以外にも google.protobuf.StringValue、google.protobuf.Int64Value など「 Protocol Buffers の wrappers.proto で定義された Message 型」への変換もサポートしています。これらは Nullable な値を表現するために利用する Message 型であり、利用頻度が高く、変換の手間を削減することが開発生産性に繋がっています。
まとめ 以上、Wantedly での gRPC Server/Client の開発環境についてまとめました。
gRPC は使い始めるだけであれば簡単ですが、個々の開発者が高い生産性で開発を進められるようにするには library の整備や開発フローの標準化などが必要になります。我々の取り組みはほんの一例ではありますが、「既に gRPC での開発を行っている方」や「これから gRPC での開発を行おうとしている方」の参考になれば幸いです。
なお、Ruby で gRPC 開発を行っている例はまだ少なく、gRPC interceptor などの ecosystem もまだまだ弱いと感じています。これを機会に開発者が増え、ecosystem として成熟していく事を願っています。
Photo Credit Photo by Christian Wiediger on Unsplash https://unsplash.com/photos/w4WBwHgWiIU