前言
接下去我们简单来看下一个 CSI Plugin 开源项目的实现,github 地址是:https://github.com/alibaba/open-object。
这个 CSI Plugin 实现了将 Minio 作为后端存储。以这个项目为例子,主要的考虑是:(1)它的实现不复杂,整体上的代码量就 2k 行左右,便于阅读和了解;(2)项目实现了将 Minio 作为后端存储,在这个项目基础之上,我们可以进一步扩展将阿里云 OSS 作为后端存储的实现,便于进一步的实操。(3)Minio 是对象存储,了解它的实现可以解决我心中的一个疑惑,就是我们如何将对一个文件的读写访问,转换为对 bucket 的读写请求。通过后续的了解,我们可以知道这主要是依赖了像 s3f3 这样的程序,s3f3 通过 FUSE 将一个 bucket 表现为一个文件。
从部署开始
CSIDriver 资源对象
CSIDriver 包含了部署的 CSI Plugin 有关的信息。Kubernetes AD Controller 通过该对象来决定是否需要 attach。Kubelet 通过该对象决定 mount 时是否需要传递 Pod 信息。CSIDriver 资源对象未划分命名空间。
可以了解到以下几个点:
- OpenObject 这个 CSI 插件不需要 attach 步骤,并且在 mount 的时候需要传递 Pod 信息。
apiVersion: storage.k8s.io/v1beta1
kind: CSIDriver
metadata:
name: object.csi.aliyun.com
spec:
attachRequired: false
podInfoOnMount: true
volumeLifecycleModes:
- Persistent
CSI Node Plugin
可以看到 Daemonset 中包含了 3 个容器:
- init 容器,启动一个 connector,这个 connector 运行在宿主机上,监听 /etc/open-object/connector.sock。通过这个 socket 文件,connector 会收到相应的执行命令,并执行这些命令。总体上的作用相当于宿主机和容器之间的连接器。
- driver-registrar 容器,调用 csi-plugin 的 NodeGetInfo 的接口,将 CSI Node Plugin 的信息通过 kubelet 的插件注册机制在对应节点的 kubelet 上进行注册,同时把 CSI Node Plugin 的通信地址同步给 kubelet。
- CSI-Plugin 容器,这个容器里面运行的就是 CSI Node Plugin,也是我们实现的内容。
kind: DaemonSet
apiVersion: apps/v1
metadata:
name: open-object
namespace: {{ .Values.namespace }}
spec:
selector:
matchLabels:
app: open-object
template:
metadata:
labels:
app: open-object
spec:
tolerations:
- operator: Exists
serviceAccount: open-object
hostNetwork: true
hostPID: true
dnsPolicy: ClusterFirstWithHostNet
initContainers:
- name: run-connector
image: {{ .Values.images.object.image }}:{{ .Values.images.object.tag }}
securityContext:
privileged: true
capabilities:
add: ["SYS_ADMIN"]
allowPrivilegeEscalation: true
command:
- /run-connector.sh
volumeMounts:
- name: host-systemd-config
mountPath: /host/usr/lib/systemd/system
- name: host-etc
mountPath: /host/etc/open-object
containers:
- name: driver-registrar
image: {{ .Values.images.registrar.image }}:{{ .Values.images.registrar.tag }}
args:
- "--kubelet-registration-path=$(DRIVER_REG_SOCK_PATH)"
- "--v=4"
- "--csi-address=$(ADDRESS)"
env:
- name: ADDRESS
value: /csi/csi.sock
- name: DRIVER_REG_SOCK_PATH
value: /var/lib/kubelet/plugins/object.csi.aliyun.com/csi.sock
- name: KUBE_NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
...
volumeMounts:
- name: plugin-dir
mountPath: /csi
- name: registration-dir
mountPath: /registration/
- name: csi-plugin
securityContext:
privileged: true
capabilities:
add: ["SYS_ADMIN"]
allowPrivilegeEscalation: true
image: {{ .Values.images.object.image }}:{{ .Values.images.object.tag }}
args:
- "csi"
- "--endpoint=$(CSI_ENDPOINT)"
- "--nodeID=$(NODE_ID)"
- "--driver=object.csi.aliyun.com"
env:
- name: CSI_ENDPOINT
value: unix:///csi/csi.sock
- name: NODE_ID
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: TZ
value: Asia/Shanghai
...
volumeMounts:
- name: plugin-dir
mountPath: /csi
- name: pods-mount-dir
mountPath: /var/lib/kubelet/pods
mountPropagation: "Bidirectional"
- name: fuse-device
mountPath: /dev/fuse
- name: host-etc
mountPath: /host/etc/open-object
- name: host-etc-os
mountPath: /host/etc/os-release
volumes:
- name: registration-dir
hostPath:
path: /var/lib/kubelet/plugins_registry/
type: DirectoryOrCreate
- name: plugin-dir
hostPath:
path: /var/lib/kubelet/plugins/object.csi.aliyun.com
type: DirectoryOrCreate
- name: pods-mount-dir
hostPath:
path: /var/lib/kubelet/pods
type: Directory
- name: fuse-device
hostPath:
path: /dev/fuse
- name: host-etc
hostPath:
path: /etc/open-object
type: DirectoryOrCreate
- name: host-etc-os
hostPath:
path: /etc/os-release
type: File
- name: host-systemd-config
hostPath:
path: /usr/lib/systemd/system
type: DirectoryOrCreate
CSI Controller Plugin
下面这个 Deployment 部署的是 external-provisioner 组件,指定了通过哪个 socket 文件跟 CSI Controller Plugin 进行通信。但是,我们在这个 Deployment 中并没有看到 CSI Controller Plugin 组件相关的容器。这是因为对于 OpenObject 这个项目来说,它将 CSI Node Plugin 和 CSI Controller Plugin 的代码都实现在了上述提到的 csi-plugin 容器里。由于,csi-plugin 是通过 Daemonset 方式部署的,每个节点上都有,所以 external-provisioner 组件可以通过指定的 socket 文件跟 csi-plugin 容器通信。
kind: Deployment
apiVersion: apps/v1
metadata:
name: open-object-csi-provisioner
namespace: {{ .Values.namespace }}
labels:
app: open-object
component: open-object-csi-provisioner
spec:
selector:
matchLabels:
app: open-object
component: open-object-csi-provisioner
replicas: 1
template:
metadata:
labels:
app: open-object
component: open-object-csi-provisioner
spec:
tolerations:
- operator: Exists
effect: NoSchedule
key: node-role.kubernetes.io/master
priorityClassName: system-cluster-critical
serviceAccount: open-object
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
containers:
- name: csi-provisioner
image: {{ .Values.images.provisioner.image }}:{{ .Values.images.provisioner.tag }}
args:
- --csi-address=$(ADDRESS)
- --volume-name-prefix=fuse
- --extra-create-metadata=true
- --timeout=10m
env:
- name: ADDRESS
value: /var/lib/kubelet/plugins/object.csi.aliyun.com/csi.sock
- name: TZ
value: Asia/Shanghai
...
volumeMounts:
- name: socket-dir
mountPath: /var/lib/kubelet/plugins/object.csi.aliyun.com
volumes:
- name: socket-dir
hostPath:
path: /var/lib/kubelet/plugins/object.csi.aliyun.com
type: DirectoryOrCreate
根据上述的情况,我们总结下:
- CSI Node Plugin 和 CSI Controller Plugin 都实现在同一个程序里。
- 由于这个程序通过 Daemonset 部署到了每个节点上,因此相当于每个节点都运行着 CSI Node Plugin 和 CSI Controller Plugin 这两个插件,并且都可以通过 csi.sock 文件实现跟 CSI Node Plugin 和 CSI Controller Plugin 的通信。所以,虽然 external-provisioner 是通过 Deployment 部署的,但是它仍旧可以通过 csi.sock 文件实现跟 CSI Controller Plugin 的通信。
进入源码
代码实现上,我们重点关注 CSI 接口的实现。
初始化
首先是整个 CSI Plugin 的初始化:
- NewFuseDriver 函数初始化了一个 FuseDriver 结构体,这个结构体包含了 endpoint 也就是 CSI Plugin 监听的 socket 文件地址。
- ids、cs、ns 分别是 CSI Identity、CSI Controller 和 CSI Node 三类接口的具体实现。
func NewFuseDriver(nodeID, endpoint, driverName string, kubeClient *kubernetes.Clientset) (*FuseDriver, error) {
driver := csicommon.NewCSIDriver(driverName, version.Version, nodeID)
if driver == nil {
klog.Fatalln("Failed to initialize CSI Driver.")
}
s3Driver := &FuseDriver{
endpoint: endpoint,
driver: driver,
ids: newIdentityServer(driver),
cs: newControllerServer(driver),
ns: newNodeServer(driver),
}
return s3Driver, nil
}
func (s3 *FuseDriver) Run() {
// Initialize default library driver
s3.driver.AddControllerServiceCapabilities([]csi.ControllerServiceCapability_RPC_Type{
csi.ControllerServiceCapability_RPC_CREATE_DELETE_VOLUME,
csi.ControllerServiceCapability_RPC_EXPAND_VOLUME,
})
s3.driver.AddVolumeCapabilityAccessModes([]csi.VolumeCapability_AccessMode_Mode{
csi.VolumeCapability_AccessMode_MULTI_NODE_MULTI_WRITER})
s := csicommon.NewNonBlockingGRPCServer()
s.Start(s3.endpoint, s3.ids, s3.cs, s3.ns)
s.Wait()
}
CSI Controller 类接口的实现
先看下 CSI Controller 类接口的具体实现,主要实现的是 CreateVolume、DeleteVolume 和 ControllerExpandVolume 这三个接口。
- CreateVolume 接口的核心实现就是在 minio 上创建一个 bucket。
- DeleteVolume 接口的核心实现就是将 bucket 删除。
- ControllerExpandVolume 接口的核心实现就是将 bucket 的 quota 上限提高,调整 bucket 的 metadata。
type controllerServer struct {
kubeClinet *kubernetes.Clientset
*csicommon.DefaultControllerServer
}
func newControllerServer(d *csicommon.CSIDriver) *controllerServer {
cfg, err := clientcmd.BuildConfigFromFlags("", "")
if err != nil {
klog.Fatalf("Error building kubeconfig: %s", err.Error())
}
kubeClient, err := kubernetes.NewForConfig(cfg)
if err != nil {
klog.Fatalf("Error building kubernetes clientset: %s", err.Error())
}
return &controllerServer{
DefaultControllerServer: csicommon.NewDefaultControllerServer(d),
kubeClinet: kubeClient,
}
}
func (cs *controllerServer) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) (*csi.CreateVolumeResponse, error) {
...
// create volume
return driver.CreateVolume(ctx, req)
}
func (cs *controllerServer) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequest) (*csi.DeleteVolumeResponse, error) {
...
return driver.DeleteVolume(ctx, req)
}
func (cs *controllerServer) ControllerExpandVolume(ctx context.Context, req *csi.ControllerExpandVolumeRequest) (*csi.ControllerExpandVolumeResponse, error) {
...
// expand volume
return driver.ControllerExpandVolume(ctx, req)
}
func (driver *MinIODriver) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) (*csi.CreateVolumeResponse, error) {
...
if err := driver.minioClient.CreateBucket(bucketName, capacity); err != nil {
return &csi.CreateVolumeResponse{}, status.Error(codes.Internal, err.Error())
}
...
}
func (driver *MinIODriver) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequest) (*csi.DeleteVolumeResponse, error) {
...
if err := driver.minioClient.DeleteBucket(bucketName); err != nil {
return &csi.DeleteVolumeResponse{}, status.Error(codes.Internal, err.Error())
}
...
}
func (driver *MinIODriver) ControllerExpandVolume(ctx context.Context, req *csi.ControllerExpandVolumeRequest) (*csi.ControllerExpandVolumeResponse, error) {
...
capacity := req.GetCapacityRange().RequiredBytes
if DefaultFeatureGate.Enabled(Quota) {
if err := driver.minioClient.SetBucketQuota(bucketName, capacity, madmin.HardQuota); err != nil {
return &csi.ControllerExpandVolumeResponse{}, status.Error(codes.Internal, err.Error())
}
}
bucketMap, err := driver.minioClient.GetBucketMetadata(bucketName)
if err != nil {
return &csi.ControllerExpandVolumeResponse{}, status.Error(codes.Internal, err.Error())
}
bucketMap[MetaDataCapacity] = strconv.FormatInt(capacity, 10)
if err = driver.minioClient.SetBucketMetadata(bucketName, bucketMap); err != nil {
return &csi.ControllerExpandVolumeResponse{}, status.Error(codes.Internal, err.Error())
}
return &csi.ControllerExpandVolumeResponse{CapacityBytes: capacity, NodeExpansionRequired: false}, nil
}
CSI Node 类接口的实现
接下去看一下 CSI Node 类接口的具体实现,主要实现的是 NodePublishVolume、NodeUnpublishVolume 这两个接口。NodeStageVolume 和 NodeUnstageVolume 这两个接口并没有相应的逻辑,也就是说不存在多个 Pod 共享一个 volume 的情况。
- NodePublishVolume 接口会调用 MinIODriver 的 NodePublishVolume 接口。在这个接口中会封装好的 s3fs 执行命令,并将执行命令发送给 connector,connector 在宿主机上执行相应的命令。这个命令的主要作用是将 minio 的 bucket 挂载到 target path 上,也就是 /var/lib/kubelet/pods/${pod uid}/volumes/kubernetes.io****~${CSI Plugin Name}/${PV name} 上。
- NodeUnpublishVolume 接口会调用 FuseUmount 方法 unmount 掉 target path 上的挂载。代码继续跟踪下去,它是在容器内部就执行了 unmount 方法。为什么在容器内部执行 unmount 方法就可以了呢?这是因为在部署这个容器的时候,把宿主机的 /var/lib/kubelet/pods/ 目录就挂载到了容器的 /var/lib/kubelet/pods/ 上。
type nodeServer struct {
*csicommon.DefaultNodeServer
}
func newNodeServer(d *csicommon.CSIDriver) *nodeServer {
return &nodeServer{
DefaultNodeServer: csicommon.NewDefaultNodeServer(d),
}
}
func (ns *nodeServer) NodePublishVolume(ctx context.Context, req *csi.NodePublishVolumeRequest) (*csi.NodePublishVolumeResponse, error) {
volumeID := req.GetVolumeId()
targetPath := req.GetTargetPath()
...
return driver.NodePublishVolume(ctx, req)
}
func (ns *nodeServer) NodeUnpublishVolume(ctx context.Context, req *csi.NodeUnpublishVolumeRequest) (*csi.NodeUnpublishVolumeResponse, error) {
volumeID := req.GetVolumeId()
targetPath := req.GetTargetPath()
...
if err := common.FuseUmount(targetPath); err != nil {
return &csi.NodeUnpublishVolumeResponse{}, status.Error(codes.Internal, err.Error())
}
klog.Infof("s3: mountpoint %s has been unmounted.", targetPath)
return &csi.NodeUnpublishVolumeResponse{}, nil
}
func (ns *nodeServer) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolumeRequest) (*csi.NodeStageVolumeResponse, error) {
return &csi.NodeStageVolumeResponse{}, nil
}
func (ns *nodeServer) NodeUnstageVolume(ctx context.Context, req *csi.NodeUnstageVolumeRequest) (*csi.NodeUnstageVolumeResponse, error) {
return &csi.NodeUnstageVolumeResponse{}, nil
}
// NodeGetCapabilities returns the supported capabilities of the node server
func (ns *nodeServer) NodeGetCapabilities(ctx context.Context, req *csi.NodeGetCapabilitiesRequest) (*csi.NodeGetCapabilitiesResponse, error) {
...
}
func (ns *nodeServer) NodeExpandVolume(ctx context.Context, req *csi.NodeExpandVolumeRequest) (*csi.NodeExpandVolumeResponse, error) {
return &csi.NodeExpandVolumeResponse{}, status.Error(codes.Unimplemented, "NodeExpandVolume is not implemented")
}
// NodeGetVolumeStats used for csi metrics
func (ns *nodeServer) NodeGetVolumeStats(ctx context.Context, req *csi.NodeGetVolumeStatsRequest) (*csi.NodeGetVolumeStatsResponse, error) {
return nil, nil
}
// NodePublishVolume 中的 driver.NodePublishVolume
func (driver *MinIODriver) NodePublishVolume(ctx context.Context, req *csi.NodePublishVolumeRequest) (*csi.NodePublishVolumeResponse, error) {
pv, err := driver.kubeClinet.CoreV1().PersistentVolumes().Get(ctx, req.GetVolumeId(), metav1.GetOptions{})
if err != nil {
return &csi.NodePublishVolumeResponse{}, status.Error(codes.Internal, err.Error())
}
bucketName := pv.Spec.CSI.VolumeAttributes[ParamBucketNameTag]
targetPath := req.GetTargetPath()
notMnt, err := checkMount(targetPath)
if err != nil {
return &csi.NodePublishVolumeResponse{}, status.Error(codes.Internal, err.Error())
}
if !notMnt {
return &csi.NodePublishVolumeResponse{}, nil
}
if err := S3FSMount(driver.Endpoint, bucketName, targetPath, driver.AK, driver.SK); err != nil {
return &csi.NodePublishVolumeResponse{}, err
}
klog.Infof("s3: bucket %s successfully mounted to %s", bucketName, targetPath)
return &csi.NodePublishVolumeResponse{}, nil
}
// NodeUnpublishVolume 中的 common.FuseUmount
func FuseUmount(path string) error {
if err := mount.New("").Unmount(path); err != nil {
return err
}
// as fuse quits immediately, we will try to wait until the process is done
process, err := findFuseMountProcess(path)
if err != nil {
klog.Errorf("Error getting PID of fuse mount: %s", err)
return nil
}
if process == nil {
klog.Warningf("Unable to find PID of fuse mount %s, it must have finished already", path)
return nil
}
klog.Infof("Found fuse pid %v of mount %s, checking if it still runs", process.Pid, path)
return waitForProcess(process, 1)
}
扩展认识
上文提到了 s3f3,s3fs 是一个开源的用户态文件系统,它允许将 Amazon S3 bucket 挂载到 Linux 系统中,使其表现为本地文件系统。
通过 s3fs,用户可以像访问本地文件系统一样访问 Amazon S3 存储桶中的对象。这使得开发人员可以直接在应用程序中使用 S3 存储桶,而无需使用 Amazon S3 的 API 进行手动操作。s3fs 提供了对 Amazon S3 存储桶的标准文件系统操作,例如读取、写入、复制、移动和删除文件。它还支持文件权限、目录结构和符号链接等常见的文件系统功能。由于 Amazon S3 和 minio 的 API 是兼容的,因此也可以用于 minio。
s3f3 github 地址:https://github.com/s3fs-fuse/s3fs-fuse