Dubbo-go優雅上下線設計與實踐

一  背景

1  優雅上下線

在分散式場景下,微服務程序都是以容器的形式存在,在容器排程系統例如 k8s 的支援下執行,容器組 Pod 是 K8S 的最小資源單位。隨著服務的迭代和更新,當新版本上線後,需要針對線上正在執行的服務進行替換,從而釋出新版本。
在穩定生產的過程中,容器排程完全由 k8s 管控,微服務治理由服務框架或者運維人員進行維護和管理。而在釋出新版本,或者擴縮容的場景下,會終止舊的容器例項,並使用新的容器例項進行替換,對於承載高流量的線上生產環境,這個替換過程的銜接一但出現問題,將在短時間內造成大量的錯誤請求,觸發報警甚至影響正常業務。對於體量較大的廠家,釋出過程出現問題所造成的損失會是巨大的。
因此,優雅上下線的訴求被提出。這要求服務框架在擁有穩定服務呼叫能力,傳統服務治理能力的基礎之上,應當提供服務上下線過程中穩定的保障,從而減少運維成本,提高應用穩定性。

2  期望效果

我認為,理想狀態下優雅上下線的效果,是在一個承載大量流量的分散式系統內,所有元件例項都可以隨意地擴容、縮容、滾動更新,在這種情況下需要保證更新過程中穩定的 tps (每秒請求數) 和 rt(請求時延),並且保證不因為上下線造成請求錯誤。再深一步,就是系統的容災能力,在一個或多個節點不可用的情況下,能保證流量的合理排程,從而盡最大能力減少錯誤請求的出現。
  • Dubbo-go 的優雅上下線能力
Dubbo-go 對優雅上下線的探究可以追溯到三年前,早在1.5早期版本,Dubbo-go 就已經擁有優雅下線能力。透過對終止訊號量的監聽,實現反註冊、埠釋放等善後工作,摘除流量,保證客戶端請求的正確響應。
在前一段時間,隨著 Dubbo-go 3.0 的正式發版,我在一條 proposal  issue (dubbo-go issue 1685) [1] 中提到了一些生產使用者比較看重的問題,作為 3.x 版本的發力方向,並邀請大家談論對這些方向的看法,其中使用者呼聲最高的特性就是無損上下線的能力,再次感謝社群的王曉偉同學的貢獻。
經過不斷完善和生產環境測試,目前 Dubbo-go 已擁有該能力,將在後續版本中正式與大家見面。

二  Dubbo-go 優雅上下線實現思路

優雅上下線可以分為三個角度。服務端的上線,服務端的下線,和客戶端的容災策略。這三個角度,保證了生產例項在正常的釋出迭代中,不出現錯誤請求。

1  客戶端負載均衡機制

以 Apache 頂級專案 Dubbo 為典範的微服務架構在這裡就不進行贅述,在分散式場景下,即使在 K8S 內,大多數使用者也會使用第三方註冊元件提供的服務發現能力。站在運維成本、穩定性、以及分層解耦等角度,除非一些特殊情況,很少會直接使用原生 Service 進行服務發現和負載均衡,因此這些能力成為了微服務框架的標配能力。
熟悉 Dubbo 的同學一定了解過,Dubbo 支援多種負載均衡演算法,透過可擴充套件機制整合到框架內。Dubbo-go 亦是如此,針對多例項場景下,可以支援多種負載均衡演算法, 例如 RR,隨機數,柔性負載均衡等等。
下圖摘自 Dubbo 官網

Dubbo-go 的負載均衡元件

Dubbo-go 服務框架擁有一套介面級擴充套件機制,可以根據配置,載入同一組件介面的不同的實現。其中就有隨機演算法負載均衡策略,它是 Dubbo-go 預設的負載均衡演算法。在使用這種演算法進行負載均衡的情況下,所有 provider 都會根據一定的權重策略被隨機選擇。所有的provider 例項都有可能成為下游。
這種較為傳統的負載均衡演算法會帶來隱患,即不會因為之前呼叫的結果,影響到後續呼叫過程中對下游例項的選擇。因此如果有部分下游例項處在上下線階段,造成短暫的服務不可用,所有隨機到該例項的請求均會報錯,在高流量的場景下,會造成巨大損失。

叢集重試策略

