本文是kubernetes的CSI(container storage interface)插件的开发实现细节探究。本文阅读的门槛在于需要提前理解kubernetes的组件基础知识,特别是存储PV、PVC的概念。

CSI 概念

​ 在CSI没有出现以前,容器编排系统(Container Orchestration Systems,简称COs,如kubernetes)为了能够使用外部存储系统,让这些存储系统为容器工作提供存储卷。COs需要在自身的代码中嵌入大量和存储相关的代码,参见kubernetes里的volume包。所谓in-tree(k8s的代码树下)存储件插件就是在这个包中。

​ 因为要支持的存储太多,而且有的存储插件还不适合开源,那么这样的设计肯定是有很大缺陷的。

​ 之后出现了像Flexvolume这样的out-tree存储间插件机制,能让存储厂商把写好的存储件插件二进制文件放到各个node节点的预设目录下,k8s就能够自动发现他们,并调用他们完成存储卷的供应。详情技术细节可参考这里

​ 上述Flexvolume方案很类似于kubernetes里用的网络方案CNI,都是将外部插件放置在预设的目录下,以供kubernetes调用。但总的来说还是跟kubernetes这一容器编排系统绑定得太死了。于是人们又发明了CSI。

​ CSI为容器存储接口,其规约由谷歌的JIE YU 等人起草,CSI试图建立一个行业标准接口的规范,借助CSI容器编排系统可以把任意存储系统暴露给自己的容器工作负载。有关详细信息,请查看设计方案

csi 卷类型也是一种 out-tree(in-tree是指跟其它存储插件在同一个代码路径下,随 Kubernetes 的代码同时编译,out-tree则刚好相反) 的 CSI 卷插件,用于 Pod 与在同一节点上运行的外部 CSI 卷驱动程序交互。部署 CSI 兼容卷驱动后,用户可以使用 csi 作为卷类型来挂载驱动提供的存储。

​ CSI 持久化卷支持是在 Kubernetes v1.9 中引入的,作为一个 alpha 特性,必须由集群管理员明确启用。换句话说,集群管理员需要在 apiserver、controller-manager 和 kubelet 组件的 “--feature-gates =” 标志中加上 “CSIPersistentVolume = true”。

​ CSI 持久化卷具有以下字段可供用户指定:

  • driver:一个字符串值,指定要使用的卷驱动程序的名称。必须少于 63 个字符,并以一个字符开头。驱动程序名称可以包含 “。”、“ - ”、“_” 或数字。
  • volumeHandle:一个字符串值,唯一标识从 CSI 卷插件的 CreateVolume 调用返回的卷名。随后在卷驱动程序的所有后续调用中使用卷句柄来引用该卷。
  • readOnly:一个可选的布尔值,指示卷是否被发布为只读。默认是 false。

CSI 插件机制详解

​ 上面的概述其实很难让人理解CSI插件是什么样的,当然,最好理解CSI的方式永远是看specification,就像TCP网络协议看RFC一样。CSI的spec的书写格式和规范都依据RFC,所以阅读起来非常详细和舒服(当然英文要基本过关)。

​ 简单来说:CSI插件就是实现了CSI规范要求的多个gRPC接口的服务程序。

​ 一个CSI插件一般会以两种形式部署运行,分别是Controller组件和Node组件。

Controller Plugin

The controller component can be deployed as a Deployment or StatefulSet on any node in the cluster. It consists of the CSI driver that implements the CSI Controller service and one or more sidecar containers. These controller sidecar containers typically interact with Kubernetes objects and make calls to the driver’s CSI Controller service.

It generally does not need direct access to the host and can perform all its operations through the Kubernetes API and external control plane services. Multiple copies of the controller component can be deployed for HA, however it is recommended to use leader election to ensure there is only one active controller at a time.

Controller sidecars include the external-provisioner, external-attacher, external-snapshotter, and external-resizer. Including a sidecar in the deployment may be optional.

Node Plugin

The node component should be deployed on every node in the cluster through a DaemonSet. It consists of the CSI driver that implements the CSI Node service and the node-driver-registrar sidecar container.

