作者:三辰|阿里云云原生微服務基礎架構團隊技術專家,負責 MSE 引擎高可用架構
**本篇是微服務高可用最佳實踐系列分享的開篇,系列內容持續更新中,期待大家的關注。
引言
在開始正式內容之前,先給大家分享一個真實的案例。
某客戶在阿里雲上使用 K8s 叢集部署了許多自己的微服務,但是某一天,其中一臺節點的網路卡發生了異常,最終導致服務不可用,無法呼叫下游,業務受損。我們來看一下這個問題鏈是如何形成的?
- ECS 故障節點上執行著 K8s 叢集的核心基礎元件 CoreDNS 的所有 Pod,它沒有打散,導致叢集 DNS 解析出現問題。
- 該客戶的服務發現使用了有缺陷的客戶端版本(nacos-client 的 1.4.1 版本),這個版本的缺陷就是跟 DNS 有關——心跳請求在域名解析失敗後,會導致程式後續不會再續約心跳,只有重啟才能恢復。
- 這個缺陷版本實際上是已知問題,阿里雲在 5 月份推送了 nacos-client 1.4.1 存在嚴重 bug 的公告,但客戶研發未收到通知,進而在生產環境中使用了這個版本。
風險環環相扣,缺一不可。
最終導致故障的原因是服務無法呼叫下游,可用性降低,業務受損。下圖示意的是客戶端缺陷導致問題的根因:
- Provider 客戶端在心跳續約時發生 DNS 異常;
- 心跳執行緒正確地處理這個 DNS 異常,導致執行緒意外退出了;
- 註冊中心的正常機制是,心跳不續約,30 秒後自動下線。由於 CoreDNS 影響的是整個 K8s 叢集的 DNS 解析,所以 Provider 的所有例項都遇到相同的問題,整個服務所有例項都被下線;
- 在 Consumer 這一側,收到推送的空列表後,無法找到下游,那麼呼叫它的上游(比如閘道器)就會發生異常。
回顧整個案例,每一環每個風險看起來發生概率都很小,但是一旦發生就會造成惡劣的影響。
所以,本篇文章就來探討,微服務領域的高可用方案怎麼設計,細化到服務發現和配置管理領域,都有哪些具體的方案。
微服務高可用方案
首先,有一個事實不容改變:沒有任何系統是百分百沒有問題的,所以高可用架構方案就是面對失敗(風險)設計的。
風險是無處不在的,儘管有很多發生概率很小很小,卻都無法完全避免。
在微服務系統中,都有哪些風險的可能?
這只是其中一部分,但是在阿里巴巴內部十幾年的微服務實踐過程中,這些問題全部都遇到過,而且有些還不止一次。雖然看起來坑很多,但我們依然能夠很好地保障雙十一大促的穩定,背後靠的就是成熟穩健的高可用體系建設。
我們不能完全避免風險的發生,但我們可以控制它(的影響),這就是做高可用的本質。
控制風險有哪些策略?
註冊配置中心在微服務體系的核心鏈路上,牽一髮動全身,任何一個抖動都可能會較大範圍地影響整個系統的穩定性。
策略一:縮小風險影響範圍
叢集高可用
多副本: 不少於 3 個節點進行例項部署。
多可用區(同城容災): 將叢集的不同節點部署在不同可用區(AZ)中。當節點或可用區發生的故障時,影響範圍只是叢集其中的一部分,如果能夠做到迅速切換,並將故障節點自動離群,就能儘可能減少影響。
減少上下游依賴
系統設計上應該儘可能地減少上下游依賴,越多的依賴,可能會在被依賴系統發生問題時,讓整體服務不可用(一般是一個功能塊的不可用)。如果有必要的依賴,也必須要求是高可用的架構。
變更可灰度
新版本迭代釋出,應該從最小範圍開始灰度,按使用者、按 Region 分級,逐步擴大變更範圍。一旦出現問題,也只是在灰度範圍內造成影響,縮小問題爆炸半徑。
服務可降級、限流、熔斷
- 註冊中心異常負載的情況下,降級心跳續約時間、降級一些非核心功能等
- 針對異常流量進行限流,將流量限制在容量範圍內,保護部分流量是可用的
- 客戶端側,異常時降級到使用本地快取(推空保護也是一種降級方案),暫時犧牲列表更新的一致性,以保證可用性
如圖,微服務引擎 MSE 的同城雙活三節點的架構,經過精簡的上下游依賴,每一個都保證高可用架構。多節點的 MSE 例項,通過底層的排程能力,會自動分配到不同的可用區上,組成多副本叢集。
策略二:縮短風險發生持續時間
核心思路就是:儘早識別、儘快處理
識別 —— 可觀測
例如,基於 Prometheus 對例項進行監控和報警能力建設。
進一步地,在產品層面上做更強的觀測能力:包括大盤、告警收斂/分級(識別問題)、針對大客戶的保障、以及服務等級的建設。
MSE註冊配置中心目前提供的服務等級是 99.95%,並且正在向 4 個 9(99.99%)邁進。
快速處理 —— 應急響應
應急響應的機制要建立,快速有效地通知到正確的人員範圍,快速執行預案的能力(意識到白屏與黑屏的效率差異),常態化地進行故障應急的演練。
預案是指不管熟不熟悉你的系統的人,都可以放心執行,這背後需要一套沉澱好有含金量的技術支撐(技術厚度)。
策略三:減少觸碰風險的次數
減少不必要的釋出,例如:增加迭代效率,不隨意釋出;重要事件、大促期間進行封網。
從概率角度來看,無論風險概率有多低,不斷嘗試,風險發生的聯合概率就會無限趨近於 1。
策略四:降低風險發生概率
架構升級,改進設計
Nacos2.0,不僅是效能做了提升,也做了架構上的升級:
- 升級資料儲存結構,Service 級粒度提升到到 Instance 級分割槽容錯(繞開了 Service 級資料不一致造成的服務掛的問題);
- 升級連線模型(長連線),減少對執行緒、連線、DNS 的依賴。
提前發現風險
- 這個「提前」是指在設計、研發、測試階段儘可能地暴露潛在風險;
- 提前通過容量評估預知容量風險水位是在哪裡;
- 通過定期的故障演練提前發現上下游環境風險,驗證系統健壯性。
如圖,阿里巴巴大促高可用體系,不斷做壓測演練、驗證系統健壯性和彈性、觀測追蹤系統問題、驗證限流、降級等預案的可執行性。
服務發現高可用方案
服務發現包含服務消費者(Consumer)和服務提供者(Provider)。
Consumer 端高可用
通過推空保護、服務降級等手段,達到 Consumer 端的容災目的。
推空保護
可以應對開頭講的案例,服務空列表推送自動降級到快取資料。
服務消費者(Consumer)會從註冊中心上訂閱服務提供者(Provider)的例項列表。
當遇到突發情況(例如,可用區斷網,Provider端無法上報心跳) 或 註冊中心(變配、重啟、升降級)出現非預期異常時,都有可能導致訂閱異常,影響服務消費者(Consumer)的可用性。
無推空保護
- Provider 端註冊失敗(比如網路、SDKbug 等原因)
- 註冊中心判斷 Provider 心跳過期
- Consumer 訂閱到空列表,業務中斷報錯
開啟推空保護
- 同上
- Consumer 訂閱到空列表,推空保護生效,丟棄變更,保障業務服務可用
開啟方式
開啟方式比較簡單
開源的客戶端 nacos-client 1.4.2 以上版本支援
配置項
- SpingCloudAlibaba 在 spring 配置項裡增加:
spring.cloud.nacos.discovery.namingPushEmptyProtection=true
- Dubbo 加上 registryUrl 的引數:
namingPushEmptyProtection=true
提空保護依賴快取,所以需要持久化快取目錄,避免重啟後丟失,路徑為:${user.home}/nacos/naming/${namespaceId}
服務降級
Consumer 端可以根據不同的策略選擇是否將某個呼叫介面降級,起到對業務請求流程的保護(將寶貴的下游 Provider 資源保留給重要的業務 Consumer 使用),保護重要業務的可用性。
服務降級的具體策略,包含返回 Null 值、返回 Exception 異常、返回自定義 JSON 資料和自定義回撥。
MSE 微服務治理中心中預設就具備該項高可用能力。
Provider 端高可用
Provider 側通過註冊中心和服務治理提供的容災保護、離群摘除、無損下線等方案提升可用性。
容災保護
容災保護主要用於避免叢集在異常流量下出現雪崩的場景。
下面我們來具體看一下:
無容災保護(預設閾值 =0)
- 突發請求量增加,容量水位較高時,個別 Provider 發生故障;
- 註冊中心將故障節點摘除,全量流量會給剩餘節點;
- 剩餘節點負載變高,大概率也會故障;
- 最後所有節點故障,100% 無法提供服務。
開啟容災保護(閾值=0.6)
- 同上;
- 故障節點數達到保護閾值,流量平攤給所有機器;
- 最終保障 50% 節點能夠提供服務。
容災保護能力,在緊急情況下,能夠儲存服務可用性在一定的水平之上,可以說是整體系統的兜底了。
這套方案曾經救過不少業務系統。
離群例項摘除
心跳續約是註冊中心感知例項可用性的基本途徑。
但是在特定情況下,心跳存續並不能完全等同於服務可用。
因為仍然存在心跳正常,但服務不可用的情況,例如:
- Request 處理的執行緒池滿
- 依賴的 RDS 連線異常或慢 SQL
微服務治理中心提供離群例項摘除
- 基於異常檢測的摘除策略:包含網路異常和網路異常 + 業務異常(HTTP 5xx)
- 設定異常閾值、QPS 下限、摘除比例下限
離群例項摘除的能力是一個補充,根據特定介面的呼叫異常特徵,來衡量服務的可用性。
無損下線
無損下線,又叫優雅下線、或者平滑下線,都是一個意思。首先看什麼是有損下線:
Provider 例項進行升級過程中,下線後心跳在註冊中心存約以及變更生效都有一定的時間,在這個期間 Consumer 端訂閱列表仍然沒有更新到下線後的版本,如果魯莽地將 Provider 停止服務,會造成一部分的流量損失。
無損下線有很多不同的解決方案,但侵入性最低的還是服務治理中心預設提供的能力,無感地整合到釋出流程中,完成自動執行。免去繁瑣的運維指令碼邏輯的維護。
配置管理高可用方案
配置管理主要包含配置訂閱和配置釋出兩類操作。
配置管理解決什麼問題?
多環境、多機器的配置釋出、配置動態實時推送。
基於配置管理做服務高可用
微服務如何基於配置管理做高可用方案?
釋出環境管理
一次管理上百臺機器、多套環境,如何正確無誤地推送、誤操作或出現線上問題如何快速回滾,釋出過程如何灰度。
業務開關動態推送
功能、活動頁面等開關。
容災降級預案的推送
預置的方案通過推送開啟,實時調整流控閾值等。
上圖是大促期間配置管理整體高可用解決方案。比如降級非核心業務、功能降級、日誌降級、禁用高風險操作。
客戶端高可用
配置管理客戶端側同樣有容災方案。
本地目錄分為兩級,高優先順序是容災目錄、低優先順序是快取目錄。
快取目錄: 每次客戶端和配置中心進行資料互動後,會儲存最新的配置內容至本地快取目錄中,當服務端不可用狀態下,會使用本地快取目錄中內容。
容災目錄: 當服務端不可用狀態下,可以在本地的容災目錄中手動更新配置內容,客戶端會優先載入容災目錄下的內容,模擬服務端變更推送的效果。
簡單來說,當配置中心不可用時,優先檢視容災目錄的配置,否則使用之前拉取到的快取。
容災目錄的設計,是因為有時候不一定會有快取過的配置,或者業務需要緊急覆蓋使用新的內容開啟一些必要的預案和配置。
整體思路就是,無法發生什麼問題,無論如何,都要能夠使客戶端能夠讀取到正確的配置,保證微服務的可用性。
服務端高可用
在配置中心側,主要是針對讀、寫的限流。
限制連線數、限制寫:
- 限連線:單機最大連線限流,單客戶端 IP 的連線限流
- 限寫介面:釋出操作&特定配置的秒級分鐘級數量限流
控制操作風險
控制人員做配置釋出的風險。
配置釋出的操作是可灰度、可追溯、可回滾的。
配置灰度
釋出歷史&回滾
變更對比
動手實踐
最後我們一起來做一個實踐。
場景取自前面提到的一個高可用方案,在服務提供者所有機器發生註冊異常的情況下,看服務消費者在推空保護開啟的情況下的表現。
實驗架構和思路
上圖是本次實踐的架構,右側是一個簡單的呼叫場景,外部流量通過閘道器接入,這裡選擇了 MSE 產品矩陣中的雲原生閘道器,依靠它提供的可觀測能力,方便我們觀察服務呼叫情況。
閘道器的下游有 A、B、C 三個應用,支援使用配置管理的方式動態地將呼叫關係連線起來,後面我們會實踐到。
基本思路:
- 部署服務,調整呼叫關係是閘道器->A->B->C,檢視閘道器呼叫成功率。
- 通過模擬網路問題,將應用B與註冊中心的心跳鏈路斷開,模擬註冊異常的發生。
- 再次檢視閘道器呼叫成功率,期望服務 A->B 的鏈路不受註冊異常的影響。
為了方便對照,應用 A 會部署兩種版本,一種是開啟推空保護的,一種是沒有開啟的情況。最終期望的結果是,推空保護開關開啟後,能夠幫助應用 A 在發生異常的情況下,繼續能夠定址到應用B。
閘道器的流量打到應用 A 之後,可以觀察到,介面的成功率應該正好在 50%。
開始
接下來開始動手實踐吧。這裡我選用阿里雲 MSE+ACK 組合做完整的方案。
環境準備
首先,購買好一套 MSE 註冊配置中心專業版,和一套 MSE 雲原生閘道器。這邊不介紹具體的購買流程。
在應用部署前,提前準備好配置。這邊我們可以先配置 A 的下游是 C,B 的下游也是 C。
部署應用
接下來我們基於 ACK 部署三個應用。可以從下面的配置看到,應用 A 這個版本 spring-cloud-a-b
,推空保護開關已經開啟。
這裡 demo 選用的 nacos 客戶端版本是 1.4.2,因為推空保護在這個版本之後才支援。
配置示意(無法直接使用):
# A 應用 base 版本
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: spring-cloud-a
name: spring-cloud-a-b
spec:
replicas: 2
selector:
matchLabels:
app: spring-cloud-a
template:
metadata:
annotations:
msePilotCreateAppName: spring-cloud-a
labels:
app: spring-cloud-a
spec:
containers:
- env:
- name: LANG
value: C.UTF-8
- name: spring.cloud.nacos.discovery.server-addr
value: mse-xxx-nacos-ans.mse.aliyuncs.com:8848
- name: spring.cloud.nacos.config.server-addr
value: mse-xxx-nacos-ans.mse.aliyuncs.com:8848
- name: spring.cloud.nacos.discovery.metadata.version
value: base
- name: spring.application.name
value: sc-A
- name: spring.cloud.nacos.discovery.namingPushEmptyProtection
value: "true"
image: mse-demo/demo:1.4.2
imagePullPolicy: Always
name: spring-cloud-a
ports:
- containerPort: 8080
protocol: TCP
resources:
requests:
cpu: 250m
memory: 512Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: spring-cloud-a
name: spring-cloud-a
spec:
replicas: 2
selector:
matchLabels:
app: spring-cloud-a
template:
metadata:
annotations:
msePilotCreateAppName: spring-cloud-a
labels:
app: spring-cloud-a
spec:
containers:
- env:
- name: LANG
value: C.UTF-8
- name: spring.cloud.nacos.discovery.server-addr
value: mse-xxx-nacos-ans.mse.aliyuncs.com:8848
- name: spring.cloud.nacos.config.server-addr
value: mse-xxx-nacos-ans.mse.aliyuncs.com:8848
- name: spring.cloud.nacos.discovery.metadata.version
value: base
- name: spring.application.name
value: sc-A
image: mse-demo/demo:1.4.2
imagePullPolicy: Always
name: spring-cloud-a
ports:
- containerPort: 8080
protocol: TCP
resources:
requests:
cpu: 250m
memory: 512Mi
# B 應用 base 版本
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: spring-cloud-b
name: spring-cloud-b
spec:
replicas: 2
selector:
matchLabels:
app: spring-cloud-b
strategy:
template:
metadata:
annotations:
msePilotCreateAppName: spring-cloud-b
labels:
app: spring-cloud-b
spec:
containers:
- env:
- name: LANG
value: C.UTF-8
- name: spring.cloud.nacos.discovery.server-addr
value: mse-xxx-nacos-ans.mse.aliyuncs.com:8848
- name: spring.cloud.nacos.config.server-addr
value: mse-xxx-nacos-ans.mse.aliyuncs.com:8848
- name: spring.application.name
value: sc-B
image: mse-demo/demo:1.4.2
imagePullPolicy: Always
name: spring-cloud-b
ports:
- containerPort: 8080
protocol: TCP
resources:
requests:
cpu: 250m
memory: 512Mi
# C 應用 base 版本
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: spring-cloud-c
name: spring-cloud-c
spec:
replicas: 2
selector:
matchLabels:
app: spring-cloud-c
template:
metadata:
annotations:
msePilotCreateAppName: spring-cloud-c
labels:
app: spring-cloud-c
spec:
containers:
- env:
- name: LANG
value: C.UTF-8
- name: spring.cloud.nacos.discovery.server-addr
value: mse-xxx-nacos-ans.mse.aliyuncs.com:8848
- name: spring.cloud.nacos.config.server-addr
value: mse-xxx-nacos-ans.mse.aliyuncs.com:8848
- name: spring.application.name
value: sc-C
image: mse-demo/demo:1.4.2
imagePullPolicy: Always
name: spring-cloud-c
ports:
- containerPort: 8080
protocol: TCP
resources:
requests:
cpu: 250m
memory: 512Mi
部署應用:
在閘道器注冊服務
應用部署好之後,在 MSE 雲原生閘道器中,關聯上 MSE 的註冊中心,並將服務註冊進來。
我們設計的是閘道器只呼叫 A,所以只需要將 A 放進來註冊進來即可。
驗證和調整鏈路
基於 curl 命令驗證一下鏈路:
$ curl http://${閘道器IP}/ip
sc-A[192.168.1.194] --> sc-C[192.168.1.195]
驗證一下鏈路。 可以看到這時候 A 呼叫的是 C,我們將配置做一下變更,實時地將 A 的下游改為 B。
再看一下,這時三個應用的呼叫關係是 ABC,符合我們之前的計劃。
$ curl http://${閘道器IP}/ip
sc-A[192.168.1.194] --> sc-B[192.168.1.191] --> sc-C[192.168.1.180]
接下來,我們通過一段命令,連續地呼叫介面,模擬真實場景下不間斷的業務流量。
$ while true; do sleep .1 ; curl -so /dev/null http://${閘道器IP}/ip ;done
觀測呼叫
通過閘道器監控大盤,可以觀察到成功率。
注入故障
一切正常,現在我們可以開始注入故障。
這裡我們可以使用 K8s 的 NetworkPolicy 的機制,模擬出口網路異常。
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
name: block-registry-from-b
spec:
podSelector:
matchLabels:
app: spring-cloud-b
ingress:
- {}
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- protocol: TCP
port: 8080
這個 8080 埠的意思是,不影響內網呼叫下游的應用埠,只禁用其它出口流量(比如到達註冊中心的 8848 埠就被禁用了)。這裡 B 的下游是 C。
網路切斷後,註冊中心的心跳續約不上,過一會兒(30 秒後)就會將應用 B 的所有 IP 摘除。
再次觀測
再觀察大盤資料庫,成功率開始下降,這時候,在控制檯上已經看不到應用 B 的 IP 了。
回到大盤,成功率在 50% 附近不再波動。
小結
通過實踐,我們模擬了一次真實的風險發生的場景,並且通過客戶端的高可用方案(推空保護),成功實現了對風險的控制,防止服務呼叫的發生異常。