攜程 Service Mesh 可用性實踐

作者簡介

 

本文作者燒魚、Shirley 博,來自攜程 Cloud Container 團隊,目前主要從事 Service Mesh 在攜程的落地,負責控制面的性能優化及可用性建設,以及推進各類基礎設施服務的雲原生化。

一、背景

近幾年,國內各大公司大規模生產落地 Kubernetes 和 Service Mesh,拉開了雲原生革命的序幕。從 2019 年開始,團隊開始在部分場景中落地 Istio Gateway,積累 Service Mesh 經驗;2020 年中,我們開始與公司的框架部門合作着手 Service Mesh 在攜程的落地,目前生產環境已有數百個應用接入,覆蓋率還在持續的推進過程中。

Service Mesh 作爲一項新技術,相比傳統的微服務框架有很多優勢。但在生產環境落地的過程中,若無法保證可用性,出現大的故障,將會大大打擊對新技術採用的信心,也會影響最終用戶,造成對品牌的負面影響。顯而易見,可用性是一切的基石。我們在落地 Service Mesh 的過程投入了大量的精力進行可用性的建設,避免出現單點故障,保證服務的高可用。Service Mesh 在攜程的落地並不是平地起高樓,公司內關於可用性已經有一套方法與模式,Service Mesh 的可用性建設也必須考慮現有的高可用體系。

二、Service Mesh 高可用

2.1 攜程高可用架構簡介

攜程現有的高可用設計自上而下可以簡化爲:IDC 級高可用、IDC 內應用部署的高可用、IDC 內基礎設施的高可用。針對這三個層次的故障,分別有不同的設計,以下主要圍繞 IDC 級及應用部署級可用性設計進行介紹。

2.1.1 IDC 級災備 - 同城雙活

攜程內部應用一般包含多個 Group,Group 是應用發佈的最小單位,同時也是流量調度的單位。一個 DR(容災) 組一般包含兩個 IDC,一個應用的多個 Group 會部署在 DR 組內的多個 IDC,當某一個 IDC 出現故障時,迅速將流量切換到另一個 IDC。其中核心應用多 IDC 部署,數據庫跨 IDC 主備,流量調度層面也要支持跨 IDC 的管控。 

2.1.2 IDC 內高可用 - 應用多集羣部署

隨着容器化在攜程內部大規模推進,絕大部分應用都跑在 Kubernetes 集羣上。應用部署時,將同一個 Group 的實例,打散到多個 Kubernetes 集羣上。然後,各個集羣中的 Operator 與外部的註冊中心交互,將本集羣中的實例信息註冊到對應的 Group 中。單個 Kubernetes 的控制面不可用,只會影響到當前集羣實例的變更。

可見網站的高可用設計就是通過劃分故障域,進行故障隔離,切斷故障的傳導,保證故障時能夠切換或控制故障範圍。數據中心之間故障隔離,保證數據中心級的高可用;數據中心內部 Kubernetes 集羣之間故障隔離,進而保證 Kubernetes 之上的可用性。在 Service Mesh 的高可用設計上也要遵守這些故障隔離的原則。

2.2 Service Mesh 高可用設計

思路上,我們先梳理故障場景,制定目標,然後設計方案,再結合故障場景進行可用性分析,上線之後再通過故障演練進行驗證。

2.2.1 故障場景

1)Service Mesh 數據面故障,可能會導致應用請求異常,從而影響服務質量,大規模的數據面故障可能會導致數據中心級別的故障。

2)Service Mesh 控制面故障,無法給數據面下發最新的配置,數據面可以根據現在有配置提供服務。故障期間,實例 IP 的變化無法下發,路由信息也無法更新,部分服務可能會受影響。

3)目前 Service Mesh 控制面還是以 Kubernetes 爲基礎的,所以 Kubernetes 控制面的故障也會導致 Service Mesh 控制面的故障。

2.2.2 目標制定

1)數據中心級故障隔離。考慮極端的故障場景,數據面大規模故障,控制面長時間無法恢復等,設計上需要將故障控制在單個數據中心內部,防止故障傳導到其他數據中心,從而保證有其他數據中心可以提供服務,可以進行數據中心切換。

