Kubernetes PVC 延迟绑定原理
Apr 13, 2023 22:30 · 1932 words · 4 minute read
我们在 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
}
- csi-provisioner 调谐 PVC 时首先会去获取其指向的 StorageClass,查看它的
VolumeBindingMode
是否为WaitForFirstConsumer
- 查看 PVC 的注解中是否存在
annSelectedNode
即volume.kubernetes.io/selected-node
键值对,即使用该 PVC 的 Pod 调度至的节点。 - 如果不存在
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 是如何“动态”创建持久化存储的基础上,延迟绑定的实现逻辑就非常清晰明了:
- csi-provisioner 等待 PVC 的注解中携带落点
- Pod 调度后为 PVC 标注落点
- csi-provisioner 与 CSI controller 插件通信创建后端存储并同步地创建 PV 对象