當前位置: 妍妍網 > 碼農

Java服務如何優雅上下線?

2024-04-01碼農

在計畫升級的時候,需要幹掉舊的計畫,然後啟動一個新的計畫。在這個過程中往往會出現服務的不可用,那麽我們如何最大限度的做到釋出的優雅,盡可能讓我們升級的這個過程不影響到線上正在執行的業務?下面我將介紹幾種不同的架構模式下Java計畫的優雅上下線。


1. 背景

在計畫升級的時候,需要幹掉舊的計畫,然後啟動一個新的計畫。在這個過程中往往會出現服務的不可用,那麽我們如何最大限度的做到釋出的優雅,盡可能讓我們升級的這個過程不影響到線上正在執行的業務?這時我們就需要實作服務的優雅上下線。


2. 名詞解釋

服務的優雅上下線就是保證服務的穩定可用,避免流量中斷,導致業務不可用。

優雅上線其實就是等服務啟動完全就緒後,對外提供服務,也叫無失真釋出,延遲暴露,服務預熱。

優雅下線其實就是在服務收到停機指令(kill -15 pid 或 kill -2 pid 或 kill -1 pid)後,要先到註冊中心登出,拒絕新的請求後,將舊的業務處理完成。


3. 實作

3.1 單體計畫

對於單體計畫而言,優雅上下線比較容易,涉及不到服務間錯綜復雜的呼叫,我們只需要保證入口流量在切換時服務已經就緒,且服務能夠優雅停機,不會直接斷掉正在處理的業務即可。

下面我們介紹幾種在單體模式下常用的優雅下線方式。

3.1.1 JVM層面實作

JVM的優雅停機方式是透過 Runtime.getRuntime().addShutdownHook(shutdownTask); 設定優雅停機任務,來保證程式優雅結束。

那麽我們都可以在停機任務中做哪些事情來保證優雅停機呢?

1.延遲停機,等待其他任務執行

2.釋放連線資源

3.清理臨時檔

4.關閉執行緒池

executorService.shutdown(); // 無法接收新任務
executorService.awaitTermination(1500,++ TimeUnit.SECONDS); // 控制等待時間,防止程式一直執行

5......

Thread shutdownHook = new Thread(()->{
System.out.println("優雅停機執行");
});
Runtime.getRuntime().addShutdownHook(shutdownHook);

3.1.2 Spring層面實作

首先,Spring也是依托於JVM實作的,它透過JVM的shutdownHook感知到Java行程關閉,然後執行doClose方法

