目录

Kubernetes CSI(二): OpenEBS原理

Kubernetes CSI (二): OpenEBS 原理

简介

Kubernetes CSI (一): Kubernetes 存储原理 一文中详细讲解了 Kubernetes CSI 的原理,本篇文章通过原理和源码走读形式讲解 OpenEBS 原理。

OpenEBS 是一款使用Go语言编写的基于容器的块存储开源软件。OpenEBS 使得在容器中运行关键性任务和需要数据持久化的负载变得更可靠。实现了 Kubernetes CSI,所以可以很方便对接 Kubernetes 存储功能。

对于大部分第三方存储厂商,它们都只实现了分布式存储,OpenEBS 可以为 Kubernetes 有状态负载( StatefulSet ) 提供本地存储卷和分布式存储卷。

本篇文章重点讲解 OpenEBS 的本地存储卷

本地存储卷

本地存储卷很容易理解,在 Kubernetes 中本身就支持 Hostpath 类型的存储卷,

使用 HostPath 有一个局限性就是,我们的 Pod 不能随便漂移,需要固定到一个节点上,因为一旦漂移到其他节点上去了宿主机上面就没有对应的数据了,所以我们在使用 HostPath 的时候都会搭配 nodeSelector 来进行使用。

但是使用 HostPath 明显也有一些好处的,因为 PV 直接使用的是本地磁盘,尤其是 SSD 盘,它的读写性能相比于大多数远程存储来说,要好得多,所以对于一些对磁盘 IO 要求比较高的应

用,比如 etcd 就非常实用了。不过呢,相比于正常的 PV 来说,使用了 HostPath 的这些节点一旦宕机数据就可能丢失,所以这就要求使用 HostPath 的应用必须具备数据备份和恢复的能

力,允许你把这些数据定时备份在其他位置。

所以在 HostPath 的基础上,Kubernetes 依靠 PV、PVC 实现了一个新的特性,这个特性的名字叫作:Local Persistent Volume,也就是我们说的 Local PV

要想使用 Local PV 考虑的因素也比较多,下面详细看看:

Local PV

其实 Local PV 实现的功能就非常类似于 HostPath 加上 nodeAffinity ,即表示该 PV 就是一个 Hostpath 类型卷。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-local
spec:
  capacity:
    storage: 5Gi
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Delete
  storageClassName: local-storage
  local:
    path: /data/k8s/localpv # 对应主机上数据目录
  # 该 pv 与节点绑定
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - node-1

Local PV 不仅仅支持 Filesystem 类型存储,还支持 Block,LVM 类型

延迟绑定

延迟绑定就是在 Pod 调度完成之后,再绑定对应的 PVC、PV。对于使用 Local PV 的 Pod,必须延迟绑定。

比如现在明确规定,这个 Pod 只能运行在 node-1 这个节点上,且该 Pod 申请了一个 PVC。对于没有延迟属性的 StorageClass,那么就会在 Pod 调度到某个节点之前就将该 PVC 绑定到合适的 PV,如果集群 node-1,node-2 都存在 PV 可以和该 PVC 绑定,那么就有可能该 PVC 绑定了 node-2 的 PV,就会导致 Pod 调度失败。

所以为了避免这种现象,就必须在 PVC 和 PV 绑定之前就将 Pod 调度完成。所以我们在使用 Local PV 的时候,就必须延迟绑定操作,即延迟到 Pod 调度完成之后再绑定 PVC。

那么怎么才能实现延迟绑定

对于 Local PV 类型的 StorageClass 需要配置 volumeBindingMode=WaitForFirstConsumer 的属性,就是告诉 Kubernetes 在发现这个 StorageClass 关联的 PVC 与 PV 可以绑定在一起,但不要

现在就立刻执行绑定操作(即:设置 PVC 的 VolumeName 字段),而是要等到第一个声明使用该 PVC 的 Pod 出现在调度器之后,调度器再综合考虑所有的调度规则,当然也包括每个 PV

所在的节点位置,来统一决定。这个 Pod 声明的 PVC,到底应该跟哪个 PV 进行绑定。通过这个延迟绑定机制,原本实时发生的 PVC 和 PV 的绑定过程,就被延迟到了 Pod 第一次调度的时

候在调度器中进行,从而保证了这个绑定结果不会影响 Pod 的正常调度。

1
2
3
4
5
6
7
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-storage
provisioner: kubernetes.io/no-provisioner
# 延迟绑定属性
volumeBindingMode: WaitForFirstConsumer

原地重启

我们知道使用 Hostpath 存储卷类型 Pod 如果不设置节点选择器,那么重启后会调度到其他节点上运行,这样就会导致之前数据丢失。

使用 Local PV 存储卷就无需担心该问题,因为根据 Local PV 的特性会保证该 Pod 重启后始终在当前节点运行。当然这里涉及到 Kubernetes 调度器和 PVC、PV的相关原理,下面我们简单描述下。

