云原生和地域/可用区的关系

1,104 阅读16分钟

目录

1 地域和可用区

1.1 地域

1.2 可用区

1.3 地域与灾备的关系

1.4 可用区与灾备的关系

1.4.1 可用区与其他云原生架构的关系

1.4.1.1 kubernetes跨可用区部署控制面

1.4.1.2 Serverless的可用区代码缓存

1.5 topology.kubernetes.io/zone

1.5.1 Node

1.5.2 PersistentVolume

地域和可用区

公有云和分布式集群中会接触到RegionAvailability Zone这两个概念,分别对应于地域可用区,其中针对公有云来说,厂商所提供的地域和可用区数量及覆盖范围,基本代表了云厂商的实力。

地域

地域之间是完全独立的基础设施,而可用区是同一地域中电力和网络互相独立的数据中心。比如华南地域中又分为可用区华南1和可用区华南2,其中华南1遇到不可抗力崩溃了,但是华南2还是正常的,如果做了可用区级别的架构高可用那么业务就不会受到影响。所以一般一个地域的组成至少不会少于一个可用区。

一般规模的运营商一个可用区就是一个数据中心,而像aws这种规模较大的云厂商,一个可用区可能是由多个地域靠的较近数据中心组成的。比如AWS最大的地域是美国东部-弗吉尼亚北部(us-east-1),该地域有6个可用区,其中最大的可用区由8个数据中心组成。 地域和可用区的关系

地域中有些特殊的规则:

  • 在同一地域内镜像资源可共享,跨地域镜像不可共享,需要复制到其他区域,因此每个地域拥有独立的镜像资源
  • 在跨地域的实例之间,流量通常需要收费;
  • 地域的距离通常较远,灾备方案往往需要跨地域

可用区

可用区在云原生中是一个比较重要的概念,kubernetes中与之关系密切的是pod调度相关的概念。

可用区中也有一些特殊的规则:

  • 同一个地域可具有多个可用区
  • 可用区之间通过低延时链路或内网链路连通
  • 可用区与可用区之间的供电设施等相互独立,一个可用区的故障不会影响另一个可用区

地域与灾备的关系

公有云厂商会打通各个区域的S3网络,所以S3中的数据可以跨任意区域进行迁移,所以在很多云上容灾架构设计的时候,有很多区域性(或可用区性)数据会用S3做备份的方案。

当AMI,EBS快照,RDS快照等数据发送到S3后,虽然这些数据会被设计为“以对象的形式在区域内的多个可用区保存”,但是整个区域的S3任然会有风险。公有云厂商会利用S3的跨区域特性做多地域备份,比如AWS的S3 Cross-Region replication (CRR) 功能。

基于跨区域复制S3的特性,可以设计出比较完备的云原生灾备方案,比如wmware的velero,可将pv定期复制到不同区域的S3,当原始地域的带pv的组件出现问题,可以在其他区域拉起相同的组件来做灾难恢复。

不过因为是跨区域的操作,备份数据的完整性性(RPO数据恢复点目标),切换备份的速度(RTO)以及复制数据带宽成本也是都是需要考虑的问题。

可用区与灾备的关系

相比区域灾备,日常接触更多的是可用区的灾备,这是建立在单个数据中心下线的概率远大于同一地域的多个数据中心同时下线的概率上来考量的。

单个数据中心(可用区)掉线不仅仅是单个磁盘,服务器或机架的中断,而是大面积云产品不可用的场景,是一种概率较低但是具有巨大影响风险的事故。

在上云前的传统基础架构中,通常会有这样一个灾难恢复计划,它被设计为在主要数据中心发生重大中断时允许故障转移到远程第二个数据中心。由于两个数据中心之间的距离很远,而分布式存储在设计时的前提都是低延迟的,因此这种高延迟的场景使得维护跨数据中心的副本同步变得不切实际。因此,故障转移肯定会导致数据丢失或数据恢复过程非常昂贵。进而带来连锁反应,当然还不得不考虑故障转移本身也是一种高风险并且不总是经过充分测试的程序。

当然这种方案尽管有诸多的不便,但不可否认这种模型可以提供出色的保护,防止类似数据中心掉线这种影响巨大的风险。

还考虑另外一种场景,运维人员往往会因为各种原因,认为当前数据中心的掉线是暂时的,并且会很快恢复,最后做出不进行备用数据中心切换的决策。

