gRPCとProtocol Buffers 3とKubernetesとEnvoy
本記事
本記事では、gRPCについて体系的に取り上げ、開発方法や運用方法を書いていこうと思います。
私はgRPCは使ったことも、運用したこともあります。同じRPCにを実現する方法としてJSONRPC
というプロトコルがあります。
一年くらい前に取り上げた記事です。よかったら見てみてください。
また、今回の使用言語はGolangです。
コンテンツ
gRPCについて
gRPCとは
gRPCとはHTTP2上でRPCを実現するオープンソースな普遍的なフレームワークのことです
RPCとは
RPCとはRESTfulのようにルーティングや、クエリストリングを正しく指定しないいけないわけではなくて、関数を直接呼び出しているようにサーバー間通信を実現するものです。
例えばJSONPRC2.0だと以下のようなJSONをルートURLに送れば使うことができます。
curl -x POST http://hogehoge -d "{"jsonrpc": "2.0", "result": "Tunakichi", "id": 3}"
このようにURLなどを意識せずに外部の関数を実行できるわけです。
これはクライアント内の
exexMethod(param)
のように、ローカルで関数を呼び出すように簡単に外部メソッドを呼び出せるので、従来のようにhttpリクエストを何行か書いて実装して、unMarshal
して......みたいなことはなくなり、すっきりします。
公式サイト
コンセプト
1. サービス定義
ほとんどのRPCシステムのようにgRPCはパラメータと戻り値を定義することで呼び出せるようになっている。
いくつかの定義方法がある。
HTTP1のようにリクエストを送って、サーバーがレスポンスを返す形だと以下のようになる。
rpc SayHello(HelloRequest) returns (HelloResponse){ }
また、サーバーが一度リクエストして、それを元にストリームをmessage
として受け取る場合には
rpc LotsOfReplies(HelloRequest) returns (stream HelloResponse){ }
クライアントがストリームで処理を送って、サーバーがそれを待ってストリームが終わると読み込んでレスポンスを返すものは以下のものである。
rpc LotsOfGreetings(stream HelloRequest) returns (HelloResponse) { }
今後は双方向バインディングである。それぞれは独立に動作し、順序関係なくクライアントもサーバーも読み書きができる。
もちろん待つこともできる。
rpc BidiHello(stream HelloRequest) returns (stream HelloResponse){ }
特徴
特徴としては以下のような項目があります。
1. Protocol Buffersによってスキーマをあらかじめ定義する
Apache AvroもProtocol Buffersとおなじスキーマを定義するものですが、少し詳しい記事が以下の通りです。
特にRESTfulなサーバーだとAPIエンドポイントやパラメーターのドキュメント化はないがしらにされがちで、運用としてもコストがかかります。
Protocol Buffersはあらかじめ定義しなければならないので運用が楽になります。
また、コードは.proto
ファイルから生成する(generate)することになります。
.proto
は以下のようになっています。
message Person { string name = 1; int32 id = 2; bool has_ponycopter = 3; }
これをProtocol Bufferコンパイラprotoc
でコンパイルして、特定の言語のデータアクセスできるコードを生成します。
例えばname
を引数に、またmessage
が戻り値のコードを描くときは
service Greeter { // Sends a greeting rpc SayHello (HelloRequest) returns (HelloReply) {} } // The request message containing the user's name. message HelloRequest { string name = 1; } // The response message containing the greetings message HelloReply { string message = 1; }
とすれば描くことはできます。ここでService
とはgRPCで使うものなのでProtocol Bufferのドキュメントには直接書いてないかもしれません。
Service
とは、簡単にいうとクライアントとサーバーが通信するプロトコルをひとまとまりです。
message
とはリクエストとレスポンスの中身です。
2. いろんな言語とプラットフォームで使える
執筆時には、よく使う言語では実装が終わっていたので、簡単に導入できました。
対応言語は以下の通りです。
3. 簡単に始められて、高いスケール性
一行でランタイムや開発環境をインストールしてRPCをすぐ使い倒せます。
4. 双方向ストリームと統合的な認証
gRPCはHTTP2を使っているので双方向のやりとりができます。
作って見る
gRPCをインストール
go get -u google.golang.org/grpc
Protocol Buffers v3をインストール
以下のリンクでReleaseをダウンロードしてください。
それかcurl
でインストールしてください。
そのあとに解凍します。
そしてパスを通します。
export PATH=$PATH:$GOPATH/bin
次にprotoc
をインストールします。
brew istall protobuf
また、go用のprotoc
プラグインをインストールします。
go get -u github.com/golang/protobuf/protoc-gen-go
そしてコンパイルして雛形を作ります。
protoc --go_out=plugins=grpc:. -I hoge.proto
ここで-I
でInput fileである.protoを指定して、go_outでpluginsにgrpc渡して.
ディレクトリに出力しています。
またドキュメントも同時に作成できます。
protoc --doc_out=html,index.html:./ proto/*.proto
Server側
以下のように実装しました。
func main() { listenPort, err := net.Listen("tcp", ":19003") if err != nil { log.Fatalln(err) } server := grpc.NewServer() service := &service.HelloService{} pb.RegisterGreeterServer(server, service) server.Serve(listenPort) }
.pb.go
にあるinterfaceを実装するだけなのでservice
は解説しません。
server = grpc.NewServer
でサーバーをインスタンスを作って、server.serve(listenport)
でサーバーを起動します。
またここでserviceはpb.RegisterXXServiceでserverとserviceを渡して登録します。
Client側
まず以下のように実装します。
func main() { conn, err := grpc.Dial("127.0.0.1:19003", grpc.WithInsecure()) if err != nil { fmt.Println(err) } defer conn.Close() client := pb.NewGreeterClient(conn) message := &pb.HelloRequest{Name: "hoge"} res, err := client.SayHello(context.TODO(), message) fmt.Printf("result:%#v \n", res) fmt.Printf("error::%#v \n", err) }
ここから少し解説します。
conn, err := grpc.Dial("127.0.0.1:19003", grpc.WithInsecure())によって、まずコネクションを確立します。
次にクライアントをインスタンス化します。
client := pb.NewXXClient(conn)
これはDIパターンですね。
アプリケーションそれぞれですが、Serviceにおくるデータの構造体を初期化します。
message := &pb.GetXXXMessage{"hogd"}
そして呼び出します。
res, err := client.GetMyCat(context.TODO(), message
実行
サーバーとクライアントそれぞれ実行します。
go run server.go
go run client.go
すると以下のように返ってきました。
result:&helloWorld.HelloReply{Message:"hoge", XXX_NoUnkeyedLiteral:struct {}{}, XXX_unrecognized:[]uint8(nil), XXX_sizecache:0} error::<nil>
gRPCの手順
1. Protoを定義
以下のように
- なんのServiceで:
service
- どんなメソッドがあって:
rpc
- どんなものをやりとり: rpcの
引数
と戻り値
で定義される。
2. Serverの実装
Server側のコードは以下のような手順を踏まないといけません。
- まずService構造体を作る
interface
に準拠するためにレスポンスを受け取ってリプライを返すにする- サーバー自体は
server
を作って、Service構造体を登録し - サーバーを起動する
印象としてはサーバー中身のロジックを書いて起動するだけだけです。
3. Client
Client側は以下の手順を踏まないといけません。
- まず接続を確立して
- クライアントを作成し
- メソッドを叩く
印象としてはただpb
でclientを作成後、clientメソッドを叩くだけです。
gRPCの所感
感じたポイントは以下の通りです。
- 実装より先にスキーマ(Protocol Buffer)を書くため、ドキュメントやスキーマが古い、メンテされてない状況が生まれないので良い。
- middlewareがコミュニティのおかげで増えてる。
- CNCFに採択されていることから将来性も高い
- HTTP2を勝手にやってくれるので機能実装に集中でき、開発コストが抑えられる
- パフォーマンスがいい。エンコード・デコード処理も早くなり、データ量も小さくなる。
ベストプラクティスとして感じたことは以下の通りです。
- Protocol BufferなどはGit Repoにモジュールわけして、特定のアプリケーションでメンテするのを防ぐ。
- git submoduleを使ってならアプリケーションにおいてもいいかも(あまりメリットはない)
- Apache AvroなどでApache Kafkaなどストリームエンジンに入れる未来がないなら、常にProtocol Bufferに準拠して型づけすると移行時に楽かも
- もちろんデータベースや他の型準拠があると思うのでキャストヘルパーなどProtocl Bufferヘルパーをパッケージとして作ると組織的に楽かも
- MVCやMVVMでも、とりあえずM(Model)をきちんと定義しているとgRPCに移行時に楽になるかも
Protocol Buffers 3の主な注意点
基本
型
以下のような型がある。
Proto type | Swift type |
---|---|
int32 | Int32 |
sint32 | Int32 |
sfixed32 | Int32 |
uint32 | UInt32 |
fixed32 | UInt32 |
int64 | Int64 |
sint64 | Int64 |
sfixed64 | Int64 |
uint64 | UInt64 |
fixed64 | UInt64 |
bool | Bool |
float | Float |
double | Double |
string | String |
bytes | Data |
フィールド番号
バイナリフォーマットでどれがどのフィールドに一致をするかを指定するユニークな番号である。
これによってスキーマが変更されても、耐えることができる。
番号は一位であればいくらでもいいのだが、1~15までは1バイトで16~1024は2バイトである。
JSONPRCではクライアントはサーバーが要求する順番にパラメーターを渡さないといけなかった。
.proto
のためのスタイルガイド
メッセージとフィールド名
- メッセージ: キャメルケース
- フィールド名: スネークケース
つまり
Good
message HogeFugaRequest(){ string hoge_fuga_message = 1; }
Bad message name
message Hoge_fuga_request(){ string hoge_fuga_message = 1; }
Bad field name
message HogeFugaRequest(){ string hogeFugaMessage = 1; }
サービスとメソッド名
どちらもキャメルケースをつかうべき。
Good
service ActionService(){ rpc GetName(ActionRequest) returns (ActionResponce); }
Bad service name
service action_service(){ rpc GetName(ActionRequest) returns (ActionResponce); }
Bad method name
service ActionService(){ rpc get_name(ActionRequest) returns (ActionResponce); }
Good
service ActionService(){ rpc get_name(ActionRequest) returns (ActionResponce); }
import
GoogleはProtocol Buffersのためにいくつかプラグインを提供しているので使うにはimport
する。
syntax = "proto3"; import "google/protobuf/any.proto"; import "google/protobuf/descriptor.proto"; message SelfDescribingMessage { // Set of FileDescriptorProtos which describe the type and its dependencies. google.protobuf.FileDescriptorSet descriptor_set = 1; // The message and its type, encoded as an Any message. google.protobuf.Any message = 2; }
KubernetesとgRPC
相性
HTTP2は一度コネクションを確立すると、永続化をするため特定のPodと接続すると仮に新しいPodが作成されたとしてもそのPodにはリクエストを送らない仕様になっている。つまりLBでサービスを構築しているのならばgRPCでは全くスケールしない。
特にLoad Balancer(LB)との相性が悪く、LBが提供するのはL4のロードバランシングである。それに対してL7のロードバランシングが必要です。
L4のロードバランシングはIPアドレスによるもので、L7によるロードバランシングはURLやHTTPヘッダーなどによるロードバランシング方法です。
ここで登場するのがIstioで使われているEnvoyかLinkerdで使われているProxyである。
対策
1. Proxyを挟む(Server side Load Balancing)
Sidecar Patternである。特にUber製のEnvoyがデフォルトスタンダートになっているらしい。
以下のようにProxy(HTTP2が使えるようなもの)とClientをgRPCで接続して、バックエンドの負荷分散をする仕組みである。
これはIstioを使うことで簡単に設定できる。
詳しくは以下の記事をご覧ください。
2. Clientがバックエンドに接続を確立しまくる(Client side Load Balancing)
あらかじめいろんなサーバーと接続を確立して、どれかに実行することによって分散をする仕組みである。
これはKuberetesで唯一のDNSラウンドロビンにできるHeadless Service
を使うことで解決できるらしいです。
これがやっているのは定期的にDNSに問い合わせて、PodのIPアドレスを取得しているものです。
resolver, _ := naming.NewDNSResolverWithFreq(1 * time.Second) balancer := grpc.RoundRobin(resolver) conn, _ := grpc.DialContext(context.Background(), grpcHost, grpc.WithInsecure(), grpc.WithBalancer(balancer))
ここでNewDNSResolverWithFreq
はデフォルト値は30minなのでPodがAutoscaleする間隔に設定するのがいいでしょう。
3. 二つの比較
項目 | メリット | デメリット |
---|---|---|
Serverside LB | クライアントがシンプルでバックエンドを知らなくて済む。アプリケーションを書き換えなくていい。 | レイテンシが増える。データの流れの中にProxyが入ってくる。 |
ClientSide | パフォーマンスがいい。 | クライアントが複雑になり、クライアントがヘルスチェックなどをしないといけない。 |
スケール性を考えてSeverside LBが良いと思います。
4. LBに注意して運用するという例も
たとえばPodはスケールしないようにすることもできます。
例えばalpha
版ではありますがKuberenetesにはVerticalPodAutoScale
というものがあるのでそれを適用してスケールアップしてもいいかもしれません。
参考文献
ロードバランサー(L4)とロードバランサー(L7)の違いを教えてください | ニフクラ