服務優雅上下線

写的代码很烂發表於2024-11-27

目錄
  • 概述
  • Java語言層面實現優雅停機
  • 作業系統層面的停機策略
  • SpringBoot 框架層面的優雅停機
  • Actuator
    • 執行緒池銷燬
  • 總結

概述

優雅停機一直是一個非常嚴謹的話題,但由於其僅僅存在於重啟、下線這樣的部署階段,導致很多人忽視了它的重要性,但沒有它,你永遠不能得到一個完整的應用生命週期,永遠會對系統的健壯性持懷疑態度。

同時,優雅停機又是一個龐大的話題

  • 作業系統層面,提供了 kill -9 (SIGKILL)和 kill -15(SIGTERM) 兩種停機策略
  • 語言層面,Java 應用有 JVM shutdown hook 這樣的概念
  • 框架層面,Spring Boot 提供了 actuator 的下線 endpoint,提供了 ContextClosedEvent 事件
  • 容器層面,Docker :當執行 docker stop 命令時,容器內的程序會收到 SIGTERM 訊號,那麼 Docker Daemon 會在 10s 後,發出 SIGKILL 訊號;K8S 在管理容器生命週期階段中提供了 prestop 鉤子方法。
  • 應用架構層面,不同架構存在不同的部署方案。單體式應用中,一般依靠 nginx 這樣的負載均衡元件進行手動切流,逐步部署叢集;微服務架構中,各個節點之間有複雜的呼叫關係,上述這種方案就顯得不可靠了,需要有自動化的機制。

為避免該話題過度發散,本文的重點將會集中在框架和應用架構層面。

優雅下線操作旨在讓服務消費者避開已經下線的機器,但這樣就算實現了優雅停機了嗎?似乎還漏掉了一步,在應用停機時,可能還存在執行到了一半的任務,試想這樣一個場景:一個請求剛到達提供者,服務端正在處理請求,收到停機指令後,提供者直接停機,留給消費者的只會是一個沒有處理完畢的超時請求。

結合上述的案例,我們總結出服務提供者優雅停機需要滿足兩點基本訴求

  1. 服務消費者不應該請求到已經下線的服務提供者;
  2. 在途請求需要處理完畢,不能被停機指令中斷;

優雅停機的意義:應用的重啟、停機等操作,不影響業務的連續性

Java語言層面實現優雅停機

JVM shutdown hook 是 Java 虛擬機器提供的一個鉤子(hook),用於在 JVM 關閉之前執行一些必要的清理操作。在 Java 中,可以透過 Runtime 類的 addShutdownHook 方法註冊一個 shutdown hook,當 JVM 接收到中斷訊號或者呼叫 System.exit 方法時,就會執行註冊的 shutdown hook。

JVM shutdown hook 的具體實現方式如下:

  1. 建立一個繼承自 Thread 類的子類,用於實現 shutdown hook 的邏輯。
  2. 在子類中重寫 run 方法,編寫需要在 JVM 關閉前執行的清理操作。
  3. 在程式中使用 Runtime 類的 addShutdownHook 方法註冊 shutdown hook:

實現程式碼如下所示:

Runtime.getRuntime().addShutdownHook(new MyShutdownHook());

在上述示例中,MyShutdownHook 是繼承自 Thread 的子類,用於實現 shutdown hook 的邏輯。

shutdown hook注意事項:

  • JVM shutdown hook 的執行順序是不確定的。當 JVM 接收到中斷訊號或者呼叫 System.exit 方法時,就會同時啟動所有已註冊的 shutdown hook,但是它們的執行順序是不確定的。因此,在編寫 shutdown hook 時,需要考慮到多個 shutdown hook 之間的互動和依賴關係。
  • 程式退出時,JVM 會併發執行所有的應用 Shutdown Hook,並且只有所有 Shutdown Hook 都執行完,程式才正常退出。
  • 執行 Shutdown Hook 時,應該認為應用內的各種服務、資源都已經處於不可靠狀態。因此,編寫 Shutdown Hook 時要特別小心,不要有死鎖。Shutdown Hook 應該是執行緒安全的,且不依賴於應用資源,比如,假設你的 Shutdown Hook 依賴另一個服務,這個服務又註冊了自己的 Shutdown Hook,已經先行一步清理完自己的資源,這時候你的 Shutdown Hook 就會有問題

作業系統層面的停機策略

作業系統層面,提供了 kill -9 (SIGKILL)和 kill -15(SIGTERM) 兩種停機策略。

在 Linux 中,kill 命令用於向程序傳送訊號,以通知該程序執行某些特定的操作。其中,kill 命令最常用的兩個引數是 -9 和 -15,它們分別表示傳送 SIGKILL 和 SIGTERM 訊號。