在上云后,可以采用更简单,更有效的保护来避免这些情况。一般在公有云的每个区域会包含多个不同的可用区。每个可用区又被设计为独立于其他可用区。可用区就是数据中心,某些大型可用区甚至由不少于2个数据中心组成。可用区又提供了廉价,低延迟的网络连接到同一地域的其他可用区。这就为同步方式跨数据中心复制数据的方案设计提供了可能,以便故障转移可以自动化并对运维人员透明。

不仅如此,还有更多样化的手段实现灾备。例如,一组应用程序服务器可以分布在多个可用区中,并附加到ELB。当特定可用区的虚机实例未通过健康检查时,ELB将停止向这些节点发送流量。此外,公有云的“主机自动伸缩”可确保正确数量的虚机实例可用于运行应用程序,根据需求启动和终止实例,并提供扩展策略定义。 公有云上的许多成熟产品都是根据多可用区原则设计的。例如,RDS使用多可用区部署为数据库实例提供高可用性和自动故障转移支持。

另外一些公有云厂商会将可用区和地域之间的灾备组成联合方案。可用区之间往往可设计同城灾备数据中心方案;而远程灾备方案,如金融业普遍存在的两地三中心方案,则往往需要跨地域实现。这种复杂的解决方案会被标以“金融云”的标签对外输出。由于同城与异地数据中心分属不同的region,同城数据中心可采用不同的AZ,而与异地数据中心之间既不能通过负载均衡进行流量分担,也不能跨地域访问云数据库,所以跨地域可用区一般会结合业务,打通专线来做架构设计。

可用区与其他云原生架构的关系

一个可用区(zone)表示一个逻辑故障域。Kubernetes 集群通常会跨越多个可用区以提高可用性。 虽然可用区的确切定义是基础设施方来决定的,但是一般站在kubernetes使用者的角度,kubernetes控制面都是跨可用区的,可用区常见的属性包括:

  • 可用区内的网络延迟非常低。
  • 可用区内的网络通讯没有成本,云厂商不会再对使用者收这部分的带宽费用
  • 独立于其他可用区的故障域。例如,一个可用区中的节点可以共享交换机,但不同可用区则不会。

对于 Kubernetes 来说,跨越多个地域(region)的集群很罕见。地域的常见属性包括:

  • 地域间比地域内更高的网络延迟
  • 地域间网络流量更高的成本
  • 独立于其他可用区或是地域的故障域。例如,一个地域内的节点可以共享电力基础设施(例如 UPS 或发电机),但不同地域内的节点显然不会。

Kubernetes 对可用区和地域的结构做出一些假设:

  • 地域和可用区是层次化的:可用区是地域的严格子集,任何可用区都不能再 2 个地域中出现。
  • 可用区名字在地域中独一无二:例如地域 "africa-east-1" 可由可用区 "africa-east-1a" 和 "africa-east-1b" 构成。

公有云的容器产品一般都会充分利用同地域条件下,跨可用区低延迟这个特性来设计产品。比如kubernetes集群的组建和serverless代码缓存。

kubernetes跨可用区部署控制面

kubernetes官方建议在部署控制面组件(api-server,scheduler,etcd,controller-manager)时跨可用区部署。而且如果可用性是非常重要的指标的话,至少应该跨三个可用区部署,这样至少两个可用区掉线后集群还能正常调度。比如下图是阿里云kubernetes集群的架构,master节点分别部署在a,b,c三个可用区:

跨可用区组建kubernetes集群

k8s没有内置的机制把某个可用区的流量引到指定的可用区控制面,不过可以使用公有云负载均衡等措施来实现这个目标。

这样架构就变成了,每个可用区都有一个控制面实例,假如发生可用区级别的网络或者硬件故障(比如下图的可用区B),出问题的可用区B流量发送端点(pod或者kubelet)不会自动把所有流量打到自己所属的控制面实例上。通过托管的带健康检查的负载均衡器或者DNS将pod或者kubelet的流量发送到自己所属的可用区控制面上。这样实际上就做到了灾难情况下,控制流数据在自己所属可用区内。

可用区掉线

另外,这里提到整个可用区掉线,在方案设计的时候如果需要考虑到这种情况,一般是运行一个修复性的job,这个job需要设定容忍能够最少将一个work节点修复为可用状态。这方面kubernetes官方没有现成方案

Serverless的可用区代码缓存

代码下载是serverless冷启动的一个耗时比较大的环节,根据代码包大小不一样,耗时从几十毫秒到几秒都有可能。有的云运营商会对上传的代码做多级缓存设计。除了做虚拟机级别的缓存,最有效的就是可用区内部的缓存了。一个可用区内的时延在网络建设标准要求中是1~2毫秒,因此将同一个地域的代码全量缓存到各个可用区,保证代码能在可用区内能下载到,降低下载的时间。

