实现 Kubernetes 动态LocalVolume挂载本地磁盘

前言

在 Kubernetes 体系中,存在大量的存储插件用于实现基于网络的存储系统挂载,比如NFS、GFS、Ceph和云厂商的云盘设备。但在某些用户的环境中,可能无法或没有必要搭建复杂的网络存储系统,需要一个更简单的存储方案。另外网络存储系统避免不了性能的损耗,然而对于一些分布式数据库,其在应用层已经实现了数据同步和冗余,在存储层只需要一个高性能的存储方案。

在这些情况下如何实现Kubernetes 应用的数据持久化呢?

  • HostPath Volume

对 Kubernetes 有一定使用经验的伙伴首先会想到HostPath Volume,这是一种可以直接挂载宿主机磁盘的Volume实现,将容器中需要持久化的数据挂载到宿主机上,它当然可以实现数据持久化。然而会有以下几个问题:

(1)HostPath Volume与节点无关,意味着在多节点的集群中,Pod的重新创建不会保障调度到原来的节点,这就意味着数据丢失。于是我们需要搭配设置调度属性使Pod始终处于某一个节点,这在带来配置复杂性的同时还破坏了Kubernetes的调度均衡度。

(2)HostPath Volume的数据不易管理,当Volume不需要使用时数据无法自动完成清理从而形成较多的磁盘浪费。

  • Local Persistent Volume

Local Persistent Volume 在 Kubernetes 1.14中完成GA。相对于HostPath Volume,Local Persistent Volume 首先考虑解决调度问题。使用了Local Persistent Volume 的Pod调度器将使其始终运行于同一个节点。用户不需要在额外设置调度属性。并且它在第一次调度时遵循其他调度算法,一定层面上保持了均衡度。

遗憾的是 Local Persistent Volume 默认不支持动态配置。在社区方案中有提供一个静态PV配置器sig-storage-local-static-provisioner,其可以达成的效果是管理节点上的磁盘生命周期和PV的创建和回收。虽然可以实现PV的创建,但它的工作模式与常规的Provisioners,它不能根据PVC的需要动态提供PV,需要在节点上预先准备好磁盘和PV资源。

如何在此方案的基础上进一步简化,在节点上基于指定的数据目录,实现动态的LocalVolume挂载呢?

技术方案

需要达成的效果如下:

(1)基于Local Persistent Volume 实现的基础思路;

(2)实现各节点的数据目录的管理;

(3)实现动态 PV 分配;

StorageClass定义

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: rainbondslsc
provisioner: rainbond.io/provisioner-sslc
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer

其中有一个关键性参数volumeBindingMode,该参数有两个取值,分别是ImmediateWaitForFirstConsumer

Immediate 模式下PVC与PV立即绑定,主要是不等待相关Pod调度完成,不关心其运行节点,直接完成绑定。相反的 WaitForFirstConsumer模式下需要等待Pod调度完成后进行PV绑定。因此PV创建时可以获取到Pod的运行节点。

我们需要实现的 provisioner 工作在 WaitForFirstConsumer 模式下,在创建PV时获取到Pod的运行节点,调用该节点的驱动服务创建磁盘路径进行初始化,进而完成PV的创建。

Provisioner的实现

Provisioner分为两个部分,一个是控制器部分,负责PV的创建和生命周期,另一部分是节点驱动服务,负责管理节点上的磁盘和数据。

PV控制器部分

控制器部分实现的代码参考: Rainbond 本地存储控制器

控制器部分的主要逻辑是从 Kube-API 监听 PersistentVolumeClaim 资源的变更,基于spec.storageClassName字段判断资源是否应该由当前控制器管理。如果是走以下流程: (1)基于PersistentVolumeClaim获取到StorageClass资源,例如上面提到的rainbondslsc。

(2)基于StorageClass的provisioner值判定处理流程。

(3)从PersistentVolumeClaim资源中的Annotations配置 volume.kubernetes.io/selected-node 获取PVC所属Pod的运行节点。该值是由调度器设置的,这是一个关键信息获取。