如果使用 kill pid 則預設等價於 kill -15 pid

  • SIGKILL 訊號是一個不能被阻塞、處理或忽略的訊號,它會立即終止目標程序。使用 kill -9 命令傳送 SIGKILL 訊號可以強制終止程序,即使程序正在執行某些關鍵操作也會被立即終止,這可能會導致資料損壞或其他不良影響。因此,一般情況下不建議使用 kill -9 命令,除非必要情況下無法透過其他方式終止程序。
  • SIGTERM 訊號是一個可以被阻塞、處理或忽略的訊號,它也可以通知目標程序終止,但是它相對於 SIGKILL 訊號來說更加溫和,目標程序可以在接收到 SIGTERM 訊號時進行一些清理操作,例如儲存資料、關閉檔案、釋放資源等,然後再終止程序。使用 kill -15 命令傳送 SIGTERM 訊號通常被認為是一種優雅的方式來終止程序,因為這種方式允許程序在終止之前執行一些必要的清理操作,避免了資料損壞和其他不良影響。

可以先簡單理解下這兩者的區別:kill -9 pid 可以理解為作業系統從核心級別強行殺死某個程序,kill -15 pid 則可以理解為傳送一個通知,告知應用主動關閉。這麼對比還是有點抽象,那我們就從應用的表現來看看,這兩個命令殺死應用到底有啥區別。

因此,一般情況下建議首先嚐試使用 kill -15 命令傳送 SIGTERM 訊號來終止程序,只有在程序無法透過 SIGTERM 訊號終止時才考慮使用 kill -9 命令傳送 SIGKILL 訊號。

SpringBoot 框架層面的優雅停機

上面解釋過了,使用 kill -15 pid 的方式可以比較優雅的關閉 SpringBoot 應用,我們可能有以下的疑惑:SpringBoot/Spring 是如何響應這一關閉行為的呢?是先關閉了 tomcat,緊接著退出 JVM,還是相反的次序?它們又是如何互相關聯的?

嘗試從日誌開始著手分析,AnnotationConfigEmbeddedWebApplicationContext 列印出了 Closing 的行為,直接去原始碼中一探究竟,最終在其父類 AbstractApplicationContext 中找到了關鍵的程式碼:

@Override
public void registerShutdownHook() {
  if (this.shutdownHook == null) {
    this.shutdownHook = new Thread() {
      @Override
      public void run() {
        synchronized (startupShutdownMonitor) {
          doClose();
        }
      }
    };
    Runtime.getRuntime().addShutdownHook(this.shutdownHook);
  }
}

@Override
public void close() {
   synchronized (this.startupShutdownMonitor) {
      doClose();
      if (this.shutdownHook != null) {
         Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
      }
   }
}

protected void doClose() {
   if (this.active.get() && this.closed.compareAndSet(false, true)) {
      LiveBeansView.unregisterApplicationContext(this);
      // 釋出應用內的關閉事件
      publishEvent(new ContextClosedEvent(this));
      // Stop all Lifecycle beans, to avoid delays during individual destruction.
      if (this.lifecycleProcessor != null) {
         this.lifecycleProcessor.onClose();
      }
      // spring 的 BeanFactory 可能會快取單例的 Bean 
      destroyBeans();
      // 關閉應用上下文 &BeanFactory
      closeBeanFactory();
      // 執行子類的關閉邏輯
      onClose();
      this.active.set(false);
}

為了方便排版以及便於理解,我去除了原始碼中的部分異常處理程式碼,並新增了相關的註釋。在容器初始化時,ApplicationContext 便已經註冊了一個 Shutdown Hook,這個鉤子呼叫了 Close()方法,於是當我們執行 kill -15 pid 時,JVM 接收到關閉指令,觸發了這個 Shutdown Hook,進而由 Close() 方法去處理一些善後手段。具體的善後手段有哪些,則完全依賴於 ApplicationContextdoClose() 邏輯,包括了註釋中提及的銷燬快取單例物件,釋出 close 事件,關閉應用上下文等等,特別的,當 ApplicationContext 的實現類是 AnnotationConfigEmbeddedWebApplicationContext 時,還會處理一些 Tomcat/Jetty 一類內建應用伺服器關閉的邏輯。

窺見了 SpringBoot 內部的這些細節,更加應該瞭解到優雅關閉應用的必要性。JAVA 和 C 都提供了對 Signal 的封裝,我們也可以手動捕獲作業系統的這些 Signal,在此不做過多介紹,有興趣的朋友可以自己嘗試捕獲下。

Actuator

spring-boot-starter-actuator 模組提供了一個 restful 介面,用於優雅停機。

新增依賴:

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

新增配置

#啟用 shutdown
endpoints.shutdown.enabled=true
#禁用密碼驗證
endpoints.shutdown.sensitive=false

生產中請注意該埠需要設定許可權,如配合 spring-security 使用。

執行 curl -X POST host:port/shutdown 指令,關閉成功便可以獲得如下的返回:

{"message":"Shutting down, bye..."}

雖然 SpringBoot 提供了這樣的方式,但按我目前的瞭解,沒見到有人用這種方式停機,kill -15 pid 的方式達到的效果與此相同,將其列於此處只是為了方案的完整性。

執行緒池銷燬

儘管 JVM 關閉時會幫我們回收一定的資源,但一些服務如果大量使用非同步回撥,定時任務,處理不當很有可能會導致業務出現問題,在這其中,執行緒池如何關閉是一個比較典型的問題。

@Service
public class SomeService {
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    public void concurrentExecute() {
        executorService.execute(new Runnable() {
            @Override
            public void run() {
                System.out.println("executed...");
            }
        });
    }
}

我們需要想辦法在應用關閉時(JVM 關閉,容器停止執行),關閉執行緒池。

初始方案:什麼都不做。在一般情況下,這不會有什麼大問題,因為 JVM 關閉,會釋放之,但顯然沒有做到本文一直在強調的兩個字,沒錯 —- 優雅。

方法一的弊端在於執行緒池中提交的任務以及阻塞佇列中未執行的任務變得極其不可控,接收到停機指令後是立刻退出?還是等待任務執行完成?抑或是等待一定時間任務還沒執行完成則關閉?

@Service
public class SomeService implements DisposableBean{

    ExecutorService executorService = Executors.newFixedThreadPool(10);

    public void concurrentExecute() {
        executorService.execute(new Runnable() {
            @Override
            public void run() {
                System.out.println("executed...");
            }
        });
    }

    @Override
    public void destroy() throws Exception {
        executorService.shutdownNow();
        //executorService.shutdown();
    }
}

緊接著問題又來了,是 shutdown 還是 shutdownNow 呢?這兩個方法還是經常被誤用的,簡單對比這兩個方法。

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

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

檢視 shutdown 和 shutdownNow 的 java doc,會發現如下的提示:

shutdown():Initiates an orderly shutdown in which previously submitted tasks are executed, but no new tasks will be accepted.Invocation has no additional effect if already shut down.This method does not wait for previously submitted tasks to complete execution.Use {@link #awaitTermination awaitTermination} to do that.

shutdownNow():Attempts to stop all actively executing tasks, halts the processing of waiting tasks, and returns a list of the tasks that were awaiting execution. These tasks are drained (removed) from the task queue upon return from this method.This method does not wait for actively executing tasks to terminate. Use {@link #awaitTermination awaitTermination} to do that.There are no guarantees beyond best-effort attempts to stop processing actively executing tasks. This implementation cancels tasks via {@link Thread#interrupt}, so any task that fails to respond to interrupts may never terminate.

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

最終方案:參考 spring 中執行緒池的回收策略,我們得到了最終的解決方案。

public abstract class ExecutorConfigurationSupport extends CustomizableThreadFactory
      implements DisposableBean{
    @Override
	public void destroy() {
		shutdown();
	}

	/**
	 * Perform a shutdown on the underlying ExecutorService.
	 * @see java.util.concurrent.ExecutorService#shutdown()
	 * @see java.util.concurrent.ExecutorService#shutdownNow()
	 * @see #awaitTerminationIfNecessary()
	 */
	public void shutdown() {
		if (this.waitForTasksToCompleteOnShutdown) {
			this.executor.shutdown();
		}
		else {
			this.executor.shutdownNow();
		}
		awaitTerminationIfNecessary();
	}

	/**
	 * Wait for the executor to terminate, according to the value of the
	 * {@link #setAwaitTerminationSeconds "awaitTerminationSeconds"} property.
	 */
	private void awaitTerminationIfNecessary() {
		if (this.awaitTerminationSeconds > 0) {
			try {
				this.executor.awaitTermination(this.awaitTerminationSeconds, TimeUnit.SECONDS));
			}
			catch (InterruptedException ex) {
				Thread.currentThread().interrupt();
			}
		}
	}
}

總結

本文從作業系統、語言、框架層面分別闡述了一個Java應用優雅停機流程,從程序層面,優先推薦使用 SIGTERM 訊號通知程序進入銷燬流程,JVM虛擬機器程序會監聽該事件,並執行註冊的ShutdownHook。在框架層面不同的框架都會自行註冊自身框架的ShutdownHook,從而保證各種框架的正常銷燬退出。

參考

Java應用的優雅停機

[執行緒池中shutdown()和shutdownNow()方法的區別](https://www.cnblogs.com/aspirant/p/10265863.html)

相關文章