topology.kubernetes.io/zone

kubernetes架构中,和地域/可用区关系密切的是topology.kubernetes.io/zonetopology.kubernetes.io/region这两个标签。使用者可以安全的假定topology.kubernetes.io类的标签是固定不变的。即使标签严格来说是可变的,但使用者依然可以假定一个节点只有通过销毁、重建的方式,才能在可用区间移动。

topology.kubernetes.io类标签可以被标记到NodePersistentVolume中。

Node

kubernetes的节点在启动时,每个节点上的 kubelet 会向 Kubernetes API 中代表该kubelet的Node对象添加标签。 如果是在公有云场景下,一般云运营商会提供cloud-provider,这个组件会自动提供一些区域和可用区的信息,这些信息会作为label标签注入到node中。

这些标签如下所示:

topology.kubernetes.io/zone=us-east-1c
topology.kubernetes.io/region=us-central1

这个注解具体是在哪个阶段注入的呢?

  • kubelet的源码中有个类叫kubelet_node_status.go,这个类中提供一个同步节点状态的方法syncNodeStatus,以供kubelet在启动之后调用。
  • 这个方法在kubelet的启动参数register-node等于true的情况下(register-node是节点自注册首选的方式,该参数默认为true),会调用registerWithAPIServer方法
  • registerWithAPIServer这个注册方法用for循环不停的尝试注册node,其实就是不停的初始化node节点信息,然后尝试用patch方法向apiserver发送请求。而这个”初始化节点“中就包含区域和可用区的信息。

kubectl启动

注解的具体值怎么获取呢?

一种是像下图华为云的容器集群,直接在kubelet的启动参数中加入参数“--node-labels”,这个参数的值中包含区域和可用区的信息。这种情况kubelet会在拿到区域和可用区信息后自己组装label,然后向api-server注册node信息。

以参数方式注入

还有一种方式是通过kubelet在启动时指定获取外部的信息,其实也就是以前说的cloud-provider。这里分为两种情况,

  • cloud-providercloud-controller-manager替换以前:以前的cloud-provider逻辑是嵌在kube-controller-manager,api-server和kubelet中,这种情况下在kubelet启动的过程中,kubelet内部的cloud-provider逻辑会实例化元数据信息(InstanceID, ProviderID, ExternalID, Zone Info),kubelet用ZoneInfo元数据来构造topology.kubernetes.io相关的label。下面代码中的kl.cloud就是被实例化的cloud-provider:

    func (kl *Kubelet) initialNode(ctx context.Context) (*v1.Node, error) {
    	......
    	if kl.cloud != nil {
    		......
    		zones, ok := kl.cloud.Zones()
    		if ok {
    			zone, err := zones.GetZone(ctx)
    			if err != nil {
    				return nil, fmt.Errorf("failed to get zone from cloud provider: %v", err)
    			}
    			if zone.FailureDomain != "" {
    				klog.InfoS("Adding node label from cloud provider", "labelKey", v1.LabelFailureDomainBetaZone, "labelValue", zone.FailureDomain)
    				node.ObjectMeta.Labels[v1.LabelFailureDomainBetaZone] = zone.FailureDomain
    				klog.InfoS("Adding node label from cloud provider", "labelKey", v1.LabelTopologyZone, "labelValue", zone.FailureDomain)
    				node.ObjectMeta.Labels[v1.LabelTopologyZone] = zone.FailureDomain
    			}
    			if zone.Region != "" {
    				klog.InfoS("Adding node label from cloud provider", "labelKey", v1.LabelFailureDomainBetaRegion, "labelValue", zone.Region)
    				node.ObjectMeta.Labels[v1.LabelFailureDomainBetaRegion] = zone.Region
    				klog.InfoS("Adding node label from cloud provider", "labelKey", v1.LabelTopologyRegion, "labelValue", zone.Region)
    				node.ObjectMeta.Labels[v1.LabelTopologyRegion] = zone.Region
    			}
    		}
    	}
    
    	kl.setNodeStatus(node)
    
    	return node, nil
    }
    

    上面提到的cloud-provider由各大公有云运营商提供,且运行在控制面上,下图是一些遗留的实现作为参考:

cloud-provider开源实现

比如aws的实现,是利用aws-sdk-go中封装好的http方法,携带token请求aws的openapi获取ec2的metadata。其中metadata就包含区域和可用区等信息。

