Kubernetes PVC 延迟绑定原理

Apr 13, 2023 22:30 · 1932 words · 4 minute read Kubernetes Container Golang

我们在 Kubernetes 集群中使用节点的本地磁盘作为持久化存储卷时(例如 OpenEBS 项目),相应 StorageClass 的 volumeBindingMode 字段要设置为 WaitForFirstConsumer

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: openebs-lvmpv
parameters:
  fsType: ext4
  storage: lvm
  volgroup: lvmvg
provisioner: local.csi.openebs.io
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer

WaitForFirstConsumer 即 PV 的延迟绑定模式,直到使用 PVC 的 Pod 落点确定才会去创建和绑定 PV。

本文以 OpenEBS 项目为例,带大家深入剖析 Kubernetes 是如何通过各组件的协作来实现 PV 延迟绑定的。

当使用 OpenEBS 的 LVM StorageClass 的 PVC 创建成功:

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: local-disk-test0
  namespace: demo
spec:
  storageClassName: openebs-lvmpv
  volumeMode: Block
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1G

csi-provisioner

Provisioning 由 controller 插件负责,它所在的 Pod 中有一个叫做 csi-provisioner 的 sidecar 容器。通常情况下(例如 Ceph RBD CSI),csi-provisioner 监听到 PVC 创建事件,就会发送 CreateVolume CSI 请求至 controller 插件,在存储系统中创建一个新的存储卷。

但由于本地存储的特殊性,无需 attach 至主机,只要创建就已经天然“插在”了主机上,不可能集群中的每个节点都创建一块本地盘,Pod 被调度哪个节点上就使用那个节点上的本地盘。

所以 csi-provisioner 一定会等到 PVC 携带了 Pod 的落点信息,确定需要创建本地盘的节点后,才会发送 CreateVolume CSI 请求。我们来看一下源码验证上述猜想是否成立 https://github.com/kubernetes-csi/external-provisioner/blob/v3.1.0/vendor/sigs.k8s.io/sig-storage-lib-external-provisioner/v8/controller/controller.go#L1156-L1197

func (ctrl *ProvisionController) shouldProvision(ctx context.Context, claim *v1.PersistentVolumeClaim) (bool, error) {
    // a lot of code here
    if found {
        if ctrl.knownProvisioner(provisioner) {
            claimClass := util.GetPersistentVolumeClaimClass(claim)
            class, err := ctrl.getStorageClass(claimClass)
            if err != nil {
                return false, err
            }
            if class.VolumeBindingMode != nil && *class.VolumeBindingMode == storage.VolumeBindingWaitForFirstConsumer {
                // When claim is in delay binding mode, annSelectedNode is
                // required to provision volume.
                // Though PV controller set annStorageProvisioner only when
                // annSelectedNode is set, but provisioner may remove
                // annSelectedNode to notify scheduler to reschedule again.
                if selectedNode, ok := claim.Annotations[annSelectedNode]; ok && selectedNode != "" {
                    return true, nil
                }
                return false, nil
            }
            return true, nil
        }
    }

    return false, nil
}
  1. csi-provisioner 调谐 PVC 时首先会去获取其指向的 StorageClass,查看它的 VolumeBindingMode 是否为 WaitForFirstConsumer
  2. 查看 PVC 的注解中是否存在 annSelectedNodevolume.kubernetes.io/selected-node 键值对,即使用该 PVC 的 Pod 调度至的节点。
  3. 如果不存在 annSelectedNode,表示 Pod 落点暂未确定,不能去创建本地盘。

我们来挖掘 PVC 何时会被打上 volume.kubernetes.io/selected-node 注解。

kube-scheduler

Kubernetes 代码仓库中全局搜索 volume.kubernetes.io/selected-node 关键字,在 https://github.com/kubernetes/kubernetes/blob/v1.23.6/pkg/controller/volume/persistentvolume/util/util.go#L50-L52 文件中:

    // AnnSelectedNode annotation is added to a PVC that has been triggered by scheduler to
    // be dynamically provisioned. Its value is the name of the selected node.
    AnnSelectedNode = "volume.kubernetes.io/selected-node"

继续全局搜索 AnnSelectedNode 关键字,来到调度器 kube-scheduler 的源码 https://github.com/kubernetes/kubernetes/blob/3af11fc12dcd4121e70f6227b52b40a9c4e42507/pkg/scheduler/framework/plugins/volumebinding/binder.go#L368-L433

