原文:Kubernetes 与 CSI

CSI 即 Container Storage Interface(容器存储接口),向 Kubernetes 这样的 Container Orchestration Systems(容器编排系统)上的容器化工作负载暴露裸存储块或文件系统。第三方 SP(存储提供方)遵循 CSI 编写和部署插件为 Kubernetes 集群提供持久化的存储系统,但无需接触核心 Kubernetes 代码。

几个经典的 CSI 项目:

专业术语

术语 定义
in-tree 代码在核心 Kubernetes 仓库中,将被编译、链接内置在核心的 Kubernetes 二进制可执行文件中
out-of-tree 代码在其他仓库中
CSI Volume Plugin in-tree 的 CSI 插件作为适配器,使得 out-of-tree 的第三方 CSI 驱动能够被 Kubernetes 使用
CSI Volume Driver out-of-tree 的兼容 CSI 的插件实现,通过 CSI 插件被 Kubernetes 使用

因为 Kubernetes 已经成为了容器编排系统的事实标准,所以下文中的 Kubernetes 就是容器编排系统,反之亦然。

CSI 规范的背景

因为 Volume Plugin(数据卷插件)是要编译进 Kubernetes 二进制执行文件中的,添加一个新的存储系统需要修改核心代码仓库中的代码,非常麻烦:

  • 因为处于同一个代码仓库中,数据卷插件的开发与 Kubernetes 紧密耦合。
  • Kubernetes 开发者和社区得负责测试和维护所有数据卷插件,这是相当大的额外工作量。
  • 因为在同一个二进制中,数据卷插件的 bug 可能会造成整个 Kubernetes 组件崩溃。
  • 数据卷插件有 kubernetes 组件(kubelet 和 kube-controller-manager)所有的权限。
  • 插件被迫同 kubernetes 开源,不能选择只发布二进制文件。
  • 数据卷插件通常需要许多额外挂载和文件系统工具,可能要在宿主机操作系统上另外安装。

要解决上述痛点:

  1. 数据卷插件代码从 Kubernetes 核心代码仓库分离出来,在各自的代码仓库中维护。
  2. 插件以独立进程的形式运行,不再内置于 Kubernetes 组件二进制执行文件中。
  3. 将额外的挂载和文件系统工具构建进容器镜像中,插件以容器形式由 Kubernetes 托管。

数据卷插件下面改称为存储插件

进而又引出了一个大问题——Kubernetes 组件与存储插件如何通信:

  1. 因为 Kubernetes 组件与插件都是独立执行的进程,有可能运行在不同的宿主机上,通信毫无疑问要走网络。
  2. Kubernetes 组件与插件往往隐藏在系统的背后,不面向消费者,用 gRPC 协议(字节码传输)比传统的 HTTP 更高效;而且有丰富的代码生成工具,开发者只需关注协议本身。
  3. Kubernetes 组件与存储系统解耦,无需知道正在使用的存储系统;无论与哪个存储插件通信,只要使用统一的接口发送创建、使用磁盘请求,这个接口就是 CSI 规范(协议)。

所以 CSI 规范主要关注于 Kubernetes 和存储插件之间的协议

CSI Volume Driver 存储插件架构

我们按存储插件的功能来看其架构:

  1. 在存储系统中创建/销毁存储卷
  2. attach/detach 存储卷至集群节点
  3. mount/unmount 存储卷至操作系统

两类功能对应存储插件的两个不同组件,插件容器全都由 Kubernetes 托管

  1. controller 插件:关注于存储系统 API 调用
  2. node 插件:关注于主机操作系统使用存储卷;有些存储系统也会由 node 插件来插拔存储卷

Node 插件

由于 Kubernetes 集群中的所有节点都有可能使用存储系统的存储卷,所以每个节点都要部署一个存储插件守护进程,kubelet 只与同节点上存储插件通过 CSI 协议通信;同宿主机上的网络通信最好使用 Unix Domain Socket。

因此 CSI 存储插件要在宿主机上创建 /var/lib/kubelet/plugins/[SanitizedCSIDriverName]/csi.sock 文件。

$ kubectl get CSIDriver
kubectl get CSIDriver
NAME                            ATTACHREQUIRED   PODINFOONMOUNT   STORAGECAPACITY   TOKENREQUESTS   REQUIRESREPUBLISH   MODES        AGE
local.csi.openebs.io            false            true             true              <unset>         false               Persistent   64d
rook-ceph.cephfs.csi.ceph.com   true             false            false             <unset>         false               Persistent   64d
rook-ceph.rbd.csi.ceph.com      true             false            false             <unset>         false               Persistent   64d

