天天看點

淺析 CSI 工作原理

近期一直在做 CSI 相關的工作,随着開發的深入,愈加認為 CSI 的細節相當繁瑣。通過整理 CSI 的工作流程,加深對 CSI 的了解,并與大家分享我對 CSI 的認識。

我會通過兩篇文章介紹下 CSI,本篇是第一篇,重點介紹 CSI 的基本元件和工作原理,本文基于 Kubernetes 作為 CSI 的 COs(Container Orchestration Systems)。第二篇将拿幾個典型的 CSI 項目分析具體實作。

CSI 的基本元件

CSI 的 cloud providers 有兩種類型,一種為 in-tree 類型,一種為 out-of-tree 類型。前者是指運作在 k8s 核心元件内部的存儲插件;後者是指獨立在 k8s 元件之外運作的存儲插件。本文主要介紹 out-of-tree 類型的插件。

out-of-tree 類型的插件主要是通過 gRPC 接口跟 k8s 元件互動,并且 k8s 提供了大量的 SideCar 元件來配合 CSI 插件實作豐富的功能。對于 out-of-tree 類型的插件來說,所用到的元件分為 SideCar 元件和第三方需要實作的插件。

SideCar 元件

external-attacher

淺析 CSI 工作原理

監聽 VolumeAttachment 對象,并調用 CSI driver Controller 服務的

ControllerPublishVolume

ControllerUnpublishVolume

接口,用來将 volume 附着到 node 上,或從 node 上删除。

如果存儲系統需要 attach/detach 這一步,就需要使用到這個元件,因為 K8s 内部的 Attach/Detach Controller 不會直接調用 CSI driver 的接口。

external-provisioner

淺析 CSI 工作原理

監聽 PVC 對象,并調用 CSI driver Controller 服務的

CreateVolume

DeleteVolume

接口,用來提供一個新的 volume。前提是 PVC 中指定的 StorageClass 的 provisioner 字段和 CSI driver Identity 服務的

GetPluginInfo

接口的傳回值一樣。一旦新的 volume 提供出來,K8s 就會建立對應的 PV。

而如果 PVC 綁定的 PV 的回收政策是 delete,那麼 external-provisioner 元件監聽到 PVC 的删除後,會調用 CSI driver Controller 服務的

DeleteVolume

接口。一旦 volume 删除成功,該元件也會删除相應的 PV。

該元件還支援從快照建立資料源。如果在 PVC 中指定了 Snapshot CRD 的資料源,那麼該元件會通過

SnapshotContent

對象擷取有關快照的資訊,并将此内容在調用

CreateVolume

接口的時候傳給 CSI driver,CSI driver 需要根據資料源快照來建立 volume。

external-resizer

淺析 CSI 工作原理

監聽 PVC 對象,如果使用者請求在 PVC 對象上請求更多存儲,該元件會調用 CSI driver Controller 服務的

NodeExpandVolume

接口,用來對 volume 進行擴容。

external-snapshotter

淺析 CSI 工作原理

該元件需要與 Snapshot Controller 配合使用。Snapshot Controller 會根據叢集中建立的 Snapshot 對象建立對應的 VolumeSnapshotContent,而 external-snapshotter 負責監聽 VolumeSnapshotContent 對象。當監聽到 VolumeSnapshotContent 時,将其對應參數通過

CreateSnapshotRequest

傳給 CSI driver Controller 服務,調用其

CreateSnapshot

接口。該元件還負責調用

DeleteSnapshot

ListSnapshots

接口。

livenessprobe

淺析 CSI 工作原理

負責監測 CSI driver 的健康情況,并通過 Liveness Probe 機制彙報給 k8s,當監測到 CSI driver 有異常時負責重新開機 pod。

node-driver-registrar

淺析 CSI 工作原理

通過直接調用 CSI driver Node 服務的

NodeGetInfo

