新手學分散式 - Envoy Proxy XDS Server動態配置的一點使用心得
Envoy Proxy 動態API的使用總結
Envoy Proxy和其它L4/L7反向搭理工具最大的區別就是原生支援動態配置。 首先來看一下Envoy的大致架構
從上圖可以簡單理解:Listener負責接受外部的請求,然後經過Filter/Router處理之後,在轉發到具體的Cluster。 其中Listener,Router,Cluster和Host地址都是可以動態配置的,配置這些資料的服務就稱之為X Discovery Services,簡稱XDS。
本文主要描述如何編寫XDS Server更新邏輯。
Envoy Porxy XDS Service通過GRPC服務進行資料更新,所有Proto檔案可以參考 https://github.com/envoyproxy/envoy/tree/master/api/envoy/api/v2 。 使用者可以根據proto檔案自行生成相對應語言的GRPC程式碼檔案。如果使用golang來實現的話,Envoy已經提供了一份編譯好的GRPC程式碼,地址在這裡: https://github.com/envoyproxy/go-control-plane/tree/master/envoy/api/v2
每個XDS Service都有兩種GRPC服務, Stream
和Delta
。 Stream
用來更新全量資料,Delta
用來更新增量資料。下面以RDS Service為例來看看如何實現一個 XDS Service。
RDS Service
可以提供所有的Route
資訊,一個簡化後的典型Route
配置如下:
# 完整的Route API定義參考 https://www.envoyproxy.io/docs/envoy/latest/api-v2/api/v2/rds.proto#envoy-api-msg-routeconfiguration route_config: name: local_route virtual_hosts: - name: local_service domains: ["*"] require_tls: NONE routes: - match: prefix: "/MyService" route: { cluster: my-grpc-svc_cluster }
上面的配置語義為: 當收到一個Path字首為/MyService
的請求後,將此請求轉發到my-grpc-svc_cluster
. (my-grpc-svc_cluster
表示的是後端Upstream資訊,可以是STATIC型別也可以由CDS Service
動態提供)
RDS Service
的作用就是動態生成類似上面的語義配置。 先來看相對簡單的StreamRoutes
如何實現。
GRPC描述檔案中,對此函式的定義如下:
service RouteDiscoveryService { rpc StreamRoutes(stream DiscoveryRequest) returns (stream DiscoveryResponse) { } rpc DeltaRoutes(stream DeltaDiscoveryRequest) returns (stream DeltaDiscoveryResponse) { } rpc FetchRoutes(DiscoveryRequest) returns (DiscoveryResponse) { option (google.api.http) = { post: "/v2/discovery:routes" body: "*" }; } }
從返回值可以看出StreamRoutes
是一個流函式,RDS會通過這個流實時將資料推送給Envoy。 所以大致的實現模型就是如下的樣子:
func (r rds) StreamRoutes(ls envoy_api_v2.RouteDiscoveryService_StreamRoutesServer) error {
for{
select{
case x <- c>:
ls.Send(xxx)
}
}
}
Send
函式接受的是DiscoveryResponse
指標,而這個DiscoveryResponse
從定義來看是自解釋動態結構體。 具體資料型別由typeUrl
屬性來決定。 具體到Route
來說,typeURL
是"type.googleapis.com/envoy.api.v2.RouteConfiguration". (型別說明參見 https://www.envoyproxy.io/docs/envoy/latest/api-docs/xds_protocol.html?highlight=type%20url#resource-types )
資料則由Resource
來儲存。
Resource
是[]*any.Any
型別,說白了就是萬能的Interface{}。所以建立any.Any
時需要指定具體的資料型別("type.googleapis.com/envoy.api.v2.RouteConfiguration"). data
則是經過ProtoMessage
編碼後的二進位制資料。 所以建立any.Any應該是下面的樣子:
data, err := proto.Marshal(xxxx)
if err != nil {
logrus.Errorf("Marshal Error. %s", err)
continue
}
any := &any.Any{
TypeUrl: "type.googleapis.com/envoy.api.v2.Cluster",
Value: data,
})
xxxx
是RDS需要返回給Envoy的路由資料,也就是RouteConfiguration
。所以下面來看如何構建RouteConfiguration
。 通過API定義可知,有一些資料是必輸項(通過proto校驗描述檔案也可以獲取必輸項,但不如看API文件來的直接)。 假設我們要實現開篇簡單的Route配置,那麼 RouteConfiguration
應該這樣定義:
r:=&envoy_api_v2.RouteConfiguration{
Name: "local_route",
VirtualHosts: []*route.VirtualHost{
&route.VirtualHost{
Name: "local_service",
Domains: []string{
"*",
},
Routes: []*route.Route{
&route.Route{
Match: &route.RouteMatch{
PathSpecifier: &route.RouteMatch_Prefix{Prefix: "/MyService"},
},
Action: &route.Route_Route{Route: &route.RouteAction{
ClusterSpecifier: &route.RouteAction_Cluster{Cluster: "my-grpc-svc_cluster"},
}},
},
},
},
},
},
需要注意兩個地方:
- Name: "local_route"。 這裡的Name一定要和Listener中定義的RouteConfig Name保持一致。 如果不一致,Listener不會載入這段Route配置(換言之,這個Name就是雙方的關聯主鍵)
- Cluster 名稱也要保持一致。 同理,如果不一致,後續請求轉發時就會找不到UPstream
經過這些步驟,一個近似完整的Route DiscoveryResponse
就定義完成了。 而後就可以通過呼叫Send來發送給Envoy。
然而此時事情並沒有結束, 開篇說過Stream
同步全量,Delta
同步增量。 再詳細一點,在StreamRoutes
中每次都需要傳輸當前所有的Route配置,而不僅僅是發生過變更的資料 . 個人感覺這種處理方式,對於資料組織來說很麻煩,但對於Envoy資料更新來說確很方便(每次都是全量資料,不用做merge了)。 merge總是一件耗時費力的事情,就看事情誰來做,這次envoy決定讓使用者來做了。
所以我們需要調整一下StreamRoutes
實現模型:
func (r rds) StreamRoutes(ls envoy_api_v2.RouteDiscoveryService_StreamRoutesServer) error {
for{
select{
case x <- c>:
// x表示變動的資料
n := merge(x) //對x進行merge操作,返回當前最新全量資料n
var srvRoute []*route.Route
for _, d := range n{
srvRoute = append(srvRoute, &route.Route{
Match: &route.RouteMatch{
PathSpecifier: &route.RouteMatch_Prefix{Prefix: xxxx},
},
Action: &route.Route_Route{Route: &route.RouteAction{
ClusterSpecifier: &route.RouteAction_Cluster{Cluster: xxxx},
}},
})
}
rc := []*envoy_api_v2.RouteConfiguration{
&envoy_api_v2.RouteConfiguration{
Name: "local_route",
VirtualHosts: []*route.VirtualHost{
&route.VirtualHost{
Name: "local_service",
Domains: []string{
"*",
},
Routes: srvRoute,
},
},
},
}
var resource []*any.Any
for _, rca := range rc {
data, err := proto.Marshal(rca)
if err != nil {
return err
}
resource = append(resource, &any.Any{
TypeUrl: "type.googleapis.com/envoy.api.v2.RouteConfiguration",
Value: data,
})
}
ls.Send(&envoy_api_v2.DiscoveryResponse{
VersionInfo: xxx,
Resources: resource,
Canary: false,
TypeUrl: "type.googleapis.com/envoy.api.v2.RouteConfiguration",
Nonce: time.Now().String(),
})
}
}
}
調整之後,每次就會返回Envoy最新的Route資料。 上面的模型僅考慮了單Envoy例項的情況,並未考慮多例項。 當多例項連結RDS Service
時, 從c獲取資料,就會變成非冪等事件,從而無法保證所有Envoy例項資料保持一致。
實現StreamRoutes
之後,在來看如何實現DeltaRoutes
。
Delta
是用來同步增量資料的,從函式原型來看,入參也是一個Stream,所以函式原型應該和StreamRoutes
差不多。 如果你也這樣想,就錯了
Delta
的stream只是用來傳輸資料的(猜測是為了提高資料傳輸效率,而並不是為了保持長連線)。 每次傳輸完成之後,Envoy都會主動斷開這個連結。 也就是說,Envoy是定時呼叫DeltaRoutes
來獲取增量更新資料的。如果按照stream
的實現模型來編寫邏輯,將會發現經過一段時間後,這個stream會莫名的變成closed
狀態。 原因就是envoy接收到此次事件後,主動關閉了stream。
所以如果要使用Delta
模式,那麼會無法保證Envoy無法實時響應資料變化(因為這個定時呼叫的存在)。 而如果使用Stream
模式,那麼使用者需要自行維護資料正確性(如果merge很複雜,正確性就會下降)。
所以選擇Stream
還是Delta
對於使用者來說是個問題