$ tree /var/lib/kubelet/plugins/
/var/lib/kubelet/plugins/
├── kubernetes.io
│   └── csi
│       ├── pv
│       └── volumeDevices
│           ├── publish
│           └── staging
├── lvm-localpv
│   └── csi.sock
├── rook-ceph.cephfs.csi.ceph.com
│   └── csi.sock
└── rook-ceph.rbd.csi.ceph.com
    └── csi.sock

该环境上有三种 CSI Volume Driver,每种插件都会创建其 usock 文件。

因此每一种 CSI 存储插件,都要在集群中的每个节点上(跟随 kubelet)部署一个存储插件守护进程(DaemonSet):

$ kubectl get ds -n rook-ceph
NAME               DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
csi-cephfsplugin   3         3         3       3            3           <none>          65d
csi-rbdplugin      3         3         3       3            3           <none>          65d

$ kubectl get ds -n kube-system | grep openebs
openebs-lvm-node   3         3         3       3            3           <none>                                                           64d

$ kubectl get po -n rook-ceph
NAME                                           READY   STATUS    RESTARTS        AGE
csi-cephfsplugin-bnhjd                         2/2     Running   10 (3d3h ago)   65d
csi-cephfsplugin-q4jbz                         2/2     Running   6 (3d3h ago)    65d
csi-cephfsplugin-qgq5q                         2/2     Running   8 (47d ago)     65d
csi-rbdplugin-4pbx9                            2/2     Running   10 (3d3h ago)   65d
csi-rbdplugin-4x2j8                            2/2     Running   6 (3d3h ago)    65d
csi-rbdplugin-j8p4p                            2/2     Running   8 (47d ago)     65d

$ kubectl get po -n kube-system | grep openebs
kubectl get po -n kube-system | grep openebs
openebs-lvm-node-2dntx                 2/2     Running     8 (3d3h ago)    64d
openebs-lvm-node-8j4cg                 2/2     Running     0               12d
openebs-lvm-node-rbmpw                 2/2     Running     2 (3d3h ago)    52d

我们注意到每个存储插件 Pod 都有两个容器,这是因为除存储插件本身外,Kubernetes 还提供了一个 sidecar 容器用于帮助将存储插件的 usock 注册至 kubelet:

$ ps -ef | grep csi-node-driver-registrar
root       4262   3699  0  2022 ?        00:01:44 /csi-node-driver-registrar --v=0 --csi-address=/csi/csi.sock --kubelet-registration-path=/var/lib/kubelet/plugins/rook-ceph.rbd.csi.ceph.com/csi.sock
root       5961   3489  0  2022 ?        00:03:24 /csi-node-driver-registrar --v=0 --csi-address=/csi/csi.sock --kubelet-registration-path=/var/lib/kubelet/plugins/rook-ceph.cephfs.csi.ceph.com/csi.sock
root     571470 571420  0 Jan25 ?        00:00:58 /csi-node-driver-registrar --v=5 --csi-address=/plugin/csi.sock --kubelet-registration-path=/var/lib/kubelet/plugins/lvm-localpv/csi.sock

kubelet 在存储卷插到主机上后容器启动前,向同节点上的存储插件发送 NodePublishVolume CSI 请求,由 Node 存储插件将存储卷挂载至指定挂载点。有些存储系统的 Node 存储插件还会实现收到 NodeStageVolume CSI 请求后将存储卷插到主机上(Ceph RBD CSI)。

Controller 插件

controller 插件负责创建(provision)和删除存储卷还有 attach/detach 存储卷至主机。

因为 CSI Volume Driver 的代码可能不安全,一般不运行在控制面节点上(主节点),也就意味着主节点上的 Kube controller manager 无法通过 Unix Domain Socket 与 controller 存储插件通信。同 node 存储插件一样,Kubernetes 还提供了几个 sidecar 容器来做中间人,而非 Kube controller manager 直接去调用 CSI 接口。

$ kubectl get deployment -n rook-ceph
NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
csi-cephfsplugin-provisioner   2/2     2            2           65d
csi-rbdplugin-provisioner      2/2     2            2           65d