接口,将 CSI driver 的資訊通過 kubelet 的插件注冊機制在對應節點的 kubelet 上進行注冊。

external-health-monitor-controller

淺析 CSI 工作原理

通過調用 CSI driver Controller 服務的

ListVolumes

或者

ControllerGetVolume

接口,來檢查 CSI volume 的健康情況,并上報在 PVC 的 event 中。

external-health-monitor-agent

淺析 CSI 工作原理

通過調用 CSI driver Node 服務的

NodeGetVolumeStats

接口,來檢查 CSI volume 的健康情況,并上報在 pod 的 event 中。

第三方插件

第三方存儲提供方(即 SP,Storage Provider)需要實作 Controller 和 Node 兩個插件,其中 Controller 負責 Volume 的管理,以 StatefulSet 形式部署;Node 負責将 Volume mount 到 pod 中,以 DaemonSet 形式部署在每個 node 中。

CSI 插件與 kubelet 以及 k8s 外部元件是通過 Unix Domani Socket gRPC 來進行互動調用的。CSI 定義了三套 RPC 接口,SP 需要實作這三組接口,以便與 k8s 外部元件進行通信。三組接口分别是:CSI Identity、CSI Controller 和 CSI Node,下面詳細看看這些接口定義。

CSI Identity

用于提供 CSI driver 的身份資訊,Controller 和 Node 都需要實作。接口如下:

service Identity {
  rpc GetPluginInfo(GetPluginInfoRequest)
    returns (GetPluginInfoResponse) {}

  rpc GetPluginCapabilities(GetPluginCapabilitiesRequest)
    returns (GetPluginCapabilitiesResponse) {}

  rpc Probe (ProbeRequest)
    returns (ProbeResponse) {}
}           

GetPluginInfo

是必須要實作的,node-driver-registrar 元件會調用這個接口将 CSI driver 注冊到 kubelet;

GetPluginCapabilities

是用來表明該 CSI driver 主要提供了哪些功能。

CSI Controller

用于實作建立/删除 volume、attach/detach volume、volume 快照、volume 擴縮容等功能,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;
    }
}           

在上面介紹 k8s 外部元件的時候已經提到,不同的接口分别提供給不同的元件調用,用于配合實作不同的功能。比如

CreateVolume

/

DeleteVolume

配合 external-provisioner 實作建立/删除 volume 的功能;

ControllerPublishVolume

ControllerUnpublishVolume

配合 external-attacher 實作 volume 的 attach/detach 功能等。

CSI Node

用于實作 mount/umount volume、檢查 volume 狀态等功能,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) {}
}           

NodeStageVolume

用來實作多個 pod 共享一個 volume 的功能,支援先将 volume 挂載到一個臨時目錄,然後通過

NodePublishVolume

将其挂載到 pod 中;

NodeUnstageVolume

為其反操作。

工作流程

下面來看看 pod 挂載 volume 的整個工作流程。整個流程流程分别三個階段:Provision/Delete、Attach/Detach、Mount/Unmount,不過不是每個存儲方案都會經曆這三個階段,比如 NFS 就沒有 Attach/Detach 階段。

整個過程不僅僅涉及到上面介紹的元件的工作,還涉及 ControllerManager 的 AttachDetachController 元件和 PVController 元件以及 kubelet。下面分别詳細分析一下 Provision、Attach、Mount 三個階段。

Provision

淺析 CSI 工作原理

先來看 Provision 階段,整個過程如上圖所示。其中 extenal-provisioner 和 PVController 均 watch PVC 資源。

  1. 當 PVController watch 到叢集中有 PVC 建立時,會判斷目前是否有 in-tree plugin 與之相符,如果沒有則判斷其存儲類型為 out-of-tree 類型,于是給 PVC 打上注解

    volume.beta.kubernetes.io/storage-provisioner={csi driver name}

  2. 當 extenal-provisioner watch 到 PVC 的注解 csi driver 與自己的 csi driver 一緻時,調用 CSI Controller 的

    CreateVolume

    接口;
  3. 當 CSI Controller 的

    CreateVolume

    接口傳回成功時,extenal-provisioner 會在叢集中建立對應的 PV;
  4. PVController watch 到叢集中有 PV 建立時,将 PV 與 PVC 進行綁定。