func (b *volumeBinder) AssumePodVolumes(assumedPod *v1.Pod, nodeName string, podVolumes *PodVolumes) (allFullyBound bool, err error) {
    // a lot code here
    // Assume PVCs
    newProvisionedPVCs := []*v1.PersistentVolumeClaim{}
    for _, claim := range podVolumes.DynamicProvisions {
        // The claims from method args can be pointing to watcher cache. We must not
        // modify these, therefore create a copy.
        claimClone := claim.DeepCopy()
        metav1.SetMetaDataAnnotation(&claimClone.ObjectMeta, pvutil.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
}

整个 Kubernetes 代码仓库中只有这一处为 PVC 添加 volume.kubernetes.io/selected-node 注解键值对。

nodeName 即 Pod 落点,而唯一实现了 Pod 调度过程中 Reserve 扩展点的 VolumeBinding 插件,它的 AssumePodVolumes 方法将 Pod 需要的 PVC 和对应的 PV 在缓存中绑定。

Pod 调度过程如图所示,在两个周期中都已经定义好各阶段的扩展点,随着调度的进行,各扩展点都会调用到注册过的插件。

对 Kubernetes 调度器感兴趣的同学请查看调度器系列

csi-provisioner again

再回到 csi-provisioner,此时 PVC 已存在 annSelectedNode 注解,则 Pod 落点已定。

直接看 ProvisionController 控制器的 syncClaim 方法:

func (ctrl *ProvisionController) syncClaim(ctx context.Context, obj interface{}) error {
    claim, ok := obj.(*v1.PersistentVolumeClaim)
    if !ok {
        return fmt.Errorf("expected claim but got %+v", obj)
    }

    should, err := ctrl.shouldProvision(ctx, claim)
    if err != nil {
        ctrl.updateProvisionStats(claim, err, time.Time{})
        return err
    } else if should {
        startTime := time.Now()

        status, err := ctrl.provisionClaimOperation(ctx, claim)
        // a lot of code here
    }
    return nil
}

csi-provisioner 在此会去执行 Provision(创建后端存储卷),这里需要先了解Kubernetes 动态 PV 实现原理

查看 ProvisionController 控制器的 provisionClaimOperation 方法:

func (ctrl *ProvisionController) provisionClaimOperation(ctx context.Context, claim *v1.PersistentVolumeClaim) (ProvisioningState, error) {
    // a lot of code here
    var selectedNode *v1.Node
    // Get SelectedNode
    if nodeName, ok := getString(claim.Annotations, annSelectedNode, annAlphaSelectedNode); ok {
        if ctrl.nodeLister != nil {
            selectedNode, err = ctrl.nodeLister.Get(nodeName)
        } else {
            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 ProvisioningNoChange, err
        }
    }

    options := ProvisionOptions{
        StorageClass: class,
        PVName:       pvName,
        PVC:          claim,
        SelectedNode: selectedNode, // not nil
    }
    ctrl.eventRecorder.Event(claim, v1.EventTypeNormal, "Provisioning", fmt.Sprintf("External provisioner is provisioning volume for claim %q", claimToClaimKey(claim)))

    volume, result, err := ctrl.provisioner.Provision(ctx, options)
    // a lot of code here
}

与远端存储不同selectedNode 会被赋值 Pod 将被调度至的节点。options 变量会作为参数传递给 Provision 方法:

func (p *csiProvisioner) Provision(ctx context.Context, options controller.ProvisionOptions) (*v1.PersistentVolume, controller.ProvisioningState, error) {
    // a lot of code here
    result, state, err := p.prepareProvision(ctx, claim, options.StorageClass, options.SelectedNode)
    if result == nil {
        return nil, state, err
    }
    req := result.req
    volSizeBytes := req.CapacityRange.RequiredBytes
    pvName := req.Name
    provisionerCredentials := req.Secrets

    createCtx := markAsMigrated(ctx, result.migratedVolume)
    createCtx, cancel := context.WithTimeout(createCtx, p.timeout)
    defer cancel()
    rep, err := p.csiClient.CreateVolume(createCtx, req)

    // a lot of code here
    pv := &v1.PersistentVolume{
        ObjectMeta: metav1.ObjectMeta{
            Name: pvName,
        },
        Spec: v1.PersistentVolumeSpec{
            AccessModes:  options.PVC.Spec.AccessModes,
            MountOptions: options.StorageClass.MountOptions,
            Capacity: v1.ResourceList{
                v1.ResourceName(v1.ResourceStorage): bytesToQuantity(respCap),
            },
            // TODO wait for CSI VolumeSource API
            PersistentVolumeSource: v1.PersistentVolumeSource{
                CSI: result.csiPVSource,
            },
        },
    }

    if options.StorageClass.ReclaimPolicy != nil {
        pv.Spec.PersistentVolumeReclaimPolicy = *options.StorageClass.ReclaimPolicy
    }

    if p.supportsTopology() {
        pv.Spec.NodeAffinity = GenerateVolumeNodeAffinity(rep.Volume.AccessibleTopology)
    }
}

向 controller 插件发送 CreateVolume CSI 请求创建后端存储卷。

PV 对象也是由 csi-provisioner 创建的,而本地盘的 PV 会额外携带拓扑信息,即 NodeAffinity:

$ kubectl get pv pvc-e6a39d6e-8fb1-4717-845d-6388dfc7afe3 -o jsonpath={.spec.nodeAffinity} | jq
{
  "required": {
    "nodeSelectorTerms": [
      {
        "matchExpressions": [
          {
            "key": "openebs.io/nodename",
            "operator": "In",
            "values": [
              "ecs1"
            ]
          }
        ]
      }
    ]
  }
}

总结

在理解了 Kubernetes 是如何“动态”创建持久化存储的基础上,延迟绑定的实现逻辑就非常清晰明了:

  1. csi-provisioner 等待 PVC 的注解中携带落点
  2. Pod 调度后为 PVC 标注落点
  3. csi-provisioner 与 CSI controller 插件通信创建后端存储并同步地创建 PV 对象