$ kubectl get po -n rook-ceph | grep provisioner
csi-cephfsplugin-provisioner-77cd78d94-n5hpr   5/5     Running   0                3d15h
csi-cephfsplugin-provisioner-77cd78d94-rwj54   5/5     Running   8 (3d15h ago)    48d
csi-rbdplugin-provisioner-64b876d6fd-2nmcz     5/5     Running   6 (3d15h ago)    48d
csi-rbdplugin-provisioner-64b876d6fd-kfvp6     5/5     Running   0                3d15h
  1. 创建/删除存储卷

    $ kubectl get deployment csi-rbdplugin-provisioner -n rook-ceph -o=jsonpath='{.spec.template.spec.containers}' | jq -r '.[] | select(.name=="csi-provisioner")'
    {
    "args": [
        "--csi-address=$(ADDRESS)",
        "--v=0",
        "--timeout=2m30s",
        "--retry-interval-start=500ms",
        "--leader-election=true",
        "--leader-election-namespace=rook-ceph",
        "--leader-election-lease-duration=137s",
        "--leader-election-renew-deadline=107s",
        "--leader-election-retry-period=26s",
        "--default-fstype=ext4",
        "--extra-create-metadata=true"
    ],
    "env": [
        {
        "name": "ADDRESS",
        "value": "unix:///csi/csi-provisioner.sock"
        }
    ],
    "image": "registry-1.ict-mec.net:18443/mececs/csi-provisioner:release-3.1-1e3344f23-20220913113748",
    "imagePullPolicy": "IfNotPresent",
    "name": "csi-provisioner",
    "resources": {},
    "terminationMessagePath": "/dev/termination-log",
    "terminationMessagePolicy": "File",
    "volumeMounts": [
        {
        "mountPath": "/csi",
        "name": "socket-dir"
        }
    ]
    }
    
    $ kubectl get deployment csi-cephfsplugin-provisioner -n rook-ceph -o=jsonpath='{.spec.template.spec.containers}' | jq -r '.[] | select(.name=="csi-provisioner")'
    {
    "args": [
        "--csi-address=$(ADDRESS)",
        "--v=0",
        "--timeout=2m30s",
        "--retry-interval-start=500ms",
        "--leader-election=true",
        "--leader-election-namespace=rook-ceph",
        "--leader-election-lease-duration=137s",
        "--leader-election-renew-deadline=107s",
        "--leader-election-retry-period=26s",
        "--extra-create-metadata=true"
    ],
    "env": [
        {
        "name": "ADDRESS",
        "value": "unix:///csi/csi-provisioner.sock"
        }
    ],
    "image": "registry-1.ict-mec.net:18443/mececs/csi-provisioner:release-3.1-1e3344f23-20220913113748",
    "imagePullPolicy": "IfNotPresent",
    "name": "csi-provisioner",
    "resources": {},
    "terminationMessagePath": "/dev/termination-log",
    "terminationMessagePolicy": "File",
    "volumeMounts": [
        {
        "mountPath": "/csi",
        "name": "socket-dir"
        }
    ]
    }
    

    controller 存储插件同 Pod 中有一个 sidecar 容器叫 csi-provisioner。它是一个 external provisioner,由它发送 CreateVolume CSI 请求至 controller 存储插件,去存储系统那创建一个新的存储卷。

  2. Attach 和 Detach

    $ kubectl get deployment csi-rbdplugin-provisioner -n rook-ceph -o=jsonpath='{.spec.template.spec.containers}' | jq -r '.[] | select(.name=="csi-attacher")'
    {
    "args": [
        "--v=0",
        "--timeout=2m30s",
        "--csi-address=$(ADDRESS)",
        "--leader-election=true",
        "--leader-election-namespace=rook-ceph",
        "--leader-election-lease-duration=137s",
        "--leader-election-renew-deadline=107s",
        "--leader-election-retry-period=26s"
    ],
    "env": [
        {
        "name": "ADDRESS",
        "value": "/csi/csi-provisioner.sock"
        }
    ],
    "image": "registry-1.ict-mec.net:18443/google_containers/csi-attacher:v3.4.0",
    "imagePullPolicy": "IfNotPresent",
    "name": "csi-attacher",
    "resources": {},
    "terminationMessagePath": "/dev/termination-log",
    "terminationMessagePolicy": "File",
    "volumeMounts": [
        {
        "mountPath": "/csi",
        "name": "socket-dir"
        }
    ]
    }
    
    $ kubectl get deployment csi-cephfsplugin-provisioner -n rook-ceph -o=jsonpath='{.spec.template.spec.containers}' | jq -r '.[] | select(.name=="csi-attacher")'
    {
    "args": [
        "--v=0",
        "--csi-address=$(ADDRESS)",
        "--leader-election=true",
        "--timeout=2m30s",
        "--leader-election-namespace=rook-ceph",
        "--leader-election-lease-duration=137s",
        "--leader-election-renew-deadline=107s",
        "--leader-election-retry-period=26s"
    ],
    "env": [
        {
        "name": "ADDRESS",
        "value": "/csi/csi-provisioner.sock"
        }
    ],
    "image": "registry-1.ict-mec.net:18443/google_containers/csi-attacher:v3.4.0",
    "imagePullPolicy": "IfNotPresent",
    "name": "csi-attacher",
    "resources": {},
    "terminationMessagePath": "/dev/termination-log",
    "terminationMessagePolicy": "File",
    "volumeMounts": [
        {
        "mountPath": "/csi",
        "name": "socket-dir"
        }
    ]
    }
    

    controller 存储插件所在 Pod 中还有一个 sidecar 容器叫 csi-attacher。这是一个 external attacher,监视(watch) VolumeAttachment 对象并发送 ControllerPublishVolume CSI 请求至 controller 存储插件,将新的存储卷插到指定节点。而 VolumeAttachment 对象是由 kube controller manager 中的 attach/detach 控制器创建的。如果 VolumeAttachment 对象被删除,attacher 则发送 ControllerUnpublishVolume CSI 请求至 controller 存储插件,存储卷会被拔出。至于怎么插拔的,Kubernetes 并不关心。

    并非所有存储系统都由接收到 ControllerPublishVolume CSI 请求的 controller 插件去插拔磁盘,有些则是由 node 插件接收到 NodeStageVolume CSI 请求后去完成。