Attach

淺析 CSI 工作原理

Attach 階段是指将 volume 附着到節點上,整個過程如上圖所示。

  1. ADController 監聽到 pod 被排程到某節點,并且使用的是 CSI 類型的 PV,會調用内部的 in-tree CSI 插件的接口,該接口會在叢集中建立一個 VolumeAttachment 資源;
  2. external-attacher 元件 watch 到有 VolumeAttachment 資源建立出來時,會調用 CSI Controller 的

    ControllerPublishVolume

  3. ControllerPublishVolume

    接口調用成功後,external-attacher 将對應的 VolumeAttachment 對象的 Attached 狀态設為 true;
  4. ADController watch 到 VolumeAttachment 對象的 Attached 狀态為 true 時,更新 ADController 内部的狀态 ActualStateOfWorld。

Mount

淺析 CSI 工作原理

最後一步将 volume 挂載到 pod 裡的過程涉及到 kubelet。整個流程簡單地說是,對應節點上的 kubelet 在建立 pod 的過程中,會調用 CSI Node 插件,執行 mount 操作。下面再針對 kubelet 内部的元件細分進行分析。

首先 kubelet 建立 pod 的主函數

syncPod

中,kubelet 會調用其子元件 volumeManager 的

WaitForAttachAndMount

方法,等待 volume mount 完成:

func (kl *Kubelet) syncPod(o syncPodOptions) error {
...
    // Volume manager will not mount volumes for terminated pods
    if !kl.podIsTerminated(pod) {
        // Wait for volumes to attach/mount
        if err := kl.volumeManager.WaitForAttachAndMount(pod); err != nil {
            kl.recorder.Eventf(pod, v1.EventTypeWarning, events.FailedMountVolume, "Unable to attach or mount volumes: %v", err)
            klog.Errorf("Unable to attach or mount volumes for pod %q: %v; skipping pod", format.Pod(pod), err)
            return err
        }
    }
...
}           

volumeManager 中包含兩個元件:desiredStateOfWorldPopulator 和 reconciler。這兩個元件互相配合就完成了 volume 在 pod 中的 mount 和 umount 過程。整個過程如下:

淺析 CSI 工作原理

desiredStateOfWorldPopulator 和 reconciler 的協同模式是生産者和消費者的模式。volumeManager 中維護了兩個隊列(嚴格來講是 interface,但這裡充當了隊列的作用),即 DesiredStateOfWorld 和 ActualStateOfWorld,前者維護的是目前節點中 volume 的期望狀态;後者維護的是目前節點中 volume 的實際狀态。

而 desiredStateOfWorldPopulator 在自己的循環中隻做了兩個事情,一個是從 kubelet 的 podManager 中擷取目前節點建立的 Pod,将其需要挂載的 volume 資訊記錄到 DesiredStateOfWorld 中;另一件事是從 podManager 中擷取目前節點中被删除的 pod,檢查其 volume 是否在 ActualStateOfWorld 的記錄中,如果沒有,将其在 DesiredStateOfWorld 中也删除,進而保證 DesiredStateOfWorld 記錄的是節點中所有 volume 的期望狀态。相關代碼如下(為了精簡邏輯,删除了部分代碼):

// Iterate through all pods and add to desired state of world if they don't
// exist but should
func (dswp *desiredStateOfWorldPopulator) findAndAddNewPods() {
    // Map unique pod name to outer volume name to MountedVolume.
    mountedVolumesForPod := make(map[volumetypes.UniquePodName]map[string]cache.MountedVolume)
    ...
    processedVolumesForFSResize := sets.NewString()
    for _, pod := range dswp.podManager.GetPods() {
        dswp.processPodVolumes(pod, mountedVolumesForPod, processedVolumesForFSResize)
    }
}