if ctrl.kubeVersion.AtLeast(utilversion.MustParseSemantic("v1.11.0")) {
		// Get SelectedNode
		if nodeName, ok := claim.Annotations[annSelectedNode]; ok {
			selectedNode, err = ctrl.client.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}) // TODO (verult) cache Nodes
			if err != nil {
				err = fmt.Errorf("failed to get target node: %v", err)
				ctrl.eventRecorder.Event(claim, v1.EventTypeWarning, "ProvisioningFailed", err.Error())
				return err
			}
		}

		// Get AllowedTopologies
		allowedTopologies, err = ctrl.fetchAllowedTopologies(claimClass)
		if err != nil {
			err = fmt.Errorf("failed to get AllowedTopologies from StorageClass: %v", err)
			ctrl.eventRecorder.Event(claim, v1.EventTypeWarning, "ProvisioningFailed", err.Error())
			return err
		}
	}

(4)调用节点服务创建对应存储目录或独立磁盘。

path, err := p.createPath(options)
	if err != nil {
		if err == dao.ErrVolumeNotFound {
			return nil, err
		}
		return nil, fmt.Errorf("create local volume from node %s failure %s", options.SelectedNode.Name, err.Error())
	}
	if path == "" {
		return nil, fmt.Errorf("create local volume failure,local path is not create")
	}

(5) 创建对应的PV资源。

pv := &v1.PersistentVolume{
		ObjectMeta: metav1.ObjectMeta{
			Name:   options.PVName,
			Labels: options.PVC.Labels,
		},
		Spec: v1.PersistentVolumeSpec{
			PersistentVolumeReclaimPolicy: options.PersistentVolumeReclaimPolicy,
			AccessModes:                   options.PVC.Spec.AccessModes,
			Capacity: v1.ResourceList{
				v1.ResourceName(v1.ResourceStorage): options.PVC.Spec.Resources.Requests[v1.ResourceName(v1.ResourceStorage)],
			},
			PersistentVolumeSource: v1.PersistentVolumeSource{
				HostPath: &v1.HostPathVolumeSource{
					Path: path,
				},
			},
			MountOptions: options.MountOptions,
			NodeAffinity: &v1.VolumeNodeAffinity{
				Required: &v1.NodeSelector{
					NodeSelectorTerms: []v1.NodeSelectorTerm{
						{
							MatchExpressions: []v1.NodeSelectorRequirement{
								{
									Key:      "kubernetes.io/hostname",
									Operator: v1.NodeSelectorOpIn,
									Values:   []string{options.SelectedNode.Labels["kubernetes.io/hostname"]},
								},
							},
						},
					},
				},
			},
		},
	}

其中关键性参数是设置PV的NodeAffinity参数,使其绑定在选定的节点。然后使用 HostPath 类型的PersistentVolumeSource指定挂载的路径。

当PV资源删除时,根据PV绑定的节点进行磁盘资源的释放:

nodeIP := func() string {
					for _, me := range pv.Spec.NodeAffinity.Required.NodeSelectorTerms[0].MatchExpressions {
						if me.Key != "kubernetes.io/hostname" {
							continue
						}
						return me.Values[0]
					}
					return ""
				}()

				if nodeIP == "" {
					logrus.Errorf("storage class: rainbondslsc; name: %s; node ip not found", pv.Name)
					return
				}

				if err := deletePath(nodeIP, path); err != nil {
					logrus.Errorf("delete path: %v", err)
					return
				}

节点驱动服务

节点驱动服务主要提供两个API,分配磁盘空间和释放磁盘空间。在实现上,简化方案则是直接在指定路径下创建子路径和释放子路径。较详细的方案可以像 sig-storage-local-static-provisioner 一样,实现对节点上存储设备的管理,包括发现、初始化、分配、回收等等。

使用方式

Rainbond中,使用者仅需指定挂载路径和选择本地存储即可。

对应的翻译为 Kubernetes 资源后PVC配置如下:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  labels:
    app_id: 6f67c68fc3ee493ea7d1705a17c0744b
    creater_id: "1614686043101141901"
    creator: Rainbond
    name: gr39f329
    service_alias: gr39f329
    service_id: deb5552806914dbc93646c7df839f329
    tenant_id: 3be96e95700a480c9b37c6ef5daf3566
    tenant_name: 2c9v614j
    version: "20210302192942"
    volume_name: log
  name: manual3432-gr39f329-0
  namespace: 3be96e95700a480c9b37c6ef5daf3566
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 500Gi
  storageClassName: rainbondslsc
  volumeMode: Filesystem

总结

基于上诉的方案,我们可以自定义实现一个基础的动态LocalVolume,适合于集群中间件应用使用。这也是云原生应用管理平台 Rainbond 中本地存储的实现思路。在该项目中有较多的 Kubernetes 高级用法实践封装,研究源码访问:https://github.com/goodrain/rainbond

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章