Kong中使用grpc-web插件代理grpc服务时遇到的坑

在大型分布式系统中,有很多的微服务对外提供服务,也会有各种微服务的协议需要集成,比如http,https,grpc的,这时就需要一个API网关提供高性能、高可用的API托管服务,帮助服务的开发者便捷地对外提供服务,而不用考虑安全控制、流量控制、审计日志等问题,统一在网关层将安全认证,流量控制,审计日志,黑白名单等实现。网关的下一层,是内部服务,内部服务只需开发和关注具体业务相关的实现。网关可以提供API发布、管理、维护等主要功能。开发者只需要简单的配置操作即可把自己开发的服务发布出去,同时置于网关的保护之下。我们项目中使用的API Gateway是Kong,在代理grpc服务的时候,我们需要将此grpc服务以http/https协议提供给前端访问,因此需要用到Kong提供的grpc-web插件来帮忙将http/https的请求代理到后端的grpc服务上,在kong-plugin-grpc-web的官网上提供了配置实例,但按照此实例配置没法代理成功,而且基本网上都没有找到其他demo和资料,解决过程很简单,就是根据错误信息查看源码,分析出实际项目中该如何使用grpc-web插件来配置grpc服务。

关于Kong

Kong是一款基于Nginx_Lua模块写的高可用,易扩展由Mashape公司开源的API Gateway项目。由于Kong是基于Nginx的,所以可以水平扩展多个Kong服务器,通过前置的负载均衡配置把请求均匀地分发到各个Server,来应对大批量的网络请求。Kong采用插件机制进行功能定制,插件集(可以是0或n个)在API请求响应循环的生命周期中被执行。插件使用Lua编写,目前已有几个基础功能:HTTP基本认证、密钥认证、CORS( Cross-origin Resource Sharing,跨域资源共享)、TCP、UDP、文件日志、API请求限流、请求转发以及nginx监控。这篇文章中我们会用到另一个开源的插件,官网地址:

https://github.com/Kong/kong-plugin-grpc-web

关于Kong gRPC-Web插件

A Kong plugin to allow access to a gRPC service via the gRPC-Web protocol. Primarily, this means JS browser apps using the gRPC-Web library.
A service that presents a gRPC API can be used by clients written in many languages, but the network specifications are oriented primarily to connections within a datacenter. In order to expose the API to the Internet, and to be called from brower-based JS apps, gRPC-Web was developed.This plugin translates requests and responses between gRPC-Web and "real" gRPC. Supports both HTTP/1.1 and HTTP/2, over plaintext (HTTP) and TLS (HTTPS) connections.

这是来自官网的描述,简单的说就是开发者可以使用任何语言开发gRPC服务,前端js程序需要通过gRPC-Web协议来访问gRPC服务,使用此插件可以实现使用HTTP REST请求来请求后端的gRPC服务,请求和返回数据是json格式。

Springboot开发grpc服务

为了测试gRPC代理,使用springboot开发一个简单的gRPC服务。

第一步,定义proto文件,并放在src/main/proto目录下,命名为HelloWorld.proto

syntax = "proto3";

option java_multiple_files = true;
package com.example.grpc.helloworld;

message Person {
  string first_name = 1;
  string last_name = 2;
}

message Greeting {
  string message = 1;
}

service HelloWorldService {
  rpc sayHello (Person) returns (Greeting);}

第二步,设置Maven