下圖摘自Dubbo 官網
Dubbo-go 的叢集重試策略是從 Dubbo 借鑑過來的,預設使用 Failover(故障轉移) 邏輯,當然也有failback,fallfast 等策略,也是依靠了元件可擴充套件能力整合進框架內。
無論是上面提到的負載均衡,還是重試邏輯,都是基於“面向切面程式設計“的思路,構造一個抽象化 invoker 的實現,從而將流量層層向下遊傳遞。對於 Failover 策略,會在負載均衡選擇下游例項的基礎上,增加對錯誤請求的重試邏輯。一旦請求報錯,會選擇下一個 invoker 進行嘗試,直到請求成功,或超過最大請求次數為止。
叢集重試策略只是增加了嘗試的次數,降低了錯誤率,但本質上還是無狀態的,當下遊服務不可用時,會造成災難性的後果。

黑名單機制

黑名單機制是我去年實習,師兄安排做的第一個需求,大致思路很簡單,將請求拋錯的 invoker 對應例項的 ip 地址加入黑名單,後續不再將流量匯入該例項,等過一段時間,嘗試請求它,如果成功就從黑名單中刪除。
這個機制實現邏輯非常簡單,但本質上是將無狀態負載均衡演算法升級為了有狀態的。對於一個不可用的下游例項,一次請求會快速將該例項拉黑,其他請求就會識別出黑名單記憶體在該例項,從而避免了其他的流量。
對於這種策略,在黑名單中保留的超時、嘗試從黑名單移除的策略等,這些變數都應當結合具體場景考慮,本質上就是一個有狀態的故障轉移策略。普適性較強。

P2C 柔性負載均衡演算法

柔性負載均衡演算法是 Dubbo3 生態的一個重要特性,Dubbo-go 社群正在攜手 Dubbo 一同探索和實踐。一些讀者應該在之前 Dubbo-go 3.0 釋出的文章中看過相關介紹。簡單來說,是一個有狀態的,不像黑名單那麼“一刀切”的,考慮變數更廣泛、更全面的一種負載均衡策略,會在 P2C 演算法的基礎之上,考慮各個下游例項的請求時延、機器資源效能等變數,透過一定策略來確定哪個下游例項最合適,而具體策略,將結合具體應用場景,交由感興趣的社群成員來探索,目前是來自位元組的牛學蔚(github@justxuewei) 在負責。 
上述諸多負載均衡策略,都是站在客戶端的角度,盡最大能力讓請求訪問至在健康的例項上。在無損上下線角度來考慮,對於處於釋出階段的不正常工作的例項,可以由客戶端透過合理的演算法和策略,例如黑名單機制來過濾掉。
我認為客戶端負載均衡是通用能力,對無損上下線場景的作用只是錦上添花,並不是的核心要素。究其本質,還是要從被“上下線”的服務端例項來考慮,從而解決根本問題。

2  服務端優雅上線邏輯

相較於客戶端,服務端作為服務的提供者、使用者業務邏輯的實體,在我們討論的場景下邏輯較為複雜。在討論服務端之前,我們還是先重溫一下基礎的服務呼叫模型。

傳統服務呼叫模型

參考 Dubbo 官網給出的架構圖,完成一次服務呼叫,一般需要三個元件:註冊中心,服務端,客戶端。

1. 服務端首先需要暴露服務,監聽埠,從而具備接受請求的能力。

2. 服務端將當前服務資訊例如ip和埠,註冊在中心化的註冊中心上,例如Nacos。

3. 客戶端訪問註冊中心,獲取要呼叫的服務ip和埠,完成服務發現。

4. 服務呼叫,客戶端針對對應 ip 和埠進行請求。
這簡單的四個步驟,就是 Dubbo-go 優雅上下線策略的核心關注點。正常情況下,四個步驟依此執行下來非常順利,邏輯也非常清晰。而放在一個大規模的生產叢集內,在服務上下線時就會出現很多值得考量的細節。
我們要明白,上下線過程中的錯誤是怎麼產生的?我們只需要關注兩個錯誤,就是:“一個請求被髮送給了一個不健康的例項”,以及“正在處理請求的程序被殺死”,上下線過程中幾乎所有的錯誤都是來自於他們。

服務優雅上線邏輯細節

