この記事はOpenSaaS Studio Advent Calendar 2019、19日目の記事です。
今回はBazelではなくgRPCについてのお話。
はじめに
こんな要件がありました。
- 利用者の好みに合わせて選べるように、アプリのAPIをRESTとgRPCの両方で使えるようにしたい。
- 楽にメンテできるように、RESTとgRPCで個別に実装する部分をできるだけ減らしたい。
- アプリのほとんどがgRPCを採用しているので、APIの裏側のロジック(マイクロサービス)はgRPCで作りたい。
RESTでgRPCサーバといい感じにやりとりできる方法を調べたところ、こんな方法が見つかりました。
-
grpc-gateway
- プロトコル定義ファイルから生成したリバースプロキシがRESTとgRPCを中継する。
- リバースプロキシを自分で運用する必要がある(のでちょっと手間かな、という第一印象)。
- envoyのgRPC-JSON transcoder
- envoyのHTTPフィルタの一つ。プロトコル定義ファイルから生成したproto descriptorをもとにRESTとgRPCを中継する。
- envoyを運用する必要があるが、istioを使っているなら追加の手間は増えない(かな、という第一印象)。
というわけで、この二つの方法を試した結果をまとめてみました。実際に使う場合はレスポンスをカスタマイズしたくなると思うので、その結果もまとめています。
検証で使用したソースコードはそれぞれタグを切って置いてあるので試してみたい場合は参考にしてください。
stubやproto descriptorもコミットしているので、プロトコル定義を変更しなければgRPC周りのツールもいらないはず。
素直に使ってみる
まずはプロトコル定義。詳細は公式のドキュメントに譲りますが、importとEcho
rpcの内部にgrpc-gateway用の設定が入っています。
syntax = "proto3";
package service;
import "google/api/annotations.proto";
message StringMessage {
string value = 1;
}
service EchoService {
rpc Echo(StringMessage) returns (StringMessage) {
option (google.api.http) = {
post: "/v1/example/echo"
body: "*"
};
}
}
gRPCサーバ
定義したAPIに渡されたパラメータを使って、Hello, XXX
と返します。また、エラー時の挙動も確認したいので特定のinputの場合に固定でエラーを返すようにしています。
それ以外は普通にgRPCサーバを起動するだけなので説明は省略。
func (s *server) Echo(ctx context.Context, in *pb.StringMessage) (*pb.StringMessage, error) {
input := in.GetValue()
log.Printf("Received: %v", input)
if input == "error" {
return nil, status.Error(codes.NotFound, "not found")
}
return &pb.StringMessage{
Value: fmt.Sprintf("Hello, %v", input),
}, nil
}
grpc-gateway
こちらも公式のドキュメントの通りに実装しただけなので説明は省略。必要ならgateway/main.go
を見てください。
リバースプロキシを起動するとRESTでAPIが叩けるようになるのでcurlでリクエストを投げてみます。
正常系
こんなリクエストを投げてみる。
curl -D - -s -H 'Content-Type:application/json' -d '{"value":"JSON"}' -X POST http://localhost:8080/v1/example/echo
すると、こんなレスポンスが返ってきました。
HTTP/1.1 200 OK
Content-Type: application/json
Grpc-Metadata-Content-Type: application/grpc
Date: Sat, 21 Dec 2019 16:35:43 GMT
Content-Length: 23
{"value":"Hello, JSON"}
Grpc-Metadata-Content-Type
というヘッダが設定されていますが、それ以外は想定通りといったところ。
異常系
curl -D - -s -H 'Content-Type:application/json' -d '{"value":"error"}' -X POST http://localhost:8080/v1/example/echo
すると、こんなレスポンスが返ってきました。
HTTP/1.1 404 Not Found
Content-Type: application/json
Trailer: Grpc-Trailer-Content-Type
Date: Sat, 21 Dec 2019 15:13:55 GMT
Transfer-Encoding: chunked
Grpc-Trailer-Content-Type: application/grpc
{"error":"not found","code":5,"message":"not found"}
ステータスコードは想定通りですが、ボディにerror
やcode
といったgRPC起因だと思われる項目が設定されていて、ヘッダにもTrailer
やGrpc-Trailer-Content-Type
といった同様の項目が見受けられます。
gRPC-JSON transcoder
transcoderを追加したenvoyの設定ファイルと生成したproto descriptorを使って作成したDockerイメージでenvoyを起動しました。
envoyの設定は公式のドキュメントを参考にproto_descriptor
やservices
を修正したくらいなので説明は省略。必要ならenvoy/envoy.yaml
を見てください。
envoyを起動するとRESTでAPIが叩けるようになるのでcurlでリクエストを投げてみます。
正常系
リクエストはgrpc-gatewayの時と同じなので省略。
すると、こんなレスポンスが返ってきました。
HTTP/1.1 200 OK
content-type: application/json
x-envoy-upstream-service-time: 0
grpc-status: 0
grpc-message:
content-length: 28
date: Sat, 21 Dec 2019 16:41:06 GMT
server: envoy
{
"value": "Hello, JSON"
}
内部実装が分かりそうなヘッダがいくつか設定されていますが、それ以外は想定通りといったところ。
異常系
リクエストはgrpc-gatewayの時と同じなので省略。
すると、こんなレスポンスが返ってきました。
HTTP/1.1 404 Not Found
content-type: application/grpc
grpc-status: 5
grpc-message: not found
x-envoy-upstream-service-time: 1
content-length: 0
date: Sat, 21 Dec 2019 15:11:17 GMT
server: envoy
ステータスコードは想定通りですがボディが設定されておらず、grpc-message
というヘッダにエラーメッセージが設定されています。
また、content-type
もapplication/grpc
となっています。
レスポンスをカスタマイズしてみる
そのまま使うと外部に公開するのはなかなか難しそうなので、レスポンスをカスタマイズしてみましょう。
grpc-gateway
こちらの記事を参考に、エラーハンドラを定義。
// エラーレスポンスボディ
type errorBody struct {
Err string `json:"error,omitempty"`
}
// カスタムエラーハンドラ
func customHTTPError(ctx context.Context, _ *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, _ *http.Request, err error) {
const fallback = `{"error": "failed to marshal error message"}`
// 不要なヘッダを設定しない
w.Header().Set("Content-type", marshaler.ContentType())
w.WriteHeader(runtime.HTTPStatusFromCode(grpc.Code(err)))
// レスポンスボディに必要な値だけ設定
jErr := json.NewEncoder(w).Encode(errorBody{
Err: status.Convert(err).Message(),
})
if jErr != nil {
w.Write([]byte(fallback))
}
}
公式のドキュメントを参考にヘッダフィルタを定義。
// カスタムヘッダフィルタ
func responseHeaderFilter(ctx context.Context, w http.ResponseWriter, resp proto.Message) error {
// 不要なヘッダを削除
w.Header().Del("Grpc-Metadata-Content-Type")
return nil
}
エラーハンドラとヘッダフィルタを設定。
func run() error {
// 省略
runtime.HTTPError = customHTTPError
mux := runtime.NewServeMux(runtime.WithForwardResponseOption(responseHeaderFilter))
// 省略
}
リバースプロキシを起動してcurlでリクエストを投げてみます。
正常系
リクエストは同じなので省略。
レスポンスはこちら。
HTTP/1.1 200 OK
Content-Type: application/json
Date: Sat, 21 Dec 2019 16:34:32 GMT
Content-Length: 23
{"value":"Hello, JSON"}
不要なヘッダは設定されておらず、良さそうな感じです。
異常系
リクエストは同じなので省略。
レスポンスはこちら。
HTTP/1.1 404 Not Found
Content-Type: application/json
Date: Sat, 21 Dec 2019 16:25:24 GMT
Content-Length: 22
{"error":"not found"}
正常系の時と同じく、良さそうな感じです。
gRPC-JSON transcoder
transcoderで変換する方法が分からなかった(おそらくそこまで自由にできるものではなさそう)ので、envoyのHTTPフィルタの一種であるLuaフィルタでカスタマイズしてみます。なんでも、HTTPフィルタの中でLuaスクリプトが記述できるらしいです。
ということで追加した設定がこちら。bodyに値を設定できそうなAPIがなかったので、ヘッダを色々操作してるくらい。
レスポンスの変換には直接関係ないですが、追加したスクリプトのテストはどうするのがいいんでしょうか。E2Eのテストでヘッダが想定通りになっているかを確認すればいいのかな?
http_filters:
- name: envoy.lua
typed_config:
"@type": type.googleapis.com/envoy.config.filter.http.lua.v2.Lua
inline_code: |
function envoy_on_response(response_handle)
response_handle:headers():remove('grpc-status')
response_handle:headers():remove('x-envoy-upstream-service-time')
message = response_handle:headers():get('grpc-message')
if message ~= '' then
response_handle:headers():add('error-message', message)
end
response_handle:headers():remove('grpc-message')
end
envoyを起動してcurlでリクエストを投げてみます。
正常系
リクエストは同じなので省略。
レスポンスはこちら。
HTTP/1.1 200 OK
content-type: application/json
content-length: 28
date: Sat, 21 Dec 2019 16:36:54 GMT
server: envoy
{
"value": "Hello, JSON"
}
server
を消し忘れてましたが、概ね良さそう。
異常系
リクエストは同じなので省略。
レスポンスはこちら。
HTTP/1.1 404 Not Found
content-type: application/grpc
content-length: 0
error-message: not found
date: Sat, 21 Dec 2019 15:33:06 GMT
server: envoy
レスポンスボディに触れていないのでエラーメッセージがヘッダに設定されたままですが、ヘッダは色々カスタマイズできそう。
まとめ
grpc-gatewayはリバースプロキシが必要で運用の手間が増えるが、レスポンスヘッダ/ボディともに高い自由度でカスタマイズできるので公開するAPIに向いてそうですね。
一方、envoyのフィルタはレスポンスのカスタマイズの自由度が低いので、公開するAPIに採用するのは避けた方が良さそう。ただし、既にenvoyが稼働していれば追加のサーバは必要ないため、内部向けのAPIとして使えるケースもあるかもしれない。その場合でも、メンテナンス性を考えるとできるだけカスタマイズはせずにシンプルに使うようにした方が良さそうです。