一言不合就重構
hello,大家好呀,我是小樓。
前段時間不是在忙么,忙的內容之一就是花了點時間重構了一個服務的健康檢查組件,目前已經慢慢在灰度上線,本文就來分享下這次重構之旅,也算作個總結吧。
背景
服務健康檢查簡介
服務健康檢查是應對分布式應用下某些服務節點不健康問題的一種解法。如下圖,消費者調用提供方集群,通常通過注冊中心獲取提供方的地址,根據負載均衡算法選取某臺具體機器發起調用。
假設某臺機器意外宕機,服務消費方不能感知,就會導致流量有損,如果此時有一種檢測服務節點健康狀態并及時剔除的機制,就能大大增加線上服務的穩定性。
原服務健康檢查實現原理
我們是自研的注冊中心,健康檢查也算注冊中心的一部分,其原理很簡單,可分為三個階段:
- 從注冊中心獲取需要檢查的實例(即地址,由ip、port組成)
- 對每個地址發起 TCP 建鏈請求,建鏈成功視為健康
- 對判定為不健康的實例進行摘除,對原不健康現在健康的實例進行恢復,摘除恢復通過調用注冊中心提供的接口實現
當然這是大致流程,還有不少細節,例如獲取探活實例時一些不需要探活的服務會被排除(如一些基礎服務如MySQL、Redis);為了防止網絡抖動導致健康狀態判定有誤,會增加一些判定策略,如連續 N 次建連失敗視為不健康;對不健康實例摘除時也計算了摘除閾值,如一個集群的機器都被判定為不健康,那也不能把它們全摘了,因為此時全摘和不摘差別不大(請求都會報錯),甚至全摘還要承擔風險,考慮集群容量問題,可以設個閾值,如最多只能摘三分之一的機器。
原服務健康檢查存在的問題
1. 容量問題
原組件是物理機時代的產物,當時實例數量并不多,所以最初是單機設計,只部署在一臺物理機上,隨著公司業務發展,實例數量增多,單機達到瓶頸,于是做了一次升級,通過配置文件來指定每個節點的健康檢查任務分片。
2. 容災問題
單機就必然存在宕機風險,即使檢查任務已經做了分片,但是寫在配置中,無法動態調配,當某個節點宕機,則它負責的實例健康檢查就會失效。
3.部署效率問題
部署在物理機且分片是寫在配置中,無論是擴容還是機器過保置換,都要修改配置,人為操作效率太低,而且容易出錯。
4. 新需求支持效率問題
隨著云原生時代的邁進,對健康檢查提出了一些新的需求,例如只探端口的聯通性可能不能代表服務的健康程度,甚至公司內還有一些其他不在注冊中心上的服務也想復用這個健康檢查組件的能力,日益增長的需求同原組件沉重的歷史包袱之間存在著不可調和的矛盾。
5. 迭代過程中的穩定性問題
原組件沒有灰度機制,開發了新功能上線是一把梭,如果出問題,就是個大故障,影響面非常廣。
需要解決這么多問題,如果在原基礎上改,穩定性和效率都非常令人頭疼,于是一個念頭油然而生:重構!
技術方案調研
業界常見服務健康檢查方案
在設計新方案前,我們看看業界對于健康檢查都是怎么做的,從兩個角度展開調研,注冊中心的健康檢查和非注冊中心的健康檢查
注冊中心健康檢查
方案 | 代表產品 | 優點 | 缺點 |
SDK 心跳上報 | Nacos 1.x 臨時實例 | 處理心跳消耗資源過多 | |
SDK 長連接 + 心跳保持 | Nacox 2.x 臨時實例、SofaRegistry、Zookeeper | 感知快 | SDK 實現復雜 |
集中式主動健康檢查 | Nacos 永久實例 | 無需SDK參與,可實現語義探活 | 集中式壓力大時,時延增大 |
非注冊中心健康檢查
K8S 健康檢查 — LivenessProbe
與集中式健康檢查做對比
LivenessProbe | 原健康檢查組件 | |
實現方式 | k8s原生,分布式(sidecar模式) | 自研,集中式 |
檢查發起者 | kubelet,與業務容器在同一物理機 | 集中部署的服務 |
適用范圍 | k8s容器(彈性云) | 容器、物理機、虛擬機等 |
支持的檢查方式 | tcp、http、exec、grpc | tcp、http |
健康檢查基本配置 | 容器啟動延時檢查時間、檢查間隔時間、檢查超時時間、最小連續成功數、最小連續失敗數 | 檢查超時時間、連續失敗次數、最大摘除比例 |
檢測不健康時動作 | 殺死容器,容器再根據重啟策略決定是否重啟 | 從注冊中心上摘除 |
兜底 | 無 | 有,可配摘除比例 |
結合公司背景進行選型
我們的大背景是技術棧不統一,編程語言有 Java、Go、PHP、C++等,基于成本考慮,我們更傾向瘦SDK的方案。
于是注冊中心常見的 SDK 長連接+心跳保持方案被排除,SDK主動上報心跳也不考慮。
而 K8S 的健康檢查方案僅僅使用于 K8S 體系,我們還有物理機,而且 K8S 的 LivenessProbe 并不能做到開箱即用,至少我們不想讓節點不健康時被殺死,兜底策略也需要重新開發。
所以最終我們還是選擇了與原健康檢查組件相同的方案 — 集中式主動健康檢查。
理想態
基于原健康檢查組件在使用中的種種問題,我們總結出一個好的健康檢查組件該有的樣子:
- 故障自動轉移
- 可水平擴容
- 快速支持豐富靈活的需求
- 新需求迭代,本身的穩定性需要有保障
設計開發
總體設計
組件由四大模塊組成:
Dispatcher:負責從數據源獲取數據,生成并派發任務
Prober:負責健康檢查任務的執行
Decider:根據健康檢查結果決策是否需要變更健康狀態
Performer:根據決策結果執行相應動作
各模塊對外暴露接口,隱藏內部實現。數據源面向接口編程,可替換。
服務發現模型
在詳細介紹各個模塊的設計之前,先簡單介紹一下我們的服務發現模型,有助于后續的表述和理解。
一個服務名在公司內是唯一的,調用時需指定服務名,獲取對應的地址。
一個服務又可以包含多個集群,集群可以是物理上的隔離集群,也可以是邏輯上的隔離集群,集群下再包含地址。
協程模型設計
編程語言我們選擇的是 Go,原因有二:第一是健康檢查這種 IO 密集型任務與 Go 的協程調度比較契合,開發速度,資源占用都還可以;第二是我們組一直用 Go,經驗豐富,所以語言選擇我們沒有太多的考慮。
但在協程模型的設計上,我們做了一些思考。
數據源的獲取,由于服務、集群信息不經常變化,所以緩存在內存中,每分鐘進行一次同步,地址數據需要實時拉取。
Dispatcher 先獲取所有的服務,然后根據服務獲取集群,到這里都是在一個協程內完成,接下來獲取地址有網絡開銷,所以開 N 個協程,每個協程負責一部分集群地址,每個地址都生成一個單獨的任務,派發給 Prober。
Prober 負責健康檢查,完全是 IO 操作,內部用一個隊列存放派發來的任務,然后開很多協程從隊列中取任務去做健康檢查,檢查完成后將結果交給 Decider 做決策。
Decider 決策時比較重要的是需要算出是否會被兜底,這里有兩點需要考慮:
一是最初獲取的實例狀態可能不是最新了,需要重新獲取一次;
二是對于同一個集群不能并發地去決策,決策應該串行才不會導致決策混亂,舉個反例,如果一個集群3臺機器,最多摘除1臺,如果2臺同時掛掉,并發決策時,2個協程各自以為能摘,最后結果是摘除了2臺,和預期只摘1臺不符。這個如何解決?我們最后搞了 N 個隊列存放健康檢查結果,按服務+集群的哈希值路由到隊列,保證每個集群的檢測結果都路由到同一個隊列,再開 N 個協程,每個協程消費一個隊列,這樣就做到了順序執行。
決策之后的動作執行就是調用更新接口,所以直接共用決策的協程。用一張大圖來總結:
水平擴容 & 故障自動轉移
水平擴容與故障自動轉移只要能做到動態地數據分片即可,每個健康檢查組件在啟動時將自己注冊到一個中心的協調器(可以是 etcd),并且監聽其他節點的在線狀態,派發任務時,按服務名哈希,判斷該任務是否應該由自己調度,是則執行,否則丟棄。
當某個節點掛掉或者擴容時,其他節點都能感知到當前集群的變化,自動進行數據分片的重新劃分。
小流量機制
小流量的實現采取部署兩個集群的方式,一個正常集群,一個小流量集群,小流量集群負責部分不重要的服務,作為灰度,正常集群負責其他服務的健康檢查任務。
只需要共享一個小流量的配置即可,我們按組織、服務、集群、環境等維度去設計這個配置,基本可以任意粒度配置。
可擴展性
可擴展性也是設計里非常重要的一環,可從數據源、檢查方式擴展、過濾器等方面稍微談一談。
數據源可插拔
面向接口編程,我們將數據源抽象為讀數據源與寫數據源,只要符合這兩個接口的數據源,就能無縫對接。
檢查方式易擴展
健康檢查其實就是給定一個地址,再加一堆配置去進行檢查,至于怎么檢查可以自己實現,目前已實現的有TCP、HTTP方式,未來還可能會實現諸如Dubbo、gRPC、thrift等語義級別的檢查方式。
過濾器
在派發任務時,有一個可能會隨時修改的邏輯是過濾掉一些不需要檢查的服務、集群、實例,這塊用責任鏈的模式就能很好地實現,后期想增刪就只需要插拔鏈中的一環即可。
可擴展性是代碼層面的內容,所以這里只列舉了部分比較典型的例子。
灰度上線
由于我們是重寫了一個組件來代替原組件,需要平滑地替換舊系統,為此我們做了2方面的工作:
設計了一個可按組織、服務、集群、環境等維度的降級開關,降級分為3檔,不降級、半降級、全降級。不降級很好理解,就是啥正常工作,全降級是雖然檢查,但是不摘除也不恢復,相當于空跑,半降級是只恢復健康但不摘除。試想如果健康檢查在上線過程中,誤摘除,此時降級,豈不是無法恢復健康?所以我們讓它保留恢復能力。
我們利用上述的小流量設計來逐步將服務遷移到新組件上來,灰度的服務新組件負責,非灰度的服務老組件負責,等全部灰度完成,停掉老組件,新組件的灰度集群再切換為正常集群。
踩坑調優
在灰度過程中,我們發現了一個問題,有的集群機器非常多,超過了1000臺,而我們的決策是順序執行,而且決策有時還會去實時查詢實例狀態,假設平均每次決策10ms,1000臺順序決策完也得10s,我們期望每輪的檢測要在3秒左右完成,光這一個集群就得10秒,顯然不能接受。
為了我們做了第一次的優化:
我們當時在線上環境測試,一個集群有2000多臺機器,但大部分機器是禁用的狀態,也就是這部分機器其實做健康檢查是個無用功,禁用的機器,無論是否健康都不會被消費,所以我們的第一個優化便是在派發任務時過濾掉禁用的機器,這樣就解決了線下環境的問題。
但我們上到生產環境時仍然發現決策很慢,線上一個集群只有少量的機器被禁用,第一次的優化基本就沒什么效果了,而且線上機器數量可能更多,任務堆積會很嚴重,我們發現其他的隊列可能比較空閑,只有大集群所在的隊列很忙。
所以我們進行了第二次優化:
從業務視角出發,其實需要順序決策的只有不健康的實例,對于健康的實例決策時不需要考慮兜底,所以我們將按檢查結果進行分類,健康的檢查結果隨機派發到任意隊列處理,不健康的檢查結果嚴格按服務+集群路由到特定隊列處理,這樣既保證了兜底決策時的順序,也解決了隊列負載不均衡的狀況。
總結
本文從健康檢查的背景,原組件存在的問題,以及我們的理想態出發,調研了業界的方案,結合實際情況,選擇了適合的方案,并總結之前系統的問題,設計一個更加合理的新系統,從開發閉環到上線。
我覺得系統設計是一個取舍的過程,別人的方案不見得是最優的,適合的才是最好的,而且有時并不是純技術解決問題,可能從業務角度去思考,可能更加豁然開朗。