# staging/src/k8s.io/legacy-cloud-providers/aws/aws.go
func getAvailabilityZone(metadata EC2Metadata) (string, error) {
	return metadata.GetMetadata("placement/availability-zone")
}

# github.com\aws\aws-sdk-go\aws\ec2metadata\api.go
func (c *EC2Metadata) GetMetadata(p string) (string, error) {
	return c.GetMetadataWithContext(aws.BackgroundContext(), p)
}

func (c *EC2Metadata) GetMetadataWithContext(ctx aws.Context, p string) (string, error) {
	op := &request.Operation{
		Name:       "GetMetadata",
		HTTPMethod: "GET",
		HTTPPath:   sdkuri.PathJoin("/latest/meta-data", p),
	}
	output := &metadataOutput{}

	req := c.NewRequest(op, nil, output)

	req.SetContext(ctx)

	err := req.Send()
	return output.Content, err
}

这里还有一些其他的实现:

  • Oracle Cloud Infrastructure(GetZoneByNodeName)

  • Rancher(GetZone)

  • cloud-providercloud-controller-manager替换以后:cloud-provider的逻辑被抽离到cloud-controller-manager中,这种情况下,在kubelet启动时指定”cloud-provider=external“,指定这个值后kubelet在执行实际的逻辑之前会等待cloud-controller-manager组件的初始化。然后kebelet注册Node还是由kubelet完成,不过不会带有区域和可用区信息,只不过在cloud-controller-manager接收到node的注册信息后,会再次初始化node节点信息,利用特定云平台的信息为 Node 对象添加注解和标签,例如节点所在的 区域(Region)和所具有的资源(CPU、内存等等)。下面是tencentCloud的cloud-controller-manager,可以看到在实例化controller的时候,addCloudNode作为函数被加载,该方法的其中一个作用就是为节点添加可用区相关的label。

func NewCloudNodeController(
	nodeInformer coreinformers.NodeInformer,
	kubeClient clientset.Interface,
	cloud cloudprovider.Interface,
	nodeMonitorPeriod time.Duration,
	nodeStatusUpdateFrequency time.Duration) *CloudNodeController {
......
cnc.nodeInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc:    cnc.AddCloudNode,
		UpdateFunc: cnc.UpdateCloudNode,
	})

	return cnc
}

func (cnc *CloudNodeController) AddCloudNode(obj interface{}) {
		......
		if zones, ok := cnc.cloud.Zones(); ok {
			zone, err := getZoneByProviderIDOrName(zones, curNode)
			if err != nil {
				return fmt.Errorf("failed to get zone from cloud provider: %v", err)
			}
			if zone.FailureDomain != "" {
				glog.V(2).Infof("Adding node label from cloud provider: %s=%s", kubeletapis.LabelZoneFailureDomain, zone.FailureDomain)
				curNode.ObjectMeta.Labels[kubeletapis.LabelZoneFailureDomain] = zone.FailureDomain
			}
			if zone.Region != "" {
				glog.V(2).Infof("Adding node label from cloud provider: %s=%s", kubeletapis.LabelZoneRegion, zone.Region)
				curNode.ObjectMeta.Labels[kubeletapis.LabelZoneRegion] = zone.Region
			}
		}
		......
}

Kubernetes 能以多种方式使用node上的topology.kubernetes.io类的标签。 例如,调度器自动地尝试将 ReplicaSet 中的 Pod 打散在单可用区集群的不同节点上(以减少节点故障的影响)。 在多可用区的集群中,这类打散分布的行为也会应用到可用区(以减少可用区故障的影响)。 做到这一点靠的是 SelectorSpreadPriority

SelectorSpreadPriorityscheduler中打分调度阶段中的其中一种算法,这是一种最大能力分配方法(best effort)。如果集群中的可用区是异构的(例如:不同数量的节点,不同类型的节点,或不同的 Pod 资源需求),这种分配方法可以防止平均分配 Pod 到可用区,因为在异构集群中调度可能不应该是平均的。反之如果有需求,可以用同构的可用区(相同数量和类型的节点)来减少潜在的不平衡分布。

PersistentVolume

PersistentVolume上标记topology.kubernetes.io标签,也可分为两个阶段。

  • 第一个阶段是kubernetes1.13之前,这个功能是通过PersistentVolumeLabel准入控制器来实现
  • 第二个阶段是kubernetes1.13之后,这个功能被移动到cloud-controller-manager

scheduler在调度的时候会通过 VolumeZonePredicate 这一predicate的预测保障声明了某卷的 Pod 只能分配到该卷相同的可用区。 因为卷不支持跨可用区挂载。

