实现 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
,该参数有两个取值,分别是Immediate
和 WaitForFirstConsumer
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