如何接入 K8s 持久化儲存?K8s CSI 實現機制淺析

騰訊雲原生發表於2021-10-26

作者

王成,騰訊雲研發工程師,Kubernetes contributor,從事資料庫產品容器化、資源管控等工作,關注 Kubernetes、Go、雲原生領域。

概述

進入 K8s 的世界,會發現有很多方便擴充套件的 Interface,包括 CSI, CNI, CRI 等,將這些介面抽象出來,是為了更好的提供開放、擴充套件、規範等能力。

K8s 持久化儲存經歷了從 in-tree Volume 到 CSI Plugin(out-of-tree) 的遷移,一方面是為了將 K8s 核心主幹程式碼與 Volume 相關程式碼解耦,便於更好的維護;另一方面則是為了方便各大雲廠商實現統一的介面,提供個性化的雲端儲存能力,以期達到雲端儲存生態圈的開放共贏。

本文將從持久卷 PV 的 建立(Create)、附著(Attach)、分離(Detach)、掛載(Mount)、解除安裝(Unmount)、刪除(Delete) 等核心生命週期,對 CSI 實現機制進行了解析。

相關術語

Term Definition
CSI Container Storage Interface.
CNI Container Network Interface.
CRI Container Runtime Interface.
PV Persistent Volume.
PVC Persistent Volume Claim.
StorageClass Defined by provisioner(i.e. Storage Provider), to assemble Volume parameters as a resource object.
Volume A unit of storage that will be made available inside of a CO-managed container, via the CSI.
Block Volume A volume that will appear as a block device inside the container.
Mounted Volume A volume that will be mounted using the specified file system and appear as a directory inside the container.
CO Container Orchestration system, communicates with Plugins using CSI service RPCs.
SP Storage Provider, the vendor of a CSI plugin implementation.
RPC Remote Procedure Call.
Node A host where the user workload will be running, uniquely identifiable from the perspective of a Plugin by a node ID.
Plugin Aka “plugin implementation”, a gRPC endpoint that implements the CSI Services.
Plugin Supervisor Process that governs the lifecycle of a Plugin, MAY be the CO.
Workload The atomic unit of "work" scheduled by a CO. This MAY be a container or a collection of containers.

本文及後續相關文章都基於 K8s v1.22

流程概覽

PV 建立核心流程:

  • apiserver 建立 Pod,根據 PodSpec.Volumes 建立 Volume;
  • PVController 監聽到 PV informer,新增相關 Annotation(如 pv.kubernetes.io/provisioned-by),調諧實現 PVC/PV 的繫結(Bound);
  • 判斷 StorageClass.volumeBindingModeWaitForFirstConsumer 則等待 Pod 排程到 Node 成功後再進行 PV 建立,Immediate 則立即呼叫 PV 建立邏輯,無需等待 Pod 排程;
  • external-provisioner 監聽到 PV informer, 呼叫 RPC-CreateVolume 建立 Volume;
  • AttachDetachController 將已經繫結(Bound) 成功的 PVC/PV,經過 InTreeToCSITranslator 轉換器,由 CSIPlugin 內部邏輯實現 VolumeAttachment 資源型別的建立;
  • external-attacher 監聽到 VolumeAttachment informer,呼叫 RPC-ControllerPublishVolume 實現 AttachVolume;
  • kubelet reconcile 持續調諧:通過判斷 controllerAttachDetachEnabled || PluginIsAttachable 及當前 Volume 狀態進行 AttachVolume/MountVolume,最終實現將 Volume 掛載到 Pod 指定目錄中,供 Container 使用;

從 CSI 說起