The Kubernetes kubelet runs on every node and is responsible for making the CSI Node service calls. These calls mount and unmount the storage volume from the storage system, making it available to the Pod to consume. Kubelet makes calls to the CSI driver through a UNIX domain socket shared on the host via a HostPath volume. There is also a second UNIX domain socket that the node-driver-registrar uses to register the CSI driver to kubelet.

​ 从描述中可以看到,Controller组件一般是以Deployment或StatefulSet形式部署的,它实现了CSI Controller service,它会与Kubernetes API、外部存储服务的控制面交互,但它并不会实际处理存储卷在宿主机上的挂载等事情。

​ 而Node组件因为要运行在所有node节点上,因此一般是以DaemonSet形式部署的,它实现了CSI Node service,它会暴露出一个uds文件出来,从而让kubelet在进行存储卷操作时,通过这个uds文件调用它的gRPC接口。

​ 上面的两个组件配合,就完成了把存储卷暴露给工作负载的功能。

​ 下面我们看一下一个CSI插件要实现的三个gRPC接口服务:

  • Identity Service: Both the Node Plugin and the Controller Plugin MUST implement this sets of RPCs.
  • Controller Service: The Controller Plugin MUST implement this sets of RPCs.
  • Node Service: The Node Plugin MUST implement this sets of RPCs.

​ 各个接口如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
service Identity {
  rpc GetPluginInfo(GetPluginInfoRequest)
    returns (GetPluginInfoResponse) {}

  rpc GetPluginCapabilities(GetPluginCapabilitiesRequest)
    returns (GetPluginCapabilitiesResponse) {}

  rpc Probe (ProbeRequest)
    returns (ProbeResponse) {}
}

service Controller {
  rpc CreateVolume (CreateVolumeRequest)
    returns (CreateVolumeResponse) {}

  rpc DeleteVolume (DeleteVolumeRequest)
    returns (DeleteVolumeResponse) {}

  rpc ControllerPublishVolume (ControllerPublishVolumeRequest)
    returns (ControllerPublishVolumeResponse) {}

  rpc ControllerUnpublishVolume (ControllerUnpublishVolumeRequest)
    returns (ControllerUnpublishVolumeResponse) {}

  rpc ValidateVolumeCapabilities (ValidateVolumeCapabilitiesRequest)
    returns (ValidateVolumeCapabilitiesResponse) {}

  rpc ListVolumes (ListVolumesRequest)
    returns (ListVolumesResponse) {}

  rpc GetCapacity (GetCapacityRequest)
    returns (GetCapacityResponse) {}

  rpc ControllerGetCapabilities (ControllerGetCapabilitiesRequest)
    returns (ControllerGetCapabilitiesResponse) {}

  rpc CreateSnapshot (CreateSnapshotRequest)
    returns (CreateSnapshotResponse) {}

  rpc DeleteSnapshot (DeleteSnapshotRequest)
    returns (DeleteSnapshotResponse) {}

  rpc ListSnapshots (ListSnapshotsRequest)
    returns (ListSnapshotsResponse) {}

  rpc ControllerExpandVolume (ControllerExpandVolumeRequest)
    returns (ControllerExpandVolumeResponse) {}
}

service Node {
  rpc NodeStageVolume (NodeStageVolumeRequest)
    returns (NodeStageVolumeResponse) {}

  rpc NodeUnstageVolume (NodeUnstageVolumeRequest)
    returns (NodeUnstageVolumeResponse) {}

  rpc NodePublishVolume (NodePublishVolumeRequest)
    returns (NodePublishVolumeResponse) {}

  rpc NodeUnpublishVolume (NodeUnpublishVolumeRequest)
    returns (NodeUnpublishVolumeResponse) {}

  rpc NodeGetVolumeStats (NodeGetVolumeStatsRequest)
    returns (NodeGetVolumeStatsResponse) {}


  rpc NodeExpandVolume(NodeExpandVolumeRequest)
    returns (NodeExpandVolumeResponse) {}


  rpc NodeGetCapabilities (NodeGetCapabilitiesRequest)
    returns (NodeGetCapabilitiesResponse) {}

  rpc NodeGetInfo (NodeGetInfoRequest)
    returns (NodeGetInfoResponse) {}
}

