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