服務上線時,按照上述的步驟,首先要暴露服務,監聽埠。在保證服務提供者可以正常提供服務之後,再將自身資訊註冊在註冊中心上,從而會有來自客戶端的流量傳送至自己的ip。這個順序一定不能亂,否則將會出現服務沒有準備好,就收到了請求的情況,造成錯誤。
上面所說的只是簡單的情況。在真實場景下,我們所說的一個服務端例項,往往包含一組相互依賴的客戶端和服務端。在 Dubbo 生態的配置中,被稱為 Service (服務)和 Reference(引用) 。
舉一個業務同學非常熟悉的例子,在一個服務函式內,會執行一些業務邏輯,並且針對多個下游服務發起呼叫,這些下游可能包含資料庫、快取、或者其他服務提供者,執行完畢後,返回獲得的結果。這對應到 Dubbo 生態的概念中,其實現就是:Service 負責監聽埠和接受請求,接受的請求會向上層轉發至應用業務程式碼,而開發者編寫的業務程式碼會透過客戶端,也就是 Reference,請求下游物件。當然這裡的下游協議有多種,我們只考慮 dubbo 協議棧。
由上面提到這種常見的服務模型,我們可以認為 Service 是 依賴 Reference 的,一個 Service 的所有 Reference 必須都正常工作後,當前 Service 才能正確接受來自上游的服務。這也就推匯出了,Service 應該在 Reference 之後載入,當載入完成所有 Reference 後,保證這些客戶端都可用,再載入 Service,暴露能工作的服務,最後再註冊到註冊中心,喊上游來呼叫。如果反過來,Service 準備好了而 Reference 沒有,則會造成請求錯誤。
因此,服務上線邏輯是  Consumer  載入 -> Provider 載入 -> Registry 服務註冊。
有讀者可能會疑惑,如果 Consumer 依賴當前 例項自己的 Provider 怎麼辦,Dubbo 的實現是可以不走網路直接發起函式呼叫,Go 這邊也可以按照這種思路來處理,不過實現還待開發。這種情況相對較少,更多的還是上述大家熟悉的情況。

3  服務端優雅下線邏輯

相比於服務上線,服務下線需要考慮的點更多一些。我們重新回到上一節提到的服務呼叫模型四步驟:
1. 服務端首先需要暴露服務,監聽埠,從而具備接受請求的能力。

2. 服務端將當前服務資訊例如ip和埠,註冊在中心化的註冊中心上,例如Nacos。

3. 客戶端訪問註冊中心,獲取要呼叫的服務ip和埠,完成服務發現。

4. 服務呼叫,客戶端針對對應 ip 和埠進行請求。
如果一個服務將要下線,則一定要把相關的善後工作做好。現在的線上情況是這樣:客戶端正在源源不斷地給當前例項請求,如果這個時候直接結束當前程序,一方面,將在一瞬間會有大量的 tcp 建立連線失敗,只能寄希望於第一章提到的客戶端負載均衡策略了;另一方面,有大量正在處理的請求被強制丟棄。這很不優雅!所以當例項知道自己要被終止後,首先要做的就是告訴客戶端:“我這個服務要被終止了,快把流量切走”。這體現在實現中,就是把自身的服務資訊從註冊中心刪除。客戶端拿不到當前例項IP後,不會再將請求發過來,這個時候再終止程序才優雅。
上面所說的,也只是簡單的情況。在真實場景之下,客戶端可能並沒有那麼快地把流量切走,並且當前服務手裡還有一大批正在處理的任務,如果貿然終止程序,可以形象地理解成將端在手裡的一盆水撒了一地。
有了這些鋪墊,我們來詳細地聊一聊服務下線的步驟。

優雅下線的使用和觸發

上面的小故事裡面提到,程序首先要知道自己“要被終止”了,從而觸發優雅下線邏輯。這個訊息可以是訊號量,當 k8s 要終止容器程序,會由 kubelet 向程序傳送 SIGTERM 訊號量。在 Dubbo-go 框架內預置了一系列終止訊號量的監聽邏輯,從而在收到終止訊號後,依然能由程序自己來控制自己的行動,也就是執行優雅下線邏輯。
不過有些應用會自己監聽 SIGTERM 訊號處理下線邏輯。比如,關閉 db 連線、清理快取等,尤其是充當接入層的閘道器型別應用,web 容器和 RPC 容器同時存在。這個時候先關閉 web 容器還是先關閉 RPC 容器就顯得尤其最重要。所以 Dubbo-go 允許使用者透過配置internal.signal來控制 signal訊號監聽的時機,並透過 graceful_shutdown.BeforeShutdown()在合適的時機優雅關閉 rpc 容器。同樣,Dubbo-go 也允許使用者在配置中選擇是否啟用新號監聽。

反註冊

上面提到,服務端需要告訴客戶端自己要終止了,這個過程就是透過註冊中心進行反註冊(Unregister)。常見的服務註冊中介軟體,例如 Nacos 、Zookeeper、Polaris 等都會支援服務反註冊,並將刪除動作以事件的形式通知給上游客戶端。客戶端一定是隨時保持對註冊中心的監聽的,能否成功請求與否,很大程度取決於來自注冊中心的訊息有沒有被客戶端及時監聽和作出響應。
在 Dubbo-go 的實現中,客戶端會第一時間拿到刪除事件,將該例項對應 invoker 從快取中刪除。從而保證後續的請求不會再流向該 invoker 對應的下游。

