一文读懂容器存储接口 CSI

**导读:**在一文我们重点介绍了 K8s 内部的存储流程,以及 PV、PVC、StorageClass、Kubelet 等之间的调用关系。接下来本文将将重点放在 CSI(Container Storage Interface)容器存储接口上,探究什么是 CSI 及其内部工作原理。
背景
K8s 原生支持一些存储类型的 PV,如 iSCSI、NFS、CephFS 等等(详见),这些 in-tree 类型的存储代码放在 Kubernetes 代码仓库中。这里带来的问题是 K8s 代码与三方存储厂商的代码强耦合:
更改 in-tree 类型的存储代码,用户必须更新 K8s 组件,成本较高
in-tree 存储代码中的 bug 会引发 K8s 组件不稳定
K8s 社区需要负责维护及测试 in-tree 类型的存储功能
in-tree 存储插件享有与 K8s 核心组件同等的特权,存在安全隐患
三方存储开发者必须遵循 K8s 社区的规则开发 in-tree 类型存储代码
CSI 容器存储接口标准的出现解决了上述问题,将三方存储代码与 K8s 代码解耦,使得三方存储厂商研发人员只需实现 CSI 接口(无需关注容器平台是 K8s 还是 Swarm 等)。
CSI 核心流程介绍
在详细介绍 CSI 组件及其接口之前,我们先对 K8s 中 CSI 存储流程进行一个介绍。《一文读懂 K8s 持久化存储流程》一文介绍了 K8s 中的 Pod 在挂载存储卷时需经历三个的阶段:Provision/Delete(创盘/删盘)、Attach/Detach(挂接/摘除)和 Mount/Unmount(挂载/卸载),下面以图文的方式讲解 K8s 在这三个阶段使用 CSI 的流程。
1. Provisioning Volumes

1.集群管理员创建 StorageClass 资源,该 StorageClass 中包含 CSI 插件名称(provisioner:pangu.csi.alibabacloud.com)以及存储类必须的参数(parameters: type=cloud_ssd)。sc.yaml 文件如下:

2.用户创建 PersistentVolumeClaim 资源,PVC 指定存储大小及 StorageClass(如上)。pvc.yaml 文件如下:

3.**卷控制器(PersistentVolumeController)**观察到集群中新创建的 PVC 没有与之匹配的 PV,且其使用的存储类型为 out-of-tree,于是为 PVC 打 annotation:volume.beta.kubernetes.io/storage-provisioner=[out-of-tree CSI 插件名称](本例中即为 provisioner:pangu.csi.alibabacloud.com)。
4.External Provisioner 组件观察到 PVC 的 annotation 中包含 "volume.beta.kubernetes.io/storage-provisioner" 且其 value 是自己,于是开始创盘流程。
获取相关 StorageClass 资源并从中获取参数(本例中 parameters 为 type=cloud_ssd),用于后面 CSI 函数调用。
通过 unix domain socket 调用外部 CSI 插件的CreateVolume 函数。
5.外部 CSI 插件返回成功后表示盘创建完成,此时External Provisioner 组件会在集群创建一个 PersistentVolume 资源。
6.卷控制器会将 PV 与 PVC 进行绑定。

2. Attaching Volumes

1.AD 控制器(AttachDetachController)观察到使用 CSI 类型 PV 的 Pod 被调度到某一节点,此时AD 控制器会调用**内部 in-tree CSI 插件(csiAttacher)**的 Attach 函数。
2.**内部 in-tree CSI 插件(csiAttacher)**会创建一个 VolumeAttachment 对象到集群中。
3.External Attacher 观察到该 VolumeAttachment 对象,并调用外部 CSI插件的ControllerPublish 函数以将卷挂接到对应节点上。外部 CSI 插件挂载成功后,External Attacher会更新相关 VolumeAttachment 对象的 .Status.Attached 为 true。

4.AD 控制器内部 in-tree CSI 插件(csiAttacher)观察到 VolumeAttachment 对象的 .Status.Attached 设置为 true,于是更新AD 控制器内部状态(ActualStateOfWorld),该状态会显示在 Node 资源的 .Status.VolumesAttached 上。

3. Mounting Volumes