​ 虽然这些接口看着很多,但如果你不想实现CSI的某些功能,一些接口也不用实现。例如不想实现存储卷的动态供应,ControllerCreateVolumeDeleteVolume即可不实现。另外有些接口属于元数据接口,仅仅是声明该CSI的Capability,Info的,如Identity的所有接口,ControllerGetCapabilitiesControllerGetCapabilities接口,NodeNodeGetCapabilitiesNodeGetInfo接口。再看一下storage volume的lifecycle,这些接口之前的调用关系就很清楚了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 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  |
               +------------+

Figure 6: The lifecycle of a dynamically provisioned volume, from
creation to destruction, when the Node Plugin advertises the
STAGE_UNSTAGE_VOLUME capability.

​ 上面这个图是一个较为复杂的卷供应生命周期图,从这个图我们可以看出一个存储卷的供应分别调用了Controller PluginCreateVolumeControllerPublishVolumeNode PluginNodeStageVolumeNodePublishVolume这4个gRPC接口,存储卷的销毁分别调用了Node PluginNodeUnpublishVolumeNodeUnstageVolumeControllerControllerUnpublishVolumeDeleteVolume这4个gRPC接口,这每个接口要完成的工作参见CSI规范

​ 实际上这个生命周期是复杂版本的,规约里还列举了一些其他的场景,最简单的甚至可以:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
     +-+  +-+
      |X|  | |
      +++  +^+
       |    |
  Node |    | Node
Publish |    | Unpublish
Volume |    | Volume
   +---v----+---+
   | PUBLISHED  |
   +------------+

Figure 8: Plugins MAY forego other lifecycle steps by contraindicating
them via the capabilities API. Interactions with the volumes of such
plugins is reduced to `NodePublishVolume` and `NodeUnpublishVolume`
calls.

​ 这里卷的交互实际上是直接退化成了只要实现Node级别的PUB和UNPUB就行了。

​ 实际上,只看规范可能会看得云里雾里的,我是参考一个现成的CSI插件实例来理解CSI规范的。这里推荐下tencentcloud cbs块存储的CSI插件,这个CSI插件实现得相当规范,分别在controller.go里实现了Controller Service里的几个gRPC接口、identity.go里实现了Identify Service里的几个gRPC接口、node.go里实现了Node Service里的几个gRPC接口。

csi-archetype还列出了开发csi可以运用的大体框架,也能够帮助理解要实现和部署一个csi大致需要什么东西。

CSI插件的部署

​ 按CSI规范实现了相应的gRPC接口后,一个CSI插件就基本成型了。但这并不是全部,回想下目前整个CSI插件的功能逻辑,我们只是实现了存储卷驱动的核心逻辑,但并没有与Kubernetes产生任何联动啊。这写好的CSI插件如何工作呢?

​ 与Kubernetes的联动逻辑比较统一,基本就是watch Kubrernetes API,根据watch到的资源状态调用相应的CSI接口,根据CSI接口的返回结果更新Kubernetes里的资源状态。官方为了简化开发CSI插件的复杂度,提供了一系列的sidecar来完成这些工作。而CSI的开发人员要做的就是在部署CSI插件时声明将相应的sidecar与CSI插件捆绑部署在一起。

Kubernetes CSI Sidecar Containers are a set of standard containers that aim to simplify the development and deployment of CSI Drivers on Kubernetes.

These containers contain common logic to watch the Kubernetes API, trigger appropriate operations against the “CSI volume driver” container, and update the Kubernetes API as appropriate.

The containers are intended to be bundled with third-party CSI driver containers and deployed together as pods.

The containers are developed and maintained by the Kubernetes Storage community.

Use of the containers is strictly optional, but highly recommended.

​ 使用这些sidecar的好处很多,官方文档中下面这两段话说得很清楚。以后做设计时也可以参考这样优雅地实现。

Benefits of these sidecar containers include:

  • Reduction of “boilerplate” code.
  • CSI Driver developers do not have to worry about complicated, “Kubernetes specific” code.
  • Separation of concerns.
  • Code that interacts with the Kubernetes API is isolated from (and in a different container then) the code that implements the CSI interface.