我們看下doClose方法都做了哪些事

  • 釋出一個容器關閉事件

  • 呼叫Bean生命周期關閉方法

  • 銷毀所有Bean

  • 關閉Bean工廠

  • 呼叫子類別關閉函式

  • 基於對Spring源碼的分析,我們可以透過JVM的StutdownHook或者是監聽ContextClosedEvent(容器關閉事件),或者是在Bean銷毀幾個階段進行自己的優雅停機方法。

    // org.springframework.context.support.AbstractApplicationContext
    @Deprecated//Spring 5 即將廢棄
    publicvoiddestroy(){
    close();
    }
    @Override
    publicvoidclose(){
    synchronized (this.startupShutdownMonitor) {
    doClose();
    // If we registered a JVM shutdown hook, we don't need it anymore now:
    // We've already explicitly closed the context.
    if (this.shutdownHook != null) {
    try {
    Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
    }
    catch (IllegalStateException ex) {
    // ignore - VM is already shutting down
    }
    }
    }
    }
    protectedvoiddoClose(){
    LiveBeansView.unregisterApplicationContext(this);
    try {
    // Publish shutdown event.
    publishEvent(new ContextClosedEvent(this)); ➀
    }
    // Stop all Lifecycle beans, to avoid delays during individual destruction.
    if (this.lifecycleProcessor != null) {
    try {
    this.lifecycleProcessor.onClose(); ➁
    }
    catch (Throwable ex) {
    logger.warn("Exception thrown from LifecycleProcessor on context close", ex);
    }
    }
    // Destroy all cached singletons in the context's BeanFactory.
    destroyBeans(); ➂
    // Close the state of this context itself.
    closeBeanFactory(); ➃
    // Let sub classes do some final clean-up if they wish...
    onClose(); ➄
    // Reset local application listeners to pre-refresh state.
    if (this.earlyApplicationListeners != null) {
    this.applicationListeners.clear();
    this.applicationListeners.addAll(this.earlyApplicationListeners);
    }
    // Switch to inactive.
    this.active.set(false);
    }






    3.1.3 SpringBoot(Web容器Tomcat)層面

    3.1.3.1 方式一

    透過actuator 的endpoint機制關閉服務

    首先需要引入spring-boot-starter-actuator健康檢查的包

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    添加配置,預設是關閉的

    management.endpoint.shutdown.enabled=true
    management.endpoints.web.exposure.include=shutdown

    服務啟動後呼叫 POST http://127.0.0.1/actuator/shutdown 介面即可關閉服務,這種方式風險會比較高,有被惡意呼叫的風險,需要結合一些許可權驗證的機制使用。

    實作原理:

    @Endpoint(id = "shutdown", enableByDefault = false)
    public classShutdownEndpointimplementsApplicationContextAware{
    @WriteOperation
    public Map<String, String> shutdown(){
    Thread thread = new Thread(this::performShutdown);
    thread.setContext classLoader(get class().get classLoader());
    thread.start();
    }
    privatevoidperformShutdown(){
    try {
    Thread.sleep(500L);
    }
    catch (InterruptedException ex) {
    Thread.currentThread().interrupt();
    }
    // 呼叫AbstractApplicationContext的close()方法,與上文一致
    this.context.close();
    }
    }
    // 註入Bean
    @Configuration(
    proxyBeanMethods = false
    )
    @ConditionalOnAvailableEndpoint(
    endpoint = ShutdownEndpoint. class
    )
    public classShutdownEndpointAutoConfiguration
    {
    publicShutdownEndpointAutoConfiguration(){
    }
    @Bean(
    destroyMethod = ""
    )
    @ConditionalOnMissingBean
    public ShutdownEndpoint shutdownEndpoint(){
    returnnew ShutdownEndpoint();
    }
    }

    3.1.3.2 方式二

    SpringBoot 2.3之後的版本內建了優雅停機功能,當我們設定

    # 開啟優雅停機
    server.shutdown=graceful # 預設是immediate
    spring.lifecycle.timeout-per-shutdown-phase=60s # 最大等待時間,預設是30s

    Web容器停機拒絕請求的方式

    3.1.3.3 方式三

    SpringBoot2.3.0 之前的版本 註冊實作 TomcatConnectorCustomizer ApplicationListener 介面即可

    // 註冊bean
    @Bean
    public ShutdownConnectorCustomizer shutdownConnectorCustomizer(){
    returnnew ShutdownConnectorCustomizer();
    }
    // 需要在Tomcat建立時將 自訂連結器 設定進去
    @Bean
    public ConfigurableServletWebServerFactory tomcatCustomizer(){
    TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
    factory.addConnectorCustomizers(shutdownConnectorCustomizer());
    return factory;
    }
    privatestatic classShutdownConnectorCustomizerimplementsTomcatConnectorCustomizerApplicationListener<ContextClosedEvent{
    privatestaticfinal Logger log = LoggerFactory.getLogger(ShutdownConnectorCustomizer. class);
    privatevolatile Connector connector;
    @Override
    publicvoidcustomize(Connector connector){
    this.connector = connector;
    }
    @Override
    publicvoidonApplicationEvent(ContextClosedEvent event){
    this.connector.pause();
    Executor executor = this.connector.getProtocolHandler().getExecutor();
    if (executor instanceof ThreadPoolExecutor) {
    try {
    ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
    threadPoolExecutor.shutdown();
    if (!threadPoolExecutor.awaitTermination(30, TimeUnit.SECONDS)) {
    log.warn("Tomcat thread pool did not shut down gracefully within 30 seconds. Proceeding with forceful shutdown");
    }
    catch (InterruptedException ex) {
    Thread.currentThread().interrupt();
    }
    }
    }
    }


    3.2 微服務計畫

    對於微服務計畫來說,優雅上下線就會變得更為復雜,單體套用只需要控制入口流量即可,而微服務會面臨著錯中復雜的服務呼叫,出現問題就會導致各種503,超時報錯。

    如下圖,服務B需要釋出新版本上線,就會出現如下幾種異常狀況

  • 服務B pod1下線,需要將自身任務執行完畢

  • 服務B pod1下線,告知註冊中心pod1下線,告知過程可能會存在延遲

  • 服務B pod1下線,服務A 獲取服務列表收到服務B pod1下線,這時服務B pod2未上線或者上線未就緒,不能正常處理請求

  • 服務B pod1下線,服務A 獲取服務列表未收到服務B pod1下線,這時呼叫服務B pod1,則會出現異常

  • 其中服務B pod1下線,到客戶端感知到服務B pod1下線這段時間,很容易出現問題,這時我們就很難百分百保證優雅,可以結合客戶端重試來保證可用,這時也需要伺服端具備介面冪等性,負責容易造成數據混亂。

    我們最需要解決的就是服務停止前講服務提供者取消註冊,然後再關停服務,再服務能夠正常提供服務後,再將自己註冊到註冊中心。

    3.2.1 Eureka層面實作

    優雅上線

    在微服務中,我們還會遇到,服務未完全啟動,就把自己註冊到了eureka上,然後服務發現到了該例項卻又調不通,這時我們就需要讓服務啟動好再去註冊。

    eureka本身是支持延遲註冊的,只需要配置一個延遲註冊的參數即可

    eureka:
    client:
    healthcheck:
    enabled: false
    onDemandUpdateStatusChange: false# 啟動會立即呼叫註冊,需要關閉
    initial-instance-info-replication-interval-seconds: 90 #代表第一次初始化延遲註冊的時間間隔,

    但是,需要註意了延遲註冊時間這裏存在一個坑:延遲註冊時間最多只有30秒。配置超出30s無效,並且這個bug在一直存在,在Eureka停止維護都沒修復。

    相信大家對這個bug也很感興趣,這裏我們簡單介紹一下這個bug:

    在eureka中有三個地方可能會進行註冊

  • DiscoveryClient註入

  • 呼叫是在renew方法中,這個就是心跳執行緒,心跳執行緒是1個定時任務執行緒,延遲 renewalIntervalInSecs 秒後執行,該參數是其實就是心跳間隔時間,因此會導致無論我們怎麽配置延遲註冊完時間,都是和心跳間隔時間一樣的。

  • eureka事件通知,在服務啟動時候,會觸發事件通知,當監聽到事件的事件,立馬啟會進行註冊。

  • 總結:

    延遲註冊不生效的原因,預設情況下只配置延遲註冊時間是不生效的,需要將 eureka.client.healthcheck.enabled eureka.client.onDemandUpdateStatusChange 都為false,才可以。即使我們都按照這個方法設定了,但是發送心跳的執行緒仍然會去註冊,最多時間不超過30s

    那我們該如何解決這一問題呢?

    需要修改心跳的部份程式碼

    // com.netflix.discovery.DiscoveryClient
    // Heartbeat timer
    heartbeatTask = new TimedSupervisorTask(
    "heartbeat",
    scheduler,
    heartbeatExecutor,
    renewalIntervalInSecs,
    TimeUnit.SECONDS,
    expBackOffBound,
    new HeartbeatThread()
    );

    scheduler.schedule(
    heartbeatTask,
    // 將renewalIntervalInSecs改為clientConfig.getInitialInstanceInfoReplicationIntervalSeconds()
    clientConfig.getInitialInstanceInfoReplicationIntervalSeconds(), TimeUnit.SECONDS); 
    /**
    * The heartbeat task that renews the lease in the given intervals.
    */

    private classHeartbeatThreadimplementsRunnable{
    publicvoidrun(){
    if (renew()) {
    lastSuccessfulHeartbeatTimestamp = System.currentTimeMillis();
    }
    }
    }
    /**
    * Renew with the eureka service by making the appropriate REST call
    */

    booleanrenew(){
    EurekaHttpResponse<InstanceInfo> httpResponse;
    try {
    httpResponse = eurekaTransport.registrationClient.sendHeartBeat(instanceInfo.getAppName(), instanceInfo.getId(), instanceInfo, null);
    logger.debug(PREFIX + "{} - Heartbeat status: {}", appPathIdentifier, httpResponse.getStatusCode());
    if (httpResponse.getStatusCode() == Status.NOT_FOUND.getStatusCode()) {
    REREGISTER_COUNTER.increment();
    logger.info(PREFIX + "{} - Re-registering apps/{}", appPathIdentifier, instanceInfo.getAppName());
    long timestamp = instanceInfo.setIsDirtyWithTime();
    boolean success = register();
    if (success) {
    instanceInfo.unsetIsDirty(timestamp);
    }
    return success;
    }
    return httpResponse.getStatusCode() == Status.OK.getStatusCode();
    catch (Throwable e) {
    logger.error(PREFIX + "{} - was unable to send heartbeat!", appPathIdentifier, e);
    returnfalse;
    }
    }

    利用 SpringApplicationRunListener 完成啟動後註冊

    @Slf4j
    public classEurekaRegisterListenerimplementsSpringApplicationRunListenerOrdered{
    privatefinal SpringApplication application;
    privatefinal String[] args;
    publicEurekaRegisterListener(SpringApplication sa, String[] arg){
    this.application = sa;
    this.args = arg;
    }
    @Override
    publicintgetOrder(){
    return Ordered.LOWEST_PRECEDENCE;
    }
    @Override
    publicvoidstarting(){
    }
    @Override
    publicvoidenvironmentPrepared(ConfigurableEnvironment environment){
    }
    @Override
    publicvoidcontextPrepared(ConfigurableApplicationContext context){
    }
    @Override
    publicvoidcontextLoaded(ConfigurableApplicationContext context){
    }
    @Override
    publicvoidstarted(ConfigurableApplicationContext context){
    }
    /**
    * run方法在剛剛啟動的時候會呼叫一次,然後整體服務啟動後還會被呼叫一次
    @param context
    */

    @Override
    publicvoidrunning(ConfigurableApplicationContext context){
    // 獲取eureka伺服端配置
    String eurekaServiceUrls = context.getEnvironment().getProperty("eureka.client.service-url.defaultZone");
    if (StringUtils.isEmpty(eurekaServiceUrls)) {
    log.error("not found eureka service for manual register");
    return;
    }
    // 第一次呼叫時上下文並沒有被構造因此獲取bean時失敗,會拋異常,需要捕獲並忽略!!
    EurekaInstanceConfigBean eurekaInstanceConfigBean;
    try {
    eurekaInstanceConfigBean = context.getBean(EurekaInstanceConfigBean. class);
    catch (Exception ignore) {
    return;
    }
    // eureka的配置項支持多個地址並用逗號隔開,因此此處也做了相容
    String[] serviceUrlArr = eurekaServiceUrls.split(",");
    for (String serviceUrl : serviceUrlArr) {
    // 輪詢地址,構造restTemplate
    EurekaHttpClient eurekaHttpClient = new RestTemplateTransportClientFactory().newClient(new DefaultEndpoint(serviceUrl));
    // 獲取eureka根據配置檔構造出的例項物件
    InstanceInfo instanceInfo = new EurekaConfigBasedInstanceInfoProvider(eurekaInstanceConfigBean).get();
    // 此時直接將狀態更該為UP,預設為STARTING雖然註冊但是不可用
    instanceInfo.setStatus(InstanceInfo.InstanceStatus.UP);
    // 發送rest請求去註冊
    EurekaHttpResponse<Void> register = eurekaHttpClient.register(instanceInfo);
    // 判斷當前地址是成功註冊
    if (register.getStatusCode() == 204) {
    log.info("success manual register eureka");
    return;
    }
    }
    }
    @Override
    publicvoidfailed(ConfigurableApplicationContext context, Throwable exception){
    //啟動失敗時下線eureka例項,eureka內部實作直接拿過來用!
    DiscoveryManager.getInstance().shutdownComponent();
    }
    }












    優雅下線

    首先是對於Springboot自動停機的選擇

    這裏有一些弊端需要提前聲明,直接暴露關機埠,會出現一系列安全性問題,不建議使用,如果是直接註冊JVM的 ShutdownHook 去先到註冊中心刪除資訊,再延遲關機,如下程式碼,這時會發現雖然註冊中心能夠感知下線,但是Tomcat會拒絕接收請求,資料庫執行緒池也會關閉,由於其他客戶端存在緩存,會導致請求無法正常響應。下面我們從源碼層面介紹一下為啥這樣不行。

    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    // 從eureka註冊列表中刪除例項
    DiscoveryManager.getInstance().shutdownComponent();
    // 休眠120S
    try {
    Thread.sleep(120 * 1000);
    catch (Exception ignore) {
    }
    }));

    透過對Spring源碼的探究,我們會發現在容器啟動時會註冊一個ShutdownHook

    privatevoidrefreshContext(ConfigurableApplicationContext context){
    if (this.registerShutdownHook) {
    try {
    //註冊shutdownhook
    context.registerShutdownHook();
    }
    catch (AccessControlException ex) {
    // Not allowed in some environments.
    }
    }
    refresh((ApplicationContext) context);
    }
    @Override
    publicvoidregisterShutdownHook(){
    if (this.shutdownHook == null) {
    // No shutdown hook registered yet.
    this.shutdownHook = new Thread(SHUTDOWN_HOOK_THREAD_NAME) {
    @Override
    publicvoidrun(){
    synchronized (startupShutdownMonitor) {
    //shutdownhook真正需要執行的邏輯
    doClose();
    }
    }
    };
    Runtime.getRuntime().addShutdownHook(this.shutdownHook);
    }

    我們可以看一下JVM註冊多個ShutdownHook是什麽效果,可以發現當我們添加一個ShutdownHook時,JVM就會新建立一個執行緒執行,多個Hook是會並列執行的,所有Hook執行完畢才會完全結束。

    // java.lang.Runtime#addShutdownHook
    publicvoidaddShutdownHook(Thread hook){
    SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
    sm.checkPermission(new RuntimePermission("shutdownHooks"));
    }
    ApplicationShutdownHooks.add(hook);
    }
    // java.lang.ApplicationShutdownHooks
    /* The set of registered hooks */
    privatestatic IdentityHashMap<Thread, Thread> hooks;
    //添加一個新的ShutdownHook。檢查Shutdown狀態和hook本身,但不進行任何安全檢查。
    staticsynchronizedvoidadd(Thread hook){
    if(hooks == null)
    thrownew IllegalStateException("Shutdown in progress");
    if (hook.isAlive())
    thrownew IllegalArgumentException("Hook already running");
    if (hooks.containsKey(hook))
    thrownew IllegalArgumentException("Hook previously registered");
    hooks.put(hook, hook);
    }
    //為每個hook建立一個新執行緒。hook同時執行,此方法等待它們完成。
    staticvoidrunHooks(){
    Collection<Thread> threads;
    synchronized(ApplicationShutdownHooks. class{
    threads = hooks.keySet();
    hooks = null;
    }
    for (Thread hook : threads) {
    hook.start();
    }
    for (Thread hook : threads) {
    while (true) {
    try {
    hook.join();
    break;
    catch (InterruptedException ignored) {
    }
    }
    }
    }





    這時就可以清楚的知道為什麽我們定義的Hook達不到我們期望的結果,因為多個ShutdownHook是並列執行的,互相不會有幹擾,雖然我們期望延遲關閉,但是Spring自己也是基於JVM的ShutdownHook進行關閉容器等操作的,所以我們自訂的ShutdownHook是行不通的,那我們該如何解決這個問題呢?

    其實很簡單,打不過就加入,我們直接切入到Spring註冊的ShutdownHook就可以了,他也為我們開放了一些Hook,透過上文的源碼我們會發現在關閉容器開始會釋出一個ContextClose事件,我們直接去監聽這個事件就可以實作延遲銷毀容器,延遲結束的功能,我們可以按照如下方式實作。

    由於eureka上註冊服務主動下線後,其他客戶端最多需要90S才能感知,並且我們的微服務中用Ribbon 做服務呼叫負載均衡,ribbon又緩存30S,所以最多120S,其他服務感知該節點下線,所以我們設定延遲120s,這裏的120s我們可以根據自己的計畫進行預估。

    @Component
    public classEurekaShutdownConfigimplementsApplicationListener<ContextClosedEvent>, PriorityOrdered{
    privatestaticfinal Logger log = LoggerFactory.getLogger(EurekaShutdownConfig. class);
    @Override
    publicvoidonApplicationEvent(ContextClosedEvent event){
    try {
    log.info(LogUtil.logMsg("_shutdown""msg""eureka instance offline begin!"));
    DiscoveryManager.getInstance().shutdownComponent();
    log.info(LogUtil.logMsg("_shutdown""msg""eureka instance offline end!"));
    log.info(LogUtil.logMsg("_shutdown""msg""start sleep 120S for cache!"));
    // 可以根據架構動態調整
    Thread.sleep(120 * 1000);
    log.info(LogUtil.logMsg("_shutdown""msg""stop sleep 120S for cache!"));
    catch (Throwable ignore) {
    }
    }
    @Override
    publicintgetOrder(){
    return0;
    }
    }

    3.2.2 Nacos層面實作

    優雅上線

    nacos和eureka是一樣的,也需要延遲上線來避免一些問題

    1.啟動SpringBoot,但啟動時不註冊服務到Nacos

  • 配置 spring.cloud.nacos.discovery.enabled=true ,開啟Nacos Discovery功能

  • 配置 spring.cloud.nacos.discovery.register-enabled=false ,關閉Discovery註冊

  • 配置 spring.cloud.nacos.discovery.port=${server.port:80} ,配置註冊的埠號與服務一致

  • 2.利用K8S健康檢查的【就緒狀態檢查】功能,實作服務註冊Nacos

  • 開啟K8S健康檢查功能,配置【就緒狀態檢查】

  • 配置【就緒狀態檢查】請求路徑為: /actuator/registry

  • 配置【就緒狀態檢查】執行多少時間後開始檢測的時長要大於服務Tomcat啟動時間

  • 配置【就緒狀態檢查】容器埠要與服務啟動埠一致

  • 3." /actuator/registry "請求將呼叫到自訂的Endpoint-registry中

  • 呼叫 healthEndpoint.health(); 驗證服務是否UP

  • 確定服務UP後呼叫 nacosServiceRegistry.register(registration); 將服務註冊Nacos

  • 實作/actuator/registry,程式碼如下:

    @Slf4j
    @Component
    @Endpoint(id = "registry")
    @ConditionalOnProperty(prefix = "spring.cloud.nacos.discovery", name = "server-addr")
    @ConditionalOn class({NacosServiceRegistry. classRegistration. class})
    public classRegistryEndpoint
    {
    /**
    * 這裏使用的是K8S 就緒狀態檢查回呼去註冊服務到註冊中心
    * 當服務啟動 首次獲取就緒狀態時 將服務註冊到配置中心上
    * 一旦註冊成功後就會像 /actuator/health 一樣返回成功即可
    */

    privatestaticboolean IS_INIT = false;
    /**
    * 返回結果:與"/actuator/health"介面返回成功結果一樣
    */

    privatefinal String SUC = "{"status":"UP","groups":["liveness","readiness"]}";
    privatefinal String UNKNOWN = "{"status":"UNKNOWN","groups":["liveness","readiness"]}";
    @Value("${spring.application.name}")
    private String application;
    privatefinal NacosServiceRegistry nacosServiceRegistry;
    privatefinal Registration registration;
    privatefinal HealthEndpoint healthEndpoint;
    publicRegistryEndpoint(NacosServiceRegistry nacosServiceRegistry, Registration registration, HealthEndpoint healthEndpoint){
    this.nacosServiceRegistry = nacosServiceRegistry;
    this.registration = registration;
    this.healthEndpoint = healthEndpoint;
    }
    @ReadOperation
    public String registry(){
    if (IS_INIT) {
    return SUC;
    }
    HealthComponent health = healthEndpoint.health();
    if(!org.springframework.boot.actuate.health.Status.UP.equals(health.getStatus())){
    return UNKNOWN;
    }
    log.info("將[{}] 服務註冊至註冊中心 registry into !", application);
    nacosServiceRegistry.register(registration);
    log.info("將[{}] 服務註冊至註冊中心 registry success !", application);
    IS_INIT = true;
    return SUC;
    }
    }







    優雅下線

    與eureka一樣我們也需要提前結束註冊,然後延遲關閉服務

    nacos感知速度會比eureka快很多,我們需要等待的時間就可以設定短一些,一般40s足以

    @Component
    public classNacosShutdownEventimplementsApplicationListener<ContextClosedEvent>, PriorityOrdered{
    privatestaticfinal Logger log = LoggerFactory.getLogger(EurekaShutdownConfig. class);
    @Override
    publicvoidonApplicationEvent(ContextClosedEvent event){
    try {
    log.info(LogUtil.logMsg("_shutdown""msg""nacos instance offline begin!"));
    NacosServiceRegistry nacosServiceRegistry = 
    event.getApplicationContext().getBean().getBean(NacosServiceRegistry. class);
    NacosRegistration registration = 
    event.getApplicationContext().getBean(NacosRegistration. class);
    nacosServiceRegistry.deregister(registration);
    log.info(LogUtil.logMsg("_shutdown""msg""nacos instance offline end!"));
    log.info(LogUtil.logMsg("_shutdown""msg""start sleep 40s for cache!"));
    // 睡眠40S,是因為nacos上註冊服務主動下線後,清理rabbon緩存時間,
    // nacos從其他客戶端每10s拉取一次,或者伺服端主動推播服務列表,最大40S
    Thread.sleep(35 * 1000);
    log.info(LogUtil.logMsg("_shutdown""msg""stop sleep 40s for cache!"));
    catch (Throwable ignore) {
    }
    }
    @Override
    publicintgetOrder(){
    return0;
    }
    }

    3.2.3 Dubbo層面實作

    Dubbo 預設就開啟了優雅停機, ShutdownHookListener 監聽了 Spring 的關閉事件,當 Spring 開始關閉,就會觸發 ShutdownHookListener 的內部邏輯,透過配置 dubbo.application.shutwait=30s 可以設定dubbo延遲等待時間。

    public classSpringExtensionFactoryimplementsExtensionFactory{
    privatestaticfinal Logger logger = LoggerFactory.getLogger(SpringExtensionFactory. class);
    privatestaticfinal Set<ApplicationContext> CONTEXTS = new ConcurrentHashSet<ApplicationContext>();
    privatestaticfinal ApplicationListener SHUTDOWN_HOOK_LISTENER = new ShutdownHookListener();
    publicstaticvoidaddApplicationContext(ApplicationContext context){
    CONTEXTS.add(context);
    if (context instanceof ConfigurableApplicationContext) {
    // 註冊 ShutdownHook
    ((ConfigurableApplicationContext) context).registerShutdownHook();
    // 取消 AbstractConfig 註冊的 ShutdownHook 事件
    DubboShutdownHook.getDubboShutdownHook().unregister();
    }
    BeanFactoryUtils.addApplicationListener(context, SHUTDOWN_HOOK_LISTENER);
    }
    // 繼承 ApplicationListener,這個監聽器將會監聽容器關閉事件
    privatestatic classShutdownHookListenerimplementsApplicationListener{
    @Override
    publicvoidonApplicationEvent(ApplicationEvent event){
    if (event instanceof ContextClosedEvent) {
    DubboShutdownHook shutdownHook = DubboShutdownHook.getDubboShutdownHook();
    shutdownHook.doDestroy();
    }
    }
    }
    }

    3.2.4 K8s層面實作

    這裏就要用到K8s的探針以及容器的生命周期回呼

    1.探針

    版本小於 v1.15 時支持 readiness 和 liveness 探針,在 v1.16 中添加了 startup 探針作為Alpha 功能,並在 v1.18 中升級為 Beta。

    我們在使用K8s管理容器時,可以透過探針來探測容器的狀態,我們需要了解容器的生命周期。

    readiness 存活探針可以讓 kubelet 知道應用程式何時準備接受新流量。 如果應用程式在行程啟動後需要一些時間來初始化狀態,要配置 readiness 探針讓 Kubernetes 在發送新流量之前進行等待。readiness 探針的主要作用是將流量引導至 service 後的 deployment。

    liveness 就緒探針用於重新啟動不健康的容器。 Kubelet 會定期地 ping liveness 探針,以確定健康狀況,並在 liveness 檢查不透過的情況下殺死 Pod。liveness 檢查可以幫助應用程式從死結中恢復。如果不進行 liveness 檢查,Kubernetes 會認為死結中的 Pod 處於健康狀態,因為從 Kubernetes 的角度來看,Pod 的子行程仍在執行,是健康的。透過配置 liveness 探針,kubelet 可以檢測到應用程式處於不健康狀態,並重新啟動 Pod 以恢復可用性。

    startup 啟動探針用於判斷套用是否已盡啟動, 如果同時配置了readiness,liveness,startup,會優先使用startup

    探針的參數:

  • initialDelaySeconds: 啟動 liveness、readiness 探針前要等待的秒數。

  • periodSeconds: 檢查探針的頻率。

  • timeoutSeconds: 將探針標記為超時(未透過執行狀況檢查)之前的秒數。

  • successThreshold: 探針需要透過的最小連續成功檢查數量。

  • failureThreshold: 將探針標記為失敗之前的重試次數。對於 liveness 探針,這將導致 Pod 重新啟動。對於 readiness 探針,將標記 Pod 為未就緒(unready)。

  • 結合SpringBoot的健康檢查去配置探針

    # SpringBoot配置
    management:
    server:
    port:32518
    endpoints:
    web:
    exposure:
    include:health
    endpoint:
    health:
    probes:
    enabled:true
    show-details:always
    health:
    livenessstate:#存活狀態( Liveness )
    enabled:true
    readinessstate:# 就緒狀態( Readiness )
    enabled:true
    # K8s 配置
    apiVersion:apps/v1
    kind:Deployment
    metadata:
    annotations:
    deployment.kubernetes.io/revision:'29'
    field.cattle.io/publicEndpoints:>-
    [{"port":30410,"protocol":"TCP","serviceName":"devops-test:zhj-release-nodeport","allNodes":true}]
    creationTimestamp:'2023-10-07T01:39:30Z'
    generation:18
    labels:
    app:cloud-release
    manager:cloud-hcce
    recordId:'2398'
    runMode:pro
    softServiceId:64d2e20597d214df96504a3f
    softVersionId:64f7d33497d214df96504b54
    velero.io/backup-name:devops-test-backup
    velero.io/restore-name:devops-test-backup-20231007093909
    name:zhj-release
    namespace:devops-test
    resourceVersion:'9857909'
    uid:9ce21f33-f3ea-45ba-a827-dc1f30a9b978
    spec:
    progressDeadlineSeconds:300
    replicas:1
    revisionHistoryLimit:10
    selector:
    matchLabels:
    app:cloud-release
    strategy:
    rollingUpdate:
    maxSurge:25%
    maxUnavailable:25%
    type:RollingUpdate
    template:
    metadata:
    annotations:
    cattle.io/timestamp:'2023-12-10T00:00:00Z'
    manager:cloud-hcce
    creationTimestamp:null
    labels:
    app:cloud-release
    spec:
    containers:
    -env:
    -name:spring.profiles.active
    value:test
    -name:JAVA_OPTS
    value:'-Xmx1024M'
    image:zhj-release:1.0.3-e6b07342-20231010
    imagePullPolicy:IfNotPresent
    name:cloud-release
    ports:
    -containerPort:8080
    name:port
    protocol:TCP
    lifecycle:#生命周期
    preStop:
    exec:
    command:
    -/bin/bash#使用kill 15發送系統訊號SIGTERM
    -'-c'
    -kill
    -'-n'
    -'15'
    livenessProbe:#探針配置
    failureThreshold:3
    httpGet:
    path:/actuator/health/liveness
    port:32518
    scheme:HTTP
    initialDelaySeconds:120
    periodSeconds:2
    successThreshold:1
    timeoutSeconds:2
    readinessProbe:
    failureThreshold:3
    httpGet:
    path:/actuator/health/readiness
    port:32518
    scheme:HTTP
    initialDelaySeconds:5
    periodSeconds:2
    successThreshold:1
    timeoutSeconds:2
    resources:{}
    terminationMessagePath:/dev/termination-log
    terminationMessagePolicy:File
    dnsPolicy:ClusterFirst
    restartPolicy:Always
    schedulerName:default-scheduler
    securityContext:{}
    terminationGracePeriodSeconds:30
    status:
    availableReplicas:1
    conditions:
    -lastTransitionTime:'2023-12-10T00:00:00Z'
    lastUpdateTime:'2023-12-10T00:00:00Z'
    message:Deploymenthasminimumavailability.
    reason:MinimumReplicasAvailable
    status:'True'
    type:Available
    -lastTransitionTime:'2023-12-10T00:00:00Z'
    lastUpdateTime:'2023-12-10T00:00:00Z'
    message:ReplicaSet"zhj-release-54bfcc7878"hassuccessfullyprogressed.
    reason:NewReplicaSetAvailable
    status:'True'
    type:Progressing
    observedGeneration:18
    readyReplicas:1
    replicas:1
    updatedReplicas:1

    這樣K8s可以透過endpoint提供的介面對我們的SpringBoot服務進行探測,根據響應內容判斷服務狀態

    2.容器的生命周期回呼

    當呼叫容器生命周期管理回呼時,Kubernetes 管理系統根據回呼動作執行其處理常式, httpGet 和 tcpSocket 在 kubelet 行程執行,而 exec 則由容器內執行。

  • PostStart: 容器建立成功後,執行前的任務,用於資源部署、環境準備等。

  • PreStop: 容器被終止前的任務,用於優雅關閉應用程式、通知其他系統等等。配置可參考上文。我們可以在這裏關停服務。


  • 4 其他

    4.1 執行緒池的優雅關閉

    ThreadPoolExecutor 對於關閉有兩種方式shutdown和shutdownNow

    shutdown 之後會變成 SHUTDOWN 狀態,無法接受新的任務,隨後等待正在執行的任務執行完成。意味著,shutdown 只是發出一個命令,至於有沒有關閉還是得看執行緒自己。

    shutdownNow 的處理規則則不太一樣,方法執行之後變成 STOP 狀態,並對執行中的執行緒呼叫 Thread.interrupt() 方法(但如果執行緒未處理中斷,則不會有任何事發生),所以並不代表「立刻關閉」。

    兩者都提示我們需要額外執行 awaitTermination 方法,僅僅執行 shutdown/shutdownNow 是不夠的

    // 執行緒池
    private ThreadPoolExecutor executor;
    @Bean
    @Primary
    public ThreadPoolExecutor asyncServiceExecutor(){
    executor = new ThreadPoolExecutor(52060L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000), new ThreadPoolExecutor.CallerRunsPolicy());
    return executor;
    }
    @PreDestroy
    publicvoiddestroyThreadPool(){
    shutdown();
    }
    publicvoidshutdown(){
    if (this.waitForTasksToCompleteOnShutdown) {
    this.executor.shutdown();
    }
    else {
    this.executor.shutdownNow();
    }
    awaitTerminationIfNecessary();
    }
    privatevoidawaitTerminationIfNecessary(){
    if (this.awaitTerminationSeconds > 0) {
    try {
    // 具體延遲時間因業務而定
    this.executor.awaitTermination(30, TimeUnit.SECONDS));
    }
    catch (InterruptedException ex) {
    Thread.currentThread().interrupt();
    }
    }
    }


    SpringBoot托管的執行緒池可以如下設定:

    @Slf4j
    @EnableAsync
    @Configuration
    public classTaskExecutorConfig{
    privatestaticfinalint TIMEOUT = 60;
    @Bean("taskExecutor")
    public ThreadPoolTaskExecutor taskExecutor(){
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(200);
    executor.setKeepAliveSeconds(1000);
    executor.setThreadNamePrefix("task-asyn");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
    // 銷毀之前執行shutdown方法
    executor.setWaitForTasksToCompleteOnShutdown(true);
    // shutdown\shutdownNow 之後等待60秒
    executor.setAwaitTerminationSeconds(TIMEOUT);
    return executor;
    }
    }

    4.2 MQ的優雅關閉

    Spring管理的MQ預設都實作了優雅關閉

    4.3 定時任務優雅關閉

    1.Spring @Scheduled

    可以設定執行緒池,借助Spring托管的執行緒池實作優雅關閉

    2.xxl-job

    執行器中托管執行著業務任務,任務上線和變更需要重新開機執行器,尤其是Bean模式任務。執行器重新開機可能會中斷執行中的任務。但是,XXL-JOB得益於自建執行器與自建註冊中心,可以透過灰度上線的方式,避免因重新開機導致的任務中斷的問題

    步驟如下:

  • 執行器改為手動註冊,下線一半機器列表(A組),線上執行另一半機器列表(B組);

  • 等待A組機器任務執行結束並編譯上線;執行器註冊地址替換為A組;

  • 等待B組機器任務執行結束並編譯上線;執行器註冊地址替換為A組+B組;操作結束;

  • IT交流群

    致力於幫助廣大開發者提供高效合適的工具,讓大家能夠騰出手做更多創造性的工作,也歡迎大家分享自己公司的內推資訊,相互幫助,一起進步!

    組建了程式設計師,架構師,IT從業者交流群,以 交流技術 職位內推 行業探討 為主

    加大佬 好友 ,備註"加群"