Istio作為當前服務網格(Service Mesh)領域的事實標准,流量治理(Traffic Management)是其最為基礎也最為重要的功能。本文將結合源碼對Istio流量治理的實現主體——組件Pilot進行深入地分析。(本文參考的代碼為位於Istio repo的master分支,commit為b8e30e0
)
1. 架構分析
在應用從單體架構向微服務架構演進的過程中,微服務之間的服務發現、負載均衡、熔斷、限流等流量治理需求是無法回避的問題。在Service Mesh出現之前,通常的做法是將此類公共的基礎功能以SDK的形式嵌入業務代碼中。這雖然不失為解決問題的一種方式,但這種強耦合的方案無疑會增加業務開發的難度,代碼維護的成本,同時如果存在跨語言應用間的交互,對於多語言SDK的支持造成的臃腫低效也令人很難接受。
而Service Mesh的本質則是將此類通用的功能沉淀至Proxy中,由Proxy接管服務的流量並對其進行治理,從而將服務與服務間的流量治理轉變為Proxy與Proxy之間的流量治理。Service Mesh對代碼的零侵入性使得業務開發人員能夠更為專注於業務代碼的開發而無需再對底層的流量治理功能做過多的關注。
如果僅僅只是將應用與TCP/IP網絡層之間的流量治理功能進行沉淀封裝,那么以Envoy和Linkerd 1.0為代表的純Proxy已經足夠了。而istio所做的是在這一基礎之上,增加控制平面,從而允許用戶在更高的抽象維度,以更靈活的方式對服務間的流量進行管理。同時Istio對於服務模型的抽象所帶來的高度擴展性,也讓其對於Kubernetes等多種平台的支持變得更為簡單。
如上圖所示,Pilot是Istio進行流量治理的核心組件,可以看到,其架構與Istio的設計理念是一致的。Pilot支持從Kubernetes、Consul等多種平台獲取服務發現功能。同時支持用戶通過VirtualService,DestinationRule等API制定服務間的流量治理規則。最后,Pilot將發現的服務以及用戶定義的服務間的調用規則進行融合並與底層Proxy的API進行適配后將規則下發。(底層的Proxy一般為Envoy並且Envoy已將其API抽象為Service Mesh控制平面與數據平面的標准接口——xDS,理論上任何實現了xDS協議的Proxy都能無縫接入Istio)Proxy則負責對后端服務發出的流量進行劫持並依據Pilot下發的規則對流量進行處理。
2. 代碼結構分析
Pilot的核心代碼位於目錄istio/pilot/pkg
內,其代碼結構如下所示:
[root@s istio]# tree -L 1 pilot/pkg/ pilot/pkg/ ├── bootstrap ├── config ├── kube ├── model ├── networking ├── proxy ├── request └── serviceregistry
一個有着良好設計的項目,其代碼結構必然與其設計架構的模塊划分方式是一致的,因此Pilot各主要目錄的功能如下:
- bootstrap: Pilot模塊的入口,構建執行框架並對各子模塊進行初始化
- model: 核心數據結構定義,包括對於服務發現等概念的標准化抽象
- serviceregistry: Kubernetes等各個服務發現平台對於model中關於服務發現抽象模型的具體實現
- config: VirtualService等用戶定義規則在源碼中統一用config進行抽象,此目錄包含了對於config多種獲取方式的封裝
- proxy: 封裝與下層Proxy的交互,主要包含xDS Server的實現
- networking: 接口轉換,將發現的服務以及用戶定義的規則轉換為xDS協議中的Cluster, Endpoint, Listener以及Route
3. 服務發現
// istio/pilot/pkg/model/service.go // 僅保留核心字段 type Service struct { Hostname Hostname `json:"hostname"` Address string `json:"address,omitempty"` Ports PortList `json:"ports,omitempty"` MeshExternal bool ... }
上述Service
結構是Istio對於一個服務的標准抽象,每一個服務都由一個完整域名(FQDN)以及一個或多個端口構成,例如catalog.mystore.com:8080
。事實上,Istio中的服務不僅可以通過網格內的服務發現機制獲得,還可以由用戶利用Istio的ServiceEntry這一API將網格外部的服務手動注入到網格中,從而允許在網格內部調用外部服務。最后,例如Kubernetes這樣的平台會為其中的Service定義一個Virtual IP,DNS對於此類FQDN的解析會得到該Virtual IP。用戶可直接通過該Virtual IP訪問服務,平台會自動將流量負載均衡到各個服務的實例。
事實上,服務是一種動態資源,增刪改查是不可避免的。同時,處於數據平面的Proxy需要根據配置對服務間的流量進行管理,因此必須確保Proxy眼中的服務視圖是最新的,否則就無法正確對流量進行轉發。所以一旦底層平台的服務發生變更就應該立即推送到Proxy中。Pilot中通過定義標准接口Controller
解決了該問題。Controller
結構如下:
type Controller interface { AppendServiceHandler(f func(*Service, Event)) error AppendInstanceHandler(f func(*ServiceInstance, Event)) error Run(stop <-chan struct{}) }
將處理函數通過Append*Handler
進行注冊,一旦服務或者服務對應的實例發生變更,處理函數就會自動執行。處理函數中的操作一般即為重新計算配置並下發至Proxy。
由上文可知Istio支持Kubernetes等多種服務發現平台,各平台對於服務的定義都會有所不同,因此需要為各平台定義相應的Adapter用於和Istio的標准服務模型適配。
[root@s istio]# tree -L 1 pilot/pkg/serviceregistry/ pilot/pkg/serviceregistry/ ├── aggregate ├── consul ├── external ├── kube ├── memory └── platform.go
由上圖可知,當前Istio支持Kubernetes,Consul,Memory(主要用於測試)以及External等多種服務發現方式。雖然Istio聲稱並不與Kubernetes耦合,但是對其顯然是優先支持的。因此,下文將以Kubernetes平台為例,說明Istio的服務發現機制。
Kubernetes是容器編排領域的事實標准,不過由於其聲明式API的存在,將其稱之為分布式平台框架更為合適。通過構建一個控制器,我們可以對Kubernetes的多種資源(原生的或自定義的)資源進行監聽並根據資源相關的事件(Add, Update, Delete)對資源進行處理。
顯然,Kubernetes中與服務發現相關的是Service和Endpoints兩個原生的資源對象。對於原生資源對象控制器的構建是非常方便的,官方提供的client-go
已經為我們屏蔽了大量與Kubernetes API-Server交互的大量細節。我們需要做的只是編寫對應資源發生變更時的處理函數即可。
// istio/pilot/pkg/serviceregistry/kube/controller.go informer.AddEventHandler( cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { c.queue.Push(Task{handler: handler.Apply, obj: obj, event: model.EventAdd}) }, UpdateFunc: func(old, cur interface{}) { if !reflect.DeepEqual(old, cur) { c.queue.Push(Task{handler: handler.Apply, obj: cur, event: model.EventUpdate}) } else { .... } }, DeleteFunc: func(obj interface{}) { c.queue.Push(Task{handler: handler.Apply, obj: obj, event: model.EventDelete}) }, })
可以看到,一旦資源發生變動,事件類型、資源實例以及資源的處理函數就會被加入一個隊列中,再由隊列進行異步處理,處理過程即為依次調用通過上文中的Controller
接口注冊的處理函數。
總體來說,針對Kubernetets平台的服務發現是兩層訂閱模式。例如對於Service對象,首先通過訂閱獲取Kubernetes原生的Service並加入隊列,隊列在處理時將Kubernetes原生的Service對象轉換成Istio定義的標准Service結構。最后將轉換后的Service和事件類型交由通過Controller.AppendServiceHandler()
注冊的處理函數。對於其他服務發現平台的適配,設計架構也是類似的。
不過由於Pilot有同時使用多種服務發現平台的需求,因此需要聚合多個平台的服務發現接口。上文未提及的serviceregistry
的aggregate
子目錄即用於此目的。事實上,它只是在多個平台的接口之上做了一層封裝,對於具體的某個接口則由依次調用各已注冊平台的相應接口實現。
4. 流量管理資源對象
服務發現確保了服務間的可訪問性,但是對於服務網格來說,更重要的是需要能夠對服務間的訪問進行控制,本質上就是需要定義服務間的訪問規則。用戶僅需利用Istio提供的抽象的資源對象定義服務間的訪問關系,而無須關心底層復雜的流量轉發過程,就能輕松實現A/B測試、金絲雀發布、熔斷、故障注入等一系列復雜的流量管理操作。
Istio中與流量管理相關的資源對象主要為VirtualService
、DestinationRule
、ServiceEntry
和Gateway
:
- VirtualService: 本質上是一張路由表,其中定義了一系列的路由規則,發往某個host的流量會根據匹配的規則流向指定的service(通常是service的一個subset)
apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: name: reviews spec: hosts: - reviews http: - match: - headers: end-user: exact: jason route: - destination: host: reviews subset: v2 - route: - destination: host: reviews subset: v3
如上定義的VirtualService,發往reviews的流量,默認發往v3這個subset。對於HTTP頭部指定end-user為jason的流量則發往v2這個subset。
- DestinationRule: 定義VirtualService中引用的subset(subset本質上是對服務實例的划分)以及對於發往目標服務或者其subset的流量的管理策略,具體包括對於負載均衡、Proxy中的連接池大小等一系列屬性的配置
apiVersion: networking.istio.io/v1alpha3 kind: DestinationRule metadata: name: reviews spec: host: reviews trafficPolicy: loadBalancer: simple: RANDOM subsets: - name: v1 labels: version: v1 - name: v2 labels: version: v2 - name: v3 labels: version: v3
如上定義的DestinationRule將服務reviews根據label的不同划分為v1,v2,v3三個subset並且將服務層面的負載均衡策略定義為RANDOM,事實上也可以在subset層面對流量測量進行定義從而覆蓋服務層面的全局定義
- ServiceEntry: 上文已多次提及,用於定義網格外部的服務
- Gateway: 位於網格邊界的負載均衡器,用於接收進出網格的HTTP/TCP連接,一般會暴露一系列端口,從而允許外界訪問網格內的服務
對於上述Istio中流量管理相關的各種資源對象,乃至Istio的所有其他的資源對象,經過用戶配置之后,都需要進行持久化存儲並且在發生變更的時候需要能夠及時提送至Pilot,使其能夠重新計算配置以下發至底層的各個Proxy。
與服務發現類似,Pilot同樣可以對接多種資源對象的發現平台,各個平台的Adapter實現位於istio/pilot/pkg/config
目錄,如下所示:
[root@s istio]# tree -L 1 pilot/pkg/config/ pilot/pkg/config/ ├── aggregate ├── clusterregistry ├── coredatamodel ├── kube ├── memory └── monitor
同樣,本文將針對Kubernetes平台進行說明。已知Kubernetes允許用戶自定義資源對象(CRD),從而允許用戶像操作Service
,Pod
等原生資源對象一樣對自定義對象進行操作,也可以定義與上文類似的控制器對自定義資源的相關事件進行監聽並做相應的處理。因此,我們完全可以將Istio的各種對象以CRD的形式注冊到Kubernetes中並創建相應的控制器監聽其狀態。事實上,Istio也的確是這樣做的。
Istio在istio/pilot/pkg/model/config.go
中對各種資源對象進行了定義,如下所示:
// VirtualService describes v1alpha3 route rules VirtualService = ProtoSchema{ Type: "virtual-service", ... Group: "networking", Version: "v1alpha3", ... } // Gateway describes a gateway (how a proxy is exposed on the network) Gateway = ProtoSchema{ Type: "gateway", ... Group: "networking", Version: "v1alpha3", ... }
首先,Istio會將它們以CRD的形式注冊到Kubernetes中,之后你會發現istio/pilot/pkg/config/kube/crd
和istio/pilot/pkg/serviceregistry/kube
是極其類似的,都是為各種資源對象創建informer
進行監聽並注冊相應的處理函數。因此不再贅述。
最后,Istio將從各個平台獲得的各種資源對象都被統一抽象為如下結構:
// istio/pilot/pkg/model/config.go type Config struct { ConfigMeta // 資源配置的元數據,例如資源類型,名稱等等 Spec proto.Message // 具體的配置內容 }
同樣,對外暴露如下的統一接口,屏蔽底層差異的同時,滿足外部對於各種資源對象的操作需求:
type ConfigStoreCache interface { ConfigStore // 也是一個接口,包含了對於資源對象的Get, List等基本操作 RegisterEventHandler(typ string, handler func(Config, Event)) // 對每種資源對象的變更都注冊處理函數,一般就是重新計算配置並下發至Proxy Run(stop <-chan struct{}) ... }
5. xDS協議
Istio通過服務發現獲取了整個網格的服務視圖,用戶則通過Istio提供的一系列資源對象定義了服務間的訪問規則,然而網格中真正進行流量轉發的是底層的Proxy。因此,Pilot還需要將服務及其流量管理規則下發至Proxy,而下發過程中,兩者之間交互的協議即為xDS協議。
Istio底層使用的Proxy官方默認為Envoy,Envoy作為CNCF第三個“畢業”的項目,其成熟度和穩定性都已經經歷了大量實踐的檢驗。事實上,xDS協議正是由Envoy社區提出的,在Envoy剛開源的時候,就有大量關於能否讓Envoy支持Consul,Kubernetes,Marathon等服務發現平台的請求在社區提出。但是社區最后發現,與其直接對各種服務發現平台提供支持,還不如提供一套簡單中立的API,明確划分控制平面和數據平面的界限,再由用戶利用這套API將Envoy集成到其具體的工作流中,滿足其特定需求,這一想法最終演化出了xDS協議。
可以發現對於Envoy來說,Istio只是基於其實現的控制平面中的一種,Istio和Envoy事實上擁有的是兩套資源對象,Pilot通過xDS將配置下發之前還需要進行一次配置的轉換。因此,首先對Envoy主要的資源對象進行簡要介紹:
- Cluster: 一個Cluster可以簡單地與上文中的一個Service或者一個Service的Subset相對應,其配置的主要字段如下所示:
{
"name": "...",
"type": "...",
"eds_cluster_config": "{...}",
"hosts": [],
...
}
Cluster的類型由type
字段指定,共分為如下五種:
- Static: 直接在
hosts
字段指定Service實例的IP和端口 - Strict DNS:
hosts
字段指定后端的Service Name和端口,通過DNS獲取后端Service實例的IP地址,若返回多個IP地址,則Envoy會在之間進行負載均衡 - Logical DNS: 與
Strict DNS
類似,但僅使用DNS返回的第一個IP - Original destination:直接使用HTTP header中指定的目標IP地址
- EDS: 通過上層的控制平面獲取后端的Service實例的IP和端口,Istio+Envoy模式下最常見的Cluster類型
- ClusterLoadAssignment: Cluster后端的具體實例集合,可以簡單地與Kubernetes中的Endpoints相對應,其配置的主要字段如下:
{
"cluster_name": "...",
"endpoints": [],
...
}
其中cluster_name
指定了關聯的Cluster,endpoints
則包含了若干具體實例的IP地址和端口信息
- Listener: 監聽並截取發往某個IP地址和端口的流量並處理,在Istio+Envoy體系下,由於基本上所有流量都會通過Iptables轉發進入Envoy,因此只有一個特殊的"Virtual Listener"用於統一接收流量,再由其根據流量的目的IP和端口轉發至具體的Listener進行處理。其配置的主要字段如下:
{
"name": "...",
"address": "...",
"filter_chains": [],
...
}
address
字段指定了Listener監聽的地址,而filter_chains
字段則定義了一系列的filter用於對流量進行處理。當遍歷完各個filter之后,對於envoy.tcp_proxy
類型的filter會直接指定需要導向的Cluster,但是對於envoy.http_connection_manager
類型的filter則會利用rds
字段,指向特定的路由表,根據路由表決定后端的Cluster
- RouteConfiguration: 其配置的主要字段如下:
// RouteConfiguration
{
"name": "...",
"virtual_hosts": [],
....
}
// VirtualHost
{
"name": "...",
"domains": [],
"routes": [],
...
}
// Route
{
"match": "{...}",
"route": "{...}",
...
}
RouteConfiguration
結構即表示上文所述的路由表,因為一個路由表可能包含通往多個Service的路由,因此通過VirtualHost
對一個Service進行抽象。而VirtualHost
中的domains
字段用於和接收到的HTTP請求的host header進行匹配,一旦匹配成功,則該VirtualHost
被選中。之后再進入VirtualHost
的routes
字段進行二級匹配,例如match
字段指定匹配的前綴為/
,則執行下一個字段route
,一般其中指定了后端的Cluster。
如上圖所示,當需要訪問details:9080
時,Envoy會通過Iptables截取流量並轉入相應的Listener進行處理。Listener遍歷各個Filter,之后通過Route或者直接指定目標Cluster。多數Cluster通過與控制平面,例如Istio進行交互獲取LoadAssignment,並從中選擇目標Service實例的IP和端口,對於STATIC等類型的Cluster,IP和端口則不需要通過控制平面,可以直接獲取,由此與具體的實例建立連接。
上文簡述了Envoy中的Listener等核心資源對象及其作用,早先Envoy將xDS協議划分為CDS
,EDS
,LDS
,RDS
四個部分,分別用於獲取Cluster
,Cluster LoadAssignment
,Listener
,RouteConfiguration
四類資源對象。但是,經過仔細研究可以發現,這些資源對象之間是存在一定的依賴關系的。例如,EDS
依賴於CDS
,RDS
依賴於LDS
。若各資源對象分別建立連接從多個控制平面獲取相應的對象,則資源對象間的時序關系將難以控制。因此,在Istio中,上述四類資源對象都通過單個的gRPC流從單個的控制平面實例中獲取,這種聚合獲取資源的方式稱為ADS(Aggregated Discovery Services)。
回歸到源碼中,Pilot中與Envoy交互部分的代碼被封裝在目錄istio/pilot/pkg/proxy
,具體關於xDS協議的實現,則位於istio/pilot/pkg/proxy/envoy/v2
中。當前,Pilot處理xDS協議的核心框架則位於StreamAggregatedResources
方法,如下所示:
// istio/pilot/pkg/proxy/envoy/v2/ads.go func (s *DiscoveryServer) StreamAggregatedResources(stream ads.AggregatedDiscoveryService_StreamAggregatedResourcesServer) error { // 初始化連接 con := newXdsConnection(peerAddr, stream) ... // 接收來自Proxy的事件 reqChannel := make(chan *xdsapi.DiscoveryRequest, 1) go receiveThread(con, reqChannel, &receiveError) for { select { case discReq, ok := <-reqChannel: ... switch discReq.TypeUrl { case ClusterType: ... err := s.pushCds(con, s.globalPushContext(), versionInfo()) case ListenerType: ... case RouteType: ... case EndpointType: ... } ... case pushEv := <-con.pushChannel: ... err := s.pushConnection(con, pushEv) ... } } }
上述方法為我們清晰地勾勒出了xDS協議的架構。首先Pilot接收並初始化來自Envoy的連接,之后則進入循環,等待相應的事件並進行處理,事件源主要包含如下兩部分:
- Envoy:當Envoy初始化的時候會主動與Pilot建立連接並發送請求獲取配置,一般發送請求的順序為:CDS -> EDS -> LDS -> RDS,Pilot則根據請求的類型下方相應的配置
- 變更:如前文所述,服務以及用戶對其訪問規則的配置並不是一成不變的,而底層Envoy所需的xDS API事實上是由發現的服務及對其的配置推導而來。因此,每當服務發現獲取到的服務或者用戶對Istio資源對象的配置發生變更,都會導致Envoy配置的重新計算並下發,select語句的第二個case正是用於處理此種情況。
6. 總結
本文結合源碼對Istio的核心組件Pilot進行了深入的介紹,經過上文的分析不難發現,Pilot在整個體系中的角色其實是適配器+API轉換層+推送器
:
- 適配器:雖然前文對於Service以及Istio資源對象進行了分別的描述,但事實上兩者是一致的,都只是一種資源,Pilot對它進行了標准的定義並用它去適配Kubernetes等各個平台
- API轉換層:Pilot從上層獲取了Service以及VirtualServices等Istio資源對象,但是它們與xDS定義的Listener等Envoy要求的資源對象並不存在直接對應關系,因此需要進行一層API的轉換
- 推送器:Service以及VirtualService等Istio資源對象並非一成不變的,而Listener等xDS API對象則由前者直接推導得到,因此一旦前者發生變更,后者必須重新推導並推送