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