CSI(Container Storage Interface) 是由來自 Kubernetes、Mesos、Docker 等社群 member 聯合制定的一個行業標準介面規範(https://github.com/container-storage-interface/spec),旨在將任意儲存系統暴露給容器化應用程式。

CSI 規範定義了儲存提供商實現 CSI 相容的 Volume Plugin 的最小操作集和部署建議。CSI 規範的主要焦點是宣告 Volume Plugin 必須實現的介面。

先看一下 Volume 的生命週期:

   CreateVolume +------------+ DeleteVolume
 +------------->|  CREATED   +--------------+
 |              +---+----^---+              |
 |       Controller |    | Controller       v
+++         Publish |    | Unpublish       +++
|X|          Volume |    | Volume          | |
+-+             +---v----+---+             +-+
                | NODE_READY |
                +---+----^---+
               Node |    | Node
              Stage |    | Unstage
             Volume |    | Volume
                +---v----+---+
                |  VOL_READY |
                +---+----^---+
               Node |    | Node
            Publish |    | Unpublish
             Volume |    | Volume
                +---v----+---+
                | PUBLISHED  |
                +------------+

The lifecycle of a dynamically provisioned volume, from
creation to destruction, when the Node Plugin advertises the
STAGE_UNSTAGE_VOLUME capability.

從 Volume 生命週期可以看到,一塊持久卷要達到 Pod 可使用狀態,需要經歷以下階段:

CreateVolume -> ControllerPublishVolume -> NodeStageVolume -> NodePublishVolume

而當刪除 Volume 的時候,會經過如下反向階段:

NodeUnpublishVolume -> NodeUnstageVolume -> ControllerUnpublishVolume -> DeleteVolume

上面流程的每個步驟,其實就對應了 CSI 提供的標準介面,雲端儲存廠商只需要按標準介面實現自己的雲端儲存外掛,即可與 K8s 底層編排系統無縫銜接起來,提供多樣化的雲端儲存、備份、快照(snapshot)等能力。

多元件協同

為實現具有高擴充套件性、out-of-tree 的持久卷管理能力,在 K8s CSI 實現中,相關協同的元件有:

元件介紹

  • kube-controller-manager:K8s 資源控制器,主要通過 PVController, AttachDetach 實現持久卷的繫結(Bound)/解綁(Unbound)、附著(Attach)/分離(Detach);
  • CSI-plugin:K8s 獨立拆分出來,實現 CSI 標準規範介面的邏輯控制與呼叫,是整個 CSI 控制邏輯的核心樞紐;
  • node-driver-registrar:是一個由官方 K8s sig 小組維護的輔助容器(sidecar),它使用 kubelet 外掛序號產生器制向 kubelet 註冊外掛,需要請求 CSI 外掛的 Identity 服務來獲取外掛資訊;
  • external-provisioner:是一個由官方 K8s sig 小組維護的輔助容器(sidecar),主要功能是實現持久卷的建立(Create)、刪除(Delete);
  • external-attacher:是一個由官方 K8s sig 小組維護的輔助容器(sidecar),主要功能是實現持久卷的附著(Attach)、分離(Detach);
  • external-snapshotter:是一個由官方 K8s sig 小組維護的輔助容器(sidecar),主要功能是實現持久卷的快照(VolumeSnapshot)、備份恢復等能力;
  • external-resizer:是一個由官方 K8s sig 小組維護的輔助容器(sidecar),主要功能是實現持久卷的彈性擴縮容,需要雲廠商外掛提供相應的能力;
  • kubelet:K8s 中執行在每個 Node 上的控制樞紐,主要功能是調諧節點上 Pod 與 Volume 的附著、掛載、監控探測上報等;
  • cloud-storage-provider:由各大雲端儲存廠商基於 CSI 標準介面實現的外掛,包括 Identity 身份服務、Controller 控制器服務、Node 節點服務;

元件通訊

由於 CSI plugin 的程式碼在 K8s 中被認為是不可信的,因此 CSI Controller Server 和 External CSI SideCar、CSI Node Server 和 Kubelet 通過 Unix Socket 來通訊,與雲端儲存廠商提供的 Storage Service 通過 gRPC(HTTP/2) 通訊:

RPC 呼叫

從 CSI 標準規範可以看到,雲端儲存廠商想要無縫接入 K8s 容器編排系統,需要按規範實現相關介面,相關介面主要為:

  • Identity 身份服務:Node Plugin 和 Controller Plugin 都必須實現這些 RPC 集,協調 K8s 與 CSI 的版本資訊,負責對外暴露這個外掛的資訊。
  • Controller 控制器服務:Controller Plugin 必須實現這些 RPC 集,建立以及管理 Volume,對應 K8s 中 attach/detach volume 操作。
  • Node 節點服務:Node Plugin 必須實現這些 RPC 集,將 Volume 儲存卷掛載到指定目錄中,對應 K8s 中的 mount/unmount volume 操作。

相關 RPC 介面功能如下:

建立/刪除 PV

K8s 中持久卷 PV 的建立(Create)與刪除(Delete),由 external-provisioner 元件實現,相關工程程式碼在:【https://github.com/kubernetes-csi/external-provisioner】

首先,通過標準的 cmd 方式獲取命令列引數,執行 newController -> Run() 邏輯,相關程式碼如下:

// external-provisioner/cmd/csi-provisioner/csi-provisioner.go
main() {
...
	// 初始化控制器,實現 Volume 建立/刪除介面
	csiProvisioner := ctrl.NewCSIProvisioner(
		clientset,
		*operationTimeout,
		identity,
		*volumeNamePrefix,
		*volumeNameUUIDLength,
		grpcClient,
		snapClient,
		provisionerName,
		pluginCapabilities,
		controllerCapabilities,
		...
	)
	...
	// 真正的 ProvisionController,包裝了上面的 CSIProvisioner
	provisionController = controller.NewProvisionController(
		clientset,
		provisionerName,
		csiProvisioner,
		provisionerOptions...,
	)
	...
	run := func(ctx context.Context) {
		...
        // Run 執行起來
		provisionController.Run(ctx)
	}
}

接著,呼叫 PV 建立/刪除流程:

PV 建立:runClaimWorker -> syncClaimHandler -> syncClaim -> provisionClaimOperation -> Provision -> CreateVolume
PV 刪除:runVolumeWorker -> syncVolumeHandler -> syncVolume -> deleteVolumeOperation -> Delete -> DeleteVolume

由 sigs.k8s.io/sig-storage-lib-external-provisioner 抽象了相關介面:

// 通過 vendor 方式引入 sigs.k8s.io/sig-storage-lib-external-provisioner
// external-provisioner/vendor/sigs.k8s.io/sig-storage-lib-external-provisioner/v7/controller/volume.go
type Provisioner interface {
	// 呼叫 PRC CreateVolume 介面實現 PV 建立
	Provision(context.Context, ProvisionOptions) (*v1.PersistentVolume, ProvisioningState, error)
	// 呼叫 PRC DeleteVolume 介面實現 PV 刪除
	Delete(context.Context, *v1.PersistentVolume) error
}

Controller 調諧

K8s 中與 PV 相關的控制器有 PVController、AttachDetachController。

PVController

PVController 通過在 PVC 新增相關 Annotation(如 pv.kubernetes.io/provisioned-by),由 external-provisioner 元件負責完成對應 PV 的建立/刪除,然後 PVController 監測到 PV 建立成功的狀態,完成與 PVC 的繫結(Bound),調諧(reconcile)任務完成。然後交給 AttachDetachController 控制器進行下一步邏輯處理。

值得一提的是,PVController 內部通過使用 local cache,高效實現了 PVC 與 PV 的狀態更新與繫結事件處理,相當於在 K8s informer 機制之外,又自己維護了一個 local store 進行 Add/Update/Delete 事件處理。

首先,通過標準的 newController -> Run() 邏輯:

// kubernetes/pkg/controller/volume/persistentvolume/pv_controller_base.go
func NewController(p ControllerParameters) (*PersistentVolumeController, error) {
	...
	// 初始化 PVController
	controller := &PersistentVolumeController{
		volumes:                       newPersistentVolumeOrderedIndex(),
		claims:                        cache.NewStore(cache.DeletionHandlingMetaNamespaceKeyFunc),
		kubeClient:                    p.KubeClient,
		eventRecorder:                 eventRecorder,
		runningOperations:             goroutinemap.NewGoRoutineMap(true /* exponentialBackOffOnError */),
		cloud:                         p.Cloud,
		enableDynamicProvisioning:     p.EnableDynamicProvisioning,
		clusterName:                   p.ClusterName,
		createProvisionedPVRetryCount: createProvisionedPVRetryCount,
		createProvisionedPVInterval:   createProvisionedPVInterval,
		claimQueue:                    workqueue.NewNamed("claims"),
		volumeQueue:                   workqueue.NewNamed("volumes"),
		resyncPeriod:                  p.SyncPeriod,
		operationTimestamps:           metrics.NewOperationStartTimeCache(),
	}
	...
	// PV 增刪改事件監聽
	p.VolumeInformer.Informer().AddEventHandler(
		cache.ResourceEventHandlerFuncs{
			AddFunc:    func(obj interface{}) { controller.enqueueWork(controller.volumeQueue, obj) },
			UpdateFunc: func(oldObj, newObj interface{}) { controller.enqueueWork(controller.volumeQueue, newObj) },
			DeleteFunc: func(obj interface{}) { controller.enqueueWork(controller.volumeQueue, obj) },
		},
	)
	...
	// PVC 增刪改事件監聽
	p.ClaimInformer.Informer().AddEventHandler(
		cache.ResourceEventHandlerFuncs{
			AddFunc:    func(obj interface{}) { controller.enqueueWork(controller.claimQueue, obj) },
			UpdateFunc: func(oldObj, newObj interface{}) { controller.enqueueWork(controller.claimQueue, newObj) },
			DeleteFunc: func(obj interface{}) { controller.enqueueWork(controller.claimQueue, obj) },
		},
	)
	...
	return controller, nil
}

接著,呼叫 PVC/PV 繫結/解綁邏輯:

PVC/PV 繫結:claimWorker -> updateClaim -> syncClaim -> syncBoundClaim -> bind
PVC/PV 解綁:volumeWorker -> updateVolume -> syncVolume -> unbindVolume

AttachDetachController

AttachDetachController 將已經繫結(Bound) 成功的 PVC/PV,內部經過 InTreeToCSITranslator 轉換器,實現由 in-tree 方式管理的 Volume 向 out-of-tree 方式管理的 CSI 外掛模式轉換。

接著,由 CSIPlugin 內部邏輯實現 VolumeAttachment 資源型別的建立/刪除,調諧(reconcile) 任務完成。然後交給 external-attacher 元件進行下一步邏輯處理。

相關核心程式碼在 reconciler.Run() 中實現如下:

// kubernetes/pkg/controller/volume/attachdetach/reconciler/reconciler.go
func (rc *reconciler) reconcile() {

	// 先進行 DetachVolume,確保因 Pod 重新排程到其他節點的 Volume 提前分離(Detach)
	for _, attachedVolume := range rc.actualStateOfWorld.GetAttachedVolumes() {
		// 如果不在期望狀態的 Volume,則呼叫 DetachVolume 刪除 VolumeAttachment 資源物件
		if !rc.desiredStateOfWorld.VolumeExists(
			attachedVolume.VolumeName, attachedVolume.NodeName) {
			...
			err = rc.attacherDetacher.DetachVolume(attachedVolume.AttachedVolume, verifySafeToDetach, rc.actualStateOfWorld)
			...
		}
	}
	// 呼叫 AttachVolume 建立 VolumeAttachment 資源物件
	rc.attachDesiredVolumes()
	...
}

附著/分離 Volume

K8s 中持久卷 PV 的附著(Attach)與分離(Detach),由 external-attacher 元件實現,相關工程程式碼在:【https://github.com/kubernetes-csi/external-attacher】

external-attacher 元件觀察到由上一步 AttachDetachController 建立的 VolumeAttachment 物件,如果其 .spec.Attacher 中的 Driver name 指定的是自己同一 Pod 內的 CSI Plugin,則呼叫 CSI Plugin 的ControllerPublish 介面進行 Volume Attach。

首先,通過標準的 cmd 方式獲取命令列引數,執行 newController -> Run() 邏輯,相關程式碼如下:

// external-attacher/cmd/csi-attacher/main.go
func main() {
    ...
    ctrl := controller.NewCSIAttachController(
		clientset,
		csiAttacher,
		handler,
		factory.Storage().V1().VolumeAttachments(),
		factory.Core().V1().PersistentVolumes(),
		workqueue.NewItemExponentialFailureRateLimiter(*retryIntervalStart, *retryIntervalMax),
		workqueue.NewItemExponentialFailureRateLimiter(*retryIntervalStart, *retryIntervalMax),
		supportsListVolumesPublishedNodes,
		*reconcileSync,
	)

	run := func(ctx context.Context) {
		stopCh := ctx.Done()
		factory.Start(stopCh)
		ctrl.Run(int(*workerThreads), stopCh)
	}
    ...
}

接著,呼叫 Volume 附著/分離邏輯:

Volume 附著(Attach):syncVA -> SyncNewOrUpdatedVolumeAttachment -> syncAttach -> csiAttach -> Attach -> ControllerPublishVolume
Volume 分離(Detach):syncVA -> SyncNewOrUpdatedVolumeAttachment -> syncDetach -> csiDetach -> Detach -> ControllerUnpublishVolume

kubelet 掛載/解除安裝 Volume

K8s 中持久卷 PV 的掛載(Mount)與解除安裝(Unmount),由 kubelet 元件實現。

kubelet 通過 VolumeManager 啟動 reconcile loop,當觀察到有新的使用 PersistentVolumeSource 為CSI 的 PV 的 Pod 排程到本節點上,於是呼叫 reconcile 函式進行 Attach/Detach/Mount/Unmount 相關邏輯處理。

// kubernetes/pkg/kubelet/volumemanager/reconciler/reconciler.go
func (rc *reconciler) reconcile() {
	// 先進行 UnmountVolume,確保因 Pod 刪除被重新 Attach 到其他 Pod 的 Volume 提前解除安裝(Unmount)
	rc.unmountVolumes()

	// 接著通過判斷 controllerAttachDetachEnabled || PluginIsAttachable 及當前 Volume 狀態
	// 進行 AttachVolume / MountVolume / ExpandInUseVolume
	rc.mountAttachVolumes()

	// 解除安裝(Unmount) 或分離(Detach) 不再需要(Pod 刪除)的 Volume
	rc.unmountDetachDevices()
}

相關呼叫邏輯如下:

Volume 掛載(Mount):reconcile -> mountAttachVolumes -> MountVolume -> SetUp -> SetUpAt -> NodePublishVolume
Volume 解除安裝(Unmount):reconcile -> unmountVolumes -> UnmountVolume -> TearDown -> TearDownAt -> NodeUnpublishVolume

小結

本文通過分析 K8s 中持久卷 PV 的 建立(Create)、附著(Attach)、分離(Detach)、掛載(Mount)、解除安裝(Unmount)、刪除(Delete) 等核心生命週期流程,對 CSI 實現機制進行了解析,通過原始碼、圖文方式說明了相關流程邏輯,以期更好的理解 K8s CSI 執行流程。

可以看到,K8s 以 CSI Plugin(out-of-tree) 外掛方式開放儲存能力,一方面是為了將 K8s 核心主幹程式碼與 Volume 相關程式碼解耦,便於更好的維護;另一方面在遵從 CSI 規範介面下,便於各大雲廠商根據業務需求實現相關的介面,提供個性化的雲端儲存能力,以期達到雲端儲存生態圈的開放共贏。

PS: 更多內容請關注 k8s-club

相關資料

  1. CSI 規範
  2. Kubernetes 原始碼
  3. kubernetes-csi 原始碼
  4. kubernetes-sig-storage 原始碼
  5. K8s CSI 概念
  6. K8s CSI 介紹

關於我們

更多關於雲原生的案例和知識,可關注同名【騰訊雲原生】公眾號~

福利:

   ①公眾號後臺回覆【手冊】,可獲得《騰訊雲原生路線圖手冊》&《騰訊雲原生最佳實踐》~

   ②公眾號後臺回覆【系列】,可獲得《15個系列100+篇超實用雲原生原創乾貨合集》,包含Kubernetes 降本增效、K8s 效能優化實踐、最佳實踐等系列。

【騰訊雲原生】雲說新品、雲研新術、雲遊新活、雲賞資訊,掃碼關注同名公眾號,及時獲取更多幹貨!!

相關文章