注意以下几个地方,spring-boot-starter-webprotobuf-maven-pluginbuild-helper-maven-pluginspring-boot-starter-web将自动的使用内置的tomcat来部署grpc服务。protobuf-maven-plugin是用来根据定义的proto文件来生成grpc-based的java代码。build-helper-maven-plugin此插件是用来设置将产生的java source code作为编译的一部分,也能帮助IntellJ工具找到源码,否则虽然编译能成功,但IntellJ会出现很多的红线提示错误,不太友好,而且也不能F3定位到源码的位置。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example.grpc</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
        <grpc-spring-boot-starter.version>3.0.0</grpc-spring-boot-starter.version>
        <os-maven-plugin.version>1.6.1</os-maven-plugin.version>
        <protobuf-maven-plugin.version>0.6.1</protobuf-maven-plugin.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>io.github.lognet</groupId>
            <artifactId>grpc-spring-boot-starter</artifactId>
            <version>${grpc-spring-boot-starter.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-log4j2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

    <build>
        <extensions>
            <extension>
                <groupId>kr.motd.maven</groupId>
                <artifactId>os-maven-plugin</artifactId>
                <version>${os-maven-plugin.version}</version>
            </extension>
        </extensions>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.xolstice.maven.plugins</groupId>
                <artifactId>protobuf-maven-plugin</artifactId>
                <version>${protobuf-maven-plugin.version}</version>
                <configuration>
                    <protocArtifact>com.google.protobuf:protoc:3.5.1-1:exe:${os.detected.classifier}</protocArtifact>
                    <pluginId>grpc-java</pluginId>
                    <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.16.1:exe:${os.detected.classifier}</pluginArtifact>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>compile-custom</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>build-helper-maven-plugin</artifactId>
                <version>1.4</version>
                <executions>
                    <execution>
                        <id>test</id>
                        <phase>generate-sources</phase>
                        <goals>
                            <goal>add-source</goal>
                        </goals>
                        <configuration>
                            <sources>
                                <source>${basedir}/target/generated-sources</source>
                            </sources>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

第三步,实现grpc服务

服务实现代码很简单,如下:

@GRpcService
@Slf4j
public class HelloWorldServiceImpl
        extends HelloWorldServiceGrpc.HelloWorldServiceImplBase {

    @Override
    public void sayHello(Person request,                         StreamObserver<Greeting> responseObserver) {
        log.info("server received {}", request);

        String message = "Hello " + request.getFirstName() + " "
                + request.getLastName() + "!";
        Greeting greeting =
                Greeting.newBuilder().setMessage(message).build();
        log.info("server responded {}", greeting);

        responseObserver.onNext(greeting);
        responseObserver.onCompleted();
    }
}

使用springboot运行此项目,将默认启动6565端口发布此grpc服务。下面我们就使用grpc-web 插件部署此服务

安装KONG

第一步,创建Docker网络

docker network create kong-net

第二步,安装Postgresql或者Cassandra

安装Cassandra作为存储

docker run -d --name kong-database \
               --network=kong-net \
               -p 9042:9042 \
               cassandra:3

或者安装Postgresql作为存储

docker run -d --name kong-database \
               --network=kong-net \
               -p 5432:5432 \
               -e "POSTGRES_USER=kong" \
               -e "POSTGRES_DB=kong" \
               -e "POSTGRES_PASSWORD=kong" \
               postgres:9.6

第三步,初始kong数据

docker run --rm \
     --network=kong-net \
     -e "KONG_DATABASE=postgres" \
     -e "KONG_PG_HOST=kong-database" \
     -e "KONG_PG_USER=kong" \
     -e "KONG_PG_PASSWORD=kong" \
     -e "KONG_CASSANDRA_CONTACT_POINTS=kong-database" \
     kong:latest kong migrations bootstrap

第四步,启动kong

docker run -d --name kong \
     --network=kong-net \
     -e "KONG_DATABASE=postgres" \
     -e "KONG_PG_HOST=kong-database" \
     -e "KONG_PG_USER=kong" \
     -e "KONG_PG_PASSWORD=kong" \
     -e "KONG_CASSANDRA_CONTACT_POINTS=kong-database" \
     -e "KONG_PROXY_ACCESS_LOG=/dev/stdout" \
     -e "KONG_ADMIN_ACCESS_LOG=/dev/stdout" \
     -e "KONG_PROXY_ERROR_LOG=/dev/stderr" \
     -e "KONG_ADMIN_ERROR_LOG=/dev/stderr" \
     -e "KONG_ADMIN_LISTEN=0.0.0.0:8001, 0.0.0.0:8444 ssl" \
     -p 8000:8000 \
     -p 8443:8443 \
     -p 127.0.0.1:8001:8001 \
     -p 127.0.0.1:8444:8444 \
     kong:latest

使用grpc-web插件

完成了Kong的安装和初始化,简单的grpc服务也完成,现在就按照grpc-web官网上的方法来设置grpc-web插件。使用的是Kong admin命令来设置,最新版的kong-dashboard暂时没有升级到支持最新版的KONG,

第一步,创建grpc服务

curl -XPOST localhost:8001/services \
  --data name=grpc \
  --data protocol=grpc \
  --data host=localhost \
  --data port=6565

第二步,创建http route

curl -XPOST localhost:8001/services/grpc/routes \
  --data protocols=http \
  --data name=web-service \
  --data paths=/

第三步,为此route设置grpc-web

curl -XPOST localhost:8001/routes/web-service/plugins \
  --data name=grpc-web

部署完成后,访问网址http://localhost:8000,返回400错误,原因unkonwn path /。

问题分析

出现这个错误,说明KONG已经接收到了此请求,但后端grpc服务没有收到请求,因此问题就出现在grpc-web插件上,于是打开grpc-web插件源码来进行分析。找到下面的代码片段:

 local dec, err = deco.new(
    kong_request_get_header("Content-Type"),
    kong_request_get_path(), conf.proto)
  if not dec then
    kong.log.err(err)
    return kong_response_exit(400, err)
  end

此代码段的第五行返回400错误,因此问题可能发生在deco.new方法上,此方法返回nil导致错误发生,于是继续向上分析deco.new方法,代码如下:

function deco.new(mimetype, path, protofile)
  local text_encoding = text_encoding_from_mime[mimetype]
  local framing = framing_form_mime[mimetype]
  local msg_encoding = msg_encodign_from_mime[mimetype]

  local input_type, output_type
  if msg_encoding ~= "proto" then
    if not protofile then
      return nil, "transcoding requests require a .proto file defining the service"
    end

    input_type, output_type = rpc_types(path, protofile)
    if not input_type then
      return nil, output_type
    end
  end

  return setmetatable({
    mimetype = mimetype,
    text_encoding = text_encoding,
    framing = framing,
    msg_encoding = msg_encoding,
    input_type = input_type,
    output_type = output_type,
  }, deco)
end

此处,有两处返回nil,第一处返回nil,应该不可能,因为我们设置了proto文件,所以怀疑应该是第二处的nil导致的,因此继续向上查找rpc_types方法,代码如下:

local function rpc_types(path, protofile)
  if not protofile then
    return nil
  end

  local info = get_proto_info(protofile)
  local types = info[path]
  if not types then
    return nil, ("Unkown path %q"):format(path)
  end

  return types[1], types[2]
end

此代码中出错信息是Unknown path,因此基本断定是此处返回的错误信息,是由于info这个表中不能那个找到我们传入的path,继续向上分析如何产生这个info表的。也就是get_proto_info方法。

local function get_proto_info(fname)
  local info = _proto_info[fname]
  if info then
    return info
  end

  local p = protoc.new()
  local parsed = p:parsefile(fname)

  info = {}

  for _, srvc in ipairs(parsed.service) do
    for _, mthd in ipairs(srvc.method) do
      info[("/%s.%s/%s"):format(parsed.package, srvc.name, mthd.name)] = {
        mthd.input_type,
        mthd.output_type,
      }
    end
  end

  _proto_info[fname] = info

  p:loadfile(fname)
  return info
end

从这个方法就能明白是如何产生info表的,首先从缓存中获取,如果能获取到直接返回,否则就解析proto文件生成info表,生成规则是proto里的package name,servicename和method name作为key,格式为info[("/%s.%s/%s"):format(parsed.package, srvc.name, mthd.name)] ,方法的input_type和output_type作为value。因此我么传入的/这个key肯定不能存在,所以会返回400错误。

水落石出

问题定位到了,解决方法就比较简单,在创建route时,path不能按照文档的实例中传入简单的/,而应该传入/package name.service name/method name这种格式。于是将创建route那步改成如下请求即可。

curl -XPOST localhost:8001/services/grpc/routes \
  --data protocols=http \
  --data name=web-service \
  --data paths=/com.example.grpc.helloworld.HelloWorldService/sayHello

测试

curl  -H "Content-type: application/json" -XPOST -d '{"first_name": "david","last_name": "zhang"}'  http://localhost:8000/com.example.grpc.helloworld.HelloWorldService/sayHello 

返回字符串:hello,david zhang!

写在最后

虽然花了很多时间最后解决了此问题,也能收获一点点的成就感,但总觉得此插件官网的文档太过简单,不知道是不是代码和文档没有配套更新导致的,理论上不应该犯这种低级错误,而且同一篇文章被引用了很多地方,都是同一种配置方法,估计都没有亲自验证就发布了。联系到前几天看到一篇公众号文章,发现里面有些问题,所以给作者留言了,我倒不是要较真,只是觉得一篇受众那么广的博客,还是需要对读者负责,不能误导读者。作者倒反馈很迅速,只是不太友好,哪里错误?说我没有亲自验证,呵呵,当我把错误列出来后,也没再回复我,悄悄的把错误改了。