Aapeli Vuorinen

gRPC-Web through Envoy with nginx

This is a tutorial (and a memo for me) on how to set up gRPC-Web to proxy through nginx into Envoy and from there into a gRPC server. With TLS.

This is on Ubuntu 20.04. Update your package lists to start:

sudo apt-get update

I assume you have a domain pointed to your box, and from now I’ll refer to it as $DOMAIN.

Create basic gRPC service definition

// sample.proto
syntax = "proto3";

package sample;

service SampleRPC {
  rpc Ping(Msg) returns (Msg);
}

message Msg {
  int64 nonce = 1;
}

Take note of the package name, sample, and the service name, SampleRPC.

Install gRPC and compile service

Get Python 3 and pip:

sudo apt-get install python3 python3-pip

You can do this in a virtualenv if you wish, and it works:

pip3 install grpcio grpcio-tools
python3 -m grpc.tools.protoc -I. --python_out=. --grpc_python_out=. sample.proto

This will generate sample_pb2.py and sample_pb2_grpc.py.

Create a simple Python gRPC server

Create a server implementation, note the names from the section above.

# server.py
from concurrent import futures

import grpc
import sample_pb2, sample_pb2_grpc


class Servicer(sample_pb2_grpc.SampleRPCServicer):
    def Ping(self, request, context):
        print(f"Received request with nonce={request.nonce}")
        return sample_pb2.Msg(nonce=request.nonce)


server = grpc.server(futures.ThreadPoolExecutor(2))
sample_pb2_grpc.add_SampleRPCServicer_to_server(Servicer(), server)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()

You can run it with python3 server.py.

Install Docker

Instructions here, or short version:

sudo apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io
# optional, allows running docker without sudo
sudo usermod -aG docker ubuntu

You need to log out and back in for the last command to take effect.

Set up Envoy

Note: this config is using the outdated Envoy v2 config instead of the new v3 config, see the Envoy JSON to gRPC transcoding post post for a newer config sample.

Create an envoy.yaml:

# 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: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 5000 }
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        config:
          codec_type: auto
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route:
                  cluster: sample_cluster
                  max_grpc_timeout: 0s
              cors:
                allow_origin_string_match:
                - prefix: "*"
                allow_methods: GET, PUT, DELETE, POST, OPTIONS
                allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
                max_age: "1728000"
                expose_headers: grpc-status,grpc-message
          http_filters:
          - name: envoy.filters.http.grpc_web
          - name: envoy.filters.http.cors
          - name: envoy.filters.http.router
  clusters:
  - name: sample_cluster
    connect_timeout: 0.25s
    type: logical_dns
    http2_protocol_options: {}
    lb_policy: round_robin
    hosts: [{ socket_address: { address: localhost, port_value: 50051 }}]

Then a Dockerfile:

# Dockerfile
FROM envoyproxy/envoy:v1.14.3

COPY ./envoy.yaml /etc/envoy/envoy.yaml

EXPOSE 5000
EXPOSE 9901

CMD /usr/local/bin/envoy -c /etc/envoy/envoy.yaml

Build the container:

docker build -t sample/envoy .

Run it:

docker run -d --net=host sample/envoy

This will launch in the background, forwarding ports 5000 (where Envoy is listening for gRPC-Web traffic) and 9901 (Envoy admin page) to your box. When you deploy this, it’s probably good to disable/block port 9901, otherwise anyone can go poke at your proxy settings.

Install nginx and get a TLS cert

Get nginx:

sudo apt-get install nginx

You need a domain name, Let’s Encrypt won’t hand your IP address a cert.

sudo apt-get install certbot python3-certbot-nginx
sudo certbot --nginx --redirect -n -d $DOMAIN --agree-tos -m certs@$DOMAIN

The --redirect flag causes certbot to set up a redirect from HTTP to HTTPS. -n means non-interactive, -m is the email to receive notifications to.

Install Node and Yarn

Instructions here, short version (this will also install Node):

curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt update
sudo apt install yarn

Install protoc with the JavaScript plugin and protoc-gen-grpc-web

