淺談 istio 配置下發(下篇,istio 處理來自 k8s 的配置)

spacewander發表於2024-10-27

上篇,我們講到了 istio 和底下的 Envoy 之間的互動過程。本文則更上一層樓,看看上層部分,istio 和 k8s 是如何互動的。

istio 不僅支援從 k8s 中獲取配置,還支援透過 MCP over XDS 從實現了 MCP 協議的伺服器中獲取配置,抑或從檔案中直接獲取資料。在實踐中,沒聽說過有哪些專案透過檔案的方式來提供 istio 配置,所以這裡不談。MCP 實際上就只是把 istio 資源裝進 MCP 這個箱子裡,然後透過全量 XDS 協議下發配置給 istio。Higress 應該是最大規模應用 MCP 的開源專案。不過它的 MCP server 是從 istio 改出來的,複用了 istio 的 xDS 下發通道來下發 MCP。如果要借鑑,看起來可能會比較費力。Nacos 也實現了對 MCP 的支援,如果對 Java 有了解,可以參考下。

當然本文的重點是 istio 是如何從 k8s 中獲取配置,並轉換到可生成 xDS 的格式。所以讓我們開始溯游而上,尋根究底吧。

上篇我們講到 istio 會根據發生變化的資源類別,給不同的 Envoy 生成不同的 xDS。其中承載著當前變更的資源的結構體就是 model.PushRequest。那這個 PushRequest 是從哪裡來的呢?順著 PushRequest 的源頭,我們可以找到 ConfigUpdate 這一個方法。

條條大道通 ConfigUpdate

istio 裡時常看得到這樣的程式碼:構造 model.PushRequest 接著傳給 ConfigUpdate,比如:

// Trigger a push so we can recompute status
s.XDSServer.ConfigUpdate(&model.PushRequest{
Full:   true,
Reason: model.NewReasonStats(model.GlobalUpdate),
})

ConfigUpdate 就是 model.PushRequest 的原產地了。這個方法很簡單,大體上就是包裝了 pushChannel 寫操作。

func (s *DiscoveryServer) ConfigUpdate(req *model.PushRequest) {
...
s.pushChannel <- req
}

pushChannel 的消費者是 istio 的 debounce 機制。該防抖機制做了下變更合併,避免因頻繁變更導致 CPU 過於繁忙。相鄰的 PushRequest 會被合併成一個 PushRequest,過了 PILOT_DEBOUNCE_AFTER(100ms)後如果沒有新的配置,就繼續下發流程;否則繼續等待 PILOT_DEBOUNCE_AFTER,直到 PILOT_DEBOUNCE_MAX(10s)到達。

合併後的 PushRequest 會透過 Push 方法進行處理:

func (s *DiscoveryServer) Push(req *model.PushRequest) {
if !req.Full {
req.Push = s.globalPushContext()
s.dropCacheForRequest(req)
s.AdsPushAll(req)
return
}
// Reset the status during the push.
oldPushContext := s.globalPushContext()
...
push, err := s.initPushContext(req, oldPushContext, versionLocal)
...

req.Push = push
s.AdsPushAll(req)
}

還記得在上篇提到,endpoint only 的推送的特徵是 req.Full == false 嗎?在 Push 方法裡,endpoint only 的推送幾乎就等於走上快捷通道,很快就走到了 AdsPushAll 這個方法。Istio 會在 AdsPushAll 裡遍歷每個 Envoy,完成上篇描述的 xDS 生成和傳送操作。

其他改動需要走上更長的路徑。路上最核心的是 initPushContext 這個入口。initPushContext 主要的工作在於完成生成 xDS 所需的準備,比如將 Gateway API 的資源翻譯成 istio API 的資源、構建 Namespace 到 Gateway 的索引等等。

注意由於 debounce 的存在,endpoint only 的推送有可能被合入一個 req.Full == true 的推送。如果這種行為影響了業務(比如 endpoint 的變更被遲滯到 PILOT_DEBOUNCE_MAX 之後才得以處理),可以透過環境變數 PILOT_ENABLE_EDS_DEBOUNCE 改變它。

配置下發的三大通道

那麼 k8s 到 ConfigUpdate 中間又經過了哪些風景呢?