​ 会使用到的sidecar如下:

  • external-provisioner

    如果CSI插件要实现CREATE_DELETE_VOLUME能力(即动态供应),则CSI插件需要实现Controller Service的CreateVolumeDeleteVolume接口,并配合上该sidecar就可以了。这样当watch到指定StorageClass的 PersistentVolumeClaim资源状态变更,会自动地调用这两个接口。

  • external-attacher

    如果CSI插件要实现PUBLISH_UNPUBLISH_VOLUME能力,则CSI插件需要实现Controller Service的ControllerPublishVolumeControllerUnpublishVolume接口,并配合上该sidecar就可以了。这样当watch到VolumeAttachment资源状态变更,会自动地调用这两个接口。

  • external-snapshotter

    如果CSI插件要实现CREATE_DELETE_SNAPSHOT能力,则CSI插件需要实现Controller Service的CreateSnapshotDeleteSnapshot接口,并配合上该sidecar就可以了。这样当watch到指定SnapshotClass的VolumeSnapshot资源状态变更,会自动地调用这两个接口。

  • external-resizer

    如果CSI插件要实现EXPAND_VOLUME能力,则CSI插件需要实现Controller Service的ControllerExpandVolume接口,并配合上该sidecar就可以了。这样当watch到PersistentVolumeClaim资源的容量发生变更,会自动地调用这个接口。

  • node-driver-registrar

    CSI插件实现Node Service的NodeGetInfo接口后,配合上该sidecar。这样当CSI Node Plugin部署到kubernetes的node节点时,该sidecar会自动调用接口获取CSI插件信息,并向kubelet进行注册。

  • livenessprobe

    配合上该sidecar,kubernetes即可检测到CSI插件相关pod的健康状态,当不正常时自动重启相应pod。

怎么将这些sidecar与CSI Driver部署在一起,官方文档其实讲得很清楚的。

同样个人觉得还是结合实际的示例理解文档更快一点。比如tencentcloud cbs块存储的CSI插件的部署清单里:

  1. csi-cbsplugin.yaml以DaemonSet方式部署了csi-tencentcloud-cbs:v1.0.0这个CSI插件Driver程序,这个插件Driver程序旁边放了一个csi-node-driver-registrar:v1.0.2的sidecar,这个sidecar会自动调用接口获取CSI插件信息,并向kubelet进行注册。

  2. csi-cbsplugin-provisioner.yaml以StatefulSet方式部署了csi-provisioner:v1.0.1csi-snapshotter:v1.0.1这两个sidecar,这两个sidecar会watch指定StorageClass的 PersistentVolumeClaim资源状态变更、指定SnapshotClass的VolumeSnapshot资源状态变更,然后通过宿主机上的csi UNIX domain socket与CSI插件驱动通信,调用相应的gRPC接口方法。

  3. csi-cbsplugin-attacher.yaml以StatefulSet方式部署了csi-attacher:v1.0.1这个sidecar,这个sidecar会watch VolumeAttachment资源状态变更,然后通过宿主机上的csi UNIX domain socket与CSI插件驱动通信,调用相应的gRPC接口方法。

    其它的sidecar的使用方法都类似上面示例中的玩法,照着配置就可以了。

CSI其他设计细节

  1. 在进行存储系统操作时会使用到各种密码、身份凭证,而这些需要按照一定的规约进行配置,详见官方文档
  2. 可以通过CSIDriver这一自定义资源,定制Kubernetes与CSI存储插件交互的行为,详见官方文档
  3. 可以通过CSINodeInfo这一自定义资源,将kubernetes的node与CSI Node映射起来。还可以通过CSINodeInfo指定node节点的的topologyKey,这个能实现基于topology的CSI存储卷供应,详见官方文档1官方文档2

CSI插件的测试支持

​ 官方还为开发CSI插件提供了单元测试与e2e测试的方案

现成的CSI插件驱动

​ 目前各大知名云厂商都实现了自家存储产品的CSI插件驱动,列表在这里。正常情况下直接使用官方提供的CSI插件即可。当然如果要学习下也是可以,CSI插件的代码一般都不难,5-6个go文件而已。