下面是oraclecloud-controller-manager中关于pv可用区label的代码:

cloud-controller-manager的启动过程中,通过环境变量注入region的值,

region, ok := os.LookupEnv("OCI_SHORT_REGION")

Provision函数的作用是生成一个块存储的卷,在这个方法中使用上一步获得的region值标记pv,这个值会在scheduler的调度中用到。

func (block *blockProvisioner) Provision(options controller.ProvisionOptions, ad *identity.AvailabilityDomain) (*v1.PersistentVolume, error) {
	......
	pv := &v1.PersistentVolume{
		ObjectMeta: metav1.ObjectMeta{
			Name: *volume.Id,
			Annotations: map[string]string{
				OCIVolumeID: *volume.Id,
			},
			Labels: map[string]string{
				plugin.LabelZoneRegion:        block.region,
				plugin.LabelZoneFailureDomain: *ad.Name,
			},
		},
		......
	}
	......
	return pv, nil
}

scheduler默认会启动一系列plugins,其中NoVolumeZoneConflictPred这个插件就是用来过滤不在同一可用区的pod和pv,下面是pkg\scheduler\framework\plugins\legacy_registry.go中注册plugins的代码

func NewLegacyRegistry() *LegacyRegistry {
	registry := &LegacyRegistry{
		......
		DefaultPredicates: sets.NewString(
		NoVolumeZoneConflictPred,
		MaxEBSVolumeCountPred,
		MaxGCEPDVolumeCountPred,
		MaxAzureDiskVolumeCountPred,
		MaxCSIVolumeCountPred,
		MatchInterPodAffinityPred,
		NoDiskConflictPred,
		GeneralPred,
		PodToleratesNodeTaintsPred,
		CheckVolumeBindingPred,
		CheckNodeUnschedulablePred,
		EvenPodsSpreadPred,
	),
		......
	}
	......
    registry.registerPredicateConfigProducer(NoVolumeZoneConflictPred,
		func(_ ConfigProducerArgs, plugins *config.Plugins, _ *[]config.PluginConfig) {
			plugins.Filter = appendToPluginSet(plugins.Filter, volumezone.Name, nil)
		})
}

这个是过滤的逻辑代码,位置在pkg\scheduler\framework\plugins\volumezone\volume_zone.go,可以看到:

  • 首先提取nodelabel,如果没有可用区相关的label,就不再过滤该节点,意味着任意pv都能绑定到该pod当前所指定的节点(这里的当前节点意思是pod每一轮调度周期中指定的node,kube-scheduler会根据调度规则,轮训节点列表将pod绑定到node上,如果不符合需求将进行下一周期的调度)上,也即在调度pod时不会出现如下错误“node(s) had no available volume zone”
  • 然后从pod的spec中提取pv相关的信息,从pv的元数据中提取label,和本轮调度的nodeInfo上的label对比,如果node中的地域相关label值也存在与pv的label中,也就意味着当前node所在的地域是pv所在地域的子集,所以允许调度pod。
func (pl *VolumeZone) Filter(ctx context.Context, _ *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
	...
	node := nodeInfo.Node()
	if node == nil {
		return framework.NewStatus(framework.Error, "node not found")
	}
	nodeConstraints := make(map[string]string)
	for k, v := range node.ObjectMeta.Labels {
		if !volumeZoneLabels.Has(k) {
			continue
		}
		nodeConstraints[k] = v
	}
	if len(nodeConstraints) == 0 {
		return nil
	}

	for i := range pod.Spec.Volumes {
		volume := pod.Spec.Volumes[i]
        ......
		pvcName := volume.PersistentVolumeClaim.ClaimName
        ......
		pv, err := pl.pvLister.Get(pvName)

		for k, v := range pv.ObjectMeta.Labels {
			if !volumeZoneLabels.Has(k) {
				continue
			}
			nodeV, _ := nodeConstraints[k]
			volumeVSet, err := volumehelpers.LabelZonesToSet(v)
			if err != nil {
				klog.InfoS("Failed to parse label, ignoring the label", "label", fmt.Sprintf("%s:%s", k, v), "err", err)
				continue
			}

			if !volumeVSet.Has(nodeV) {
				klog.V(10).Infof("Won't schedule pod %q onto node %q due to volume %q (mismatch on %q)", pod.Name, node.Name, pvName, k)
				return framework.NewStatus(framework.UnschedulableAndUnresolvable, ErrReasonConflict)
			}
		}
	}
	return nil
}