K8S之CSI插件开发
简介
进入 K8s 的世界,会发现有很多方便扩展的 Interface,包括 CSI, CNI, CRI 等,将这些接口抽象出来,是为了更好的提供开放、扩展、规范等能力。
K8s 持久化存储经历了从 in-tree Volume 到 CSI Plugin(out-of-tree) 的迁移,一方面是为了将 K8s 核心主干代码与 Volume 相关代码解耦,便于更好的维护;另一方面则是为了方便各大云厂商实现统一的接口,提供个性化的云存储能力,以期达到云存储生态圈的开放共赢。
PV流程
PV 创建核心流程:
apiserver
创建 Pod,根据PodSpec.Volumes
创建 Volume;PVController
监听到 PV informer,添加相关 Annotation(如 pv.kubernetes.io/provisioned-by),调谐实现 PVC/PV 的绑定(Bound);判断
StorageClass.volumeBindingMode
:WaitForFirstConsumer
则等待 Pod 调度到 Node 成功后再进行 PV 创建,Immediate
则立即调用 PV 创建逻辑,无需等待 Pod 调度;external-provisioner
监听到 PV informer, 调用 RPC-CreateVolume 创建 Volume;AttachDetachController
将已经绑定(Bound) 成功的 PVC/PV,经过 InTreeToCSITranslator 转换器,由 CSIPlugin 内部逻辑实现VolumeAttachment
资源类型的创建;external-attacher
监听到 VolumeAttachment informer,调用 RPC-ControllerPublishVolume 实现 AttachVolume;kubelet
reconcile 持续调谐:通过判断controllerAttachDetachEnabled || PluginIsAttachable
及当前 Volume 状态进行 AttachVolume/MountVolume,最终实现将 Volume 挂载到 Pod 指定目录中,供 Container 使用;
PV生命周期
Volume 的生命周期:
CreateVolume +------------+ DeleteVolume
+------------->| CREATED +--------------+
| +---+----^---+ |
| Controller | | Controller v
+++ Publish | | Unpublish +++
|X| Volume | | Volume | |
+-+ +---v----+---+ +-+
| NODE_READY |
+---+----^---+
Node | | Node
Stage | | Unstage
Volume | | Volume
+---v----+---+
| VOL_READY |
+---+----^---+
Node | | Node
Publish | | Unpublish
Volume | | Volume
+---v----+---+
| PUBLISHED |
+------------+
The lifecycle of a dynamically provisioned volume, from
creation to destruction, when the Node Plugin advertises the
STAGE_UNSTAGE_VOLUME capability.
从 Volume 生命周期可以看到,一块持久卷要达到 Pod 可使用状态,需要经历以下阶段:
CreateVolume -> ControllerPublishVolume -> NodeStageVolume -> NodePublishVolume
而当删除 Volume 的时候,会经过如下反向阶段:
NodeUnpublishVolume -> NodeUnstageVolume -> ControllerUnpublishVolume -> DeleteVolume
上面流程的每个步骤,其实就对应了 CSI 提供的标准接口,云存储厂商只需要按标准接口实现自己的云存储插件,即可与 K8s 底层编排系统无缝衔接起来,提供多样化的云存储、备份、快照(snapshot)等能力。
CSI体系
CSI是由来自Kubernetes、Mesos、 Cloud Foundry等社区的member联合制定的一个行业标准接口规范,旨在将任意存储系统暴露给容器化应用程序。CSI规范定义了存储提供商(SP)实现CSI兼容插件的最小操作集和部署建议。CSI规范的主要焦点是声明插件必须实现的接口。
kubernetes CSI 存储体系涉及的组件:
kube-controller-manager:K8s 资源控制器,主要通过 PVController, AttachDetach 实现持久卷的绑定(Bound)/解绑(Unbound)、附着(Attach)/分离(Detach);
CSI-plugin:K8s 独立拆分出来,实现 CSI 标准规范接口的逻辑控制与调用,是整个 CSI 控制逻辑的核心枢纽;
node-driver-registrar:是一个由官方 K8s sig 小组维护的辅助容器(sidecar),它使用 kubelet 插件注册机制向 kubelet 注册插件,需要请求 CSI 插件的 Identity 服务来获取插件信息;
external-provisioner:是一个由官方 K8s sig 小组维护的辅助容器(sidecar),主要功能是实现持久卷的创建(Create)、删除(Delete);
external-attacher:是一个由官方 K8s sig 小组维护的辅助容器(sidecar),主要功能是实现持久卷的附着(Attach)、分离(Detach);
external-snapshotter:是一个由官方 K8s sig 小组维护的辅助容器(sidecar),主要功能是实现持久卷的快照(VolumeSnapshot)、备份恢复等能力;
external-resizer:是一个由官方 K8s sig 小组维护的辅助容器(sidecar),主要功能是实现持久卷的弹性扩缩容,需要云厂商插件提供相应的能力;
kubelet:K8s 中运行在每个 Node 上的控制枢纽,主要功能是调谐节点上 Pod 与 Volume 的附着、挂载、监控探测上报等;
cloud-storage-provider:由各大云存储厂商基于 CSI 标准接口实现的插件,包括 Identity 身份服务、Controller 控制器服务、Node 节点服务;
对于开发者来说,主要就是实现红色这一块
其他组件代码:https://github.com/kubernetes-csi
CSI插件
CSI 插件分为三部分 CSI Identity , CSI Controller , CSI Node,因此在实际开发之前,我们先来看下csi的三大阶段
1. Provisioning and Deleting
Provisioning and Deleting 阶段实现与外部存储供应商协调卷的创建/删除处理,简单地说就是需要实现 CreateVolume 和 DeleteVolume;假设外部存储供应商为阿里云存储那么此阶段应该完成在阿里云存储商创建一个指定大小的块设备,或者在用户删除 volume 时完成在阿里云存储上删除这个块设备;除此之外此阶段还应当响应存储拓扑分布从而保证 volume 分布在正确的集群拓扑上(此处描述不算清晰,推荐查看设计文档)。2. Attaching and Detaching
Attaching and Detaching 阶段实现将外部存储供应商提供好的卷设备挂载到本地或者从本地卸载,简单地说就是实现 ControllerPublishVolume 和 ControllerUnpublishVolume;同样以外部存储供应商为阿里云存储为例,在 Provisioning 阶段创建好的卷的块设备,在此阶段应该实现将其挂载到服务器本地或从本地卸载,在必要的情况下还需要进行格式化等操作。3. Mount and Umount
这个阶段在 CSI 设计文档中没有做详细描述,在前两个阶段完成后,当一个目标 Pod 在某个 Node 节点上调度时,kubelet 会根据前两个阶段返回的结果来创建这个 Pod;同样以外部存储供应商为阿里云存储为例,此阶段将会把已经 Attaching 的本地块设备以目录形式挂载到 Pod 中或者从 Pod 中卸载这个块设备。
CSI 的三大阶段实际上就是对应插件开发的三部分,也就是实现三个gRPC Server,可放在同一个二进制程序中实现。每一部分都需要实现一些约定的接口,
1. Identity Server
在当前 CSI Spec v1.3.0 中 IdentityServer 定义如下:
// IdentityServer is the server API for Identity service.
type IdentityServer interface {
GetPluginInfo(context.Context, *GetPluginInfoRequest) (*GetPluginInfoResponse, error)
GetPluginCapabilities(context.Context, *GetPluginCapabilitiesRequest) (*GetPluginCapabilitiesResponse, error)
Probe(context.Context, *ProbeRequest) (*ProbeResponse, error)
}
从代码上可以看出 IdentityServer 主要负责像 Kubernetes 提供 CSI 插件名称可选功能等,所以此 Server 是必须实现的。
kubernetes 外部组件如 External provisioner , External attacher 会调用这几个接口。
2. Node Server
同样当前 CSI v1.3.0 Spec 中 NodeServer 定义如下:
// NodeServer is the server API for Node service.
type NodeServer interface {
NodeStageVolume(context.Context, *NodeStageVolumeRequest) (*NodeStageVolumeResponse, error)
NodeUnstageVolume(context.Context, *NodeUnstageVolumeRequest) (*NodeUnstageVolumeResponse, error)
NodePublishVolume(context.Context, *NodePublishVolumeRequest) (*NodePublishVolumeResponse, error)
NodeUnpublishVolume(context.Context, *NodeUnpublishVolumeRequest) (*NodeUnpublishVolumeResponse, error)
NodeGetVolumeStats(context.Context, *NodeGetVolumeStatsRequest) (*NodeGetVolumeStatsResponse, error)
NodeExpandVolume(context.Context, *NodeExpandVolumeRequest) (*NodeExpandVolumeResponse, error)
NodeGetCapabilities(context.Context, *NodeGetCapabilitiesRequest) (*NodeGetCapabilitiesResponse, error)
NodeGetInfo(context.Context, *NodeGetInfoRequest) (*NodeGetInfoResponse, error)
}
kubelet 会调用 CSI 插件实现的接口,以实现 volume 的挂载和卸载。
其中 Volume 的挂载被分成了 NodeStageVolume 和 NodePublishVolume 两个阶段。NodeStageVolume 接口主要是针对块存储类型的 CSI 插件而提供的。块设备在 “Attach” 阶段被附着在 Node 上后,需要挂载至 Pod 对应目录上,但因为块设备在 linux 上只能 mount 一次,而在 kubernetes volume 的使用场景中,一个 volume 可能被挂载进同一个 Node 上的多个 Pod 实例中,所以这里提供了 NodeStageVolume 这个接口,使用这个接口把块设备格式化后先挂载至 Node 上的一个临时全局目录,然后再调用 NodePublishVolume 使用 linux 中的 bind mount 技术把这个全局目录挂载进 Pod 中对应的目录上。
NodeUnstageVolume 和 NodeUnpublishVolume 正是 volume 卸载阶段所分别对应的上述两个流程。
当然,如果是非块存储类型的 CSI 插件,也就不必实现 NodeStageVolume 和 NodeUnstageVolume 这两个接口了。
3. Controller Server
在当前 CSI Spec v1.3.0 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)
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)
ControllerGetVolume(context.Context, *ControllerGetVolumeRequest) (*ControllerGetVolumeResponse, error)
}
从这些方法上可以看出,大部分的核心逻辑应该在 ControllerServer 中实现,
CreateVolume 和 DeleteVolume 是实现 “Provision” 阶段需要实现的接口,External provisioner 组件会 CSI 插件的这个接口以创建或者删除存储卷。ControllerPublishVolume 和 ControllerUnpublishVolume 是实现 “Attach” 阶段需要实现的接口,External attach 组件会调用 CSI 插件实现的这个接口以把某个块存储卷附着或脱离某个 Node 。
通过上面的分析知道,我们需要编写 gRPC Server,最佳实践就是参考官方给出的样例项目 csi-driver-host-path。
开发
根据业务需要,通过一个业务server来实现上述接口,最后启动grpc server,将该业务server注册到grpc中。
样例代码如下:
func (hp *hostPath) Run() {
// Create GRPC servers
hp.ids = NewIdentityServer(hp.name, hp.version)
hp.ns = NewNodeServer(hp.nodeID, hp.ephemeral)
hp.cs = NewControllerServer(hp.ephemeral)
s := NewNonBlockingGRPCServer()
s.Start(hp.endpoint, hp.ids, hp.cs, hp.ns)
s.Wait()
}
func (s *nonBlockingGRPCServer) serve(endpoint string, ids csi.IdentityServer, cs csi.ControllerServer, ns csi.NodeServer) {
proto, addr, err := parseEndpoint(endpoint)
if err != nil {
glog.Fatal(err.Error())
}
if proto == "unix" {
addr = "/" + addr
if err := os.Remove(addr); err != nil && !os.IsNotExist(err) { //nolint: vetshadow
glog.Fatalf("Failed to remove %s, error: %s", addr, err.Error())
}
}
listener, err := net.Listen(proto, addr)
if err != nil {
glog.Fatalf("Failed to listen: %v", err)
}
opts := []grpc.ServerOption{
grpc.UnaryInterceptor(logGRPC),
}
server := grpc.NewServer(opts...)
s.server = server
if ids != nil {
csi.RegisterIdentityServer(server, ids)
}
if cs != nil {
csi.RegisterControllerServer(server, cs)
}
if ns != nil {
csi.RegisterNodeServer(server, ns)
}
glog.Infof("Listening for connections on address: %#v", listener.Addr())
server.Serve(listener)
}
部署
CSI Controller 部分以 StatefulSet 方式部署,CSI Node 部分以 DaemonSet 方式部署。
因为我们把这两部分实现在同一个 CSI 插件程序中,因此只需要把这个 CSI 插件与 External provisioner 、External attacher 以容器方式部署在同一个 StatefulSet 的 Pod中,把这个 CSI 插件与 Driver registrar 以容器方式部署在 DaemonSet 的 Pod 中,即可完成 CSI 的部署。
https://blog.dianduidian.com/post/%E5%BC%80%E5%8F%91%E8%87%AA%E5%B7%B1%E7%9A%84csi%E5%AD%98%E5%82%A8%E6%8F%92%E4%BB%B6/