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
- spring-cloud-gateway-grpc