大部分 k8s controller 都是透過 controller-runtime 這個庫來跟 k8s API server 互動。因為 istio 的開發年代較為久遠,在那時 controller-runtime 還沒有足夠好用,所以它基於更底層的 client-go (k8s API server 的 SDK) 自己實現了一套 controller 框架。關於 controller 框架的細節,可以看官方文件:https://github.com/istio/istio/blob/master/architecture/networking/controllers.md。不同 k8s 資源會由不同 controller 處理,也即走上不同的道路。

Endpoints 下發

Endpoints 下發是網路產品控制面的核心功能。因為 endpoint 數目和變更頻率都要比其他資源至少多上一個數量級。如果 endpoint 處理的效率能夠提升 10%,其他資源上再怎麼節約都省不了那麼多。所以讓我們優先看看 Endpoints 下發的路徑。

在 istio 1.21 之後,Endpoints 由 endpointslice controller 處理:

func (esc *endpointSliceController) onEventInternal(_, ep *v1.EndpointSlice, event model.Event) {
...
// Update internal endpoint cache no matter what kind of service, even headless service.
// As for gateways, the cluster discovery type is `EDS` for headless service.
namespacedName := getServiceNamespacedName(ep)
...
hostnames := esc.c.hostNamesForNamespacedName(namespacedName)
// Trigger EDS push for all hostnames.
esc.pushEDS(hostnames, namespacedName.Namespace)

pushEDS 是一個 for 迴圈,從 cache 中拿出之前預處理好的 istioEndpoint 物件們,呼叫:

esc.c.opts.XDSUpdater.EDSUpdate(shard, string(hostname), namespace, endpoints)

終於走到最終的 EDS 的應許之地,讓我全文列出,以作紀念:

func (s *DiscoveryServer) EDSUpdate(shard model.ShardKey, serviceName string, namespace string,
istioEndpoints []*model.IstioEndpoint,
) {
inboundEDSUpdates.Increment()
// Update the endpoint shards
pushType := s.Env.EndpointIndex.UpdateServiceEndpoints(shard, serviceName, namespace, istioEndpoints)
if pushType == model.IncrementalPush || pushType == model.FullPush {
// Trigger a push
s.ConfigUpdate(&model.PushRequest{
Full:           pushType == model.FullPush,
ConfigsUpdated: sets.New(model.ConfigKey{Kind: kind.ServiceEntry, Name: serviceName, Namespace: namespace}),
Reason:         model.NewReasonStats(model.EndpointUpdate),
})
}
}

經過九九八十一難,我們來到了 ConfigUpdate 的前面。在 istio 內部,k8s 的 Endpoint 變化和其他類似的服務地址變化一樣,都是轉換成 ServiceEntry 這種資源做處理。可以看到 EDSUpdate 判斷了是否需要 Push,以及 Push 是否為 Full Push,然後完成 Push 的操作。除了 EDSUpdate 之外,在前面也有一些情況下會觸發 Full Push,一一列出顯然超過了本文的主題。感興趣的讀者可自行閱讀。

需要指出的是,Push 是否為 Full Push 隻影響到非 EDS 資源的生成。各位讀者閱讀 pilot/pkg/xds/eds.go 即可發現,是否全量生成 EDS 資源與 Push 是否為 Full Push 無關,只和當前改變的資源裡是否有影響 EDS 的非 ServiceEntry 的資源有關。這裡面的邏輯比較彎彎繞繞。

通用配置下發

和 Endpoints 下發相比,通用配置的下發路徑可以說是簡潔明瞭。

func (s *Server) initRegistryEventHandlers() {
    ...
if s.configController != nil {
configHandler := func(prev config.Config, curr config.Config, event model.Event) {
log.Debugf("Handle event %s for configuration %s", event, curr.Key())
// For update events, trigger push only if spec has changed.
if event == model.EventUpdate && !needsPush(prev, curr) {
log.Debugf("skipping push for %s as spec has not changed", prev.Key())
return
}
pushReq := &model.PushRequest{
Full:           true,
ConfigsUpdated: sets.New(model.ConfigKey{Kind: kind.MustFromGVK(curr.GroupVersionKind), Name: curr.Name, Namespace: curr.Namespace}),
Reason:         model.NewReasonStats(model.ConfigUpdate),
}
s.XDSServer.ConfigUpdate(pushReq)
}

大部分配置的下發都會走這條路徑。從 k8s 同步過來後簡單判斷下是否需要 push(是否有某些特殊的 annotation、spec 是否有改動等等),如果需要就 push。

needsPush 裡有一條規則,凡是非 istio 的資源的變更一律推送。這導致了一個問題:https://github.com/istio/istio/issues/50998。Gateway API 裡的資源都使用 Status 來標記自己的狀態,而 Status 變更也會觸發 configHandler 呼叫。由於凡是 Gateway API 的資源都會推送,所以每次 Gateway API 資源變更,都會觸發至少兩次 full:一次是資源本身導致的、另一次是變更之後資源的 status 改變導致的。Istio 之所以這麼做,是為了 full pu sh 過程中 initPushContext 裡面的 Gateway API 翻譯成 istio API 的操作。假如某個 Gateway API 資源的 status 被更改了,希望能借助翻譯操作來保證 Gateway API 資源的 status 和真實情況一致。另外 istio 在部署完 k8s Gateway 後,會更新這個 Gateway 的 status。這時候也需要觸發 full push 來給新的 Gateway 推送配置。不過老實說這算是為了一碟醋包了一盤餃子,其實可以實現得更加精細,而不是靠 full push 來大力出奇跡。

ServiceEntry 下發

和其他資源不同,ServiceEntry 下發路徑就像騾,混合了前面兩種路徑。

func (s *Controller) serviceEntryHandler(old, curr config.Config, event model.Event) {
    log.Debugf("Handle event %s for service entry %s/%s", event, curr.Namespace, curr.Name)
    ...
fullPush := len(configsUpdated) > 0
// if not full push needed, at least one service unchanged
if !fullPush {
s.edsUpdate(serviceInstances)
return
}

...
pushReq := &model.PushRequest{
Full:           true,
ConfigsUpdated: configsUpdated,
Reason:         model.NewReasonStats(model.ServiceUpdate),
}
s.XdsUpdater.ConfigUpdate(pushReq)

上面的程式碼可以簡單地總結成一句話:如果變更的部分只涉及到 IP 型別的 endpoints,就只觸發 endpoint only 的推送。

  • 新增/刪除 ServiceEntry:涉及 hosts 的改動,觸發 full push
  • 修改 ServiceEntry 裡的 hosts:觸發 full push
  • endpoints 欄位裡只有 IP:理論上觸發 endpoint only 的推送

為什麼說是理論上呢,因為 istio 有個 bug:https://github.com/istio/istio/issues/52248。在 servicesDiff 裡判斷 Service 變更時,istio 會比較 Service 的 Addresses 欄位。如果一個 Service(這裡的 Service 由 ServiceEntry 派生出來)沒有 Addresses,那麼 istio 會透過 hash 演算法生成一個地址。結果 istio 比較時,它會將全新的 Service 的空的 Addresses 欄位和生成的地址做比較。可想而知,如果一個 ServiceEntry 沒有指定 Addresses,程式碼裡永遠走不到觸發 endpoint only 的推送的路徑。這個問題修起來還不容易,因為目前 istio 還依賴這一行為,在發生 hash 碰撞導致 Service 地址漂移時通知資料面更新。一種修復方式是,把地址生成從現在的讀操作時懶執行,改成每次同步時執行。這樣就能在同步時識別出是否需要 full push 來推送地址漂移的變化。像是節點地址這種配置型資料,一般都是讀多寫少,所以從讀操作時懶執行改成同步時執行,對效能影響不大。而且這麼改之後,絕大部分 endpoints 的變化都只需要 endpoint only 的推送,畢竟 hash 碰撞的機率很低。

總結

Istio 配置下發可以看作 model.PushRequest 的漫步過程:

  1. Istio 中的 ConfigUpdate 方法是 model.PushRequest 的來源,它會將請求傳送到 pushChannel。
  2. pushChannel 的消費者是防抖機制,它會合並相鄰的 PushRequest,避免頻繁變更導致 CPU 過於繁忙。
  3. 合併後的 PushRequest 會進入 Push 方法處理,其中 endpoint only 推送會快速進入 AdsPushAll 生成併傳送 xDS;其他變更需要呼叫 initPushContext 準備 xDS 生成所需內容。

Istio 對 Kubernetes 資源的處理主要分成三條路徑:

  1. Endpoints 由 endpointSliceController 處理,最終呼叫 EDSUpdate 觸發 ConfigUpdate。
  2. 大部分配置由 configHandler 處理,簡單判斷後直接觸發 ConfigUpdate 的 full push。
  3. ServiceEntry 的處理介於兩者之間,根據變更型別選擇觸發 endpoint only 推送或 full push。

相關文章