维护了一个 CSI Driver 有一年半的时间了,期间也被一些朋友询问 CSI 相关的问题以及如何开发自己的 CSI Driver。本篇文章就来介绍如何快速开发自己的 Kubernetes CSI Driver,本篇也是继上一篇 《浅析 CSI 工作原理》 的 CSI 系列第二篇。
本文展示的完整的项目代码可见:https://github.com/zwwhdls/csi-hdls
其实 CSI Driver 无非就是实现一些接口,实现第三方存储的逻辑。短短一句话包含的工作是很大的,也比较繁琐,需要理解清楚 CSI 的工作原理,但好在工作是有迹可循的。
近期开发了一个脚手架工具 CSIbuilder (代码仓库地址:https://github.com/zwwhdls/csibuilder),其原理类似于 kubebuilder,用户只需要输入几行命令,就可以搭建一个 CSI Driver 的代码框架,然后再填入自己的逻辑即可。
使用过程很简单,首先下载二进制包:
weiwei@hdls-mbp $ curl -L -o csibuilder.tar https://github.com/zwwhdls/csibuilder/releases/download/v0.1.0/csibuilder-darwin-amd64.tar
weiwei@hdls-mbp $ tar -zxvf csibuilder.tar && chmod +x csibuilder && mv csibuilder /usr/local/bin/
新建一个 golang 项目的工作目录:
weiwei@hdls-mbp $ export GO111MODULE=on
weiwei@hdls-mbp $ mkdir $GOPATH/src/csi-hdls
weiwei@hdls-mbp $ cd $GOPATH/src/csi-hdls
使用 csibuilder
进行 repo 初始化:
weiwei@hdls-mbp $ csibuilder init --repo hdls --owner "zwwhdls"
Init CSI Driver Project for you...
Update dependencies:
$ go mod tidy
go: warning: "all" matched no packages
Next: define a csi driver with:
$ csibuilder create api
创建一个名为 hdls
的 CSI Driver:
weiwei@hdls-mbp $ csibuilder create --csi hdls
Writing scaffold for you to edit...
Update dependencies:
$ go mod tidy
go: finding module for package google.golang.org/grpc/status
go: finding module for package github.com/container-storage-interface/spec/lib/go/csi
go: finding module for package google.golang.org/grpc
go: finding module for package google.golang.org/grpc/codes
go: finding module for package k8s.io/klog
go: found k8s.io/klog in k8s.io/klog v1.0.0
go: found github.com/container-storage-interface/spec/lib/go/csi in github.com/container-storage-interface/spec v1.7.0
go: found google.golang.org/grpc in google.golang.org/grpc v1.50.1
go: found google.golang.org/grpc/codes in google.golang.org/grpc v1.50.1
go: found google.golang.org/grpc/status in google.golang.org/grpc v1.50.1
Scaffolding complete. Enjoy your new project!
默认使用 go 1.18
,也可以在 init
阶段通过参数 --goversion=1.19
来指定使用的 go 版本。
然后就能看到项目里已经初始化好了 CSI Driver 的代码文件和部署 yaml:
weiwei@hdls-mbp $ tree
.
├── Dockerfile
├── Makefile
├── PROJECT
├── deploy
│ ├── clusterrole.yaml
│ ├── clusterrolebinding.yaml
│ ├── csidriver.yaml
│ ├── daemonset.yaml
│ ├── serviceaccount.yaml
│ └── statefulset.yaml
├── go.mod
├── go.sum
├── hack
│ └── boilerplate.go.txt
├── main.go
└── pkg
└── csi
├── controller.go
├── driver.go
├── identity.go
├── node.go
└── version.go
4 directories, 18 files
Pod 挂载的过程在上一篇文章《浅析 CSI 工作原理》 中已经详细介绍过了,我们知道 CSI 工作最重要的组件是 CSI Node,也就是其 NodePublishVolume
和 NodeUnpublishVolume
两个接口。
kubelet 调用接口时传进来的参数都可以在 request
中找到,volumeID
为 Pod 所使用的 PV 的 id(即 pv.spec.csi.volumeHandle);target
为 Pod 内所需要挂载 volume 的路径;options
为本次挂载的挂载参数。
我们所需要做的就是将我们的存储挂载到 Pod 内的 target
路径。这里以 nfs 为例:
func (n *nodeService) NodePublishVolume(ctx context.Context, request *csi.NodePublishVolumeRequest) (*csi.NodePublishVolumeResponse, error) {
...
volCtx := request.GetVolumeContext()
klog.Infof("NodePublishVolume: volume context: %v", volCtx)
hostPath := volCtx["hostPath"]
subPath := volCtx["subPath"]
sourcePath := hostPath
if subPath != "" {
sourcePath = path.Join(hostPath, subPath)
exists, err := mount.PathExists(sourcePath)
if err != nil {
return nil, status.Errorf(codes.Internal, "Could not check volume path %q exists: %v", sourcePath, err)
}
if !exists {
klog.Infof("volume not existed")
err := os.MkdirAll(sourcePath, 0755)
if err != nil {
return nil, status.Errorf(codes.Internal, "Could not make directory for meta %q", sourcePath)
}
}
}
klog.Infof("NodePublishVolume: binding %s at %s", hostPath, target)
if err := n.Mount(sourcePath, target, "none", mountOptions); err != nil {
os.Remove(target)
return nil, status.Errorf(codes.Internal, "Could not bind %q at %q: %v", hostPath, target, err)
}
return &csi.NodePublishVolumeResponse{}, nil
}
我们可以在 PV.spec.parameter 中传入 nfs server 的一些参数,从 request.GetVolumeContext()
中可以获取到,然后再以 mount bind 的方式挂载到 target
。为了逻辑简单,这里我们假定参数 hostPath
为宿主机上已经挂载好的 nfs 路径。
而 NodeUnpublishVolume
接口就是上面 NodePublishVolume
接口的反向操作。我们只需要将 target
路径 umount
掉即可,代码如下:
// NodeUnpublishVolume unmount the volume from the target path
func (n *nodeService) NodeUnpublishVolume(ctx context.Context, request *csi.NodeUnpublishVolumeRequest) (*csi.NodeUnpublishVolumeResponse, error) {
target := request.GetTargetPath()
if len(target) == 0 {
return nil, status.Error(codes.InvalidArgument, "Target path not provided")
}
// TODO modify your volume umount logic here
...
klog.Infof("NodeUnpublishVolume: unmounting %s", target)
if err := n.Unmount(target); err != nil {
return nil, status.Errorf(codes.Internal, "Could not unmount %q: %v", target, err)
}
return &csi.NodeUnpublishVolumeResponse{}, nil
}
CSI Controller 接口主要是配合实现 PV 的自动创建,只有在使用 StorageClass 时才会工作。这里的逻辑通常为在文件系统下创建一个子目录,并将其放在 PV 的 spec
中。在本文展示的 demo 中,创建子目录这一步也是在 CSI Node 的接口中实现,所以在 CSI Controller 的接口只需要将子目录传在 PV 的参数中即可。
CreateVolume
的代码如下:
func (d *controllerService) CreateVolume(ctx context.Context, request *csi.CreateVolumeRequest) (*csi.CreateVolumeResponse, error) {
if len(request.Name) == 0 {
return nil, status.Error(codes.InvalidArgument, "Volume Name cannot be empty")
}
if request.VolumeCapabilities == nil {
return nil, status.Error(codes.InvalidArgument, "Volume Capabilities cannot be empty")
}
requiredCap := request.CapacityRange.GetRequiredBytes()
volCtx := make(map[string]string)
for k, v := range request.Parameters {
volCtx[k] = v
}
volCtx["subPath"] = request.Name
volume := csi.Volume{
VolumeId: request.Name,
CapacityBytes: requiredCap,
VolumeContext: volCtx,
}
return &csi.CreateVolumeResponse{Volume: &volume}, nil
}
返回值中的 VolumeContext
会放在自动创建的 PV 的 spec.csi.volumeAttributes
中。
以上就是实现一个 CSI Driver 最简单可用功能的完整展示,有了 csibuilder 之后,CSI Driver 的代码编写也变得异常简单。不过 CSI 的功能远不止这些,还包括 attach、stage、expand、snapshot 等。csibuilder 目前还是一个刚刚完工的状态,在后续的迭代中会陆续支持这些功能。