当 Pod 重启后,调度器首先判断该 Pod下的 PVC 是否已经绑定,如果已经绑定,那么就根据 PVC Annotation volume.kubernetes.io/selected-node 字段过滤到其他 node。

这样就保证该 Pod 就一直在 PV 所在 node 上运行。

这段代码逻辑参考以下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// For PVCs that are bound, then it checks that the corresponding PV's node affinity is
// satisfied by the given node.

// kubernetes/pkg/scheduler/framework/plugins/volumebinding/binder.go:316
func (b *volumeBinder) FindPodVolumes(pod *v1.Pod, boundClaims, claimsToBind []*v1.PersistentVolumeClaim, node *v1.Node) (podVolumes *PodVolumes, reasons ConflictReasons, err error) {
...
		// Find matching volumes and node for unbound claims
	if len(claimsToBind) > 0 {
		var (
			claimsToFindMatching []*v1.PersistentVolumeClaim
			claimsToProvision    []*v1.PersistentVolumeClaim
		)

		// 调度器对集群中的 node 与 pvc Annotation volume.kubernetes.io/selected-node 字段比较,过滤掉不匹配 node
		for _, claim := range claimsToBind {
			if selectedNode, ok := claim.Annotations[volume.AnnSelectedNode]; ok {
				if selectedNode != node.Name {
					// Fast path, skip unmatched node.
					unboundVolumesSatisfied = false
					return
				}
				claimsToProvision = append(claimsToProvision, claim)
			} else {
				claimsToFindMatching = append(claimsToFindMatching, claim)
			}
		}
...
}

上面说的 PVC Annotation 是在 Pod 调度完成后,调度器设置该 Annotation,其 value 是节点名称

这段代码逻辑参考以下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// AssumePodVolumes will take the matching PVs and PVCs to provision in pod's
// volume information for the chosen node, and:
// 1. Update the pvCache with the new prebound PV.
// 2. Update the pvcCache with the new PVCs with annotations set
// 3. Update PodVolumes again with cached API updates for PVs and PVCs.

// kubernetes/pkg/scheduler/framework/plugins/volumebinding/binder.go:359
func (b *volumeBinder) AssumePodVolumes(assumedPod *v1.Pod, nodeName string, podVolumes *PodVolumes) (allFullyBound bool, err error) {
	...

	newProvisionedPVCs := []*v1.PersistentVolumeClaim{}
	for _, claim := range podVolumes.DynamicProvisions {
		claimClone := claim.DeepCopy()
		// 设置 volume.kubernetes.io/selected-node annotation,value 为该 pod 调度的 nodeName
		metav1.SetMetaDataAnnotation(&claimClone.ObjectMeta, volume.AnnSelectedNode, nodeName)
		err = b.pvcCache.Assume(claimClone)
		if err != nil {
			b.revertAssumedPVs(newBindings)
			b.revertAssumedPVCs(newProvisionedPVCs)
			return
		}

		newProvisionedPVCs = append(newProvisionedPVCs, claimClone)
	}

	podVolumes.StaticBindings = newBindings
	podVolumes.DynamicProvisions = newProvisionedPVCs
	return
}

OpenEBS 使用

OpenEBS 对本地存储卷功能支持非常好:

  • OpenEBS可以使用宿主机裸块设备或分区,或者使用Hostpaths上的子目录,或者使用LVMZFS来创建持久化卷
  • 本地卷直接挂载到StatefulSet Pod中,而不需要OpenEBS在数据路径中增加任何开销
  • OpenEBS为本地卷提供了额外的工具,用于监控、备份/恢复、灾难恢复、由ZFSLVM支持的快照等

同时 OpenEBS 屏蔽了我们使用 Local PV 复杂性。OpenEBS 部署及使用也是非常方便。

这里我们只使用 local-pv-hostpath,即使用本地文件系统的存储,根据官网介绍,不需要提前安装 iscsi

Helm 部署

1
2
3
$ helm repo add openebs https://openebs.github.io/charts
$ helm repo update
$ helm install openebs --namespace openebs openebs/openebs --create-namespace

kubectl 部署

1
kubectl apply -f https://openebs.github.io/charts/openebs-operator.yaml

安装成功后,默认会部署两个 storageclass ,我们目前需要 openebs-hostpath。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ kubectl get pods -n openebs
NAME                                           READY   STATUS    RESTARTS   AGE
openebs-localpv-provisioner-69c8648db7-cnj45   1/1     Running   0          33m
openebs-ndm-bbgpv                              1/1     Running   0          33m
openebs-ndm-bxsbb                              1/1     Running   0          33m
openebs-ndm-cluster-exporter-9d75d564d-qvqz6   1/1     Running   0          33m
openebs-ndm-kdg7b                              1/1     Running   0          33m
openebs-ndm-node-exporter-9zm62                1/1     Running   0          33m
openebs-ndm-node-exporter-hlj7h                1/1     Running   0          33m
openebs-ndm-node-exporter-j6wj7                1/1     Running   0          33m
openebs-ndm-node-exporter-s5hk4                1/1     Running   0          33m
openebs-ndm-operator-789985cc47-r4hwj          1/1     Running   0          33m
openebs-ndm-qm4sw                              1/1     Running   0          33m
1
2
3
4
$ kubectl get sc
NAME               PROVISIONER        RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
openebs-device     openebs.io/local   Delete          WaitForFirstConsumer   false                  116s
openebs-hostpath   openebs.io/local   Delete          WaitForFirstConsumer   false                  116s

默认存储目录为 ****/var/openebs/local, 如果需要更改的话,直接修改 openebs-operator.yamlvalue 字段即可

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: openebs-hostpath
  annotations:
    openebs.io/cas-type: local
    cas.openebs.io/config: |
      #hostpath type will create a PV by 
      # creating a sub-directory under the
      # BASEPATH provided below.
      - name: StorageType
        value: "hostpath"
      #Specify the location (directory) where
      # where PV(volume) data will be saved. 
      # A sub-directory with pv-name will be 
      # created. When the volume is deleted, 
      # the PV sub-directory will be deleted.
      #Default value is /var/openebs/local
      - name: BasePath
        value: "/var/openebs/local/"

验证

接下来我们创建一个 PVC 资源对象,Pod 使用这个 PVC 就可以从 OpenEBS 动态 Local PV Provisioner 中请求 Hostpath Local PV 了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# local-hostpath-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: local-hostpath-pvc
spec:
  storageClassName: openebs-hostpath
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi

直接创建这个 PVC 即可:

1
2
3
4
$ kubectl apply -f local-hostpath-pvc.yaml
$ kubectl get pvc local-hostpath-pvc
NAME                 STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS       AGE
local-hostpath-pvc   Pending                                      openebs-hostpath   12s

我们可以看到这个 PVC 的状态是 Pending,这是因为对应的 StorageClass 是延迟绑定模式,所以需要等到 Pod 消费这个 PVC 后才会去绑定,接下来我们去创建一个 Pod 来使用这个 PVC。

声明一个如下所示的 Pod 资源清单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# local-hostpath-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: hello-local-hostpath-pod
spec:
  volumes:
  - name: local-storage
    persistentVolumeClaim:
      claimName: local-hostpath-pvc
  containers:
  - name: hello-container
    image: busybox
    command:
       - sh
       - -c
       - 'while true; do echo "`date` [`hostname`] Hello from OpenEBS Local PV." >> /mnt/store/greet.txt; sleep $(($RANDOM % 5 + 300)); done'
    volumeMounts:
    - mountPath: /mnt/store
      name: local-storage

直接创建这个 Pod:

1
2
3
4
5
6
7
8
$ kubectl apply -f local-hostpath-pod.yaml
$ kubectl get pods hello-local-hostpath-pod
NAME                       READY   STATUS    RESTARTS   AGE
hello-local-hostpath-pod   1/1     Running   0          2m7s
$ kubectl get pvc local-hostpath-pvc
NAME                 STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS       AGE
local-hostpath-pvc   Bound    pvc-3f4a1a65-6cbc-42bf-a1f8-87ad238c0b88   5Gi        RWO            openebs-hostpath   5m41s

可以看到 Pod 运行成功后,PVC 也绑定上了一个自动生成的 PV,我们可以查看这个 PV 的详细信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
$ kubectl get pv pvc-3f4a1a65-6cbc-42bf-a1f8-87ad238c0b88 -o yaml
apiVersion: v1
kind: PersistentVolume
metadata:
  annotations:
    pv.kubernetes.io/provisioned-by: openebs.io/local
  creationTimestamp: "2021-01-07T02:48:14Z"
  finalizers:
  - kubernetes.io/pv-protection
  labels:
    openebs.io/cas-type: local-hostpath
  ......
  name: pvc-3f4a1a65-6cbc-42bf-a1f8-87ad238c0b88
  resourceVersion: "21193802"
  selfLink: /api/v1/persistentvolumes/pvc-3f4a1a65-6cbc-42bf-a1f8-87ad238c0b88
  uid: f7cccdb3-d23a-4831-86c3-4363eb1a8dee
spec:
  accessModes:
  - ReadWriteOnce
  capacity:
    storage: 5Gi
  claimRef:
    apiVersion: v1
    kind: PersistentVolumeClaim
    name: local-hostpath-pvc
    namespace: default
    resourceVersion: "21193645"
    uid: 3f4a1a65-6cbc-42bf-a1f8-87ad238c0b88
  local:
    fsType: ""
    path: /var/openebs/local/pvc-3f4a1a65-6cbc-42bf-a1f8-87ad238c0b88
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - node2
  persistentVolumeReclaimPolicy: Delete
  storageClassName: openebs-hostpath
  volumeMode: Filesystem
status:
  phase: Bound

本地数据目录位于 /var/openebs/local/pvc-3f4a1a65-6cbc-42bf-a1f8-87ad238c0b88 下面

接着我们来验证下 volume 数据,前往 node2 节点查看下上面的数据目录中的数据:

1
2
3
4
5
6
$ ls /var/openebs/local/pvc-3f4a1a65-6cbc-42bf-a1f8-87ad238c0b88
greet.txt
$ cat /var/openebs/local/pvc-3f4a1a65-6cbc-42bf-a1f8-87ad238c0b88/greet.txt
Thu Jan  7 10:48:49 CST 2021 [hello-local-hostpath-pod] Hello from OpenEBS Local PV.
Thu Jan  7 10:53:50 CST 2021 [hello-local-hostpath-pod] Hello from OpenEBS Local PV.

可以看到 Pod 容器中的数据已经持久化到 Local PV 对应的目录中去了。但是需要注意的是 StorageClass 默认的数据回收策略是 Delete,所以如果将 PVC 删掉后数据会自动删除,我们可以 Velero 这样的工具来进行备份还原。

OpenEBS Local PV 原理

由于 OpenEBS 实现了多种存储,由于篇幅问题,下面只详细讲解 Hostpath 类型原理

OpenEBS Local PV 部署架构

根据上一篇 Kubernetes CSI (一): Kubernetes 存储原理 说到 CSI 分为两部分:

  • External component( Kubernetes Team )
  • CSI Driver

具体作用和原理可查看原文。

OpenEBS 同样也实现了 CSI,所以它的部署架构遵从 CSI 部署统一标准。但是对于 Local PV (Hostpath) 类型并不需要那么复杂。

只需提供 openebs-localpv-provisioner 即可,无需提供 CSI Driver,因为本地数据目录挂载 Kubelet 就可以完成,无需第三方 CSI。

1
2
3
$ kubectl get pods -n openebs
NAME                                           READY   STATUS    RESTARTS   AGE
openebs-localpv-provisioner-69c8648db7-cnj45   1/1     Running   0          33m

根据上一篇文章讲解,openebs-localpv-provisioner 应该是一个 Deployment 或者 DaemonSet,由 ****External component( Kubernetes Team ) sideCar 和 CSI Identity + CSI Controller 组成。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    name: openebs-localpv-provisioner
    openebs.io/component-name: openebs-localpv-provisioner
    openebs.io/version: 3.0.0
  name: openebs-localpv-provisioner
  namespace: openebs
spec:
  progressDeadlineSeconds: 600
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      name: openebs-localpv-provisioner
      openebs.io/component-name: openebs-localpv-provisioner
  strategy:
    type: Recreate
  template:
    metadata:
      creationTimestamp: null
      labels:
        name: openebs-localpv-provisioner
        openebs.io/component-name: openebs-localpv-provisioner
        openebs.io/version: 3.0.0
    spec:
      containers:
      - args:
        - --bd-time-out=$(BDC_BD_BIND_RETRIES)
        env:
        - name: BDC_BD_BIND_RETRIES
          value: "12"
        - name: NODE_NAME
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: spec.nodeName
        - name: OPENEBS_NAMESPACE
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: metadata.namespace
        - name: OPENEBS_SERVICE_ACCOUNT
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: spec.serviceAccountName
        - name: OPENEBS_IO_ENABLE_ANALYTICS
          value: "true"
        - name: OPENEBS_IO_INSTALLER_TYPE
          value: openebs-operator
        - name: OPENEBS_IO_HELPER_IMAGE
          value: openes.io/linux-utils:3.0.0
        - name: OPENEBS_IO_BASE_PATH
          value: /data/kubernetes/var/lib/moss
        image: openes.io/provisioner-localpv:3.0.0
        imagePullPolicy: IfNotPresent
        livenessProbe:
          exec:
            command:
            - sh
            - -c
            - test `pgrep -c "^provisioner-loc.*"` = 1
          failureThreshold: 3
          initialDelaySeconds: 30
          periodSeconds: 60
          successThreshold: 1
          timeoutSeconds: 1
        name: openebs-provisioner-hostpath
        resources: {}
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      serviceAccount: openebs-maya-operator
      serviceAccountName: openebs-maya-operator
      terminationGracePeriodSeconds: 30

可以发现 openebs-localpv-provisioner 并没有 ****External component( Kubernetes Team ) sideCar,通过阅读该组件代码发现,该组件本身已经集成了 External provisioner (sig-storage-lib-external-provisioner 库) 功能,在后文会通过源码来解释。

那么在 Kubernetes 集群中,当一个 Pod 利用 OpenEBS Hostpath 是如何被创建出来的。

  • Before Provisioning:
    • PV-Controller 首先判断 PVC 使用的 StorageClass 是 in-tree 还是 out-of-tree:通过查看 StorageClass 的 Provisioner 字段是否包含 kubernetes.io/ 前缀来判断;
    • PV-Controller 更新 PVC 的 annotation:volume.beta.kubernetes.io/storage-provisioner = openebs.io/local
  • out-of-tree Provisioning(external provisioning):
    • openebs-localpv-provisioner( sig-storage-lib-external-provisioner) Watch 到 PVC;
    • openebs-localpv-provisioner( sig-storage-lib-external-provisioner) 检查 PVC 中的 Spec.VolumeName 是否为空,不为空则直接跳过该 PVC;
    • openebs-localpv-provisioner( sig-storage-lib-external-provisioner) 检查 PVC 中的 Annotations[“volume.beta.kubernetes.io/storage-provisioner”]是否等于自己的 Provisioner Name( openebs.io/local );
    • openebs-localpv-provisioner( sig-storage-lib-external-provisioner) 检查到 StorageClass 的 VolumeBindingMode = WaitForFirstConsumer,所以需要延迟绑定,等待 Pod 调度完成;
    • pod 调度完成后,openebs-localpv-provisioner( sig-storage-lib-external-provisioner) 根据 PVC Annotation volume.kubernetes.io/selected-node 值选择 PV 所在节点;
    • openebs-localpv-provisioner( sig-storage-lib-external-provisioner) 调用 openebs-localpv-provisioner 方法创建本地数据目录并返回 PV 结构体对象;
    • openebs-localpv-provisioner( sig-storage-lib-external-provisioner) 创建 PV
    • PV-Controller 同时将该 PV 与之前的 PVC 做绑定。
  • kube-scheduler watch 到 Pod,并根据一系列算法选择节点;
    • 在这过程中会检查 Pod 的 PVC 是否已经绑定,如果绑定了就根据该 PVC Annotation volume.kubernetes.io/selected-node 选择该节点作为最终运行的节点;
    • 如果 PVC 没有绑定,那么 kube-scheduler 就根据调度算法选择合适节点,并设置该 PVC Annotation volume.kubernetes.io/selected-node = node.name
  • Kubelet 将 PV 的数据目录绑定到 Pod 数据目录(在 /var/lib/kubelet/pods/{poduid}/volumes/{volume-plugin}/{pv-name} ) 。

下图简单描述以上流程:

/k8s-csi-openebs/openebs-localpv.png

openebs-localpv-provisioner 源码解析

上面分析了创建一个申请了 Local PV 的 Pod 的流程,下面通过源码走读分析以上流程。

组件源码地址:https://github.com/openebs/dynamic-localpv-provisioner.git

然后 dynamic-localpv-provisoner 组件集成了 external-provisoner 的功能,使用的是 sig-storage-lib-external-provisioner ****库,源码地址:https://github.com/kubernetes-sigs/sig-storage-lib-external-provisioner.git

dynamic-localpv-provisoner 启动后 Watch 到 PVC,根据上述流程对 PVC 一系列检查,判断是否创建 PV;

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// sigs.k8s.io/sig-storage-lib-external-provisioner/v7@v7.0.1/controller/controller.go:1124
func (ctrl *ProvisionController) shouldProvision(ctx context.Context, claim *v1.PersistentVolumeClaim) (bool, error) {
  // dynamic-localpv-provisoner 启动后 Watch 到 PVC,检查 PVC 中的 `Spec.VolumeName` 是否为空,不为空则直接跳过该 PVC。
	if claim.Spec.VolumeName != "" {
		return false, nil
	}

	if qualifier, ok := ctrl.provisioner.(Qualifier); ok {
		if !qualifier.ShouldProvision(ctx, claim) {
			return false, nil
		}
	}
	
	// 检查 PVC 中的 Annotations[“volume.beta.kubernetes.io/storage-provisioner”]是否等于自己的 Provisioner Name( openebs.io/local )
	if provisioner, found := claim.Annotations[annStorageProvisioner]; found {
		if ctrl.knownProvisioner(provisioner) {
			claimClass := util.GetPersistentVolumeClaimClass(claim)
			class, err := ctrl.getStorageClass(claimClass)
			if err != nil {
				return false, err
			}
			// 查到 StorageClass 的 VolumeBindingMode = WaitForFirstConsumer,所以需要延迟绑定
			if class.VolumeBindingMode != nil && *class.VolumeBindingMode == storage.VolumeBindingWaitForFirstConsumer {
				// 根据 PVC Annotation volume.kubernetes.io/selected-node 值选择 PV 所在节点
				if selectedNode, ok := claim.Annotations[annSelectedNode]; ok && selectedNode != "" {
					return true, nil
				}
				return false, nil
			}
			return true, nil
		}
	}

	return false, nil
}

检查过后,就需要在节点上创建 Hostpath 数据目录了,dynamic-localpv-provisoner 组件是通过创建一个临时 pod 来完成此次操作,创建完成后会被删除;

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// dynamic-localpv-provisioner/cmd/provisioner-localpv/app/helper_hostpath.go:167
func (p *Provisioner) createInitPod(ctx context.Context, pOpts *HelperPodOptions) error {
	var config podConfig
	config.pOpts, config.podName = pOpts, "init"
	//err := pOpts.validate()
	if err := pOpts.validate(); err != nil {
		return err
	}

	var vErr error
	config.parentDir, config.volumeDir, vErr = hostpath.NewBuilder().WithPath(pOpts.path).
		WithCheckf(hostpath.IsNonRoot(), "volume directory {%v} should not be under root directory", pOpts.path).
		ExtractSubPath()
	if vErr != nil {
		return vErr
	}

	config.taints = pOpts.selectedNodeTaints

	config.pOpts.cmdsForPath = append(config.pOpts.cmdsForPath, filepath.Join("/data/", config.volumeDir))

	// 该 pod 用于在调度节点上创建数据目录
	iPod, err := p.launchPod(ctx, config)
	if err != nil {
		return err
	}

	// 创建完即可删除
	if err := p.exitPod(ctx, iPod); err != nil {
		return err
	}

	return nil
}

数据目录创建完成后,就表示 volume 存储卷创建完成,就可以创建 PV 了

创建 PV 是在 external-provisoner 完成的,在 dynamic-localpv-provisoner 组件里也就是 sig-storage-lib-external-provisioner

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// sig-storage-lib-external-provisioner/v7@v7.0.1/controller/volume_store.go:208
func (b *backoffStore) StoreVolume(claim *v1.PersistentVolumeClaim, volume *v1.PersistentVolume) error {
	// Try to create the PV object several times
	var lastSaveError error
	err := wait.ExponentialBackoff(*b.backoff, func() (bool, error) {
		klog.Infof("Trying to save persistentvolume %q", volume.Name)
		var err error
		// 创建 pv
		if _, err = b.client.CoreV1().PersistentVolumes().Create(context.Background(), volume, metav1.CreateOptions{}); err == nil || apierrs.IsAlreadyExists(err) {
			// Save succeeded.
			if err != nil {
				klog.Infof("persistentvolume %q already exists, reusing", volume.Name)
			} else {
				klog.Infof("persistentvolume %q saved", volume.Name)
			}
			return true, nil
		}
		// Save failed, try again after a while.
		klog.Infof("Failed to save persistentvolume %q: %v", volume.Name, err)
		lastSaveError = err
		return false, nil
	})

	if err == nil {
		// Save succeeded
		msg := fmt.Sprintf("Successfully provisioned volume %s", volume.Name)
		b.eventRecorder.Event(claim, v1.EventTypeNormal, "ProvisioningSucceeded", msg)
		return nil
	}

	// Save failed. Now we have a storage asset outside of Kubernetes,
	// but we don't have appropriate PV object for it.
	// Emit some event here and try to delete the storage asset several
	// times.
	strerr := fmt.Sprintf("Error creating provisioned PV object for claim %s: %v. Deleting the volume.", claimToClaimKey(claim), lastSaveError)
	klog.Error(strerr)
	b.eventRecorder.Event(claim, v1.EventTypeWarning, "ProvisioningFailed", strerr)

	var lastDeleteError error
	err = wait.ExponentialBackoff(*b.backoff, func() (bool, error) {
		if err = b.ctrl.provisioner.Delete(context.Background(), volume); err == nil {
			// Delete succeeded
			klog.Infof("Cleaning volume %q succeeded", volume.Name)
			return true, nil
		}
		// Delete failed, try again after a while.
		klog.Infof("Failed to clean volume %q: %v", volume.Name, err)
		lastDeleteError = err
		return false, nil
	})
	if err != nil {
		// Delete failed several times. There is an orphaned volume and there
		// is nothing we can do about it.
		strerr := fmt.Sprintf("Error cleaning provisioned volume for claim %s: %v. Please delete manually.", claimToClaimKey(claim), lastDeleteError)
		klog.Error(strerr)
		b.eventRecorder.Event(claim, v1.EventTypeWarning, "ProvisioningCleanupFailed", strerr)
	}

	return lastSaveError
}

接下来就是 PV Controller 将 PVC 和 PV 进行绑定,Kubelet Local-Volume 将 Local-PV 数据目录挂载到 Pod 目录。

Kubelet 实现了很多 Volume plugin,有:awsebs、azure_file、azuredd、cephfs、configmap、csi、downwardapi、emptydir、fc、flexvolume、gcepd、git_repo、hostpath、iscsi、local、nfs、portworx、projected、rbd、secret、vsphere_volume

通过判断 PV 支持哪个 Volume plugin,然后再去调用对应 Volume plugin 来执行 mount 操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 该 interface 定义了 VolumePlugin 的方法
// kubernetes/pkg/volume/plugins.go:124
type VolumePlugin interface {
	// Init initializes the plugin.  This will be called exactly once
	// before any New* calls are made - implementations of plugins may
	// depend on this.
	Init(host VolumeHost) error

	// Name returns the plugin's name.  Plugins must use namespaced names
	// such as "example.com/volume" and contain exactly one '/' character.
	// The "kubernetes.io" namespace is reserved for plugins which are
	// bundled with kubernetes.
	GetPluginName() string

	// GetVolumeName returns the name/ID to uniquely identifying the actual
	// backing device, directory, path, etc. referenced by the specified volume
	// spec.
	// For Attachable volumes, this value must be able to be passed back to
	// volume Detach methods to identify the device to act on.
	// If the plugin does not support the given spec, this returns an error.
	GetVolumeName(spec *Spec) (string, error)

	// 判断该 Volume 属于哪个 Volume plugin
	CanSupport(spec *Spec) bool

	// RequiresRemount returns true if this plugin requires mount calls to be
	// reexecuted. Atomically updating volumes, like Downward API, depend on
	// this to update the contents of the volume.
	RequiresRemount(spec *Spec) bool

	// NewMounter creates a new volume.Mounter from an API specification.
	// Ownership of the spec pointer in *not* transferred.
	// - spec: The v1.Volume spec
	// - pod: The enclosing pod
	(spec *Spec, podRef *v1.Pod, opts VolumeOptions) (Mounter, error)

	// NewUnmounter creates a new volume.Unmounter from recoverable state.
	// - name: The volume name, as per the v1.Volume spec.
	// - podUID: The UID of the enclosing pod
	NewUnmounter(name string, podUID types.UID) (Unmounter, error)

	// ConstructVolumeSpec constructs a volume spec based on the given volume name
	// and volumePath. The spec may have incomplete information due to limited
	// information from input. This function is used by volume manager to reconstruct
	// volume spec by reading the volume directories from disk
	ConstructVolumeSpec(volumeName, volumePath string) (ReconstructedVolume, error)

	// SupportsMountOption returns true if volume plugins supports Mount options
	// Specifying mount options in a volume plugin that doesn't support
	// user specified mount options will result in error creating persistent volumes
	SupportsMountOption() bool

	// SupportsBulkVolumeVerification checks if volume plugin type is capable
	// of enabling bulk polling of all nodes. This can speed up verification of
	// attached volumes by quite a bit, but underlying pluging must support it.
	SupportsBulkVolumeVerification() bool

	// SupportsSELinuxContextMount returns true if volume plugins supports
	// mount -o context=XYZ for a given volume.
	SupportsSELinuxContextMount(spec *Spec) (bool, error)
}

以上 CanSupport(spec *Spec) bool 可以知道 Volume 对应的 VolumePlugin。下面看看 Local PV 是如何实现该方法的。

1
2
3
4
5
// kubernetes/pkg/volume/local/local.go:82
func (plugin *localVolumePlugin) CanSupport(spec *volume.Spec) bool {
	// 根据 local-pv 的资源定义,知道 local-pv 的 spec.PersistentVolume.Spec.Local 不可能为空。
	return (spec.PersistentVolume != nil && spec.PersistentVolume.Spec.Local != nil)
}

然后 Kubelet 根据 volumePlugin 类型调用 NewMounter() 来获取一个 mount 对象,进而执行 mount 操作。下面看看 Local PV 的 mounter 是如何挂载的

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
// kubernetes/pkg/volume/local/local.go:525
func (m *localVolumeMounter) SetUpAt(dir string, mounterArgs volume.MounterArgs) error {
	m.plugin.volumeLocks.LockKey(m.globalPath)
	defer m.plugin.volumeLocks.UnlockKey(m.globalPath)
	// globalPath 即为 Local PV 定义的数据目录,不存在即报错
	if m.globalPath == "" {
		return fmt.Errorf("LocalVolume volume %q path is empty", m.volName)
	}
	// 检查数据目录路径是否合法,不能含有 "../"
	err := validation.ValidatePathNoBacksteps(m.globalPath)
	if err != nil {
		return fmt.Errorf("invalid path: %s %v", m.globalPath, err)
	}
	// 检查 Pod 目录是否为挂载点
	notMnt, err := mount.IsNotMountPoint(m.mounter, dir)
	klog.V(4).Infof("LocalVolume mount setup: PodDir(%s) VolDir(%s) Mounted(%t) Error(%v), ReadOnly(%t)", dir, m.globalPath, !notMnt, err, m.readOnly)
	if err != nil && !os.IsNotExist(err) {
		klog.Errorf("cannot validate mount point: %s %v", dir, err)
		return err
	}

	if !notMnt {
		return nil
	}
	// linux mount 支持一个挂载点挂载多个目录,这里防止 pv 目录为多个路径
	refs, err := m.mounter.GetMountRefs(m.globalPath)
	if mounterArgs.FsGroup != nil {
		if err != nil {
			klog.Errorf("cannot collect mounting information: %s %v", m.globalPath, err)
			return err
		}

		// 判断目录是否被其他 pod 使用
		refs = m.filterPodMounts(refs)
		if len(refs) > 0 {
			fsGroupNew := int64(*mounterArgs.FsGroup)
			_, fsGroupOld, err := m.hostUtil.GetOwner(m.globalPath)
			if err != nil {
				return fmt.Errorf("failed to check fsGroup for %s (%v)", m.globalPath, err)
			}
			if fsGroupNew != fsGroupOld {
				m.plugin.recorder.Eventf(m.pod, v1.EventTypeWarning, events.WarnAlreadyMountedVolume, "The requested fsGroup is %d, but the volume %s has GID %d. The volume may not be shareable.", fsGroupNew, m.volName, fsGroupOld)
			}
		}

	}
	// linux 需要创建 pod 目录
	if runtime.GOOS != "windows" {
		// skip below MkdirAll for windows since the "bind mount" logic is implemented differently in mount_wiondows.go
		if err := os.MkdirAll(dir, 0750); err != nil {
			klog.Errorf("mkdir failed on disk %s (%v)", dir, err)
			return err
		}
	}
	// Perform a bind mount to the full path to allow duplicate mounts of the same volume.
	options := []string{"bind"}
	if m.readOnly {
		options = append(options, "ro")
	}
	mountOptions := util.JoinMountOptions(options, m.mountOptions)

	klog.V(4).Infof("attempting to mount %s", dir)
	globalPath := util.MakeAbsolutePath(runtime.GOOS, m.globalPath)
	// 将 dir( pod 目录 ) 挂载到 globalPath( pv 目录 )
	err = m.mounter.MountSensitiveWithoutSystemd(globalPath, dir, "", mountOptions, nil)
	if err != nil {
		klog.Errorf("Mount of volume %s failed: %v", dir, err)
		notMnt, mntErr := mount.IsNotMountPoint(m.mounter, dir)
		if mntErr != nil {
			klog.Errorf("IsNotMountPoint check failed: %v", mntErr)
			return err
		}
		if !notMnt {
			if mntErr = m.mounter.Unmount(dir); mntErr != nil {
				klog.Errorf("Failed to unmount: %v", mntErr)
				return err
			}
			notMnt, mntErr = mount.IsNotMountPoint(m.mounter, dir)
			if mntErr != nil {
				klog.Errorf("IsNotMountPoint check failed: %v", mntErr)
				return err
			}
			if !notMnt {
				// This is very odd, we don't expect it.  We'll try again next sync loop.
				klog.Errorf("%s is still mounted, despite call to unmount().  Will try again next sync loop.", dir)
				return err
			}
		}
		if rmErr := os.Remove(dir); rmErr != nil {
			klog.Warningf("failed to remove %s: %v", dir, rmErr)
		}
		return err
	}
	if !m.readOnly {
		// Volume owner will be written only once on the first volume mount
		if len(refs) == 0 {
			return volume.SetVolumeOwnership(m, dir, mounterArgs.FsGroup, mounterArgs.FSGroupChangePolicy, util.FSGroupCompleteHook(m.plugin, nil))
		}
	}
	return nil
}

至此 Local PV 定义的数据目录就和 Pod 在宿主机上的唯一目录绑定起来,最后该目录是如何映射到容器内部,那是 CRI 的工作了,后面可以详细聊聊。

下面给出 dynamic-localpv-provisoner 组件函数调用关系如下图:

/k8s-csi-openebs/dynamic-localpv-provisoner.png

总结

至此 OpenEBS Local PV( Hostpath ) 类型原理就讲解完了,这只是 OpenEBS 最简单的 CSI 实现,但也是使用很频繁的一类 CSI,其原理和上一篇分析的 CSI 大体原理基本类似。

结合 OpenEBS 的简单使用和源码解析,可以对 Kubernetes CSI 的原理理解更深一层,同时我们也可以自行实现一款 CSI 与 Kubernetes 对接。


WeChat Pay
关注微信公众号,可了解更多云原生详情~

相关文章