// processPodVolumes processes the volumes in the given pod and adds them to the
// desired state of the world.
func (dswp *desiredStateOfWorldPopulator) processPodVolumes(
    pod *v1.Pod,
    mountedVolumesForPod map[volumetypes.UniquePodName]map[string]cache.MountedVolume,
    processedVolumesForFSResize sets.String) {
    uniquePodName := util.GetUniquePodName(pod)
    ...
    for _, podVolume := range pod.Spec.Volumes {   
        pvc, volumeSpec, volumeGidValue, err :=
            dswp.createVolumeSpec(podVolume, pod, mounts, devices)

        // Add volume to desired state of world
        _, err = dswp.desiredStateOfWorld.AddPodToVolume(
            uniquePodName, pod, volumeSpec, podVolume.Name, volumeGidValue)
        dswp.actualStateOfWorld.MarkRemountRequired(uniquePodName)
    }
}           

而 reconciler 就是消費者,它主要做了三件事:

  1. unmountVolumes()

    :在 ActualStateOfWorld 中周遊 volume,判斷其是否在 DesiredStateOfWorld 中,如果不在,則調用 CSI Node 的接口執行 unmount,并在 ActualStateOfWorld 中記錄;
  2. mountAttachVolumes()

    :從 DesiredStateOfWorld 中擷取需要被 mount 的 volume,調用 CSI Node 的接口執行 mount 或擴容,并在 ActualStateOfWorld 中做記錄;
  3. unmountDetachDevices()

    : 在 ActualStateOfWorld 中周遊 volume,若其已經 attach,但沒有使用的 pod,并在 DesiredStateOfWorld 也沒有記錄,則将其 unmount/detach 掉。

我們以

mountAttachVolumes()

為例,看看其如何調用 CSI Node 的接口。

func (rc *reconciler) mountAttachVolumes() {
    // Ensure volumes that should be attached/mounted are attached/mounted.
    for _, volumeToMount := range rc.desiredStateOfWorld.GetVolumesToMount() {
        volMounted, devicePath, err := rc.actualStateOfWorld.PodExistsInVolume(volumeToMount.PodName, volumeToMount.VolumeName)
        volumeToMount.DevicePath = devicePath
        if cache.IsVolumeNotAttachedError(err) {
            ...
        } else if !volMounted || cache.IsRemountRequiredError(err) {
            // Volume is not mounted, or is already mounted, but requires remounting
            err := rc.operationExecutor.MountVolume(
                rc.waitForAttachTimeout,
                volumeToMount.VolumeToMount,
                rc.actualStateOfWorld,
                isRemount)
            ...
        } else if cache.IsFSResizeRequiredError(err) {
            err := rc.operationExecutor.ExpandInUseVolume(
                volumeToMount.VolumeToMount,
                rc.actualStateOfWorld)
            ...
        }
    }
}           

執行 mount 的操作全在

rc.operationExecutor

中完成,再看 operationExecutor 的代碼:

func (oe *operationExecutor) MountVolume(
    waitForAttachTimeout time.Duration,
    volumeToMount VolumeToMount,
    actualStateOfWorld ActualStateOfWorldMounterUpdater,
    isRemount bool) error {
    ...
    var generatedOperations volumetypes.GeneratedOperations
        generatedOperations = oe.operationGenerator.GenerateMountVolumeFunc(
            waitForAttachTimeout, volumeToMount, actualStateOfWorld, isRemount)

    // Avoid executing mount/map from multiple pods referencing the
    // same volume in parallel
    podName := nestedpendingoperations.EmptyUniquePodName

    return oe.pendingOperations.Run(
        volumeToMount.VolumeName, podName, "" /* nodeName */, generatedOperations)
}           

該函數先構造執行函數,再執行,那麼再看構造函數:

