前言
為了更好的說明給gRPC-spring-boot-starter項目提交bug修復的pr的原因,解答作者的問題。以博文的形式記錄了整個過程的上下文,目前pr未合併還在溝通處理中,希望此博文可以更清楚描述問題
pr地址:https://github.com/yidongnan/grpc-spring-boot-starter/pull/454
gRPC-spring-boot-starter是什麼?
這是一個spring-boot-starter項目,用來在spring boot框架下,快速便捷的使用grpc技術,開箱即用。它提供如下等功能特性:
-
在 spring boot 應用中,通過
@GrpcService
自動配置並運行一個嵌入式的 gRPC 服務。 -
使用
@GrpcClient
自動創建和管理您的 gRPC Channels 和 stubs -
支持Spring Cloud(向Consul或Eureka或Nacos註冊服務並獲取 gRPC 服務端信息)
-
支持Spring Sleuth作為分佈式鏈路跟蹤解決方案(如果brave-instrument-grpc存在)
-
支持全局和自定義的 gRPC 服務端/客戶端攔截器
-
支持metric (基於micrometer/actuator)
-
也適用於 (non-shaded) grpc-netty
選型gRPC-spring-boot-starter
博主新入職公司接手的項目採用grpc做微服務通訊框架,項目底層框架採用的spring boot,然後grpc的使用是純手工配置的,代碼寫起來比較繁瑣, 而且這種繁瑣的模板化代碼充斥在每個採用了grpc的微服務項目裡。所以技術選型後找到了gRPC-spring-boot-starter 這個開源項目,這個項目代碼質量不錯,非常規範,文檔也比較齊全。但是鑑於之前工作經驗遇到過開源項目的問題(博主選型的原則,如果有合適的輪子,就摸透這個輪子,然後基於這個輪子二開,沒有就自己造一個輪子),而且一般解決週期比較長,所以 最後,我們沒有直接採用他們的發行包,而是fork了項目後,打算自己維護。正因為如此,才為後面迅速解決問題上線成為可能。也驗證了二開這個選擇是正確的。
bug出現,grpc未優雅下線
風風火火重構了所有代碼,全部換成gRPC-spring-boot-starter後就上線了,上線後一切都非常好,但是項目在第二次需求上線投產時發生了一些問題。 這個時候還不確定是切換grpc實現導致的問題,現象就是,線上出現了大量的請求異常。上線完成後,異常就消失了。後面每次滾動更新都會出現類似的異常。 這個時候就很容易聯繫到是否切換grpc實現後,grpc未優雅下線,導致滾動更新時,大量的進行中的請求未正常處理,導致這部分流量異常?因為我們線上 流量比較大,幾乎每時每刻都有大量請求,所以我們要求線上服務必須支持無縫滾動更新。如果流量比較小,這個問題可能就不會暴露出來,這也解釋了之前和同事討論的點,為什麼這麼明顯的問題沒有被及早的發現。不過都目前為止,這一切都只是猜測,真相繼續往下。
定位bug,尋找真實原因
有了上面的猜測,直接找到了gRPC-spring-boot-starter管理維護GrpcServer生命週期的類GrpcServerLifecycle,這個類實現了spring的SmartLifecycle接口,這個接口是用來註冊SpringContextShutdownHook的鉤子用的,它的實現如下:
@Slf4j
public class GrpcServerLifecycle implements SmartLifecycle {
private static AtomicInteger serverCounter = new AtomicInteger(-1);
private volatile Server server;
private volatile int phase = Integer.MAX_VALUE;
private final GrpcServerFactory factory;
public GrpcServerLifecycle(final GrpcServerFactory factory) {
this.factory = factory;
}
@Override
public void start() {
try {
createAndStartGrpcServer();
} catch (final IOException e) {
throw new IllegalStateException("Failed to start the grpc server", e);
}
}
@Override
public void stop() {
stopAndReleaseGrpcServer();
}
@Override
public void stop(final Runnable callback) {
stop();
callback.run();
}
@Override
public boolean isRunning() {
return this.server != null && !this.server.isShutdown();
}
@Override
public int getPhase() {
return this.phase;
}
@Override
public boolean isAutoStartup() {
return true;
}
/**
* Creates and starts the grpc server.
*
* @throws IOException If the server is unable to bind the port.
*/
protected void createAndStartGrpcServer() throws IOException {
final Server localServer = this.server;
if (localServer == null) {
this.server = this.factory.createServer();
this.server.start();
log.info("gRPC Server started, listening on address: " + this.factory.getAddress() + ", port: "
+ this.factory.getPort());
// Prevent the JVM from shutting down while the server is running
final Thread awaitThread = new Thread(() -> {
try {
this.server.awaitTermination();
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "grpc-server-container-" + (serverCounter.incrementAndGet()));
awaitThread.setDaemon(false);
awaitThread.start();
}
}
/**
* Initiates an orderly shutdown of the grpc server and releases the references to the server. This call does not
* wait for the server to be completely shut down.
*/
protected void stopAndReleaseGrpcServer() {
final Server localServer = this.server;
if (localServer != null) {
localServer.shutdown();
this.server = null;
log.info("gRPC server shutdown.");
}
}
}
也就是說當spring容器關閉時,會觸發ShutdownHook,進而關閉GrpcServer服務,問題就出現在這裡,從stopAndReleaseGrpcServer()方法可知,Grpc進行shudown()後,沒有進行任何操作,幾乎瞬時就返回了,這就導致了進程在收到kill命令時,Grpc的服務會被瞬間回收掉,而不會等待執行中的處理完成,這個判斷可以從shutdown()的文檔描述中進一步得到確認,如:
/**
* Initiates an orderly shutdown in which preexisting calls continue but new calls are rejected.
* After this call returns, this server has released the listening socket(s) and may be reused by
* another server.
*
* <p>Note that this method will not wait for preexisting calls to finish before returning.
* {@link #awaitTermination()} or {@link #awaitTermination(long, TimeUnit)} needs to be called to
* wait for existing calls to finish.
*
* @return {@code this} object
* @since 1.0.0
*/
public abstract Server shutdown();
文檔指出,調用shutdown()後,不在接收新的請求流量,進行中的請求會繼續處理完成,但是請注意,它不會等待現有的調用請求完成,必須使用awaitTermination()方法等待請求完成,也就是說,這裡處理關閉的邏輯裡,缺少了awaitTermination()等待處理中的請求完成的邏輯。
模擬環境,反覆驗證
驗證方法:
這個場景的問題非常容易驗證,只需要在server端模擬業務阻塞耗時長一點,然後kill掉java進程,看程序是否會立刻被kill。正常優雅下線關閉的話,會等待阻塞的時間後進程kill。否則就會出現不管業務阻塞多長時間,進程都會立馬kill。
驗證定位的bug
先驗證下是否如上面所說,不加awaitTermination()時,進程是否立馬就死了。直接使用gRPC-spring-boot-starter裡自帶的demo程序,在server端的方法里加上如下模擬業務執行耗時的代碼:
@GrpcService public class GrpcServerService extends SimpleGrpc.SimpleImplBase {
@Override
public void sayHello(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
HelloReply reply = HelloReply._newBuilder_().setMessage("Hello ==> " \+ req.getName()).build();
try {
System._err_.println("收到請求,阻塞等待");
TimeUnit._MINUTES_.sleep(1);
System._err_.println("阻塞完成,請求結束");
} catch (InterruptedException e) {
e.printStackTrace();
}
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}
上面代碼模擬的執行一分鐘的方法,然後觸發grpc client調用。接著找到server端的進程號,直接kill掉。發現進程確實立馬就kill了。繼續加大阻塞的時間,從一分鐘加大到六分鐘,重複測試,還是立馬就kill掉了,沒有任何的等待。
驗證修復後的效果
先將上面的代碼修復下,正確的關閉邏輯應該如下,在Grpc發出shutdown指令後,阻塞等待所有請求正常結束,同時,這裡阻塞也會夯住主進程不會裡面掛掉。
protected void stopAndReleaseGrpcServer() {
final Server localServer = this.server;
if (localServer != null) {
localServer.shutdown();
try {
this.server.awaitTermination();
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
}
this.server = null;
log.info("gRPC server shutdown.");
}
}
同樣,如上述步驟驗證,當kill掉java進程後,此時java進程並沒有立馬就被kill,而是被awaitTermination()阻塞住了線程,直到業務方法中模擬的業務阻塞結束後,java進程才被kill掉,這正是我們想要達到的優雅下線關閉的效果。被kill時的,線程堆棧如下:
即使被kill了,還是能打印如下的日誌【阻塞完成,請求結束】,進一步驗證了修復後確實解決了問題: