Spring Boot 系列:最新版優雅停機詳解

架構技術專欄發表於2020-10-14

愛生活,愛編碼,本文已收錄架構技術專欄關注這個喜歡分享的地方。

開源專案:

優雅停機

目前Spring Boot已經發展到了2.3.4.RELEASE,伴隨著2.3版本的到來,優雅停機機制也更加完善了。

目前版本的Spring Boot 優雅停機支援Jetty, Reactor Netty, Tomcat和 Undertow 以及反應式和基於 Servlet 的 web 應用程式都支援優雅停機功能。

優雅停機的目的:

如果沒有優雅停機,伺服器此時直接直接關閉(kill -9),那麼就會導致當前正在容器內執行的業務直接失敗,在某些特殊的場景下產生髒資料。

增加了優雅停機配置後:

在伺服器執行關閉(kill -2)時,會預留一點時間使容器內部業務執行緒執行完畢,此時容器也不允許新的請求進入。新請求的處理方式跟web伺服器有關,Reactor Netty、 Tomcat將停止接入請求,Undertow的處理方式是返回503.

新版配置

YAML配置

新版本配置非常簡單,server.shutdown=graceful 就搞定了(注意,優雅停機配置需要配合Tomcat 9.0.33(含)以上版本)

server:
  port: 6080
  shutdown: graceful #開啟優雅停機
spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s #設定緩衝時間 預設30s

在設定了緩衝引數timeout-per-shutdown-phase 後,在規定時間內如果執行緒無法執行完畢則會被強制停機。

下面我們來看下停機時,加了優雅停日誌和不加的區別:


//未加優雅停機配置
Disconnected from the target VM, address: '127.0.0.1:49754', transport: 'socket'
Process finished with exit code 130 (interrupted by signal 2: SIGINT)

加了優雅停機配置後,可明顯發現的日誌 Waiting for active requests to cpmplete,此時容器將在ShutdownHook執行完畢後停止。

關閉方式

1、 一定不要使用kill -9 操作,使用kill -2 來關閉容器。這樣才會觸發java內部ShutdownHook操作,kill -9不會觸發ShutdownHook。

2、可以使用端點監控 POST 請求 /actuator/shutdown 來執行優雅關機。

新增ShutdownHook

通過上面的日誌我們發現Druid執行了自己的ShutdownHook,那麼我們也來新增下ShutdownHook,有幾種簡單的方式:

1、實現DisposableBean介面,實現destroy方法

@Slf4j
@Service
public class DefaultDataStore implements DisposableBean {


    private final ExecutorService executorService = new ThreadPoolExecutor(OSUtil.getAvailableProcessors(), OSUtil.getAvailableProcessors() + 1, 1, TimeUnit.MINUTES, new ArrayBlockingQueue<>(200), new DefaultThreadFactory("UploadVideo"));


    @Override
    public void destroy() throws Exception {
        log.info("準備優雅停止應用使用 DisposableBean");
        executorService.shutdown();
    }
}

2、使用@PreDestroy註解

@Slf4j
@Service
public class DefaultDataStore {


    private final ExecutorService executorService = new ThreadPoolExecutor(OSUtil.getAvailableProcessors(), OSUtil.getAvailableProcessors() + 1, 1, TimeUnit.MINUTES, new ArrayBlockingQueue<>(200), new DefaultThreadFactory("UploadVideo"));


    @PreDestroy
    public void shutdown() {
        log.info("準備優雅停止應用 @PreDestroy");
        executorService.shutdown();
    }

}

這裡注意,@PreDestroy 比 DisposableBean 先執行

關閉原理