Okay, so apparently the Python stuff doesn’t come with the JavaScript stuff, so here we go:

export GRPC_WEB_VERSION=1.2.0
wget -O protoc-gen-grpc-web https://github.com/grpc/grpc-web/releases/download/$GRPC_WEB_VERSION/protoc-gen-grpc-web-$GRPC_WEB_VERSION-linux-x86_64
chmod +x protoc-gen-grpc-web
export PROTOC_VERSION=3.12.3
wget -O protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOC_VERSION/protoc-$PROTOC_VERSION-linux-x86_64.zip
sudo apt-get install unzip
unzip protoc.zip

Then generate the JavaScript code:

./bin/protoc -I. --js_out="import_style=commonjs,binary:." --plugin=protoc-gen-grpc-web=./protoc-gen-grpc-web --grpc-web_out="import_style=commonjs,mode=grpcweb:." sample.proto

Creating a client

I’m not a frontend guy, but here’s Aapeli’s Quick Intro to the JavaScript World™. Set up a basic yarn environment:

yarn add webpack webpack-cli grpc-web google-protobuf

You need webpack as gRPC-Web has no way of generating code without imports.

Now create a basic script:

// index.js
const { Msg } = require("./sample_pb.js")
const { SampleRPCPromiseClient } = require("./sample_grpc_web_pb.js")

const client = new SampleRPCPromiseClient("https://$DOMAIN/rpc")
const req = new Msg()
req.setNonce(42)
client.ping(req, null).then(res => {
    document.getElementById("out").innerHTML = "Got response from server, nonce: " + res.getNonce()
}).catch(console.error)

Note the $DOMAIN! Then create a page:

<!-- index.html -->
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <script src="dist/main.js"></script>
    </head>
    <body>
        <div id="out">Pinging server...</div>
    </body>
</html>

Finally compile it:

yarn webpack index.js

Now drop it somewhere where it can be served.

Configure nginx

These edits go in /etc/nginx/sites-enabled/default. Add the following section to your server:

# /etc/nginx/sites-enabled/default, server section
location /rpc/ {
    proxy_http_version 1.1;
    proxy_pass http://127.0.0.1:5000/;
}

Then restart nginx:

sudo systemctl restart nginx

Serving the web stuff from your home folder

To serve your home folder with nginx, modify the root directive in the server section:

# /etc/nginx/sites-enabled/default, server section
root /home/ubuntu;

Note that this is probably not a very bright idea in the long run.

Also responding to vanilla gRPC queries

If you also want to service vanilla gRPC queries, you need to do two things. Add http2 to the listen directives like this:

# /etc/nginx/sites-enabled/default, server section
listen [::]:443 ssl http2 ipv6only=on; # managed by Certbot
listen 443 ssl http2; # managed by Certbot

Then add a section in that same server block:

# /etc/nginx/sites-enabled/default, same server section as above
location /sample. {
    grpc_pass 127.0.0.1:50051;
}

Note the sample which is your protobuf package name.

Restart nginx again:

sudo systemctl restart nginx

Testing it out

Make sure the Python server, the Envoy proxy, and nginx are all running.

Now if you visit that site, the page body should change to something like Got response from server, nonce: 42.

Other bits

If you enabled vanilla gRPC queries, then this will connect through nginx where TLS is terminated directly to the Python server, should work from the internet:

# secure_client.py
import grpc
import sample_pb2, sample_pb2_grpc

stub = sample_pb2_grpc.SampleRPCStub(grpc.secure_channel("$DOMAIN:443", credentials=grpc.ssl_channel_credentials()))

response = stub.Ping(sample_pb2.Msg(nonce=5))
print(f"Received response from server with nonce={response.nonce}")

This will connect directly to the Python server and will only succeed from your server box:

# insecure_client.py
import grpc
import sample_pb2, sample_pb2_grpc

stub = sample_pb2_grpc.SampleRPCStub(grpc.insecure_channel("127.0.0.1:50051"))

response = stub.Ping(sample_pb2.Msg(nonce=5))
print(f"Received response from server with nonce={response.nonce}")