envoyのgRPC-JSON transcoderをローカルで実行する

# はじめに
HTTP/JSONからgRPCへのtranscodingを行うには大まかに次の2種類の方法があります。

* **gRPC gateway**を使用する。
* **gRPC-JSON transcoder**を使用する。

前者はプラグインとして提供されていてHTTPリバースプロキシを作成することになります。これはgolangにしか対応されていません。

後者についてはgrpc-httpjson-transcodingというライブラリがあり**Istio**や**Google cloud endpoint**で現在も使用されています。
**envoy proxy**単体でもgRPC serviceにHTTP/JSONインタフェースを適用することで実行可能です。

今回はenvoy, gRPCサーバをDockerコンテナとして立ててgRPC-JSON transcoderのテストを行います。HTTPリクエストはGET/POSTを試しています。

<img width="522" alt="スクリーンショット 2020-03-14 19.18.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307853/4ef593b1-b9c3-8ec3-f54f-8e0d49fd85ba.png">


コードは[GitHub](https://github.com/samskeyti88/docker-grpc-transcoder)にあげてあります。

# 環境
macOS Mojave 10.14.1
docker 18.09.2
docker-compose 1.23.2

# 実装

ビルド前に用意しておくファイルは次のようになります。envoyについては公式のDockerイメージを使用するので自分でDockerfileは作成しません。

```console
├── docker-compose.yaml
├── pb
│   └── helloworld.proto
├── server
│   ├── Dockerfile
│   └── main.go
└── envoy
    └── envoy.yaml
```

<br>まずは`docker-compose.yaml`です。こちらはgRPCサーバとenvoyを定義しています。
envoyとgRPCサーバの間はコンテナ間通信を行うので`links`を設定します。

```yaml:docker-compose.yaml
version: '3.7'
services:
  server:
    build: ./server
    image: grpc-server:latest
    container_name: grpc-server
    volumes:
      - './server:/go/src/server'
      - './pb:/go/src/pb'
    expose:
      - 50051
    command: 
      - /go/src/server/bin/server
  envoy:
    image: envoyproxy/envoy:v1.13.1
    volumes:
      - './envoy:/etc/envoy'
      - './pb:/etc/pb'
    expose:
      - 51051
    ports:
      - 51051:51051
    links:
      - server
volumes:
  data:
    driver: 'local'
```

<br>次にprotoファイルです。GETとPOSTの2つ分定義しています。

```proto:pb/helloworld.proto
syntax = "proto3";

package helloworld;
import "google/api/annotations.proto";

service Greeter {
  rpc GetTest(HelloRequest) returns (HelloResponse) {
    option (google.api.http) = {
      get: "/hello"
    };
  }
  rpc PostTest(HelloRequest) returns (HelloResponse) {
    option (google.api.http) = {
      post: "/hello"
      body: "*"
    };
  }
}

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}
```

<br>次にgRPCサーバの`Dockerfile`です。alpineをベースイメージにして必要となるものをインストールしています。

```dockerfile:server/Dockerfile
FROM alpine:3.8

RUN apk add --no-cache curl git go unzip musl-dev libc6-compat

ENV PROTOBUF_VERSION 3.11.4

RUN curl -sL https://github.com/google/protobuf/releases/download/v${PROTOBUF_VERSION}/protoc-${PROTOBUF_VERSION}-linux-x86_64.zip -o /tmp/protoc.zip && \
    unzip /tmp/protoc.zip -d /tmp && \
    cp -r /tmp/bin /tmp/include /usr/local/ && \
    rm -rf /tmp/*

ENV GOPATH /go
ENV PATH $PATH:$GOPATH/bin:/usr/local/go/bin

RUN mkdir /go && \
    go get -u google.golang.org/grpc && \
    go get -u github.com/golang/protobuf/protoc-gen-go && \
    go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway && \
    go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger
```

<br>`main.go`ではprotoファイルに書かれた内容を元にGETとPOSTの関数を定義します。
GETのときには`Hello World`、POSTのときには`Hello <NAME>`となるようにしました。
実行には次章で後述するpb.goファイルが必要となります。

```go:server/main.go
package main

import (
	"log"
	"net"

	pb "pb"
	"golang.org/x/net/context"
	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection"
)

const (
	port = ":50051"
)

type server struct{}

func (s *server) GetTest(ctx context.Context, in *pb.HelloRequest) (*pb.HelloResponse, error) {
	return &pb.HelloResponse{Message: "Hello World"}, nil
}

func (s *server) PostTest(ctx context.Context, in *pb.HelloRequest) (*pb.HelloResponse, error) {
	return &pb.HelloResponse{Message: "Hello " + in.Name}, nil
}

func main() {
	lis, err := net.Listen("tcp", port)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	s := grpc.NewServer()
	pb.RegisterGreeterServer(s, &server{})
	
	reflection.Register(s)
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}
```

<br>最後にenvoyの設定ファイル`envoy.yaml`です。
コンテナ間通信のため、host名(address)は宛先のコンテナ名である`grpc-server`を指定しています。

```yaml:envoy/envoy.yaml
admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }

static_resources:
  listeners:
  - name: listener1
    address:
      socket_address: { address: 0.0.0.0, port_value: 51051 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager
          stat_prefix: grpc_json
          codec_type: AUTO
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route: { cluster: grpc, timeout: { seconds: 60 } }
          http_filters:
          - name: envoy.grpc_json_transcoder
            config:
              proto_descriptor: "/etc/pb/helloworld.pb"
              services: ["helloworld.Greeter"]
              print_options:
                add_whitespace: true
                always_print_primitive_fields: true
                always_print_enums_as_ints: false
                preserve_proto_field_names: false
          - name: envoy.router

  clusters:
  - name: grpc
    connect_timeout: 1.25s
    type: logical_dns
    lb_policy: round_robin
    dns_lookup_family: V4_ONLY
    http2_protocol_options: {}
    load_assignment:
      cluster_name: grpc
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: grpc-server
                port_value: 50051
```

# サーバ設定
envoyとgRPCサーバを起動する前にpbファイル作成とgolangファイルビルドを行います。

最終的なフォルダ構成は次のようになります。(*)が付いたファイルが新たに作成されるものです。

```console
├── docker-compose.yaml
├── pb
│   ├── helloworld.proto
│   ├── helloworld.pb (*)
│   └── helloworld.pb.go (*)
├── server
│   ├── Dockerfile
│   ├── main.go
│   └── bin
│       └── server (*)
└── envoy
    └── envoy.yaml
```


<br>まずはgRPCサーバをコンテナとして起動します。

```console
$ docker-compose run --rm server /bin/sh
```

<br>protoファイルからpbファイルを作成します。これはenvoyで使用されるバイナリファイルです。

```console
# protoc \
   -I $GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
   -I /go/src/pb/  \
   --include_imports  \
   --include_source_info \
   --descriptor_set_out=/go/src/pb/helloworld.pb \
   helloworld.proto
```

<br>protoファイルからpb.goファイルを作成します。これはgRPCサーバで使用されます。

```console
# protoc \
   -I $GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
   --proto_path /go/src/pb \
   --go_out=plugins=grpc:/go/src/pb \
   helloworld.proto
```

<br>次にgo buildを行いサーバ起動用のバイナリファイルを作成します。

```console
# cd /go/src/server &&  go build -i -v -o bin/server
```

<br>exitします。コンテナ起動時のオプションに`--rm`をつけていたためコンテナは自動で削除されます。

```console
# exit
```

# サーバ起動

docker-composeでenvoyとgRPCサーバ両方を起動します。

```
$ docker-compose up -d
```

# 確認

まずはGETリクエストをenvoyに対して送ります。`Hello World`がレスポンスとして返ってくることが確認できます。

```console
$ curl  http://localhost:51051/hello  -v
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 51051 (#0)
> GET /hello HTTP/1.1
> Host: localhost:51051
> User-Agent: curl/7.54.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< content-type: application/json
< x-envoy-upstream-service-time: 8
< grpc-status: 0
< grpc-message: 
< content-length: 30
< date: Sat, 14 Mar 2020 07:31:18 GMT
< server: envoy
< 
{
 "message": "Hello World"
}
* Connection #0 to host localhost left intact
```

<br>次にPOSTリクエストを送ります。リクエストボディに含めた`{"name":"samskeyti88"}`がレスポンスに反映されることが確認できます。

```console
$ curl -X POST http://localhost:51051/hello -d '{"name":"samskeyti88"}' -v
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 51051 (#0)
> POST /hello HTTP/1.1
> Host: localhost:51051
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Length: 22
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 22 out of 22 bytes
< HTTP/1.1 200 OK
< content-type: application/json
< x-envoy-upstream-service-time: 0
< grpc-status: 0
< grpc-message: 
< content-length: 36
< date: Sat, 14 Mar 2020 07:50:14 GMT
< server: envoy
< 
{
 "message": "Hello samskeyti88"
}
* Connection #0 to host localhost left intact
```

# おわりに
envoyのgRPC-JSON transcoderを使用してHTTPリクエストをgRPCに変換できることをDockerコンテナを用いて確認しました。
自分でこの変換を実装するよりははるかに楽にできることが分かります。

# 参考
[gRPC-JSON transcoder](https://www.envoyproxy.io/docs/envoy/v1.12.2/configuration/http/http_filters/grpc_json_transcoder_filter)
[How to build a REST API with gRPC and get the best of two worlds](https://medium.com/@lorenzhofmann.w/how-to-build-a-rest-api-with-grpc-and-get-the-best-of-two-worlds-9a4e491f30ae)
[JimmyCYJ/grpc-transcoding-experiment](https://github.com/JimmyCYJ/grpc-transcoding-experiment)