服務發現與配置管理高可用最佳實踐

阿里巴巴雲原生發表於2022-01-05

作者:三辰|阿里云云原生微服務基礎架構團隊技術專家,負責 MSE 引擎高可用架構

**本篇是微服務高可用最佳實踐系列分享的開篇,系列內容持續更新中,期待大家的關注。

引言

在開始正式內容之前,先給大家分享一個真實的案例。

某客戶在阿里雲上使用 K8s 叢集部署了許多自己的微服務,但是某一天,其中一臺節點的網路卡發生了異常,最終導致服務不可用,無法呼叫下游,業務受損。我們來看一下這個問題鏈是如何形成的?

  1. ECS 故障節點上執行著 K8s 叢集的核心基礎元件 CoreDNS 的所有 Pod,它沒有打散,導致叢集 DNS 解析出現問題。
  2. 該客戶的服務發現使用了有缺陷的客戶端版本(nacos-client 的 1.4.1 版本),這個版本的缺陷就是跟 DNS 有關——心跳請求在域名解析失敗後,會導致程式後續不會再續約心跳,只有重啟才能恢復。
  3. 這個缺陷版本實際上是已知問題,阿里雲在 5 月份推送了 nacos-client 1.4.1 存在嚴重 bug 的公告,但客戶研發未收到通知,進而在生產環境中使用了這個版本。

風險環環相扣,缺一不可。

最終導致故障的原因是服務無法呼叫下游,可用性降低,業務受損。下圖示意的是客戶端缺陷導致問題的根因:

  1. Provider 客戶端在心跳續約時發生 DNS 異常;
  2. 心跳執行緒正確地處理這個 DNS 異常,導致執行緒意外退出了;
  3. 註冊中心的正常機制是,心跳不續約,30 秒後自動下線。由於 CoreDNS 影響的是整個 K8s 叢集的 DNS 解析,所以 Provider 的所有例項都遇到相同的問題,整個服務所有例項都被下線;
  4. 在 Consumer 這一側,收到推送的空列表後,無法找到下游,那麼呼叫它的上游(比如閘道器)就會發生異常。

回顧整個案例,每一環每個風險看起來發生概率都很小,但是一旦發生就會造成惡劣的影響。

所以,本篇文章就來探討,微服務領域的高可用方案怎麼設計,細化到服務發現和配置管理領域,都有哪些具體的方案。

微服務高可用方案

首先,有一個事實不容改變:沒有任何系統是百分百沒有問題的,所以高可用架構方案就是面對失敗(風險)設計的。

風險是無處不在的,儘管有很多發生概率很小很小,卻都無法完全避免。

在微服務系統中,都有哪些風險的可能?

這只是其中一部分,但是在阿里巴巴內部十幾年的微服務實踐過程中,這些問題全部都遇到過,而且有些還不止一次。雖然看起來坑很多,但我們依然能夠很好地保障雙十一大促的穩定,背後靠的就是成熟穩健的高可用體系建設。

我們不能完全避免風險的發生,但我們可以控制它(的影響),這就是做高可用的本質。

控制風險有哪些策略?

註冊配置中心在微服務體系的核心鏈路上,牽一髮動全身,任何一個抖動都可能會較大範圍地影響整個系統的穩定性。

策略一:縮小風險影響範圍

叢集高可用

多副本: 不少於 3 個節點進行例項部署。

多可用區(同城容災): 將叢集的不同節點部署在不同可用區(AZ)中。當節點或可用區發生的故障時,影響範圍只是叢集其中的一部分,如果能夠做到迅速切換,並將故障節點自動離群,就能儘可能減少影響。

減少上下游依賴

系統設計上應該儘可能地減少上下游依賴,越多的依賴,可能會在被依賴系統發生問題時,讓整體服務不可用(一般是一個功能塊的不可用)。如果有必要的依賴,也必須要求是高可用的架構。

變更可灰度

新版本迭代釋出,應該從最小範圍開始灰度,按使用者、按 Region 分級,逐步擴大變更範圍。一旦出現問題,也只是在灰度範圍內造成影響,縮小問題爆炸半徑。

服務可降級、限流、熔斷

  • 註冊中心異常負載的情況下,降級心跳續約時間、降級一些非核心功能等
  • 針對異常流量進行限流,將流量限制在容量範圍內,保護部分流量是可用的
  • 客戶端側,異常時降級到使用本地快取(推空保護也是一種降級方案),暫時犧牲列表更新的一致性,以保證可用性

如圖,微服務引擎 MSE 的同城雙活三節點的架構,經過精簡的上下游依賴,每一個都保證高可用架構。多節點的 MSE 例項,通過底層的排程能力,會自動分配到不同的可用區上,組成多副本叢集。

策略二:縮短風險發生持續時間

核心思路就是:儘早識別、儘快處理

識別 —— 可觀測

例如,基於 Prometheus 對例項進行監控和報警能力建設。

進一步地,在產品層面上做更強的觀測能力:包括大盤、告警收斂/分級(識別問題)、針對大客戶的保障、以及服務等級的建設。

MSE註冊配置中心目前提供的服務等級是 99.95%,並且正在向 4 個 9(99.99%)邁進。

快速處理 —— 應急響應

應急響應的機制要建立,快速有效地通知到正確的人員範圍,快速執行預案的能力(意識到白屏與黑屏的效率差異),常態化地進行故障應急的演練。

預案是指不管熟不熟悉你的系統的人,都可以放心執行,這背後需要一套沉澱好有含金量的技術支撐(技術厚度)。

策略三:減少觸碰風險的次數

減少不必要的釋出,例如:增加迭代效率,不隨意釋出;重要事件、大促期間進行封網。

從概率角度來看,無論風險概率有多低,不斷嘗試,風險發生的聯合概率就會無限趨近於 1。

策略四:降低風險發生概率

架構升級,改進設計

Nacos2.0,不僅是效能做了提升,也做了架構上的升級:

  1. 升級資料儲存結構,Service 級粒度提升到到 Instance 級分割槽容錯(繞開了 Service 級資料不一致造成的服務掛的問題);
  2. 升級連線模型(長連線),減少對執行緒、連線、DNS 的依賴。

提前發現風險

  1. 這個「提前」是指在設計、研發、測試階段儘可能地暴露潛在風險;
  2. 提前通過容量評估預知容量風險水位是在哪裡;
  3. 通過定期的故障演練提前發現上下游環境風險,驗證系統健壯性。

如圖,阿里巴巴大促高可用體系,不斷做壓測演練、驗證系統健壯性和彈性、觀測追蹤系統問題、驗證限流、降級等預案的可執行性。

服務發現高可用方案

服務發現包含服務消費者(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 三個應用,支援使用配置管理的方式動態地將呼叫關係連線起來,後面我們會實踐到。

基本思路:

  1. 部署服務,調整呼叫關係是閘道器->A->B->C,檢視閘道器呼叫成功率。
  2. 通過模擬網路問題,將應用B與註冊中心的心跳鏈路斷開,模擬註冊異常的發生。
  3. 再次檢視閘道器呼叫成功率,期望服務 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% 附近不再波動。

小結

通過實踐,我們模擬了一次真實的風險發生的場景,並且通過客戶端的高可用方案(推空保護),成功實現了對風險的控制,防止服務呼叫的發生異常。

相關文章