func (og *operationGenerator) GenerateMountVolumeFunc(
    waitForAttachTimeout time.Duration,
    volumeToMount VolumeToMount,
    actualStateOfWorld ActualStateOfWorldMounterUpdater,
    isRemount bool) volumetypes.GeneratedOperations {

    volumePlugin, err :=
        og.volumePluginMgr.FindPluginBySpec(volumeToMount.VolumeSpec)

    mountVolumeFunc := func() volumetypes.OperationContext {
        // Get mounter plugin
        volumePlugin, err := og.volumePluginMgr.FindPluginBySpec(volumeToMount.VolumeSpec)
        volumeMounter, newMounterErr := volumePlugin.NewMounter(
            volumeToMount.VolumeSpec,
            volumeToMount.Pod,
            volume.VolumeOptions{})
        ...
        // Execute mount
        mountErr := volumeMounter.SetUp(volume.MounterArgs{
            FsUser:              util.FsUserFrom(volumeToMount.Pod),
            FsGroup:             fsGroup,
            DesiredSize:         volumeToMount.DesiredSizeLimit,
            FSGroupChangePolicy: fsGroupChangePolicy,
        })
        // Update actual state of world
        markOpts := MarkVolumeOpts{
            PodName:             volumeToMount.PodName,
            PodUID:              volumeToMount.Pod.UID,
            VolumeName:          volumeToMount.VolumeName,
            Mounter:             volumeMounter,
            OuterVolumeSpecName: volumeToMount.OuterVolumeSpecName,
            VolumeGidVolume:     volumeToMount.VolumeGidValue,
            VolumeSpec:          volumeToMount.VolumeSpec,
            VolumeMountState:    VolumeMounted,
        }

        markVolMountedErr := actualStateOfWorld.MarkVolumeAsMounted(markOpts)
        ...
        return volumetypes.NewOperationContext(nil, nil, migrated)
    }

    return volumetypes.GeneratedOperations{
        OperationName:     "volume_mount",
        OperationFunc:     mountVolumeFunc,
        EventRecorderFunc: eventRecorderFunc,
        CompleteFunc:      util.OperationCompleteHook(util.GetFullQualifiedPluginNameForVolume(volumePluginName, volumeToMount.VolumeSpec), "volume_mount"),
    }
}           

這裡先去注冊到 kubelet 的 CSI 的 plugin 清單中找到對應的插件,然後再執行

volumeMounter.SetUp

,最後更新 ActualStateOfWorld 的記錄。這裡負責執行 external CSI 插件的是 csiMountMgr,代碼如下:

func (c *csiMountMgr) SetUp(mounterArgs volume.MounterArgs) error {
    return c.SetUpAt(c.GetPath(), mounterArgs)
}

func (c *csiMountMgr) SetUpAt(dir string, mounterArgs volume.MounterArgs) error {
    csi, err := c.csiClientGetter.Get()
    ...

    err = csi.NodePublishVolume(
        ctx,
        volumeHandle,
        readOnly,
        deviceMountPath,
        dir,
        accessMode,
        publishContext,
        volAttribs,
        nodePublishSecrets,
        fsType,
        mountOptions,
    )
    ...
    return nil
}           

可以看到,在 kubelet 中調用 CSI Node

NodePublishVolume

NodeUnPublishVolume

接口的是 volumeManager 的 csiMountMgr。至此,整個 Pod 的 volume 流程就已經梳理清楚了。

總結

本文從 CSI 的元件、CSI 接口、以及 volume 如何挂載到 pod 上的流程,三個方面入手,分析了 CSI 整個體系工作的過程。CSI 是整個容器生态的标準存儲接口,CO 通過 gRPC 方式和 CSI 插件通信,而為了做到普适,k8s 設計了很多外部元件來配合 CSI 插件來實作不同的功能,進而保證了 k8s 内部邏輯的純粹以及 CSI 插件的簡單易用。