Spring Cloud Gateway 在 3.1.x 版本中增加了针对 gRPC 的网关代理功能支持,本片文章描述一下如何实现相关支持.本文主要基于 Spring Cloud Gateway 的 官方文档 进行一个实践练习。有兴趣的可以翻看官方文档。
由于 Grpc 是基于 HTTP2 协议进行传输的,因此 Srping Cloud Gateway 在支持了 HTTP2 的基础上天然支持对 Grpc 服务器的代理,只需要在现有代理基础上针对 grpc 协议进行一些处理即可。
以下为实现步骤,这里提供了示例代码,可以按需取用.
生成服务器证书
由于 Grpc 协议使用了 Http2 作为通信协议, Http2 在正常情况下是基于 TLS 层上进行通信的,这就要求我们需要配置服务器证书,这里为了测试,使用脚本生成了一套 CA 证书:
#!/bin/bash
# 指定生成证书的目录
dir=$(dirname "$0")/../resources/x509
[ -d "$dir" ] && find "$dir" -type f -exec rm -rf {} \;
mkdir -p "$dir"
pushd "$dir" || exit
# 生成.key 私钥文件 和 csr 证书签名请求文件
openssl req -new -nodes -sha256 -newkey rsa:2048 -keyout ca.key -out ca.csr \
-subj "/C=CN/ST=Zhejiang/L=Hangzhou/O=Ghimi Technology/OU=Ghimi Cloud/CN=ghimi.top"
# 生成自签名 .crt 证书文件
openssl x509 -req -in ca.csr -key ca.key -out ca.crt -days 3650
# 生成服务器私钥文件 和 csr 证书请求文件(私钥签名文件)
openssl req -new -nodes -sha256 -newkey rsa:2048 -keyout server.key -out server.csr \
-subj "/C=CN/ST=Zhejiang/L=Hangzhou/O=Ghimi Technology/OU=Ghimi Blog/CN=blog.ghimi.top"
# 3. 生成 server 证书,由 ca证书颁发
openssl x509 -req -in server.csr -out server.crt -CA ca.crt -CAkey ca.key -CAcreateserial -days 3650 -extensions SAN \
-extfile <(cat /etc/ssl/openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:dns.ghimi.top,IP:127.0.0.1,IP:::1"))
# 将 crt 证书转换为 pkcs12 格式,生成 server.p12 文件,密码 123456
openssl pkcs12 -export -in server.crt -inkey server.key -CAfile ca.crt \
-password pass:123456 -name server -out server.p12
# 导出服务器证书和证书私钥为 java keystore 格式 server.jks 为最终的导出结果 密码 123456
keytool -importkeystore -srckeystore server.p12 -destkeystore server.jks \
-srcstoretype pkcs12 -deststoretype jks -srcalias server -destalias server \
-deststorepass 123456 -srcstorepass 123456
# 将 ca 证书导入到 server.jks 中
keytool -importcert -keystore server.jks -file ca.crt -alias ca -storepass 123456 -noprompt
popd || exit
构建 Grpc 服务
首先我们需要创建一个 Maven 工程,并编写 gRPC 相关的服务器代码:
添加 gRPC 所需要的相关依赖:
<!-- grpc 关键依赖-->
io.grpc:grpc-netty-shaded:jar:1.64.0:runtime -- module io.grpc.netty.shaded [auto]
io.grpc:grpc-protobuf:jar:1.64.0:compile -- module io.grpc.protobuf [auto]
io.grpc:grpc-stub:jar:1.64.0:compile -- module io.grpc.stub [auto]
io.grpc:grpc-netty:jar:1.64.0:compile -- module io.grpc.netty [auto]
用 protobuf 生成一个 Java gRPC模板:
syntax = "proto3";
option java_multiple_files = true;
option java_package = "service";
message HelloReq {
string name = 1;
}
message HelloResp {
string greeting = 1;
}
service HelloService {
rpc hello(HelloReq) returns (HelloResp);
}
然后在 pom.xml 添加 prptobuf 生成插件:
<!-- project.build.plugins -->
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:3.25.1:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:1.64.0:exe:${os.detected.classifier}</pluginArtifact>
<!--设置grpc生成代码到指定路径-->
<!--<outputDirectory>${project.build.sourceDirectory}</outputDirectory>-->
<!--生成代码前是否清空目录-->
<clearOutputDirectory>true</clearOutputDirectory>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
注意在指定 protoc 的版本时要和上面 grpc 依赖的 protobuf 版本保持一致,否则可能会出现类找不到的报错。
然后执行执行 Maven 命令生成 Protobuf 对应的 Java 代码:
mvn protobuf:compile protobuf:compile-custom
之后就可以基于生成的 Protobuf Java 代码编写一个 gRPC Server 了 :
public static void main(String[] args) throws IOException, InterruptedException {
TlsServerCredentials.Builder tlsBuilder = TlsServerCredentials.newBuilder();
File serverCert = new ClassPathResource("/x509/server.crt").getFile();
File serverKey = new ClassPathResource("/x509/server.key").getFile();
File caCert = new ClassPathResource("/x509/ca.crt").getFile();
ServerCredentials credentials = tlsBuilder.trustManager(caCert).keyManager(serverCert, serverKey).build();
// ServerCredentials credentials = InsecureServerCredentials.create(); // 不建议使用,非常坑
Server server = Grpc.newServerBuilderForPort(443, credentials).addService(new HelloImpl()).build();
server.start().awaitTermination();
}
static class HelloImpl extends HelloServiceGrpc.HelloServiceImplBase {
@Override
public void hello(HelloReq request, StreamObserver<HelloResp> responseObserver) {
String msg = "hello " + request.getName() + " from server";
System.out.println("server received a req,reply: " + msg);
HelloResp res = HelloResp.newBuilder().setGreeting(msg).build();
responseObserver.onNext(res);
responseObserver.onCompleted();
}
}
尝试启动 GrpcServer ,检查端口是否已被监听,当前端口绑定在 443 上,这里 GrpcServer 的服务器证书一定要配置.
编写 GrpcClient 代码:
public static void main(String[] args) throws InterruptedException, IOException {
// 当服务器配置了证书时需要指定 ca 证书
TlsChannelCredentials.Builder tlsBuilder = TlsChannelCredentials.newBuilder();
File caCert = new ClassPathResource("/x509/ca.crt").getFile();
ChannelCredentials credentials = tlsBuilder.trustManager(caCert).build();
// 不做服务器证书验证时使用这个
// ChannelCredentials credentials = InsecureChannelCredentials.create();
ManagedChannelBuilder<?> builder = Grpc.newChannelBuilder("127.0.0.1:7443", credentials);
ManagedChannel channel = builder.build();
HelloServiceGrpc.HelloServiceBlockingStub stub = HelloServiceGrpc.newBlockingStub(channel);
service.HelloReq.Builder reqBuilder = service.HelloReq.newBuilder();
HelloResp resp = stub.hello(reqBuilder.setName("ghimi").build());
System.out.printf("success greeting from server: %s", resp.getGreeting());
channel.shutdown().awaitTermination(5, TimeUnit.MINUTES);
}
执行 GrpcClient,调用 443 端口的 GrpcServer 查看执行效果:
现在我们就可以开发 Spring Cloud Gateway 了,首先添加依赖,我这里添加了 spring-cloud-starter-gateway:3.1.9 版本(为了适配 Java8,已经升级 Java11 的可以提升至更高版本)。
org.springframework.cloud:spring-cloud-starter-gateway:3.1.9
编写 GrpcGateway 启动类:
@SpringBootApplication
public class GrpcGateway {
public static void main(String[] args) {
ConfigurableApplicationContext run = SpringApplication.run(GrpcGateway.class, args);
}
}
先不做配置尝试运行一下,看下是否能够正常运行:
可以看到成功监听到了 8080 端口,这是 Spring Cloud Gateway 的默认监听端口,现在我们在 /src/main/resources/
目录下添加 application.yml
配置,配置代理 grpc 端口:
server:
port: 7443 #端口号
http2:
enabled: true
ssl:
enabled: true
key-store: classpath:x509/server.p12
key-store-password: 123456
key-store-type: pkcs12
key-alias: server
spring:
application:
name: scg_grpc
cloud:
gateway: #网关路由配置
httpclient:
ssl:
use-insecure-trust-manager: true
# trustedX509Certificates:
# - classpath:x509/ca.crt
routes:
- id: user-grpc #路由 id,没有固定规则,但唯一,建议与服务名对应
uri: https://[::1]:443 #匹配后提供服务的路由地址
predicates:
#以下是断言条件,必选全部符合条件
- Path=/** #断言,路径匹配 注意:Path 中 P 为大写
- Header=Content-Type,application/grpc
filters:
- AddResponseHeader=X-Request-header, header-value
添加 application.yml
后,重启 Spring Cloud Gateway
尝试用 GrpcClient 调用 7443 代理端口,可以看到请求成功:
报错分析
GrpcServer 和 GrpcClient 如果都配置了 InsecureServerCredentials 的情况下, GrpcClient 可以直接调用 GrpcServer 成功:
GrpcServer
TlsServerCredentials.Builder tlsBuilder = TlsServerCredentials.newBuilder();
ServerCredentials credentials = InsecureServerCredentials.create(); // 配置通过 h2c(http2 clear text) 协议访问
Server server = Grpc.newServerBuilderForPort(443, credentials).addService(new HelloImpl()).build();
server.start().awaitTermination();
GrpcClient
ChannelCredentials credentials = InsecureChannelCredentials.create(); // 通过 h2c 协议访问 GrpcServer
tlsBuilder.requireFakeFeature();
ManagedChannelBuilder<?> builder = Grpc.newChannelBuilder("127.0.0.1:443", credentials);
ManagedChannel channel = builder.build();
HelloServiceGrpc.HelloServiceBlockingStub stub = HelloServiceGrpc.newBlockingStub(channel);
service.HelloReq.Builder reqBuilder = service.HelloReq.newBuilder();
HelloResp resp = stub.hello(reqBuilder.setName("ghimi").build());
System.out.printf("success greeting from server: %s\n", resp.getGreeting());
channel.shutdown().awaitTermination(5, TimeUnit.MINUTES);
此时使用 GrpcClient 调用 GrpcServer ,可以调用成功:
但是,如果中间添加了 Spring Cloud Gateway 的话, Grpc Server 和 Grpc Client 就都不能使用 InsecureCredentials 了, Spring Cloud Gateway 在这种场景下无论与 client 还是和 server 通信都会由于不识别的协议格式而报错:
如果 GrpcServer 没有配置服务器证书而是使用了 InsecureServerCredentials.create()
,GrpcClient 虽然不使用证书访问能够直接验证成功,但是如果中间通过 GrpcGateway 的话这种机制就有可能出现问题,因为 GrpcGateway 与 GrpcServer 之间的通信是基于 Http2 的,而非 Grpc 特定的协议,在 GrpcServer 没有配置服务器证书的情况下处理的包可能会导致 GrpcGateway 无法识别,但是如果 GrpcServer 配置了证书后 GrpcGateway 就能够正常验证了。
GrpcServer 在没有配置证书的情况下通过 Srping Cloud Gateway 的方式进行代理,并且 Spring Cloud Gateway 的 spring.cloud.gateway.http-client.ssl.use-insecure-trust-manager=true
的场景下 GrpcClient 访问 Spring Cloud Gateway 会报错:
GrpcClient 报错信息:
Exception in thread "main" io.grpc.StatusRuntimeException: UNKNOWN: HTTP status code 500
invalid content-type: application/json
headers: Metadata(:status=500,x-request-header=header-value,content-type=application/json,content-length=146)
DATA-----------------------------
{"timestamp":"2024-06-28T13:18:03.455+00:00","path":"/HelloService/hello","status":500,"error":"Internal Server Error","requestId":"31f8f577/1-8"}
at io.grpc.stub.ClientCalls.toStatusRuntimeException(ClientCalls.java:268)
at io.grpc.stub.ClientCalls.getUnchecked(ClientCalls.java:249)
at io.grpc.stub.ClientCalls.blockingUnaryCall(ClientCalls.java:167)
at service.HelloServiceGrpc$HelloServiceBlockingStub.hello(HelloServiceGrpc.java:160)
at com.example.GrpcClient.main(GrpcClient.java:30)
报错信息解析,GrpcClient 报错结果来自于 Spring Cloud Gateway ,返回结果为不识别的返回内容 invalid content-type: application/json
这是由于 Spring Cloud Gateway 返回了的报错信息是 application/json
格式的,但是 GrpcClient 通过 grpc 协议通信,因此会将错误格式错误直接返回而非正确解析错误信息.
GrpcGateway 报错信息:
io.netty.handler.ssl.NotSslRecordException: not an SSL/TLS record: 00001204000000000000037fffffff000400100000000600002000000004080000000000000f0001
at io.netty.handler.ssl.SslHandler.decodeJdkCompatible(SslHandler.java:1313) ~[netty-handler-4.1.100.Final.jar:4.1.100.Final]
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
*__checkpoint ⇢ org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter [DefaultWebFilterChain]
*__checkpoint ⇢ HTTP POST "/HelloService/hello" [ExceptionHandlingWebHandler]
Original Stack Trace:
at io.netty.handler.ssl.SslHandler.decodeJdkCompatible(SslHandler.java:1313) ~[netty-handler-4.1.100.Final.jar:4.1.100.Final]
at io.netty.handler.ssl.SslHandler.decode(SslHandler.java:1383) ~[netty-handler-4.1.100.Final.jar:4.1.100.Final]
这里就是的报错信息是 GrpcGateway 无法正确解析来自 GrpcServer 的 http2 的包信息而产生的报错.这是由于 GrpcGateway 与 GrpcServer 在 h2c(Http2 Clean Text) 协议上的通信格式存在差异,从而引发报错.
最后是来自 GrpcServer 的报错:
6月 28, 2024 9:18:03 下午 io.grpc.netty.shaded.io.grpc.netty.NettyServerTransport notifyTerminated
信息: Transport failed
io.grpc.netty.shaded.io.netty.handler.codec.http2.Http2Exception: HTTP/2 client preface string missing or corrupt. Hex dump for received bytes: 16030302650100026103036f6977c824c322105c600bd1db
at io.grpc.netty.shaded.io.netty.handler.codec.http2.Http2Exception.connectionError(Http2Exception.java:109)
at io.grpc.netty.shaded.io.netty.handler.codec.http2.Http2ConnectionHandler$PrefaceDecoder.readClientPrefaceString(Http2ConnectionHandler.java:321)
at io.grpc.netty.shaded.io.netty.handler.codec.http2.Http2ConnectionHandler$PrefaceDecoder.decode(Http2ConnectionHandler.java:247)
at io.grpc.netty.shaded.io.netty.handler.codec.http2.Http2ConnectionHandler.decode(Http2ConnectionHandler.java:453)
这里就是 GrpcServer 与 GrpcGateway 通信过程由于协议包无法识别导致通信终止,从而引发报错.
报错场景2
在 Spring Cloud Gateway 的 application.yml 中同时配置了 use-insecure-trust-manager: true
和 trustedX509Certificates
导致的报错:
spring:
cloud:
gateway: #网关路由配置
httpclient:
ssl:
use-insecure-trust-manager: true
trustedX509Certificates:
- classpath:x509/ca.crt
use-insecure-trust-manager: true
表示在于 GrpcServer 通信的过程中不会验证服务器证书,这样如果证书存在什么问题的情况下就不会引发报错了,但是在同时配置了 use-insecure-trust-manager: true
和 trustedX509Certificates
的情况下 use-insecure-trust-manager: true
选项是不生效的,Spring Cloud Gateway 会还是尝试通过配置的 ca 证书去验证服务器证书,从而引发报错,因此不要同时配置 use-insecure-trust-manager: true
和 trustedX509Certificates
这两个选项。
# 同时配置了 `use-insecure-trust-manager: true` 和 `trustedX509Certificates` 后服务证书校验失败报错
javax.net.ssl.SSLHandshakeException: No subject alternative names matching IP address 0:0:0:0:0:0:0:1 found
at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:130) ~[na:na]
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
*__checkpoint ⇢ org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter [DefaultWebFilterChain]
*__checkpoint ⇢ HTTP POST "/HelloService/hello" [ExceptionHandlingWebHandler]
Original Stack Trace:
客户端通常情况下只需要配置 ca 证书,用于验证服务器证书,但是验证服务器证书这一步是可以跳过的,在一些场景下服务器证书的校验比较严格的时候容易出问题,此时可以选择不进行服务器证书校验,在 Spring Cloud Gateway 代理访问 GrpcServer 时,可以为 Spring Cloud Gateway 配置 use-insecure-trust-manager: true
来取消对 GrpcServer 的强验证。
No subject alternative names matching IP address 0:0:0:0:0:0:0:1 found
这个问题就是在校验服务器证书时,由于服务器证书校验失败导致的报错了,通常情况下, client 会校验服务器的FQDN域名信息是否与请求的连接一致:
# 请求服务器证书
openssl req -new -nodes -sha256 -newkey rsa:2048 -keyout server.key -out server.csr \
-subj "/C=CN/ST=Zhejiang/L=Hangzhou/O=Ghimi Technology/OU=Ghimi Blog/CN=blog.ghimi.top"
上面是在使用命令生成服务器证书时配置的信息,其中 CN=blog.ghimi.top
就是我配置的域名信息,这就要求我的 GrpcServer 的 ip 地址绑定了这个域名,然后 GrpcClient 通过这个域名访问:
ManagedChannelBuilder<?> builder = Grpc.newChannelBuilder("blog.ghimi.top:7443", credentials);
在这种情况下 GrpcClient 会拿服务器返回的证书与当前连接信息进行比较,如果一致则服务器验证成功,否则验证失败并抛出异常.
在 GrpcServer 只有 ip 地址没有域名的情况下,基于域名的验证就不生效了,此时去做证书验证就一定会报错:
# 同时配置了 `use-insecure-trust-manager: true` 和 `trustedX509Certificates` 后服务证书校验失败报错
javax.net.ssl.SSLHandshakeException: No subject alternative names matching IP address 0:0:0:0:0:0:0:1 found
at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:130) ~[na:na]
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
*__checkpoint ⇢ org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter [DefaultWebFilterChain]
*__checkpoint ⇢ HTTP POST "/HelloService/hello" [ExceptionHandlingWebHandler]
Original Stack Trace:
报错信息中提到的 subject alternative names
就是在域名失效后的另外一种验证手段,他要求ca在签发服务器证书时向服务器证书中添加一段附加信息,这个信息中可以添加证书的可信 ip 地址:
# 通过 ca 证书颁发服务器证书
openssl x509 -req -in server.csr -out server.crt -CA ca.crt -CAkey ca.key -CAcreateserial -days 3650 -extensions SAN \
-extfile <(cat /etc/ssl/openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:dns.ghimi.top,IP:127.0.0.1"))
上面脚本中的 IP:127.0.0.1
就是添加的可信地址,我们可以同时添加多个服务器地址,以上面的报错为例,我们只需要在生成服务器证书的时候添加 ::1
的本地 ipv6 地址即可修复该错误:
openssl x509 -req -in server.csr -out server.crt -CA ca.crt -CAkey ca.key -CAcreateserial -days 3650 -extensions SAN \
-extfile <(cat /etc/ssl/openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:dns.ghimi.top,IP:127.0.0.1,IP:::1"))
报错场景4 使用 pkcs12 配置了多张自签名 ca 证书识别失效问题
解决方案,改为使用 Java Keystore 格式的证书即可修复.
参考资料
- Spring Cloud Gateway and gRPC
- spring-cloud-gateway-grpc
- gRPC-Spring-Boot-Starter 文档
- rx-java
- Working with Certificates and SSL
- 介绍一下 X.509 数字证书中的扩展项 subjectAltName