1 前言
一年之前,我(老徐)曾經(jīng)寫過一篇《研究優(yōu)雅停機時的一點思考》,主要介紹了 kill -9,kill -15 兩個 linux 指令的含義,并且針對性的聊到了 Spring Boot 應用如何正確的優(yōu)雅停機,算是本文的前置文章,如果你對上述概念不甚了解,建議先去瀏覽一遍,再回頭來看這篇文章。這篇文章將會以 Dubbo 為例,既聊架構設計,也聊源碼,聊聊服務治理框架要真正實現(xiàn)優(yōu)雅停機,需要注意哪些細節(jié)。
本文的寫作思路是從 Dubbo 2.5.x 開始,圍繞優(yōu)雅停機這個優(yōu)化點,一直追溯到最新的 2.7.x。先對 Dubbo 版本做一個簡單的科普:2.7.x 和 2.6.x 是目前官方推薦使用的版本,其中 2.7.x 是捐獻給 Apache 的版本,具備了很多新的特性,目前最新的 release 版本是 2.7.4,處于生產(chǎn)基本可用的狀態(tài);2.6.x 處于維護態(tài),主要以 bugfix 為主,但經(jīng)過了很多公司線上環(huán)境的驗證,所以求穩(wěn)的話,可以使用 2.6.x 分支最新的版本。至于 2.5.x,社區(qū)已經(jīng)放棄了維護,并且 2.5.x 存在一定數(shù)量的 bug,本文介紹的 Dubbo 優(yōu)雅停機特性便體現(xiàn)了這一點。
優(yōu)雅停機一直是一個非常嚴謹?shù)脑掝},但由于其僅僅存在于重啟、下線這樣的部署階段,導致很多人忽視了它的重要性,但沒有它,你永遠不能得到一個完整的應用生命周期,永遠會對系統(tǒng)的健壯性持懷疑態(tài)度。
同時,優(yōu)雅停機又是一個龐大的話題
- 操作系統(tǒng)層面,提供了 kill -9 (SIGKILL)和 kill -15(SIGTERM) 兩種停機策略
- 語言層面,JAVA 應用有 JVM shutdown hook 這樣的概念
- 框架層面,Spring Boot 提供了 actuator 的下線 endpoint,提供了 ContextClosedEvent 事件
- 容器層面,Docker :當執(zhí)行 docker stop 命令時,容器內(nèi)的進程會收到 SIGTERM 信號,那么 Docker Daemon 會在 10s 后,發(fā)出 SIGKILL 信號;K8S 在管理容器生命周期階段中提供了 prestop 鉤子方法
- 應用架構層面,不同架構存在不同的部署方案。單體式應用中,一般依靠 Nginx 這樣的負載均衡組件進行手動切流,逐步部署集群;微服務架構中,各個節(jié)點之間有復雜的調用關系,上述這種方案就顯得不可靠了,需要有自動化的機制。
為避免該話題過度發(fā)散,本文的重點將會集中在框架和應用架構層面,探討以 Dubbo 為代表的微服務架構在優(yōu)雅停機上的最佳實踐。Dubbo 的優(yōu)雅下線主要依賴于注冊中心組件,由其通知消費者摘除下線的節(jié)點,如下圖所示:
上述的操作旨在讓服務消費者避開已經(jīng)下線的機器,但這樣就算實現(xiàn)了優(yōu)雅停機了嗎?似乎還漏掉了一步,在應用停機時,可能還存在執(zhí)行到了一半的任務,試想這樣一個場景:一個 Dubbo 請求剛到達提供者,服務端正在處理請求,收到停機指令后,提供者直接停機,留給消費者的只會是一個沒有處理完畢的超時請求。
結合上述的案例,我們總結出 Dubbo 優(yōu)雅停機需要滿足兩點基本訴求:
- 服務消費者不應該請求到已經(jīng)下線的服務提供者
- 在途請求需要處理完畢,不能被停機指令中斷
優(yōu)雅停機的意義:應用的重啟、停機等操作,不影響業(yè)務的連續(xù)性。
3 優(yōu)雅停機初始方案 — 2.5.x
為了讓讀者對 Dubbo 的優(yōu)雅停機有一個最基礎的理解,我們首先研究下 Dubbo 2.5.x 的版本,這個版本實現(xiàn)優(yōu)雅停機的方案相對簡單,容易理解。
3.1 入口類:AbstractConfig
public abstract class AbstractConfig implements Serializable { static { Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { public void run() { ProtocolConfig.destroyAll(); } }, "DubboShutdownHook")); } }
在 AbstractConfig 的靜態(tài)塊中,Dubbo 注冊了一個 shutdown hook,用于執(zhí)行 Dubbo 預設的一些停機邏輯,繼續(xù)跟進 ProtocolConfig.destroyAll() 。
3.2 ProtocolConfig
public static void destroyAll() { if (!destroyed.compareAndSet(false, true)) { return; } AbstractRegistryFactory.destroyAll(); // ①注冊中心注銷 // Wait for registry notification try { Thread.sleep(ConfigUtils.getServerShutdownTimeout()); // ② sleep 等待 } catch (InterruptedException e) { logger.warn("Interrupted unexpectedly when waiting for registry notification during shutdown process!"); } ExtensionLoader<Protocol> loader = ExtensionLoader.getExtensionLoader(Protocol.class); for (String protocolName : loader.getLoadedExtensions()) { try { Protocol protocol = loader.getLoadedExtension(protocolName); if (protocol != null) { protocol.destroy(); // ③協(xié)議/流程注銷 } } catch (Throwable t) { logger.warn(t.getMessage(), t); } } }
Dubbo 中的 Protocol 這個詞不太能望文生義,它一般被翻譯為”協(xié)議”,但我更習慣將它理解為“流程”,從 Protocol 接口的三個方法反而更加容易理解。
public interface Protocol { <T> Exporter<T> export(Invoker<T> invoker) throws RpcException; <T> Invoker<T> refer(Class<T> type, URL url) throws RpcException; void destroy(); }
它定義了暴露、訂閱、注銷這三個生命周期方法,所以不難理解為什么 Dubbo 會把 shutdown hook 觸發(fā)后的注銷方法定義在 ProtocolConfig 中了。
回到 ProtocolConfig 的源碼中,我把 ProtocolConfig 中執(zhí)行的優(yōu)雅停機邏輯分成了三部分,其中第 1,2 部分和注冊中心(Registry)相關,第 3 部分和協(xié)議/流程(Protocol)相關,分成下面的 3.3 和 3.4 兩部分來介紹。
3.3 注冊中心注銷邏輯
public abstract class AbstractRegistryFactory implements RegistryFactory { public static void destroyAll() { LOCK.lock(); try { for (Registry registry : getRegistries()) { try { registry.destroy(); } catch (Throwable e) { LOGGER.error(e.getMessage(), e); } } REGISTRIES.clear(); } finally { // Release the lock LOCK.unlock(); } } }
這段代碼對應了 3.2 小節(jié) ProtocolConfig 源碼的第 1 部分,代表了注冊中心的注銷邏輯,更深一層的源碼不需要 debug 進去了,大致的邏輯就是刪除掉注冊中心中本節(jié)點對應的服務提供者地址。
// Wait for registry notification try { Thread.sleep(ConfigUtils.getServerShutdownTimeout()); } catch (InterruptedException e) { logger.warn("Interrupted unexpectedly when waiting for registry notification during shutdown process!"); }
這段代碼對應了 3.2 小節(jié) ProtocolConfig 源碼的第 2 部分,ConfigUtils.getServerShutdownTimeout() 默認值是 10s,為什么需要在 shutdown hook 中等待 10s 呢?在注釋中可以發(fā)現(xiàn)這段代碼的端倪,原來是為了給服務消費者一點時間,確保等到注冊中心的通知。10s 顯然是一個經(jīng)驗值,這里也不妨和大家探討一下,如何穩(wěn)妥地設置這個值呢?
- 設置的過短。由于注冊中心通知消費者取消訂閱某個地址是異步通知過去的,可能消費者還沒收到通知,提供者這邊就停機了,這就違背了我們的訴求 1:服務消費者不應該請求到已經(jīng)下線的服務提供者。
- 設置的過長。這會導致發(fā)布時間變長,帶來不必要的等待。
兩個情況對比下,起碼可以得出一個實踐經(jīng)驗:如果拿捏不準等待時間,盡量設置一個寬松的一點的等待時間。
這個值主要取決三點因素:
- 集群規(guī)模的大小。如果只有幾個服務,每個服務只有幾個實例,那么再弱雞的注冊中心也能很快的下發(fā)通知。
- 注冊中心的選型。以 Naocs 和 Zookeeper 為例,同等規(guī)模服務實例下 Nacos 在推送地址方面的能力遠超 Zookeeper。
- 網(wǎng)絡狀況。服務提供者和服務消費者與注冊中心的交互邏輯走的 TCP 通信,網(wǎng)絡狀況也會影響到推送時間。
所以需要根據(jù)實際部署場景測量出最合適的值。
3.4 協(xié)議/流程注銷邏輯
ExtensionLoader<Protocol> loader = ExtensionLoader.getExtensionLoader(Protocol.class); for (String protocolName : loader.getLoadedExtensions()) { try { Protocol protocol = loader.getLoadedExtension(protocolName); if (protocol != null) { protocol.destroy(); } } catch (Throwable t) { logger.warn(t.getMessage(), t); } }
這段代碼對應了 3.2 小節(jié) ProtocolConfig 源碼的第 3 部分,在運行時,loader.getLoadedExtension(protocolName) 這段代碼會加載到兩個協(xié)議 :DubboProtocol 和 Injvm 。后者 Injvm 實在沒啥好講的,主要來分析一下 DubboProtocol 的邏輯。
DubboProtocol 實現(xiàn)了我們前面提到的 Protocol 接口,它的 destory 方法是我們重點要看的。
public class DubboProtocol extends AbstractProtocol { public void destroy() { for (String key : new ArrayList<String>(serverMap.keySet())) { ExchangeServer server = serverMap.remove(key); if (server != null) { server.close(ConfigUtils.getServerShutdownTimeout()); } } for (String key : new ArrayList<String>(referenceClientMap.keySet())) { ExchangeClient client = referenceClientMap.remove(key); if (client != null) { client.close(ConfigUtils.getServerShutdownTimeout()); } } for (String key : new ArrayList<String>(ghostClientMap.keySet())) { ExchangeClient client = ghostClientMap.remove(key); if (client != null) { client.close(ConfigUtils.getServerShutdownTimeout()); } } stubServiceMethodsMap.clear(); super.destroy(); } }
主要分成了兩部分注銷邏輯:server 和 client,注意這里是先注銷了服務提供者后,再注銷了服務消費者,這樣做是有意為之。在 RPC 調用中,經(jīng)常是一個遠程調用觸發(fā)一個遠程調用,所以在關閉一個節(jié)點時,應該先切斷上游的流量,所以這里是先注銷了服務提供者,這樣從一定程度上,降低了后面服務消費者被調用到的可能性(當然,服務消費者也有可能被單獨調用到)。由于 server 和 client 的流程類似,所以我只選取了 server 部分來分析具體的注銷邏輯。
public void close(final int timeout) { startClose(); if (timeout > 0) { final long max = (long) timeout; final long start = System.currentTimeMillis(); if (getUrl().getParameter(Constants.CHANNEL_SEND_READONLYEVENT_KEY, true)) { // 如果注冊中心有延遲,會立即受到readonly事件,下次不會再調用這臺機器,當前已經(jīng)調用的會處理完 sendChannelReadOnlyEvent(); } while (HeaderExchangeServer.this.isRunning() // ① && System.currentTimeMillis() - start < max) { try { Thread.sleep(10); } catch (InterruptedException e) { logger.warn(e.getMessage(), e); } } } doClose(); // ② server.close(timeout); // ③ } private boolean isRunning() { Collection<Channel> channels = getChannels(); for (Channel channel : channels) { if (DefaultFuture.hasFuture(channel)) { return true; } } return false; } private void doClose() { if (!closed.compareAndSet(false, true)) { return; } stopHeartbeatTimer(); try { scheduled.shutdown(); } catch (Throwable t) { logger.warn(t.getMessage(), t); } }
化繁為簡,這里只挑選上面代碼中打標的兩個地方進行分析
- 判斷服務端是否還在處理請求,在超時時間內(nèi)一直等待到所有任務處理完畢
- 關閉心跳檢測
- 關閉 NettyServer
特別需要關注第一點,正符合我們在一開始提出的優(yōu)雅停機的訴求 2:“在途請求需要處理完畢,不能被停機指令中斷”。
3.5 優(yōu)雅停機初始方案總結
上述介紹的幾個類構成了 Dubbo 2.5.x 的優(yōu)雅停機方案,簡單做一下總結,Dubbo 的優(yōu)雅停機邏輯時序如下:
Registry 注銷 等待 -Ddubbo.service.shutdown.wait 秒,等待消費方收到下線通知 Protocol 注銷 DubboProtocol 注銷 NettyServer 注銷 等待處理中的請求完畢 停止發(fā)送心跳 關閉 Netty 相關資源 NettyClient 注銷 停止發(fā)送心跳 等待處理中的請求完畢 關閉 Netty 相關資源
Dubbo 2.5.3 優(yōu)雅停機的缺陷
如果你正在使用的 Dubbo 版本 <= 2.5.3,一些并發(fā)問題和代碼缺陷會導致你的應用不能很好的實現(xiàn)優(yōu)雅停機功能,請盡快升級。
詳情可以參考該 pull request 的變更:https://github.com/apache/dubbo/pull/568
4 Spring 容器下 Dubbo 的優(yōu)雅停機
上述的方案在不使用 Spring 時的確是無懈可擊的,但由于現(xiàn)在大多數(shù)開發(fā)者選擇使用 Spring 構建 Dubbo 應用,上述的方案會存在一些缺陷。
由于 Spring 框架本身也依賴于 shutdown hook 執(zhí)行優(yōu)雅停機,并且與 Dubbo 的優(yōu)雅停機會并發(fā)執(zhí)行,而 Dubbo 的一些 Bean 受 Spring 托管,當 Spring 容器優(yōu)先關閉時,會導致 Dubbo 的優(yōu)雅停機流程無法獲取相關的 Bean,從而優(yōu)雅停機失效。
Dubbo 開發(fā)者們迅速意識到了 shutdown hook 并發(fā)執(zhí)行的問題,開始了一系列的補救措施。
4.1 增加 ShutdownHookListener
Spring 如此受歡迎的原因之一便是它的擴展點非常豐富,例如它提供了 ApplicationListener 接口,開發(fā)者可以實現(xiàn)這個接口監(jiān)聽到 Spring 容器的關閉事件,為解決 shutdown hook 并發(fā)執(zhí)行的問題,在 Dubbo 2.6.3 中新增了 ShutdownHookListener 類,用作 Spring 容器下的關閉 Dubbo 應用的鉤子。
private static class ShutdownHookListener implements ApplicationListener { @Override public void onApplicationEvent(ApplicationEvent event) { if (event instanceof ContextClosedEvent) { // we call it anyway since dubbo shutdown hook make sure its destroyAll() is re-entrant. // pls. note we should not remove dubbo shutdown hook when spring framework is present, this is because // its shutdown hook may not be installed. DubboShutdownHook shutdownHook = DubboShutdownHook.getDubboShutdownHook(); shutdownHook.destroyAll(); } } }
當服務提供者 ServiceBean 和服務消費者 ReferenceBean 被初始化時,會觸發(fā)該鉤子被創(chuàng)建。
再來看看 AbstractConfig 中的代碼,依舊保留了 JVM 的 shutdown hook
public abstract class AbstractConfig implements Serializable { static { Runtime.getRuntime().addShutdownHook(DubboShutdownHook.getDubboShutdownHook()); } }
也就是說,在 Spring 環(huán)境下會注冊兩個鉤子,在 Non-Spring 環(huán)境下只會有一個鉤子,但看到 2.6.x 的實現(xiàn)大家是否意識到了兩個問題呢?
- 兩個鉤子并發(fā)執(zhí)行不會報錯嗎?
- 為什么在 Spring 下不取消 JVM 的鉤子,只保留 Spring 的鉤子不就可以工作了嗎?
先解釋第一個問題,這個按照我的理解,這段代碼的 Commiter 可能認為只需要有一個 Spring 的鉤子能正常注銷就完事了,不需要考慮另外一個報不報錯,因為都是獨立的線程,不會有很大的影響。
再解釋第二個問題,其實這個疑問的答案就藏在上面 ShutdownHookListener 代碼的注釋中,這段注釋的意思是說:在 Spring 框架下不能直接移除原先的 JVM 鉤子,因為 Spring 框架可能沒有注冊 ContextClosed 事件。啥意思呢?這里涉及到 Spring 框架生命周期的一個細節(jié),我打算單獨介紹一下。
4.2 Spring 的容器關閉事件詳解
在 Spring 中,我們可以使用至少三種方式來注冊容器關閉時一些收尾工作:
- 使用 DisposableBean 接口
public class TestDisposableBean implements DisposableBean { @Override public void destroy() throws Exception { System.out.println("== invoke DisposableBean =="); } }
- 使用 @PreDestroy 注解
public class TestPreDestroy { @PreDestroy public void preDestroy(){ System.out.println("== invoke preDestroy =="); } }
- 使用 ApplicationListener 監(jiān)聽 ContextClosedEvent
applicationContext.addApplicationListener(new ApplicationListener<ApplicationEvent>() { @Override public void onApplicationEvent(ApplicationEvent applicationEvent) { if (applicationEvent instanceof ContextClosedEvent) { System.out.println("== receive context closed event =="); } } });
但需要注意的是,在使用 SpringBoot 內(nèi)嵌 Tomcat 容器時,容器關閉鉤子是自動被注冊,但使用純粹的 Spring 框架或者外部 Tomcat 容器,需要顯式的調用 context.registerShutdownHook(); 接口進行注冊
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring/beans.xml"); context.start(); context.registerShutdownHook(); context.addApplicationListener(new ApplicationListener<ApplicationEvent>() { @Override public void onApplicationEvent(ApplicationEvent applicationEvent) { if (applicationEvent instanceof ContextClosedEvent) { System.out.println("== receive context closed event =="); } } });
否則,上述三種回收方法都無法工作。我們來看看 registerShutdownHook() 都干了啥
public abstract class AbstractApplicationContext extends DefaultResourceLoader implements ConfigurableApplicationContext, DisposableBean{ @Override public void registerShutdownHook() { if (this.shutdownHook == null) { // No shutdown hook registered yet. this.shutdownHook = new Thread() { @Override public void run() { synchronized (startupShutdownMonitor) { doClose(); } } }; Runtime.getRuntime().addShutdownHook(this.shutdownHook); // 重點! } } }
其實也就是顯式注冊了一個屬于 Spring 的鉤子。這也解釋上了 4.1 小節(jié)中,為什么有那段注釋了,注冊了事件不一定管用,還得保證 Spring 容器注冊了它自己的鉤子。
4.3 Dubbo 優(yōu)雅停機中級方案總結
第 4 節(jié)主要介紹了 Dubbo 開發(fā)者們在 Spring 環(huán)境下解決 Dubbo 優(yōu)雅停機并發(fā)執(zhí)行 shutdown hook 時的缺陷問題,但其實還不完善,因為在 Spring 環(huán)境下,如果沒有顯式注冊 Spring 的 shutdown, 還是會存在缺陷的,準確的說,Dubbo 2.6.x 版本可以很好的在 Non-Spring、Spring Boot、Spring + ContextClosedEvent 環(huán)境下很好的工作。
5 Dubbo 2.7 最終方案
public class SpringExtensionFactory implements ExtensionFactory { public static void addApplicationContext(ApplicationContext context) { CONTEXTS.add(context); if (context instanceof ConfigurableApplicationContext) { ((ConfigurableApplicationContext) context).registerShutdownHook(); DubboShutdownHook.getDubboShutdownHook().unregister(); } BeanFactoryUtils.addApplicationListener(context, SHUTDOWN_HOOK_LISTENER); } }
這段代碼寥寥數(shù)行,卻是經(jīng)過了深思熟慮之后的產(chǎn)物,期間迭代了 3 個大版本,真是不容易。這段代碼很好地解決了第 4 節(jié)提出的兩個問題
- 擔心兩個鉤子并發(fā)執(zhí)行有問題?那就在可以注冊 Spring 鉤子的時候取消掉 JVM 的鉤子。
- 擔心當前 Spring 容器沒有注冊 Spring 鉤子?那就顯示調用 registerShutdownHook 進行注冊。
其他細節(jié)方面的優(yōu)化和 bugfix 我就不進行詳細介紹了,可以見得實現(xiàn)一個優(yōu)雅停機需要考慮的點非常之多。
6 總結
優(yōu)雅停機看似是一個不難的技術點,但在一個通用框架中,使用者的業(yè)務場景類型非常多,這會大大加劇整個代碼實現(xiàn)的復雜度。
摸清楚整個 Dubbo 優(yōu)雅停機演化的過程,也著實花費了我一番功夫,有很多實現(xiàn)需要 checkout 到非常古老的分支,同時翻閱了很多 issue、pull request 的討論,最終才形成了這篇文章,雖然研究的過程是困難的,但獲取到真相是讓人喜悅的。
在開源產(chǎn)品的研發(fā)過程中,服務到每一個類型的用戶真的是非常難的一件事,能做的是滿足大部分用戶。例如 2.6.x 在大多數(shù)環(huán)境下其實已經(jīng)沒問題了,在 2.7.x 中則是得到了更加的完善,但是我相信,在使用 Dubbo 的部分用戶中,可能還是會存在優(yōu)雅停機的問題,只不過還沒有被發(fā)現(xiàn)。
商業(yè)化的思考:和開源產(chǎn)品一樣,商業(yè)化產(chǎn)品的研發(fā)也同樣是一個逐漸迭代的過程,需要數(shù)代開發(fā)者一起維護一份代碼,使用者發(fā)現(xiàn)問題,開發(fā)者修復問題,這樣的正反饋可以形成一個正反饋,促使產(chǎn)品更加優(yōu)秀。
喜歡的點個關注,一起學習探討新技術。