2)支持應用多集羣部署架構。從設計上考慮,Service Mesh 的高可用不應該僅僅侷限於現在的應用多集羣部署的架構,而是與應用部署架構解耦,支持多種部署架構。

2.2.3 方案設計

多個數據中心之間隔離,每個數據中心有獨立的控制面,將管理的資源收斂到每個數據中心內部。應用跨數據中心訪問時需要走 Gateway,通過 ServiceEntry 的方式導入其他集羣的 Gateway,由各個數據中心的 Gateway 收斂對外的服務入口,以數據中心爲單位隔離故障。

在數據中心內部,控制面部署到獨立的 Kubernetes 集羣部署作爲 Primary Cluster,應用所在的 Kubernetes 集羣作爲 Remote Cluster,Remote Cluster 上的 Sidecar 共用同一個 Service Mesh 控制面。通過將 Service Mesh 控制面與應用部署的 Kubernetes 的集羣拆分開的方式,可以靈活應對其他的應用多集羣部署架構。

2.2.4 可用性分析

1)當某一個數據中心出現故障時,需要將流量切換到另一個數據中心,與現有架構保持一致。

2)當 Service Mesh 數據面出現故障時,應用可服務的實例數會減少。但是基於 Envoy FailOver 的能力,隨着健康的實例數的減少,也會逐漸將流量轉到其他數據中心的 Gateway 上,服務可用性不會有太大影響。

3)Primary Cluster 出現故障時(包括 Primary Kubernetes 集羣故障和 Service Mesh 控制面故障),無法通過控制面下發新的配置。如果此時該數據中心有服務異常不可用,數據面也會自動 FailOver 其他數據中心,短時間內沒有太大影響。而數據面訪問其他數據中心的服務時,因爲其他數據中心的服務都是收斂到了 Gateway 上,所以並不感知具體的實例信息,即使其他數據中心服務實例信息變化,也不會因爲配置無法下發,導致數據面的訪問出現異常的問題。

4)在數據中心內部,Remote Cluster 出現問題時,只會影響應用的 HPA 和發佈,在 Kubernetes 的高可用層面解決,Service Mesh 層面沒有影響。

2.2.5 故障演練

1)確認是否按照設計預期的方式應對故障。將某一個數據中心內的服務,置成不可用狀態,觀察數據面是否按照預期執行了 FailOver,以及數據面請求的成功率和延遲,是否在預期範圍內。

2)分析演練的效果,挖掘是否存在隱藏的問題點。當某一個數據中心內,服務不可用時,數據面會 FailOver 到其他數據中心的 Gateway 上,此時需要重點檢查是否存在環路,防止請求在多個數據中心的 Gateway 上循環。

三、Service Mesh 自身的可用性提升

宏觀層面的高可用的架構,通常是作爲應對服務故障的兜底措施,可以應對不同級別的災難, 故障,但是數據中心級別的切換影響面較大,不能作爲常規武器,服務自身也要加強可用性建設。Service Mesh 可用性建設還要圍繞使用場景,深度完善可觀察性指標,從而迭代優化提升可用性。

3.1 場景 / 目標

1)控制面運行時,需要支撐大量客戶端的數據面連接,同時需要快速的將配置推送到數據面。

2)故障恢復,由於 Node 故障或者服務自身異常,控制面需要快速啓動提供服務。

3)發佈場景,控制面和數據面的新版本發佈,需要快速灰度和快速回滾的能力。

3.2 確定 xDS 推送指標

梳理配置下發的流程,apply 到 Kubernetes,到控制面處理該事件,並觸發 xDS 的推送,最後數據面 ack 回來。主要是控制面內部的流程比較複雜,社區的版本中針對內部的各個階段都有一些指標衡量,但是缺少一個描述整個下發流程耗時的指標。針對這個問題,內部已有相應的計劃,初步驗證了可行性。

以控制面接收到 Event 的時間做爲起始時間,中間多個事件合併時取最小值,數據面的 ack 爲結束時間。最終就可以得到控制面推送的耗時分佈,從而進行鍼對性優化。