img

上图就是 Kubernetes 官方推荐的存储插件(CSI Driver)部署架构。

  • StatefulSet 或 Deployment
    • 存储供应商自己的 CSI controller 插件容器
    • Kubernetes 提供的 sidecars:
      • external-provisioner:provision/delete 操作
      • external-attacher:attach/detach 操作
      • external-resizer:扩容操作
      • external-snapshotter:快照操作
      • livenessprobe:可选
  • DaemonSet
    • 存储供应商自己的 CSI node 插件容器
    • Kubernetes 提供的 sidecars:
      • node-driver-registrar:将 node 插件的 usock 注册至 kubelet
      • livenessprobe:可选

CSI 协议与 API

存储插件的部署架构确定后,剩下的就是 Kubernetes 组件(通常是存储插件的 sidecar 容器)与存储插件通信的协议了。CSI 规范定义了通信的格式(gRPC 协议)及方法签名。存储系统五花八门千差万别,但只要存储供应商编写存储插件自行实现 CSI API 中的方法,Kubernetes 集群就可以使用这些存储系统为容器提供持久化数据卷,无需关心对接的到底是哪种存储系统。

存储供应商必须提供 Node 和 Controller 两个插件,分别实现:

  • Node 服务:

    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) {}
    }
    
  • Controller 服务:

    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) {}
    
    rpc ControllerGetVolume (ControllerGetVolumeRequest)
        returns (ControllerGetVolumeResponse) {
            option (alpha_method) = true;
        }
    }
    
  • Identity 服务则两个插件都要实现:

    service Identity {
    rpc GetPluginInfo(GetPluginInfoRequest)
        returns (GetPluginInfoResponse) {}
    
    rpc GetPluginCapabilities(GetPluginCapabilitiesRequest)
        returns (GetPluginCapabilitiesResponse) {}
    
    rpc Probe (ProbeRequest)
        returns (ProbeResponse) {}
    }
    

我们有时排查 Kubernetes 集群存储相关问题,在查看了 controller 和 node 插件日志初步定位后,找到存储插件源码,搜索相关方法查看具体实现进一步排查。

参考文档

  • https://github.com/container-storage-interface/spec/blob/master/spec.md
  • https://kubernetes.io/docs/concepts/storage/volumes/#csi
  • https://github.com/kubernetes/design-proposals-archive/blob/main/storage/container-storage-interface.md