Spring Cloud Gateway 實現 gRpc 代理

ghimi發表於2024-07-31

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: truetrustedX509Certificates 導致的報錯:

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: truetrustedX509Certificates 的情況下 use-insecure-trust-manager: true 選項是不生效的,Spring Cloud Gateway 會還是嘗試透過配置的 ca 證書去驗證伺服器證書,從而引發報錯,因此不要同時配置 use-insecure-trust-manager: truetrustedX509Certificates這兩個選項。

# 同時配置了 `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

相關文章