nginx
gRPC
grpc-web

gRPC-WebのProxyをNginxにしてみた

先日gPRC-WebがGAされました。
gRPC-Webが正式リリース。WebブラウザからgRPCを直接呼び出し可能に

どのような仕組みになっているかというと・・・

Client(ブラウザ) -> Proxy -> gRPCサーバー

というふうにProxyを介してブラウザとgRPCサーバーとでやり取りを行っています。
今回はこのProxyのお話です。

このProxyは、公式ドキュメントやその例などでもEnvoyを使用しています。
https://github.com/grpc/grpc-web

実際、Envoyを利用するとすんなりと導入することが出来ます。
ただIP制限ができなかったり(こちら出来るようであればご指摘おねがいします)、Yamlで書かないといけなかったりとなにかとかゆいところに手が届きません。
そのため、ProxyをNginxにしてやってみようと思います。

NginxでもgRPCのプロキシーをすることができます。
Introducing gRPC Support with NGINX 1.13.10

今回作るサンプルはこちらに配置しています。
https://github.com/morix1500/grpc-web-nginx

protoファイルの作成

まずはprotoファイルを作成しインターフェイスを定義します。
名前が渡されたら、「Hello, ○○」と返却するだけのAPIです。

proto/hello.proto
syntax = "proto3";

package hello;

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}

service HelloService {
  rpc Hello(HelloRequest) returns (HelloResponse) {}
}

ここからクライアントとサーバーのコードを生成します。
今回は便利な znly/protoc を使用します。

$ docker run --rm -v $(pwd):$(pwd) \
-w $(pwd) znly/protoc:0.4.0 \
-I ./proto \
--js_out=import_style=commonjs,binary:./client/assets/_proto \
--go_out=plugins=grpc:./server/proto/ \
--grpc-web_out=import_style=commonjs,mode=grpcweb:./client/assets/_proto \
proto/hello.proto 

$ tree .
.
├── README.md
├── client
│   └── assets
│       └── _proto
│           ├── hello_grpc_web_pb.js
│           └── hello_pb.js
├── proto
│   └── hello.proto
└── server
    └── proto
        └── hello.pb.go

gRPCサーバーの作成

サクッと作りましょう。
Golangで作ります。main.go は省略します。

server/grpc/server.go
package grpc

import (
        "context"
        "fmt"
        pb "github.com/morix1500/grpc-web-nginx/server/proto"
        "google.golang.org/grpc"
        "net"
)

type HelloService struct{}

func (h HelloService) Run() int {
        s := grpc.NewServer()
        pb.RegisterHelloServiceServer(s, h)

        lis, err := net.Listen("tcp", ":5000")
        if err != nil {
                fmt.Printf("%+v\n", err)
                return 1
        }
        if err := s.Serve(lis); err != nil {
                fmt.Printf("%+v\n", err)
                return 1
        }

        return 0
}

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

以下のコマンドで起動できます。

$ cd server/
$ go run main.go

gRPCクライアントの作成

クライアントはブラウザなので、JavaScriptです。
今回はNuxt.jsでサクッと作ってみます。

client/pages/index.vue
<template>
  <div>
    <h1>Test</h1>
    <div>
      <input type="input" v-model="name" />
      <button @click="hello">send</button>
      <p>Message: {{ message }}</p>
    </div>
  </div>
</template>
<script>
import {HelloServiceClient} from "~/assets/_proto/hello_grpc_web_pb"
import {HelloRequest, HelloResponse} from "~/assets/_proto/hello_pb"
 export default {
  asyncData() {
    return {
      name: "",
      message: "",
    }
  },
  methods: {
    hello() {
      let req = new HelloRequest()
      req.setName(this.name)

      const client = new HelloServiceClient("https://127.0.0.1:8080", {}, {})
      client.hello(req, {}, (err, ret) => {
        this.message = ret.getMessage()
      })
    }
  }
}
</script>

https://127.0.0.1:8080 今回のProxyに対してgRPC通信を試みています。
なにか入力し、Sendボタンを押すと、「Hello ○○」というのが画面に表示されます。

まだProxyはないのでこれは動きません。

ちなみにこのアプリを動かすには以下のコマンドです。

$ cd client/
$ npm install
$ npm run dev

# ブラウザでアクセス
http://localhost:3000

Nginxの構築

さぁ本題です。

NginxはDockerでやりましょう。

docker-compose.yaml
version: "3"
services:
  proxy:
    image: nginx:1.15.6-alpine
    ports:
      - "8080:8080"
    expose:
      - "8080"
    volumes:
      - ./proxy/nginx/common:/etc/nginx/common
      - ./proxy/nginx/conf.d:/etc/nginx/conf.d
      - ./proxy/keys:/ssl
proxy/nginx/conf.d/sample.conf
server {
  listen 8080 ssl http2;
  server_name host.docker.internal;

  ssl_certificate /ssl/localhost+1.pem;
  ssl_certificate_key /ssl/localhost+1-key.pem;
  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_prefer_server_ciphers on;

  access_log /dev/stdout;
  error_log /dev/stderr debug;

  location ~ \.(html|js)$ {
    root /var/www/html;
  }
  location /hello.HelloService/Hello {
    grpc_set_header Content-Type application/grpc;
    grpc_pass host.docker.internal:5000;
    include common/cors.conf;
  }
}

ここの肝はここです

  location /hello.HelloService/Hello {
    grpc_set_header Content-Type application/grpc;
    grpc_pass host.docker.internal:5000;
    include common/cors.conf;
  }

locationでサービス名と関数名を記載すると、それごとの設定ができるので
サービスや関数単位でアクセス制御などができます。

そして grpc_set_header Content-Type application/grpc; これがないとgRPC-WebからgRPCサーバーへのプロキシが正しくされません。

あとはローカル用の証明書も発行します。以下はMacの例です。

$ brew install mkcert
$ mkcert -install
$ mkcert localhost 127.0.0.1
$ mv localhost+1* proxy/keys/.

ではNginxも起動していきます。

$ docker-compose up

確認

ブラウザで作ったクライアントにアクセスします。

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f3135323330332f38393731613138342d626464322d656661362d383761622d3164396166666131346431642e706e67.png

「太郎」と入力したら、「Hello, 太郎」と返ってきました。
これでgRPC-WebとgRPCサーバーで疎通確認できました!

最後に

慣れ親しんだNginxでも無事使えることがわかりました。

ただgRPC-Webは制限があり使用には注意が必要です。
WebブラウザからgRPCの双方向通信は可能なのか調べた

現在僕はdouble jump.tokyo株式会社様から gRPC基盤の構築の業務委託を受けて行っています。
その業務の一環としてこのような技術検証を行いました。