Kekeの日記

エンジニア、読書なんでも

gRPCとProtocol Buffers 3とKubernetesとEnvoy

https://grpc.io/img/grpc_square_reverse_4x.png

本記事

本記事では、gRPCについて体系的に取り上げ、開発方法や運用方法を書いていこうと思います。

私はgRPCは使ったことも、運用したこともあります。同じRPCにを実現する方法としてJSONRPCというプロトコルがあります。

一年くらい前に取り上げた記事です。よかったら見てみてください。

www.1915keke.com

また、今回の使用言語は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して......みたいなことはなくなり、すっきりします。

公式サイト

grpc.io

コンセプト

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によってスキーマをあらかじめ定義する

https://grpc.io/img/landing-1.svg

Apache AvroもProtocol Buffersとおなじスキーマを定義するものですが、少し詳しい記事が以下の通りです。

www.1915keke.com

特に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. いろんな言語とプラットフォームで使える

https://grpc.io/img/landing-2.svg

執筆時には、よく使う言語では実装が終わっていたので、簡単に導入できました。

対応言語は以下の通りです。

f:id:bobchan1915:20181006125650p:plain

3. 簡単に始められて、高いスケール性

https://grpc.io/img/landing-3.svg

一行でランタイムや開発環境をインストールしてRPCをすぐ使い倒せます。

4. 双方向ストリームと統合的な認証

https://grpc.io/img/landing-4.svg

gRPCはHTTP2を使っているので双方向のやりとりができます。

作って見る

gRPCをインストール

go get -u google.golang.org/grpc

Protocol Buffers v3をインストール

以下のリンクでReleaseをダウンロードしてください。

github.com

それか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がデフォルトスタンダートになっているらしい。

https://grpc.io/img/image_0.png

以下のようにProxy(HTTP2が使えるようなもの)とClientをgRPCで接続して、バックエンドの負荷分散をする仕組みである。

これはIstioを使うことで簡単に設定できる。

www.1915keke.com

詳しくは以下の記事をご覧ください。

blog.bugsnag.com

qiita.com

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というものがあるのでそれを適用してスケールアップしてもいいかもしれません。

参考文献

medium.com

ロードバランサー(L4)とロードバランサー(L7)の違いを教えてください | ニフクラ

blog.buoyant.io

grpc.io

Kubernetes上でgRPCサービスを動かす | SOTA

blog.bugsnag.com

qiita.com

github.com