1、使用kill pid關閉,原始碼很簡單,大家可以看下GracefulShutdown

	private void doShutdown(GracefulShutdownCallback callback) {
		List<Connector> connectors = getConnectors();
		connectors.forEach(this::close);
		try {
			for (Container host : this.tomcat.getEngine().findChildren()) {
				for (Container context : host.findChildren()) {
					while (isActive(context)) {
						if (this.aborted) {
							logger.info("Graceful shutdown aborted with one or more requests still active");
							callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE);
							return;
						}
						Thread.sleep(50);
					}
				}
			}

		}
		catch (InterruptedException ex) {
			Thread.currentThread().interrupt();
		}
		logger.info("Graceful shutdown complete");
		callback.shutdownComplete(GracefulShutdownResult.IDLE);
	}

2、使用端點監控 POST 請求 /actuator/shutdown關閉

因為actuator 都使用了SPI的擴充套件方式,所以我們看下AutoConfiguration,可以看到關鍵點就是ShutdownEndpoint

@Configuration(
    proxyBeanMethods = false
)
@ConditionalOnAvailableEndpoint(
    endpoint = ShutdownEndpoint.class
)
public class ShutdownEndpointAutoConfiguration {
    public ShutdownEndpointAutoConfiguration() {
    }

    @Bean(
        destroyMethod = ""
    )
    @ConditionalOnMissingBean
    public ShutdownEndpoint shutdownEndpoint() {
        return new ShutdownEndpoint();
    }
}

ShutdownEndpoint,為了節省篇幅只留了一點重要的

@Endpoint(
    id = "shutdown",
    enableByDefault = false
)
public class ShutdownEndpoint implements ApplicationContextAware {
     
    @WriteOperation
    public Map<String, String> shutdown() {
        if (this.context == null) {
            return NO_CONTEXT_MESSAGE;
        } else {
            boolean var6 = false;

            Map var1;
            try {
                var6 = true;
                var1 = SHUTDOWN_MESSAGE;
                var6 = false;
            } finally {
                if (var6) {
                    Thread thread = new Thread(this::performShutdown);
                    thread.setContextClassLoader(this.getClass().getClassLoader());
                    thread.start();
                }
            }

            Thread thread = new Thread(this::performShutdown);
            thread.setContextClassLoader(this.getClass().getClassLoader());
            thread.start();
            return var1;
        }
    }
  
      private void performShutdown() {
        try {
            Thread.sleep(500L);
        } catch (InterruptedException var2) {
            Thread.currentThread().interrupt();
        }

        this.context.close();  //這裡才是核心
    }
}

在呼叫了 this.context.close() ,其實就是AbstractApplicationContext 的close() 方法 (重點是其中的doClose())

/**
	 * Close this application context, destroying all beans in its bean factory.
	 * <p>Delegates to {@code doClose()} for the actual closing procedure.
	 * Also removes a JVM shutdown hook, if registered, as it's not needed anymore.
	 * @see #doClose()
	 * @see #registerShutdownHook()
	 */
	@Override
	public void close() {
		synchronized (this.startupShutdownMonitor) {
			doClose(); //重點:銷燬bean 並執行jvm shutdown hook
			// 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
				}
			}
		}
	}

後記

到這裡,關於單機版本的Spring Boot優雅停機就說完了。為什麼說單機?因為大家也能發現,在關閉時,其實只是保證了服務端內部執行緒執行完畢,呼叫方的狀態是沒關注的。

不論是Dubbo還是Cloud 的分散式服務框架,需要關注的是怎麼能在服務停止前,先將提供者在註冊中心進行反註冊,然後在停止服務提供者,這樣才能保證業務系統不會產生各種503、timeout等現象。

好在當前Spring Boot 結合Kubernetes已經幫我們搞定了這一點,也就是Spring Boot 2.3版本新功能Liveness(存活狀態) 和Readiness(就緒狀態)

簡單的提下這兩個狀態:

  • Liveness(存活狀態):Liveness 狀態來檢視內部情況可以理解為health check,如果Liveness失敗就就意味著應用處於故障狀態並且目前無法恢復,這種情況就重啟吧。此時Kubernetes如果存活探測失敗將殺死Container。
  • Readiness(就緒狀態):用來告訴應用是否已經準備好接受客戶端請求,如果Readiness未就緒那麼k8s就不能路由流量過來。

相關文章