背景介绍
OpenYurt是业界首个非侵入的边缘计算云原生开源项目,通过边缘自治,云边协同,边缘单元化,边缘流量闭环等能力为用户提供云边一体化的使用体验。在openyurt里边缘网络可以使用数据过滤框架在不同节点池里实现边缘流量闭环能力。
yurthub数据过滤框架解析
yurthub本质上是一层kube-apiserver的代理,在代理的基础上加了一层cache,一来保证边缘节点离线的情况下可以使用本地cache保证业务稳定性,有效的解决了边缘自治的问题。二来可以降低大量的list & watch操作对云上api产生一定的负载。
yurthub的数据过滤通过节点上的pod以及kubelet的请求通过Load Balancer 发送给kube-apiserver,代理接收到响应消息进行数据过滤处理,之后再将过滤后的数据返回给请求方。如果节点是边缘节点会根据请求类型对响应请求体中的资源进行本地缓存,如果是云端节点考虑到网络状态良好不进行本地缓存。
yurthub的过滤框架实现原理图:
yurthub目前包含四种过滤规则,通过addons请求的user-agent,resource,verb判断经过那个过滤器进行相应的数据过滤。
四种过滤规则功能及实现如下:
ServiceTopologyFilter:
主要针对EndpointSlice资源进行数据过滤, 但Endpoint Slice特性需要在Kubernetes v1.18或以上版本才能支持,如果在1.18版本以下建议使用endpointsFilter过滤器。 当经过该过滤器首先通过kubernetes.io/service-name找到endpointSlice资源所对应的services资源,之后判断servces资源是否存在openyurt.io/topologyKeys这个Annotations,如果存在那么通过这个Annotations的值判断数据过滤规则,最后更新response data返回给addons。
Annotations的值分为两大类:
- kubernetes.io/hostname: 只过滤出相同节点的endpoint ip
- openyurt.io/nodepool或者kubernetes.io/zone: 通过这个Annotations获取对应节点池,最后遍历endpointSlice资源,通过endpointSlice里的topology字段中的kubernetes.io/hostname字段在endpointSlice对象里找到对应的Endpoints,之后重组endpointSlice里的Endpoints后返回给addons。
代码实现:
func (fh *serviceTopologyFilterHandler) reassembleEndpointSlice(endpointSlice *discovery.EndpointSlice) *discovery.EndpointSlice {
var serviceTopologyType string
// get the service Topology type
if svcName, ok := endpointSlice.Labels[discovery.LabelServiceName]; ok {
svc, err := fh.serviceLister.Services(endpointSlice.Namespace).Get(svcName)
if err != nil {
klog.Infof("skip reassemble endpointSlice, failed to get service %s/%s, err: %v", endpointSlice.Namespace, svcName, err)
return endpointSlice
}
if serviceTopologyType, ok = svc.Annotations[AnnotationServiceTopologyKey]; !ok {
klog.Infof("skip reassemble endpointSlice, service %s/%s has no annotation %s", endpointSlice.Namespace, svcName, AnnotationServiceTopologyKey)
return endpointSlice
}
}
var newEps []discovery.Endpoint
// if type of service Topology is 'kubernetes.io/hostname'
// filter the endpoint just on the local host
if serviceTopologyType == AnnotationServiceTopologyValueNode {
for i := range endpointSlice.Endpoints {
if endpointSlice.Endpoints[i].Topology[v1.LabelHostname] == fh.nodeName {
newEps = append(newEps, endpointSlice.Endpoints[i])
}
}
endpointSlice.Endpoints = newEps
} else if serviceTopologyType == AnnotationServiceTopologyValueNodePool || serviceTopologyType == AnnotationServiceTopologyValueZone {
// if type of service Topology is openyurt.io/nodepool
// filter the endpoint just on the node which is in the same nodepool with current node
currentNode, err := fh.nodeGetter(fh.nodeName)
if err != nil {
klog.Infof("skip reassemble endpointSlice, failed to get current node %s, err: %v", fh.nodeName, err)
return endpointSlice
}
if nodePoolName, ok := currentNode.Labels[nodepoolv1alpha1.LabelCurrentNodePool]; ok {
nodePool, err := fh.nodePoolLister.Get(nodePoolName)
if err != nil {
klog.Infof("skip reassemble endpointSlice, failed to get nodepool %s, err: %v", nodePoolName, err)
return endpointSlice
}
for i := range endpointSlice.Endpoints {
if inSameNodePool(endpointSlice.Endpoints[i].Topology[v1.LabelHostname], nodePool.Status.Nodes) {
newEps = append(newEps, endpointSlice.Endpoints[i])
}
}
endpointSlice.Endpoints = newEps
}
}
return endpointSlice
}
EndpointsFilter
针对endpoints资源进行相应的数据过滤,首先判断endpoint是否存在对应的service,通过node的label: apps.openyurt.io/nodepool 获取节点池,之后获取节点池下的所有节点,遍历endpoints.Subsets下的资源 找出同一个节点池的Ready pod address以及NotReady pod address重组成新的endpoints之后返回给addons。
func (fh *endpointsFilterHandler) reassembleEndpoint(endpoints *v1.Endpoints) *v1.Endpoints {
svcName := endpoints.Name
_, err := fh.serviceLister.Services(endpoints.Namespace).Get(svcName)
if err != nil {
klog.Infof("skip reassemble endpoints, failed to get service %s/%s, err: %v", endpoints.Namespace, svcName, err)
return endpoints
}
// filter the endpoints on the node which is in the same nodepool with current node
currentNode, err := fh.nodeGetter(fh.nodeName)
if err != nil {
klog.Infof("skip reassemble endpoints, failed to get current node %s, err: %v", fh.nodeName, err)
return endpoints
}
if nodePoolName, ok := currentNode.Labels[nodepoolv1alpha1.LabelCurrentNodePool]; ok {
nodePool, err := fh.nodePoolLister.Get(nodePoolName)
if err != nil {
klog.Infof("skip reassemble endpoints, failed to get nodepool %s, err: %v", nodePoolName, err)
return endpoints
}
var newEpSubsets []v1.EndpointSubset
for i := range endpoints.Subsets {
endpoints.Subsets[i].Addresses = filterValidEndpointsAddr(endpoints.Subsets[i].Addresses, nodePool)
endpoints.Subsets[i].NotReadyAddresses = filterValidEndpointsAddr(endpoints.Subsets[i].NotReadyAddresses, nodePool)
if endpoints.Subsets[i].Addresses != nil || endpoints.Subsets[i].NotReadyAddresses != nil {
newEpSubsets = append(newEpSubsets, endpoints.Subsets[i])
}
}
endpoints.Subsets = newEpSubsets
if len(endpoints.Subsets) == 0 {
// this endpoints has no nodepool valid addresses for ingress controller, return nil to ignore it
return nil
}
}
return endpoints
}
MasterServiceFilter
针对services下的域名进行ip以及端口替换,这个过滤器的场景主要在于边缘端的pod无缝使用InClusterConfig访问集群资源。
func (fh *masterServiceFilterHandler) ObjectResponseFilter(b []byte) ([]byte, error) {
list, err := fh.serializer.Decode(b)
if err != nil || list == nil {
klog.Errorf("skip filter, failed to decode response in ObjectResponseFilter of masterServiceFilterHandler, %v", err)
return b, nil
}
// return data un-mutated if not ServiceList
serviceList, ok := list.(*v1.ServiceList)
if !ok {
return b, nil
}
// mutate master service
for i := range serviceList.Items {
if serviceList.Items[i].Namespace == MasterServiceNamespace && serviceList.Items[i].Name == MasterServiceName {
serviceList.Items[i].Spec.ClusterIP = fh.host
for j := range serviceList.Items[i].Spec.Ports {
if serviceList.Items[i].Spec.Ports[j].Name == MasterServicePortName {
serviceList.Items[i].Spec.Ports[j].Port = fh.port
break
}
}
klog.V(2).Infof("mutate master service into ClusterIP:Port=%s:%d for request %s", fh.host, fh.port, util.ReqString(fh.req))
break
}
}
// return the mutated serviceList
return fh.serializer.Encode(serviceList)
}
DiscardCloudService
该过滤器针对两种service资源 其中一种是LoadBalancer类型,因为边缘端无法访问LoadBalancer类型的资源,所以该过滤器会将这种类型的资源直接过滤掉。另外一种是针对kube-system名称空间下的x-tunnel-server-internal-svc,这个services主要存在cloud节点用于访问yurt-tunnel-server,对于edge节点会直接过滤掉该service。
func (fh *discardCloudServiceFilterHandler) ObjectResponseFilter(b []byte) ([]byte, error) {
list, err := fh.serializer.Decode(b)
if err != nil || list == nil {
klog.Errorf("skip filter, failed to decode response in ObjectResponseFilter of discardCloudServiceFilterHandler %v", err)
return b, nil
}
serviceList, ok := list.(*v1.ServiceList)
if ok {
var svcNew []v1.Service
for i := range serviceList.Items {
nsName := fmt.Sprintf("%s/%s", serviceList.Items[i].Namespace, serviceList.Items[i].Name)
// remove lb service
if serviceList.Items[i].Spec.Type == v1.ServiceTypeLoadBalancer {
if serviceList.Items[i].Annotations[filter.SkipDiscardServiceAnnotation] != "true" {
klog.V(2).Infof("load balancer service(%s) is discarded in ObjectResponseFilter of discardCloudServiceFilterHandler", nsName)
continue
}
}
// remove cloud clusterIP service
if _, ok := cloudClusterIPService[nsName]; ok {
klog.V(2).Infof("clusterIP service(%s) is discarded in ObjectResponseFilter of discardCloudServiceFilterHandler", nsName)
continue
}
svcNew = append(svcNew, serviceList.Items[i])
}
serviceList.Items = svcNew
return fh.serializer.Encode(serviceList)
}
return b, nil
}
过滤框架现状
目前的过滤框架比较僵硬,将资源过滤硬编码至代码中,只能是已注册的资源才能进行相应的过滤,为了解决这个问题,需要对过滤框架进行相应的改造。
解决方案
方案一:
使用参数或者环境变量的形式自定义过滤配置,但是这种方式有以下弊端:
- 配置复杂需要将所以需要自定义的配置写入到启动参数或者读取环境变量 例如下格式:
--filter_serviceTopology=coredns/endpointslices#list,kube-proxy/services#list;watch --filter_endpointsFilter=nginx-ingress-controller/endpoints#list;watch
- 无法热更新,每次修改配置都需要重启yurthub生效
方案二:
- 使用configmap的形式自定义过滤配置降低配置复杂度 配置格式(user-agent/resource#list,watch) 多个资源通过逗号隔开。如下所示:
filter_endpoints: coredns/endpoints#list;watch,test/endpoints#list;watch filter_servicetopology: coredns/endpointslices#list;watch filter_discardcloudservice: "" filter_masterservice: ""
- 利用Informer机制保证配置实时生效
综合以上两点在OpenYurt中我们选择了解决方案二
开发过程中遇到的问题
在边缘端Informer watch的api地址是yurthub的代理地址,那么yurthub在启动代理端口之前都是无法保证configmap的数据是正常的。 如果在启动完成之后addons的请求先于configmap数据更新 这个时候会导致数据在没有过滤的情况下就返回给了addons,这样会导致很多预期以外的问题。
为了解决这个问题 我们需要在apporve中加入WaitForCacheSync保证数据同步完成之后才能返回相应的过滤数据,但是在apporve中加入WaitForCacheSync也直接导致configmap进行watch的时候也会被阻塞,所以需要在WaitForCacheSync之前加入一个白名单机制,当yurthub使用watch/list访问configmap的时候我们直接不进行数据过滤,相应的代码逻辑如下:
func (a *approver) Approve(comp, resource, verb string) bool {
if a.isWhitelistReq(comp, resource, verb) {
return false
}
if ok := cache.WaitForCacheSync(a.stopCh, a.configMapSynced); !ok {
panic("wait for configMap cache sync timeout")
}
a.Lock()
defer a.Unlock()
for _, requests := range a.nameToRequests {
for _, request := range requests {
if request.Equal(comp, resource, verb) {
return true
}
}
}
return false
}
总结
- 通过上述的扩展能力可以看出,YurtHub不仅仅是边缘节点上的带有数据缓存能力的反向代理。而是对 Kubernetes 节点应用生命周期管理加了一层新的封装,提供边缘计算所需要的核心管控能力。
- YurtHub不仅仅适用于边缘计算场景,其实可以作为节点侧的一个常备组件,适用于使用kubernetes的任意场景。相信这也会驱动YurtHub向更高性能,更高稳定性发展。