实际编写一个CSI插件来进行实践。

如果现在已经编写好了CSI插件,那么其使用非常简单,就是创建如下的StorageClass对象即可:

kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: do-block-storage
  namespace: kube-system
  annotations:
    storageclass.kubernetes.io/is-default-class: "true" #表示使用这个StorageClass作为默认的持久化存储提供者
provisioner: com.digitalocean.csi.dobs

有了这个StorageClass之后,External Provisioner就会为集群中新出现的PVC自动创建出PV,然后调用CSI插件创建出这个PV对应的Volume,这正是CSI体系中Dynamic Provisioning的实现方式。

provisioner: com.digitalocean.csi.dobs这个字段告诉了k8s,请使用指定名称的CSI插件来啊处理这个StorageClass相关的所有操作。

k8s如何知道一个CSI插件的名字?

这就是CSI插件的第一个服务CSI Identity

CSI Identity

一个CSI插件的代码结构非常简单,如下所示:

tree $GOPATH/src/github.com/digitalocean/csi-digitalocean/driver  
$GOPATH/src/github.com/digitalocean/csi-digitalocean/driver 
├── controller.go
├── driver.go
├── identity.go
├── mounter.go
└── node.go

其中CSI Identity服务的实现,就定义在driver目录的下的identity.go文件里。

为了让k8s能够访问到CSI Identity服务,需要先在driver.go文件里,定义一个标准的gRPC Server,如下所示:

// Run starts the CSI plugin by communication over the given endpoint
func (d *Driver) Run() error {
 ...
 
 listener, err := net.Listen(u.Scheme, addr)
 ...
 
 d.srv = grpc.NewServer(grpc.UnaryInterceptor(errHandler))
 csi.RegisterIdentityServer(d.srv, d)
 csi.RegisterControllerServer(d.srv, d)
 csi.RegisterNodeServer(d.srv, d)
 
 d.ready = true // we're now ready to go!
 ...
 return d.srv.Serve(listener)
}

这里将编写好的gRPC Server注册给CSI,它就可以响应来自External Components的CSI请求了。

CSI Identity服务中,最重要的接口是GetPluginInfo,它返回的就是这个插件的名字和版本号,如下所示:

func (d *Driver) GetPluginInfo(ctx context.Context, req *csi.GetPluginInfoRequest) (*csi.GetPluginInfoResponse, error) {
 resp := &csi.GetPluginInfoResponse{
  Name:          driverName,
  VendorVersion: version,
 }
 ...
}

k8s正是通过这个方法的返回值来找到StorageClass声明要使用的CSI插件的。

还有一个接口GetPluginCapabilities也很重要,这个接口返回的是CSI插件的能力。比如如果编写的CSI插件不需要实现“Provision”和“Attach”(最简单的NFS存储插件就不需要),那么就可以通过这个接口返回,k8s就知道这个插件的能力了。

此外还有一个Probe接口,k8s通过调用这个接口来检查这个CSI插件是否正常工作。与健康检查类似,可以通过设置一个Ready标志来判断。

CSI Controller

这个代码在controller.go文件中。主要负责Volume管理流程中的Provision阶段和Attach阶段。

Provision阶段

Provision阶段负责创建或者删除Volume,对应的接口是CreateVolumeDeleteVolume。调用者是External Provisioner。主要逻辑如下:

func (d *Driver) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) (*csi.CreateVolumeResponse, error) {
 ...
 
 volumeReq := &godo.VolumeCreateRequest{
  Region:        d.region,
  Name:          volumeName,
  Description:   createdByDO,
  SizeGigaBytes: size / GB,
 }
 
 ...
 
 vol, _, err := d.doClient.Storage.CreateVolume(ctx, volumeReq)
 
 ...
 
 resp := &csi.CreateVolumeResponse{
  Volume: &csi.Volume{
   Id:            vol.ID,
   CapacityBytes: size,
   AccessibleTopology: []*csi.Topology{
    {
     Segments: map[string]string{
      "region": d.region,
     },
    },
   },
  },
 }
 
 return resp, nil
}

在内部调用存储服务的API,创建出一个存储卷。这里是

d.doClient.Storage.CreateVolume

使用什么服务调用什么服务的API。

Attach阶段

此阶段对应的接口是ControllerPublishVolumeControllerUnpublishVolume,用于挂载/取消挂载前一阶段创建的创建的存储卷。它们的调用者是External Attacher。

ControllerPublishVolume的逻辑如下:

func (d *Driver) ControllerPublishVolume(ctx context.Context, req *csi.ControllerPublishVolumeRequest) (*csi.ControllerPublishVolumeResponse, error) {
 ...
 
  dropletID, err := strconv.Atoi(req.NodeId)
  
  // check if volume exist before trying to attach it
  _, resp, err := d.doClient.Storage.GetVolume(ctx, req.VolumeId)
 
 ...
 
  // check if droplet exist before trying to attach the volume to the droplet
  _, resp, err = d.doClient.Droplets.Get(ctx, dropletID)
 
 ...
 
  action, resp, err := d.doClient.StorageActions.Attach(ctx, req.VolumeId, dropletID)
 
 ...
 
 if action != nil {
  ll.Info("waiting until volume is attached")
 if err := d.waitAction(ctx, req.VolumeId, action.ID); err != nil {
  return nil, err
  }
  }
  
  ll.Info("volume is attached")
 return &csi.ControllerPublishVolumeResponse{}, nil
}