3.3 xDS 推送的可靠性保證

舉例說明,在應用的滾動發佈過程中實例的 IP 都發生了變化,老的實例已經被刪除,但最新的實例信息可能沒有及時推送到數據面,可能導致數據面出現訪問異常。根本問題在於實例刪除的動作和配置下發是異步的,無法保證在實例刪除之前,所有的數據面已經獲取到了該實例的下線信息將流量摘除。在大規模動態擴縮,或者臨界場景時,這種異步和非確定性的方式極易導致大面積的服務不可用。

因此我們需要一個保證實例變更流程可靠的機制。

1)在我們內部使用方式上,Pod 會有一個對應的 WorkloadEntry,然後通過 ServiceEntry 註冊成爲服務。內部 kubelet 擴展了 finalizer 機制,使得 finalizer 不權阻塞對象的刪除,還會阻塞 kubelet 對 Pod 的 Kill 操作。Pod 創建時,會被對應的 Operator 打上 finalizer,所以刪除 Pod 時,Pod 會因爲 Finalizer 的存在而繼續存活,此時還可以提供服務。

2)由內部的 Operator 感知 Pod 變化,更新 WorkloadEntry 的 label,使之被更新爲不可用狀態,不會被任何 ServiceEntry 選中。此時 WorkloadEntry 的 generation 會 + 1 爲 X。

3)然後控制面 watch 到這個事件,會更新內部的 ledger 寫入 WorkloadEntry 的 NamespacedName 以及 generation,並將此時 RootHash 作爲 xDS 的 Nonce 推送給數據面,當數據面 ack 時,控制面會記錄該數據面最新的 ack 的 Nonce。此時可以根據 Nonce 以及 NamespacedName 去 ledger 中查詢已經推送下去的資源的 generation。

4)再由 Operator 調用我們的聚合服務,去查詢所有的控制面中數據面的 WorkloadEntry 的 generation,與 X 對比,如果都大於等於 X,則認爲這個 WorkloadEntry 都下發成功了。如果是推送未完成,Operator 也會進行重試,等待推送成功。

5)確認推送成功之後,Operator 會去刪掉 WorkloadEntry,去除 Pod finalizer,真正的刪除 Pod。

3.4 ServiceEntry/WorkloadEntry 事件處理耗時優化

實踐上,我們通過 ServiceEntry 和 WorkloadEntry 將部署到虛擬機和物理機上的服務導入到 Mesh 中,但是發現一個 namespace 下 5 千 ServiceEntry 和 1 萬 WorkloadEntry 的場景下,事件處理的耗時都在分鐘級,多個應用發佈時,延遲可能會到小時級別,根本無法滿足上線的需求。

深入研究發現 istio 1.7,當一個 ServiceEntry/WorkloadEntry 發生變化時,會觸發 maybeRefreshIndexes 這個方法,方法中會進行遍歷所有的 ServiceEntry 和 WorkloadEntry 進行匹配,重新生成內存的 Map,因此會執行 5000*10000=5 千萬次 workloadLabels.IsSupersetOf()。

func (s *ServiceEntryStore) maybeRefreshIndexes() {
    ...
    wles, err := s.store.List(gvk.WorkloadEntry, model.NamespaceAll)// 10000
    for _, wcfg := range wles {
        ...
        entries := seWithSelectorByNamespace[wcfg.Namespace]
        for _, se := range entries { // 5000
            workloadLabels := labels.Collection{wle.Labels}
            if !workloadLabels.IsSupersetOf(se.entry.WorkloadSelector.Labels) { // 5000 * 10000
                continue
            }
        }
    }
}

解決問題的思路肯定是全量變增量,當 WorkloadEntry 發生變化時,只要遍歷所在 namespace 的 ServiceEntry,那麼循環的次數就下降到了原來的萬分之一,事件處理耗時也得到極大優化,下降到毫秒級。

