随着应用容器化的趋势,越来越多的应用部署到了kubernetes平台,同时日益复杂的业务场景,也使得kubernetes需要支持越来越多类别的存储。kubernete对存储的支持,大致可以分为三个历程:
in-tree
flexVolume
CSI
in-tree
最开始kubernetes支持的存储逻辑代码都在kubernetes项目中的,跟着kubernetes组件一起编译和发版,这种模式叫作in-tree
。in-tree模式有很多局限性,例如:
- kubernetes代码和存储代码放在一起,都是由kubernetes社区来维护,这样会使得kubernetes维护人员既要关注kubernetes本身的发展,又要关注种类繁多和日益增加的存储需求;
- 存储的发版跟着kubernetes版本发布,如果某个存储需要更新升级,必须等到下一个kubernetes版本才能发布,这极大的限制了存储更新迭代的灵活性。
以in-tree模式下支持的nfs存储为例,我们来看看pod如何使用:
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- name: nginx
image: nginx
volumeMounts:
- name: nfs
mountPath: /data
volumes:
- name: nfs
nfs:
server: "192.168.0.1"
path: "/data/nfs"
flexVolume
考虑到in-tree模式的局限性,kubernetes决定“授人以渔”:支持用户自定义插件来实现存储对接。所谓的插件其实就是在需要处理存储逻辑时,kubelet会通过cmd的方式调用对应的可执行文件,再由这个可执行文件去对接具体的存储
,这种模式叫作flexVolume
。flexVolume插件需要注意两点:
插件的位置
flexVolume插件的调用方是kubelet,而kubelet是分布在各个节点上的,因此flexVolume插件也需要放置到各个节点上
,具体目录为:/usr/libexec/kubernetes/kubelet-plugins/volume/exec/{driver}/{fsType}
。
我们用一个示例来详细说明:
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- name: nginx
image: nginx
volumeMounts:
- name: nfs
mountPath: /data
volumes:
- name: nfs
flexVolume:
driver: "k8s/nfs"
fsType: "nfs"
options:
server: "192.168.0.1"
share: "share"
如上述yaml,pod使用了一个flexVolume。我们先关注flexVolume下的driver
和fsType
两个字段,通过前面的说明,这两个字段确定了插件可执行文件的路径,因此这个flexVolume的可执行文件就应该是节点上的/usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs(注意driver字段的“/”被替换成了“~”
)。
再看看options
字段,它对应的是go语言中的map[string]string,作用是作为kubelet以cmd形式调用插件的时候携带的相关参数。例如这里的server指nfs服务器的IP,share指nfs服务器的共享目录。很显然,options中的数据应该是插件逻辑中支持的才有意义。
插件要实现的方法
flexVolume插件需要实现下面部分方法(方法名由kubelet通过cmd ags传入):
init
attach
detach
waitforattach
isattached
mountdevice
unmountdevice
mount
unmount
不同插件可以选择性地实现对应方法,但是均要实现init
方法,因为init方法用于告知kubelet本插件支持哪些功能。以官方提供的nfs flexVolume示例(https://github.com/kubernetes/examples/blob/master/staging/volumes/flexvolume/nfs)来说,它只实现了init、mount、umount方法。
flexVolume虽然支持了用户自定义存储插件,但是仍存在一定的局限性
。比如:
-
flexVolume不支持Dynamic Provisioning,也就是创建PVC后不能自动创建PV和volume,除非再单独实现一个provisioner。
-
kubelet每一次对flexVolume插件的调用都是完全独立的操作,某一个动作(例如mount)生成的一些数据无法被下一个动作(例如umount)使用。
CSI
考虑到flexVolume的局限性,kubernetes推出了更加灵活的CSI(Container Storage Interface)
模式。
在介绍CSI之前,先梳理一下kubernetes中使用存储有哪些步骤:
创建pv对象
存储服务器上创建一个volume
把创建的volume挂载到宿主机上
格式化volume
把格式化的volume mount到pod的volume目录
创建pvc对象并和pv对象绑定
pod使用pvc
其中1、2、6步是前文《Dynamic Provisioning原理分析》中说到的Dynamic Provisioning
过程(创建pvc后自动创建pv和volume并绑定),第3步把存储volume挂载到宿主机的过程叫作Attach
(逆过程叫Detach
),第4、5两步把volume格式化并mount到pod目录的过程叫作Mount
(逆过程叫Unmount
)。
CSI可以理解为kubernetes为了支持Dynamic Provisioning
、Attach/Detach
、Mount/Unmount
等功能的抽象,实现CSI一般需要在一个进程
里实现三个gRPC service:
IdentityServer
ControllerServer
NodeServer
IdentityServer
IdentityServer定义如下:
// github.com/container-storage-interface/spec/lib/go/csi/csi.pb.go
type IdentityServer interface {
GetPluginInfo(context.Context, *GetPluginInfoRequest) (*GetPluginInfoResponse, error)
GetPluginCapabilities(context.Context, *GetPluginCapabilitiesRequest) (*GetPluginCapabilitiesResponse, error)
Probe(context.Context, *ProbeRequest) (*ProbeResponse, error)
}
IdentityServer服务负责对外暴露插件本身信息,包括插件名称以及插件所能提供的能力(capabilities)等。
ControllerServer
ControllerServer定义如下:
// github.com/container-storage-interface/spec/lib/go/csi/csi.pb.go
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名称中的controller
可以理解为kubernetes的controller,即通过list&watch某些资源的事件做对应的处理,其实也就是对存储的volume做管理,包括如下等内容:
CreateVolume/DeleteVolume
:创建和删除volume,对应的是Dynamic Provisioning过程ControllerPublishVolume/ControllerUnpublishVolume
:对应的是前文提到的Attach/Detach过程CreateSnapshot/DeleteSnapshot
:对应的快照功能ControllerExpandVolume
:对应扩容功能
这里的controller服务是有状态且部署上与节点无关
的,因此最终的服务部署类型不会是daemonSet,而是带选举的deployment或者副本数为1的statefulSet。
NodeServer
NodeServer定义如下:
// github.com/container-storage-interface/spec/lib/go/csi/csi.pb.go
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)
}
NodeServer显然就是一些与节点强相关的工作,例如前文提到的Mount,对应的是NodeStageVolume
和NodePublishVolume
两个方法,在部署上通常是daemonSet。
CSI的部署
到这里很多读者可能会很迷惑,因为前文提到CSI需要在一个进程里实现三个gRPC服务,但是同一进程里的三个gRPC服务有的需要deployment/statefulSet部署,有的需要daemonSet部署,这不是一个矛盾话题吗?
在解释这个问题之前我们先了解几个kubernetes社区(https://github.com/kubernetes-csi)维护的项目:
node-driver-registrar
:项目地址:https://github.com/kubernetes-csi/node-driver-registrar,主要负责把CSI注册到kubernetes
。external-provisioner
:项目地址:https://github.com/kubernetes-csi/external-provisioner,主要负责调CSI中ControllerServer
下的CreateVolume
和DeleteVolume
方法实现Dynamic Provisioning
功能。external-attacher
:项目地址:https://github.com/kubernetes-csi/external-attacher,主要负责调CSI中ControllerServer
下的ControllerPublishVolume
和ControllerUnpublishVolume
方法实现前文提到的Attach/Detach
操作。external-snapshotter
:项目地址:https://github.com/kubernetes-csi/external-snapshotter,主要负责调CSI中ControllerServer
下的CreateSnapshot
和DeleteSnapshot
实现快照
功能。external-resizer
:项目地址:https://github.com/kubernetes-csi/external-resizer,主要负责调CSI中ControllerServer
下的ControllerExpandVolume
方法实现扩容
功能。external-health-monitor
:项目地址:https://github.com/kubernetes-csi/external-health-monitor,主要负责对volume的健康检查
。
这些项目都有官方提供的镜像,是不是都需要和自己写的CSI进程一起部署起来呢?答案是除了node-driver-registrar
必须选择外,其它的项目都是根据存储特性和业务需求自由选择的。例如nfs没有Attach/Detach过程,我就不需要部署external-attacher服务;再比如我不需要Dynamic Provisioning功能,我就不需要部署external-provisioner服务。
我们再从服务的搭配和部署角度来看看具体应该怎么操作。假设我们现在要部署一个自己开发的nfs CSI,在这个CSI里我需要Dynamic Provisioning
功能和处理Mount/Unmount
流程,那么:
- 首先肯定是要在一个进程里编程实现前面提到的三个gRPC服务对应的方法,例如IdentityServer下的三个方法、ControllerServer下的CreateVolume/DeleteVolume方法以及NodeServer下的NodePublishVolume/NodeUnpublishVolume方法等,并以
unix sock
的方式提供gRPC服务(后文会用到),并打包成镜像; - 把
node-driver-registrar
和CSI服务作为两个容器放到一个pod里,这样node-driver-registrar服务就可以用unix sock的方式访问CSI进程里的gRPC服务并且向kubelet注册; - node-driver-registrar完成注册后,后续的Mount/Unmount等操作kubelet会直接通过unix sock访问CSI。这里有两层含义:第一层含义是kubelet会直接通过unix sock访问CSI,因此CSI需要用hostPath的方式把自己unix sock文件暴露;第二层含义是kubelet直接调用CSI服务,这意味着
node-driver-registrar
和CSI
的这个pod应该是daemonSet形式部署的; - 把
external-provisioner
和CSI
服务作为两个容器放到一个pod里,去实现Dynamic Provisioning功能。因为Dynamic Provisioning设计创建卷和删除卷,因此这个pod应该看做是有状态的,在部署上通常是带有选举的deployment部署或者副本数为1的statefulSet部署(如果需要Attach/Detach功能,也可以再加个容器把external-attacher放到这个pod中)。
我们再用个图来总结下整个nfs CSI的逻辑:
总结
本文简单讲述了kubernetes存储的发展历程:in-tree -> flexVolume -> CSI。实现一个CSI需要在一个进程中实现IdentityServer
、ControllerServer
和NodeServer
三个gRPC服务,在部署上通常会有两个负载:一个daemonSet,包含node-driver-registrar和CSI两个服务;一个deployment/statefulSet,包含external-xxx服务和CSI服务(注意这两种负载中都有CSI进程,external-xxx服务和CSI部署在同一个pod里,通常也被叫做sidecar
)。
微信公众号卡巴斯同步发布,欢迎大家关注。