其中存储卷由VolumeId来指定,而虚拟机,也就是Pod运行的宿主机,则由请求中的NodeId来指定。将存储卷挂载到指定的虚拟机上。

这一接口由External Attacher调用,而它是监听了一种名为VolumeAttachment的API对象,这种API对象的主要字段如下所示:

// VolumeAttachmentSpec is the specification of a VolumeAttachment request.
type VolumeAttachmentSpec struct {
 // Attacher indicates the name of the volume driver that MUST handle this
 // request. This is the name returned by GetPluginName().
 Attacher string
 
 // Source represents the volume that should be attached.
 Source VolumeAttachmentSource
 
 // The node that the volume should be attached to.
 NodeName string
}

这个对象的生命周期是由AttachDetachController负责管理的,正如在PV,PVC,StorageClass中提到的,这个控制循环不断检查Pod对应的PV的绑定情况,从而决定是否需要对这个PV进行Attach或者Dettach操作。

在CSI体系里,Attach操作就是创建出这样一个VolumeAttachement对象。可以看到这个对象里有Attach操作所需的PV的名字(Source)、宿主机的名字(NodeName)、存储插件的名字(Attacher)。

这样External Attacher监听到这个对象的创建后,就能够使用这个对象里的这些字段,封装成一个gRPC请求调用CSI Controller的ControllerPublishVolume

最后就是mount这个volume到Pod中,通过CSI Node服务来完成。

CSI Node

代码实现在node.go文件里。kubelet 的 VolumeManagerReconciler 控制循环会直接调用 CSI Node 服务来完成 Volume 的“Mount 阶段”。 不过具体的实现细分为了NodeStageVolumeNodePublishVolume两个接口。因为有的设备需要先格式化才能够挂载

在 kubelet 的 VolumeManagerReconciler 控制循环中,这两步操作分别叫作 MountDeviceSetUp

MountDevice就是调用CSI Node服务中的NodeStageVolume接口,格式化Volume在宿主机上对应的存储设备,然后挂载到一个临时目录

SetUp调用 CSI Node 服务的 NodePublishVolume 接口将Volume挂载到对应的宿主机目录上

部署

到现在为止CSI插件已经编写完毕,剩下的工作就是将这个插件与External Components一起部署起来。

  1. 通过DaemonSet的方式在每个节点上都启动一个CSI插件,来为kubelet提供CSI Node服务。因为CSI Node服务要被kubelet调用,所以两者一对一部署。 在这个DaemonSet中,还有一个外部组件driver-register以sidecar的方式运行着。通过访问同一个Pod的CSI插件容器的Identity服务获取插件信息注册到kubelet中。CSI Node挂载时操作的是宿主机上的文件,所以需要将把宿主机的 /var/lib/kubelet 以 Volume 的方式挂载进 CSI 插件容器的同名目录下。

  2. 通过 StatefulSet 在任意一个节点上再启动一个 CSI 插件,为 External Components 提供 CSI Controller 服务。External Provisioner 和 External Attacher 这两个外部组件,就需要以 sidecar 的方式和这次部署的 CSI 插件定义在同一个 Pod 里。 将 StatefulSet 的 replicas 设置为 1 的话,StatefulSet 就会确保 Pod 被删除重建的时候,永远有且只有一个 CSI 插件的 Pod 运行在集群中。这对 CSI 插件的正确性来说,至关重要。

小结

对于一个部署了 CSI 存储插件的 Kubernetes 集群来说:

当用户创建了一个 PVC 之后,你前面部署的 StatefulSet 里的 External Provisioner 容器,就会监听到这个 PVC 的诞生,然后调用同一个 Pod 里的 CSI 插件的 CSI Controller 服务的 CreateVolume 方法,为你创建出对应的 PV。

运行在 Kubernetes Master 节点上的 Volume Controller,就会通过 PersistentVolumeController 控制循环,发现这对新创建出来的 PV 和 PVC,并且看到它们声明的是同一个 StorageClass。所以,它会把这一对 PV 和 PVC 绑定起来,使 PVC 进入 Bound 状态。

一个使用上述PVC的Pod被调度到了宿主机A上,Volume Controller 的 AttachDetachController 控制循环就会发现,上述 PVC 对应的 Volume,需要被 Attach 到宿主机 A 上。所以,AttachDetachController 会创建一个 VolumeAttachment 对象,这个对象携带了宿主机 A 和待处理的 Volume 的名字。

StatefulSet 里的 External Attacher 容器,就会监听到这个 VolumeAttachment 对象的诞生。于是,它就会使用这个对象里的宿主机和 Volume 名字,调用同一个 Pod 里的 CSI 插件的 CSI Controller 服务的 ControllerPublishVolume 方法,完成“Attach 阶段”。

上述过程完成后,运行在宿主机 A 上的 kubelet,就会通过 VolumeManagerReconciler 控制循环,发现当前宿主机上有一个 Volume 对应的存储设备(比如磁盘)已经被 Attach 到了某个设备目录下。于是 kubelet 就会调用同一台宿主机上的 CSI 插件的 CSI Node 服务的 NodeStageVolume 和 NodePublishVolume 方法,完成这个 Volume 的“Mount 阶段”。