如何实现一个 Kubernetes CSI Driver

2022/10/31 17:28 下午 posted in  Kubernetes

维护了一个 CSI Driver 有一年半的时间了,期间也被一些朋友询问 CSI 相关的问题以及如何开发自己的 CSI Driver。本篇文章就来介绍如何快速开发自己的 Kubernetes CSI Driver,本篇也是继上一篇 《浅析 CSI 工作原理》 的 CSI 系列第二篇。

本文展示的完整的项目代码可见:https://github.com/zwwhdls/csi-hdls

CSIbuilder

其实 CSI Driver 无非就是实现一些接口,实现第三方存储的逻辑。短短一句话包含的工作是很大的,也比较繁琐,需要理解清楚 CSI 的工作原理,但好在工作是有迹可循的。

近期开发了一个脚手架工具 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

实现 CSI Node 接口

Pod 挂载的过程在上一篇文章《浅析 CSI 工作原理》 中已经详细介绍过了,我们知道 CSI 工作最重要的组件是 CSI Node,也就是其 NodePublishVolumeNodeUnpublishVolume 两个接口。

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 接口

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 目前还是一个刚刚完工的状态,在后续的迭代中会陆续支持这些功能。