1.**Volume Manager(Kubelet 组件)观察到有新的使用 CSI 类型 PV 的 Pod 调度到本节点上,于是调用内部 in-tree CSI 插件(csiAttacher)**的 WaitForAttach 函数。
2.**内部 in-tree CSI 插件(csiAttacher)**等待集群中 VolumeAttachment 对象状态 .Status.Attached 变为 true。
3.in-tree CSI 插件(csiAttacher)调用 MountDevice 函数,该函数内部通过 unix domain socket 调用外部 CSI 插件的NodeStageVolume 函数;之后插件(csiAttacher)调用内部 in-tree CSI 插件(csiMountMgr)的 SetUp 函数,该函数内部会通过 unix domain socket 调用外部 CSI 插件的NodePublishVolume 函数。
4. Unmounting Volumes

1.用户删除相关 Pod。
2.Volume Manager(Kubelet 组件)观察到包含 CSI 存储卷的 Pod 被删除,于是调用内部 in-tree CSI 插件(csiMountMgr)的 TearDown 函数,该函数内部会通过 unix domain socket 调用外部 CSI 插件的 NodeUnpublishVolume 函数。
3.Volume Manager(Kubelet 组件)调用内部 in-tree CSI 插件(csiAttacher)的 UnmountDevice 函数,该函数内部会通过 unix domain socket 调用外部 CSI 插件的 NodeUnpublishVolume 函数。
5. Detaching Volumes

1.AD 控制器观察到包含 CSI 存储卷的 Pod 被删除,此时该控制器会调用**内部 in-tree CSI 插件(csiAttacher)**的 Detach 函数。
2.csiAttacher会删除集群中相关 VolumeAttachment 对象(但由于存在 finalizer,va 对象不会立即删除)。
3.External Attacher观察到集群中 VolumeAttachment 对象的 DeletionTimestamp 非空,于是调用外部 CSI 插件的ControllerUnpublish 函数以将卷从对应节点上摘除。外部 CSI 插件摘除成功后,External Attacher会移除相关 VolumeAttachment 对象的 finalizer 字段,此时 VolumeAttachment 对象被彻底删除。
4.AD 控制器中内部 in-tree CSI 插件(csiAttacher)观察到 VolumeAttachment 对象已删除,于是更新AD 控制器中的内部状态;同时AD 控制器更新 Node 资源,此时 Node 资源的 .Status.VolumesAttached 上已没有相关挂接信息。
6. Deleting Volumes

1.用户删除相关 PVC。
2.External Provisioner 组件观察到 PVC 删除事件,根据 PVC 的回收策略(Reclaim)执行不同操作:
Delete:调用外部 CSI 插件的DeleteVolume 函数以删除卷;一旦卷成功删除,Provisioner会删除集群中对应 PV 对象。
Retain:Provisioner不执行卷删除操作。
CSI Sidecar 组件介绍
为使 K8s 适配 CSI 标准,社区将与 K8s 相关的存储流程逻辑放在了 CSI Sidecar 组件中。
1. Node Driver Registrar
1)功能
Node-Driver-Registrar 组件会将外部 CSI 插件注册到Kubelet,从而使Kubelet通过特定的 Unix Domain Socket 来调用外部 CSI 插件函数(Kubelet 会调用外部 CSI 插件的 NodeGetInfo、NodeStageVolume、NodePublishVolume、NodeGetVolumeStats 等函数)。
2)原理
Node-Driver-Registrar 组件通过Kubelet 外部插件注册机制实现注册,注册成功后:
Kubelet为本节点 Node 资源打 annotation:Kubelet调用外部 CSI 插件的NodeGetInfo 函数,其返回值 [nodeID]、[driverName] 将作为值用于 "csi.volume.kubernetes.io/nodeid" 键。
Kubelet更新 Node Label:将NodeGetInfo 函数返回的 [AccessibleTopology] 值用于节点的 Label。
Kubelet更新 Node Status:将NodeGetInfo 函数返回的 maxAttachLimit(节点最大可挂载卷数量)更新到 Node 资源的 Status.Allocatable:attachable-volumes-csi-[driverName]=[maxAttachLimit]。