反註冊過程雖然很快,但畢竟是跨越三個元件之間的事情,無法保證瞬間完成。因此便有了下一步:等待客戶端更新。

和後面步驟有些關聯的是,在當前階段只進行反註冊,而不能進行反訂閱,因為在優雅下線執行的過程中,還會有來自自身客戶端向下遊的請求,如果反訂閱,將會無法接收到下游的更新資訊,可能導致錯誤。

等待客戶端更新

服務端在優雅下線邏輯的反註冊執行後,不能快速殺死當前服務,而會阻塞當前優雅下線邏輯一小段時間,這段時間由開發人員配置,預設3s,應該大於從反註冊到客戶端刪除快取的時間。

經過了這段等待更新的時間,服務端就可以認為,客戶端已經沒有新的請求傳送過來了,便可以亮起紅燈,邏輯是拒絕一切新的請求。

等待來自上游的請求完成

這裡還是不能殺死當前程序,這就像自己的手裡還端著那盆水,之前做的只是離開了注水的水龍頭,但並沒有把盆裡的水倒乾淨。因此要做的還是等待,等待當前例項正在處理的,所有來自上游的請求都完成。

服務端會在一層 filter 維護一個併發安全的計數器,記錄所有進入當前例項但未返回的請求數目。優雅下線邏輯會在這時輪詢計數器,一旦計數器歸零,視為再也沒有來自上游的請求了,手裡端著的來自上游的水也就倒乾淨了。

等待自己發出的請求得到響應

走到這一步,整條鏈路中,自己上游的請求都移除乾淨了。但自己往下游發出的請求還是個未知數,此時此刻也許有大量由當前例項發出,但未得到響應的請求。如果這時貿然終止當前程序,會造成不可預知的問題。

因此還是類似於上述的邏輯,服務在客戶端 filter 維護一個執行緒安全的計數器,由優雅下線邏輯來輪詢,等待所有請求都已經返回,計數器歸零,方可完成這一階段的等待。
如果當前例項存在一個客戶端,源源不斷地主動向下游發起請求,計數器可能一直不歸零,那就要依靠這一階段的超時配置,來強行結束這一階段了。

銷燬協議,釋放埠

這時,就可以放心大膽地做最後的工作了,銷燬協議、關閉監聽,釋放埠,反訂閱註冊中心。使用者可能希望在下線邏輯徹底結束後,埠釋放後,執行一些自己的邏輯,所以可以提供給開發者一個回撥介面。

三  優雅上下線的效果

按照上述的介紹,我們在叢集內進行了壓測實驗和模擬上下線實驗。
使用一個 client 例項,5個 proxy 例項,5個 provider 例項,請求鏈路為:
client -> proxy -> provider
因為資源問題,我們選擇讓客戶端保證 5000 tps 的壓力,透過 dubbo-go 的 prometheus 視覺化介面暴露出成功率和錯誤請求計數,之後針對鏈路中游的 proxy 例項和鏈路下游的 provider 例項進行滾動釋出、擴容、縮容、例項刪除等一系列實驗,模擬生產釋出過程。
期間我記錄了很多資料,可以把一個比較明顯的對比展示出來。
不使用優雅上下線邏輯:更新時成功率大幅降低,錯誤數目持續升高,客戶端被迫重啟。
優雅上下線最佳化後:無錯誤請求,成功率保持在100%

四  Dubbo-go 在服務治理能力的展望

Dubbo-go v3.0 從去年年底正式發版,到現在過了一個多月左右的時間,3.0 釋出對我們而言不是大功告成,而是踏上了展望未來的一個新階梯。我們即將釋出 3.1 版本,這一版本將擁有優雅上下線能力。
在 3.0 籌備階段,我有想過如果一款服務框架從傳統設計走向未來,需要一步一步走下來,需要有多個必經之路:從最基本的使用者友好性支援、配置重構、易用性、整合測試、文件建設;到實現傳輸協議(Dubbo3) Triple-go 的跨生態、穩定、高效能、可擴充套件、生產可用;再到我們 3.0 發版之後的 服務治理能力、運維能力、視覺化能力、穩定性,其中就包括了優雅上下線、流量治理、proxyless;再到形成生態,跨生態整合。這樣走,才能一步一個腳印,不斷積累,不斷迭代。
運維能力和服務治理的充實和最佳化,將作為後續版本的重要 Feature ,我們將會進一步完善流量治理、路由、Proxyless Service Mesh、還有文中提到的柔性負載均衡演算法等方面,這些都是今年社群工作的重點。
Dubbo-go 生態,同開發者同在!
[1] https://github.com/apache/dubbo-go/issues/1685

大資料視覺化DataV課程

點選閱讀原文檢視詳情!

相關文章