具體實現上,處理事件的過程中需要更新多個 Map,爲了保證數據的準確性,還是沿用了原有的全局鎖。所以目前也只是完成從全量到增量的優化,但是事件還是串行處理方式,在大規模變更的場景下延遲會被逐步放大,最後的變更推送下去需要很長的時間。目前也在嘗試拓展一個新的 CRD,實現上使用 Sync.Map 分段式鎖,替換原有的全局鎖,通過併發處理的方式來提升事件處理的效率。

熟悉 Kubernetes 和 istio 的同學也會發現,istio 在處理 event 時是並沒有用 Kubernetes Controller Runtime 的方式編程,以 WorkloadEntry 和 ServiceEntry 爲例,當 WorkLoadEntry 變化時,應該去查找關聯的 ServiceEntry,然後觸發 ServiceEntry 的 Reconcile。目前內部也在嘗試通過引入 Controller Runtime 來處理 ServiceEntry,並且調大 MaxConcurrentReconciles,讓控制面支持併發的處理事件,進一步提升推送的時效。

3.5 控制面冷啓動

在我們實踐過程中也發現控制面 ready 之後,沒有及時給連接上來的數據面推送最新的配置,導致數據面無法獲取到最新的實例信息,從而影響了請求的轉發。

深入分析發現,控制面 ready 時只等待 Kubernetes 的 informer 完成 sync,但此時還有很多事件阻塞在內部隊列中,導致控制面在 ready 之後的一段時間內,無法處理最新的事件,影響數據面下發最新的配置。在集羣規模較大或者是 istio 資源較多的時候,尤爲明顯。對此我們增強了控制面啓動流程,阻塞控制面 ready,等待內部的隊列被清空,從而保證可以儘快處理後續的事件。雖然增加了控制面的啓動耗時,但相比於服務的可靠性的提升,這個代價還是值得的。

我們也在 1.10 中引入了 DiscoveryNamespacesFilter,在控制面上忽略一些不需要關心的 namespace,加快事件的處理。控制面冷啓動的過程中,也發現使用 DiscoveryNamespacesFilter 存在潛在的風險,也向 istio 社區提交了 PR https://github.com/istio/istio/pull/36628,目前已經合入。

func (c *Controller) SyncAll() error {
    c.beginSync.Store(true)
    var err *multierror.Error
+    err = multierror.Append(err, c.syncDiscoveryNamespaces())
    err = multierror.Append(err, c.syncSystemNamespace())
    err = multierror.Append(err, c.syncNodes())
    err = multierror.Append(err, c.syncServices())
    return err
}
+func (c *Controller) syncDiscoveryNamespaces() error {
+    var err error
+    if c.nsLister != nil {
+        err = c.opts.DiscoveryNamespacesFilter.SyncNamespaces()
+    }
+    return err
+}

3.6 灰度發佈

內部在不停的優化控制面和數據面,所以也需要經常發佈。隨着應用接入的規模變大,更多的核心的應用接入的,傳統的滾動更新也無法滿足需求,更細力度的灰度發佈也變得尤爲重要。雖然每次發佈都會經過內部多個環境的驗證,最終才上線生產,但是也存在不可控的因素,變更就會帶來風險,所以需要做到可灰度可快速回滾。

Canary 發佈

1)在同一個集羣中部署一組 Canary 的控制面。

2)然後調整 Sidecar 注入策略,將部分 Sidecar 接入 Canary 的控制面。

3)通過自動化的方式進行驗證,需要提前梳理測試的場景。

Canary 之後會再灰度發佈

4)創建一組控制面實例,逐步擴容,逐步接入流量,開始觀察。

5)此時回滾就是直接縮容新實例,數據面會快速重新連接到老版本控制面上,從而快速回滾。

6)觀察穩定之後,逐步縮容老的服務。

四、未來展望

Service Mesh 在流量管控領域具有重大的意義,不僅可拓展性強,還可以統一流量管理模型,統一多語言的異構系統。

未來我們還會持續投入,圍繞可觀察性,進一步提升服務可靠性,優化 xDS 的推送性能,支持內部大規模落地。同時增加與社區的溝通,期待與大家共同成長。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mmyOCqz8gUbqYfWqqkHyww