CSI 工作原理與JuiceFS CSI Driver 的架構設計詳解
- 2022 年 3 月 22 日
- 筆記
容器存儲介面(Container Storage Interface)簡稱 CSI,CSI 建立了行業標準介面的規範,藉助 CSI 容器編排系統(CO)可以將任意存儲系統暴露給自己的容器工作負載。JuiceFS CSI Driver 通過實現 CSI 介面使得 Kubernetes 上的應用可以通過 PVC(PersistentVolumeClaim)使用 JuiceFS。本文將詳細介紹 CSI 的工作原理以及 JuiceFS CSI Driver 的架構設計。
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
監聽 VolumeAttachment 對象,並調用 CSI driver Controller 服務的 ControllerPublishVolume
和 ControllerUnpublishVolume
介面,用來將 volume 附著到 node 上,或從 node 上刪除。
如果存儲系統需要 attach/detach 這一步,就需要使用到這個組件,因為 K8s 內部的 Attach/Detach Controller 不會直接調用 CSI driver 的介面。
external-provisioner
監聽 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
監聽 PVC 對象,如果用戶請求在 PVC 對象上請求更多存儲,該組件會調用 CSI driver Controller 服務的 NodeExpandVolume
介面,用來對 volume 進行擴容。
external-snapshotter
該組件需要與 Snapshot Controller 配合使用。Snapshot Controller 會根據集群中創建的 Snapshot 對象創建對應的 VolumeSnapshotContent,而 external-snapshotter 負責監聽 VolumeSnapshotContent 對象。當監聽到 VolumeSnapshotContent 時,將其對應參數通過 CreateSnapshotRequest
傳給 CSI driver Controller 服務,調用其 CreateSnapshot
介面。該組件還負責調用 DeleteSnapshot
、ListSnapshots
介面。
livenessprobe
負責監測 CSI driver 的健康情況,並通過 Liveness Probe 機制彙報給 K8s,當監測到 CSI driver 有異常時負責重啟 pod。
node-driver-registrar
通過直接調用 CSI driver Node 服務的 NodeGetInfo
介面,將 CSI driver 的資訊通過 kubelet 的插件註冊機制在對應節點的 kubelet 上進行註冊。
external-health-monitor-controller
通過調用 CSI driver Controller 服務的 ListVolumes
或者 ControllerGetVolume
介面,來檢查 CSI volume 的健康情況,並上報在 PVC 的 event 中。
external-health-monitor-agent
通過調用 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
先來看 Provision 階段,整個過程如上圖所示。其中 extenal-provisioner 和 PVController 均 watch PVC 資源。
- 當 PVController watch 到集群中有 PVC 創建時,會判斷當前是否有 in-tree plugin 與之相符,如果沒有則判斷其存儲類型為 out-of-tree 類型,於是給 PVC 打上註解
volume.beta.kubernetes.io/storage-provisioner={csi driver name}
; - 當 extenal-provisioner watch 到 PVC 的註解 csi driver 與自己的 csi driver 一致時,調用 CSI Controller 的
CreateVolume
介面; - 當 CSI Controller 的
CreateVolume
介面返回成功時,extenal-provisioner 會在集群中創建對應的 PV; - PVController watch 到集群中有 PV 創建時,將 PV 與 PVC 進行綁定。
Attach
Attach 階段是指將 volume 附著到節點上,整個過程如上圖所示。
- ADController 監聽到 pod 被調度到某節點,並且使用的是 CSI 類型的 PV,會調用內部的 in-tree CSI 插件的介面,該介面會在集群中創建一個 VolumeAttachment 資源;
- external-attacher 組件 watch 到有 VolumeAttachment 資源創建出來時,會調用 CSI Controller 的
ControllerPublishVolume
介面; - 當 CSI Controller 的
ControllerPublishVolume
介面調用成功後,external-attacher 將對應的 VolumeAttachment 對象的 Attached 狀態設為 true; - ADController watch 到 VolumeAttachment 對象的 Attached 狀態為 true 時,更新 ADController 內部的狀態 ActualStateOfWorld。
Mount
最後一步將 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 過程。整個過程如下:
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 就是消費者,它主要做了三件事:
unmountVolumes()
:在 ActualStateOfWorld 中遍歷 volume,判斷其是否在 DesiredStateOfWorld 中,如果不在,則調用 CSI Node 的介面執行 unmount,並在 ActualStateOfWorld 中記錄;mountAttachVolumes()
:從 DesiredStateOfWorld 中獲取需要被 mount 的 volume,調用 CSI Node 的介面執行 mount 或擴容,並在 ActualStateOfWorld 中做記錄;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 流程就已經梳理清楚了。
JuiceFS CSI Driver 工作原理
接下來再來看看 JuiceFS CSI Driver 的工作原理。架構圖如下:
JuiceFS 在 CSI Node 介面 NodePublishVolume
中創建 pod,用來執行 juicefs mount xxx
,從而保證 juicefs 客戶端運行在 pod 里。如果有多個的業務 pod 共用一份存儲,mount pod 會在 annotation 進行引用計數,確保不會重複創建。具體的程式碼如下(為了方便閱讀,省去了日誌等無關程式碼):
func (p *PodMount) JMount(jfsSetting *jfsConfig.JfsSetting) error {
if err := p.createOrAddRef(jfsSetting); err != nil {
return err
}
return p.waitUtilPodReady(GenerateNameByVolumeId(jfsSetting.VolumeId))
}
func (p *PodMount) createOrAddRef(jfsSetting *jfsConfig.JfsSetting) error {
...
for i := 0; i < 120; i++ {
// wait for old pod deleted
oldPod, err := p.K8sClient.GetPod(podName, jfsConfig.Namespace)
if err == nil && oldPod.DeletionTimestamp != nil {
time.Sleep(time.Millisecond * 500)
continue
} else if err != nil {
if K8serrors.IsNotFound(err) {
newPod := r.NewMountPod(podName)
if newPod.Annotations == nil {
newPod.Annotations = make(map[string]string)
}
newPod.Annotations[key] = jfsSetting.TargetPath
po, err := p.K8sClient.CreatePod(newPod)
...
return err
}
return err
}
...
return p.AddRefOfMount(jfsSetting.TargetPath, podName)
}
return status.Errorf(codes.Internal, "Mount %v failed: mount pod %s has been deleting for 1 min", jfsSetting.VolumeId, podName)
}
func (p *PodMount) waitUtilPodReady(podName string) error {
// Wait until the mount pod is ready
for i := 0; i < 60; i++ {
pod, err := p.K8sClient.GetPod(podName, jfsConfig.Namespace)
...
if util.IsPodReady(pod) {
return nil
}
time.Sleep(time.Millisecond * 500)
}
...
return status.Errorf(codes.Internal, "waitUtilPodReady: mount pod %s isn't ready in 30 seconds: %v", podName, log)
}
每當有業務 pod 退出時,CSI Node 會在介面 NodeUnpublishVolume
刪除 mount pod annotation 中對應的計數,當最後一個記錄被刪除時,mount pod 才會被刪除。具體程式碼如下(為了方便閱讀,省去了日誌等無關程式碼):
func (p *PodMount) JUmount(volumeId, target string) error {
...
err = retry.RetryOnConflict(retry.DefaultBackoff, func() error {
po, err := p.K8sClient.GetPod(pod.Name, pod.Namespace)
if err != nil {
return err
}
annotation := po.Annotations
...
delete(annotation, key)
po.Annotations = annotation
return p.K8sClient.UpdatePod(po)
})
...
deleteMountPod := func(podName, namespace string) error {
return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
po, err := p.K8sClient.GetPod(podName, namespace)
...
shouldDelay, err = util.ShouldDelay(po, p.K8sClient)
if err != nil {
return err
}
if !shouldDelay {
// do not set delay delete, delete it now
if err := p.K8sClient.DeletePod(po); err != nil {
return err
}
}
return nil
})
}
newPod, err := p.K8sClient.GetPod(pod.Name, pod.Namespace)
...
if HasRef(newPod) {
return nil
}
return deleteMountPod(pod.Name, pod.Namespace)
}
CSI Driver 與 juicefs 客戶端解耦,做升級不會影響到業務容器;將客戶端獨立在 pod 中運行也就使其在 K8s 的管控內,可觀測性更強;同時 pod 的好處我們也能享受到,比如隔離性更強,可以單獨設置客戶端的資源配額等。
總結
本文從 CSI 的組件、CSI 介面、volume 如何掛載到 pod 上,三個方面入手,分析了 CSI 整個體系工作的過程,並介紹了 JuiceFS CSI Driver 的工作原理。CSI 是整個容器生態的標準存儲介面,CO 通過 gRPC 方式和 CSI 插件通訊,而為了做到普適,K8s 設計了很多外部組件來配合 CSI 插件來實現不同的功能,從而保證了 K8s 內部邏輯的純粹以及 CSI 插件的簡單易用。
如有幫助的話歡迎關注我們項目 Juicedata/JuiceFS 喲! (0ᴗ0✿)