Kubelet更新 CSINode 资源(没有则创建):将 [driverName]、[nodeID]、[maxAttachLimit]、[AccessibleTopology] 更新到 Spec 中(拓扑仅保留 Key 值)。
2. External Provisioner
1)功能
创建/删除实际的存储卷,以及代表存储卷的 PV 资源。
2)原理
External-Provisioner在启动时需指定参数 -- provisioner,该参数指定 Provisioner 名称,与 StorageClass 中的 provisioner 字段对应。
External-Provisioner启动后会 watch 集群中的 PVC 和 PV 资源。
对于集群中的 PVC 资源:
判断 PVC 是否需要动态创建存储卷,标准如下:
PVC 的 annotation 中是否包含 "volume.beta.kubernetes.io/storage-provisioner" 键(由卷控制器创建),并且其值是否与 Provisioner 名称相等。
PVC 对应 StorageClass 的 VolumeBindingMode 字段若为 WaitForFirstConsumer,则 PVC 的 annotation 中必须包含 "volume.kubernetes.io/selected-node" 键(详见调度器如何处理 WaitForFirstConsumer),且其值不为空;若为 Immediate 则表示需要 Provisioner 立即提供动态存储卷。
通过特定的 Unix Domain Socket 调用外部 CSI 插件的 CreateVolume 函数。
创建 PV 资源,PV 名称为 [Provisioner 指定的 PV 前缀] - [PVC uuid]。
对于集群中的 PV 资源:
判断 PV 是否需要删除,标准如下:
判断其 .Status.Phase 是否为 Release。
判断其 .Spec.PersistentVolumeReclaimPolicy 是否为 Delete。
判断其是否包含 annotation(pv.kubernetes.io/provisioned-by),且其值是否为自己。
通过特定的 Unix Domain Socket 调用外部 CSI 插件的 DeleteVolume 接口。
删除集群中的 PV 资源。
3. External Attacher
1)功能
挂接/摘除存储卷。
2)原理
**External-Attacher **内部会时刻 watch 集群中的 VolumeAttachment 资源和 PersistentVolume 资源。
对于 VolumeAttachment 资源:
从 VolumeAttachment 资源中获得 PV 的所有信息,如 volume ID、node ID、挂载 Secret 等。
判断 VolumeAttachment 的 DeletionTimestamp 字段是否为空来判断其为卷挂接或卷摘除:若为卷挂接则通过特定的 Unix Domain Socket 调用外部 CSI 插件的ControllerPublishVolume 接口;若为卷摘除则通过特定的 Unix Domain Socket 调用外部 CSI 插件的ControllerUnpublishVolume 接口。
对于 PersistentVolume 资源:
在挂接时为相关 PV 打上 Finalizer:external-attacher/[driver 名称]。
当 PV 处于删除状态时(DeletionTimestamp 非空),删除 Finalizer:external-attacher/[driver 名称]。
4. External Resizer
1)功能
扩容存储卷。
2)原理
External-Resizer内部会 watch 集群中的 PersistentVolumeClaim 资源。
对于 PersistentVolumeClaim 资源:
判断 PersistentVolumeClaim 资源是否需要扩容:PVC 状态需要是 Bound 且 .Status.Capacity 与 .Spec.Resources.Requests 不等。
更新 PVC 的 .Status.Conditions,表明此时处于 Resizing 状态。
通过特定的 Unix Domain Socket 调用外部 CSI 插件的 ControllerExpandVolume 接口。
更新 PV 的 .Spec.Capacity。
若 CSI 支持文件系统在线扩容,ControllerExpandVolume 接口返回值中 NodeExpansionRequired 字段为 true,External-Resizer更新 PVC 的 .Status.Conditions 为 FileSystemResizePending 状态;若不支持则扩容成功,External-Resizer更新 PVC 的 .Status.Conditions 为空,且更新 PVC 的 .Status.Capacity。
Volume Manager(Kubelet 组件)观察到存储卷需在线扩容,于是通过特定的 Unix Domain Socket 调用外部 CSI 插件的NodeExpandVolume 接口实现文件系统扩容。
5. livenessprobe
1)功能
检查 CSI 插件是否正常。
2)原理
通过对外暴露一个 / healthz HTTP 端口以服务 kubelet 的探针探测器,内部是通过特定的 Unix Domain Socket 调用外部 CSI 插件的 Probe 接口。
CSI 接口介绍
三方存储厂商需实现 CSI 插件的三大接口:IdentityServer、ControllerServer、NodeServer。
1. IdentityServer
IdentityServer 主要用于认证 CSI 插件的身份信息。
// IdentityServer is the server API for Identity service.
type IdentityServer interface {
// 获取CSI插件的信息,比如名称、版本号
GetPluginInfo(context.Context, *GetPluginInfoRequest) (*GetPluginInfoResponse, error)
// 获取CSI插件提供的能力,比如是否提供ControllerService能力
GetPluginCapabilities(context.Context, *GetPluginCapabilitiesRequest) (*GetPluginCapabilitiesResponse, error)
// 获取CSI插件健康状况
Probe(context.Context, *ProbeRequest) (*ProbeResponse, error)
}
2. ControllerServer
ControllerServer 主要负责存储卷及快照的创建/删除以及挂接/摘除操作。
// ControllerServer is the server API for Controller service.
type ControllerServer interface {
// 创建存储卷
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
// 删除存储卷
DeleteVolume(context.Context, *DeleteVolumeRequest) (*DeleteVolumeResponse, error)
// 挂接存储卷到特定节点
ControllerPublishVolume(context.Context, *ControllerPublishVolumeRequest) (*ControllerPublishVolumeResponse, error)
// 从特定节点摘除存储卷
ControllerUnpublishVolume(context.Context, *ControllerUnpublishVolumeRequest) (*ControllerUnpublishVolumeResponse, error)
// 验证存储卷能力是否满足要求,比如是否支持跨节点多读多写
ValidateVolumeCapabilities(context.Context, *ValidateVolumeCapabilitiesRequest) (*ValidateVolumeCapabilitiesResponse, error)
// 列举全部存储卷信息
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
// 获取存储资源池可用空间大小
GetCapacity(context.Context, *GetCapacityRequest) (*GetCapacityResponse, error)
// 获取ControllerServer支持功能点,比如是否支持快照能力
ControllerGetCapabilities(context.Context, *ControllerGetCapabilitiesRequest) (*ControllerGetCapabilitiesResponse, error)
// 创建快照
CreateSnapshot(context.Context, *CreateSnapshotRequest) (*CreateSnapshotResponse, error)
// 删除快照
DeleteSnapshot(context.Context, *DeleteSnapshotRequest) (*DeleteSnapshotResponse, error)
// 获取所有快照信息
ListSnapshots(context.Context, *ListSnapshotsRequest) (*ListSnapshotsResponse, error)
// 扩容存储卷
ControllerExpandVolume(context.Context, *ControllerExpandVolumeRequest) (*ControllerExpandVolumeResponse, error)
}
3. NodeServer
NodeServer 主要负责存储卷挂载/卸载操作。
// NodeServer is the server API for Node service.
type NodeServer interface {
// 将存储卷格式化并挂载至临时全局目录
NodeStageVolume(context.Context, *NodeStageVolumeRequest) (*NodeStageVolumeResponse, error)
// 将存储卷从临时全局目录卸载
NodeUnstageVolume(context.Context, *NodeUnstageVolumeRequest) (*NodeUnstageVolumeResponse, error)
// 将存储卷从临时目录bind-mount到目标目录
NodePublishVolume(context.Context, *NodePublishVolumeRequest) (*NodePublishVolumeResponse, error)
// 将存储卷从目标目录卸载
NodeUnpublishVolume(context.Context, *NodeUnpublishVolumeRequest) (*NodeUnpublishVolumeResponse, error)
// 获取存储卷的容量信息
NodeGetVolumeStats(context.Context, *NodeGetVolumeStatsRequest) (*NodeGetVolumeStatsResponse, error)
// 存储卷扩容
NodeExpandVolume(context.Context, *NodeExpandVolumeRequest) (*NodeExpandVolumeResponse, error)
// 获取NodeServer支持功能点,比如是否支持获取存储卷容量信息
NodeGetCapabilities(context.Context, *NodeGetCapabilitiesRequest) (*NodeGetCapabilitiesResponse, error)
// 获取CSI节点信息,比如最大支持卷个数
NodeGetInfo(context.Context, *NodeGetInfoRequest) (*NodeGetInfoResponse, error)
}
K8s CSI API 对象
K8s 为支持 CSI 标准,包含如下 API 对象:
CSINode
CSIDriver
VolumeAttachment
1. CSINode
apiVersion: storage.k8s.io/v1beta1
kind: CSINode
metadata:
name: node-10.212.101.210
spec:
drivers:
- name: yodaplugin.csi.alibabacloud.com
nodeID: node-10.212.101.210
topologyKeys:
- kubernetes.io/hostname
- name: pangu.csi.alibabacloud.com
nodeID: a5441fd9013042ee8104a674e4a9666a
topologyKeys:
- topology.pangu.csi.alibabacloud.com/zone
作用:
2. CSIDriver
apiVersion: storage.k8s.io/v1beta1
kind: CSIDriver
metadata:
name: pangu.csi.alibabacloud.com
spec:
# 插件是否支持卷挂接(VolumeAttach)
attachRequired: true
# Mount阶段是否CSI插件需要Pod信息
podInfoOnMount: true
# 指定CSI支持的卷模式
volumeLifecycleModes:
- Persistent
作用:
3. VolumeAttachment
apiVersion: storage.k8s.io/v1
kind: VolumeAttachment
metadata:
annotations:
csi.alpha.kubernetes.io/node-id: 21481ae252a2457f9abcb86a3d02ba05
finalizers:
- external-attacher/pangu-csi-alibabacloud-com
name: csi-0996e5e9459e1ccc1b3a7aba07df4ef7301c8e283d99eabc1b69626b119ce750
spec:
attacher: pangu.csi.alibabacloud.com
nodeName: node-10.212.101.241
source:
persistentVolumeName: pangu-39aa24e7-8877-11eb-b02f-021234350de1
status:
attached: true
作用:VolumeAttachment 记录了存储卷的挂接/摘除信息以及节点信息。
支持特性
1. 拓扑支持
在 StorageClass 中有 AllowedTopologies 字段:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: csi-pangu
provisioner: pangu.csi.alibabacloud.com
parameters:
type: cloud_ssd
volumeBindingMode: Immediate
allowedTopologies:
- matchLabelExpressions:
- key: topology.pangu.csi.alibabacloud.com/zone
values:
- zone-1
- zone-2
外部 CSI 插件部署后会为每个节点打标,打标内容NodeGetInfo 函数返回的 [AccessibleTopology] 值(详见 Node Driver Registrar 部分)。
External Provisioner在调用 CSI 插件的 CreateVolume 接口之前,会在请求参数设置 AccessibilityRequirements:
对于 WaitForFirstConsumer
当 PVC 的 anno 中包含 "volume.kubernetes.io/selected-node" 且不为空,则先获取对应节点 CSINode 的 TopologyKeys,然后根据该 TopologyKeys 键从 Node 资源的 Label 获取 Values 值,最后拿该 Values 值与 StorageClass 的 AllowedTopologies 比对,判断其是否包含于其中;若不包含则报错。
对于 Immediately
将 StorageClass 的 AllowedTopologies 的值填进来,若 StorageClass 没有设置 AllowedTopologies 则将所有包含 TopologyKeys 键的节点 Value 添进来。
Scheduler 如何处理使用存储卷调度
基于社区 1.18 版本调度器
调度器的调度过程主要有如下三步:
预选(Filter):筛选满足 Pod 调度要求的节点列表。
优选(Score):通过内部的优选算法为节点打分,获得最高分数的节点即为选中的节点。
绑定(Bind):调度器将调度结果通知给 kube-apiserver,更新 Pod 的 .spec.nodeName 字段。
调度器预选阶段:处理 Pod 的 PVC/PV 绑定关系以及动态供应 PV(Dynamic Provisioning),同时使调度器调度时考虑 Pod 所使用 PV 的节点亲和性。详细调度过程如下:
调度器优选阶段不讨论。
调度器 Assume 阶段
调度器会先 Assume PV/PVC,再 Assume Pod。
调度器 Bind 阶段
BindPodVolumes:
调用 Kubernetes 的 API 更新集群中 PV/PVC 资源,使其与调度器 Cache 中的 PV/PVC 一致。
检查 PV/PVC 状态:
检查所有 PVC 是否已处于 Bound 状态。
检查所有 PV 的 NodeAffinity 是否与节点 Label 冲突。
调度器执行 Bind 操作:调用 Kubernetes 的 API 更新 Pod 的 .Spec.NodeName 字段。
2. 存储卷扩容
存储卷扩容部分在 External Resizer 部分已提到,故不再赘述。用户只需要编辑 PVC 的 .Spec.Resources.Requests.Storage 字段即可,注意只可扩容不可缩容。
若 PV 扩容失败,此时 PVC 无法重新编辑 spec 字段的 storage 为原来的值(只可扩容不可缩容)。参考 K8s 官网提供的 PVC 还原方法:
3. 单节点卷数量限制
卷数量限制在 Node Driver Registrar 部分已提到,故不再赘述。
4. 存储卷监控
存储商需实现 CSI 插件的 NodeGetVolumeStats 接口,Kubelet 会调用该函数,并反映在其 metrics上:
kubelet_volume_stats_capacity_bytes:存储卷容量
kubelet_volume_stats_used_bytes:存储卷已使用容量
kubelet_volume_stats_available_bytes:存储卷可使用容量
kubelet_volume_stats_inodes:存储卷 inode 总量
kubelet_volume_stats_inodes_used:存储卷 inode 使用量
kubelet_volume_stats_inodes_free:存储卷 inode 剩余量
5. Secret
CSI 存储卷支持传入 Secret 来处理不同流程中所需要的私密数据,目前 StorageClass 支持如下 Parameter:
csi.storage.k8s.io/provisioner-secret-name
csi.storage.k8s.io/provisioner-secret-namespace
csi.storage.k8s.io/controller-publish-secret-name
csi.storage.k8s.io/controller-publish-secret-namespace
csi.storage.k8s.io/node-stage-secret-name
csi.storage.k8s.io/node-stage-secret-namespace
csi.storage.k8s.io/node-publish-secret-name
csi.storage.k8s.io/node-publish-secret-namespace
csi.storage.k8s.io/controller-expand-secret-name
csi.storage.k8s.io/controller-expand-secret-namespace
Secret 会包含在对应 CSI 接口的参数中,如对于 CreateVolume 接口而言则包含在 CreateVolumeRequest.Secrets 中。
6. 块设备
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: nginx-example
spec:
selector:
matchLabels:
app: nginx
serviceName: "nginx"
volumeClaimTemplates:
- metadata:
name: html
spec:
accessModes:
- ReadWriteOnce
volumeMode: Block
storageClassName: csi-pangu
resources:
requests:
storage: 40Gi
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx
volumeDevices:
- devicePath: "/dev/vdb"
name: html
三方存储厂商需实现 NodePublishVolume 接口。Kubernetes 提供了针对块设备的工具包("k8s.io/kubernetes/pkg/util/mount"),在 NodePublishVolume 阶段可调用该工具的 EnsureBlock 和 MountBlock 函数。
7. 卷快照/卷克隆能力
鉴于本文篇幅,此处不做过多原理性介绍。读者感兴趣见官方介绍:、。
总结
本文首先对 CSI 核心流程进行了大体介绍,并结合 CSI Sidecar 组件、CSI 接口、API 对象对 CSI 标准进行了深度解析。在 K8s 上,使用任何一种 CSI 存储卷都离不开上面的流程,环境上的容器存储问题也一定是其中某个环节出现了问题。本文对其流程进行梳理,以便于广大程序猿(媛)排查环境问题。
容器存储的坑比较多,专有云环境下尤其如此。不过挑战越多,机遇也越多!目前国内专有云市场群雄逐鹿,阿里云云原生部门欢迎大侠的加入,一起共创未来!若有意向可投简历至邮箱:huizhi.szh@alibaba-inc.com。
KubeMeet 杭州站开放报名!

4 月 17 日,云原生基金会 CNCF 和阿里巴巴联合主办的**「KubeMeet 开发者沙龙 · 云原生应用管理专场」**来到杭州啦!这里有 Kubernetes 生态开发者都在关注的开源项目,以及阿里巴巴、携程、第四范式的一线云原生落地实践。!