kubernetes PV、PVC、StorageClass

眼下,你我可能已經錯過了互聯網技術大爆炸的時代,也沒有在數字貨幣早期的狂熱裏分到一杯羹。可就在此時此刻,在沉寂了多年的雲計算與基礎設施領域,一次以“容器”爲名的歷史變革,正呼之欲出。這一次,我們又有什麼理由作壁上觀呢?

今天我和你分享的主題是kubernetes專題之:PV、PVC、StorageClass,這些到底在說啥?

首先,希望大家對Persistent Volume(PV)和 Persistent Volume Claim(PVC)這套持久化存儲體系有基本的理解。

其中,PV 描述的,是持久化存儲數據卷。這個 API 對象主要定義的是一個持久化存儲在宿主機上的目錄,比如一個 NFS 的掛載目錄。

通常情況下,PV 對象是由運維人員事先創建在 Kubernetes 集羣裏待用的。比如,運維人員可以定義這樣一個 NFS 類型的 PV,如下所示:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs
spec:
  storageClassName: manual
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteMany
  nfs:
    server: 10.244.1.4
    path: "/"

PVC 描述的,則是 Pod 所希望使用的持久化存儲的屬性。比如,Volume 存儲的大小、可讀寫權限等等。

PVC 對象通常由開發人員創建;或者以 PVC 模板的方式成爲 StatefulSet 的一部分,然後由 StatefulSet 控制器負責創建帶編號的 PVC。

比如,開發人員可以聲明一個 1 GiB 大小的 PVC,如下所示:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nfs
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: manual
  resources:
    requests:
      storage: 1Gi

而用戶創建的 PVC 要真正被容器使用起來,就必須先和某個符合條件的 PV 進行綁定。這裏要檢查的條件,包括兩部分:

  • 第一個條件,當然是 PV 和 PVC 的 spec 字段。比如,PV 的存儲(storage)大小,就必須滿足 PVC 的要求。
  • 而第二個條件,則是 PV 和 PVC 的 storageClassName 字段必須一樣。這個機制我會在本篇文章的最後一部分專門介紹。
    在成功地將 PVC 和 PV 進行綁定之後,Pod 就能夠像使用 hostPath 等常規類型的 Volume 一樣,在自己的 YAML 文件裏聲明使用這個 PVC 了,如下所示:
apiVersion: v1
kind: Pod
metadata:
  labels:
    role: web-frontend
spec:
  containers:
  - name: web
    image: nginx
    ports:
      - name: web
        containerPort: 80
    volumeMounts:
        - name: nfs
          mountPath: "/usr/share/nginx/html"
  volumes:
  - name: nfs
    persistentVolumeClaim:
      claimName: nfs

可以看到,Pod 需要做的,就是在 volumes 字段裏聲明自己要使用的 PVC 名字。接下來,等這個 Pod 創建之後,kubelet 就會把這個 PVC 所對應的 PV,也就是一個 NFS 類型的 Volume,掛載在這個 Pod 容器內的目錄上。

不難看出,PVC 和 PV 的設計,其實跟“面向對象”的思想完全一致。

PVC 可以理解爲持久化存儲的“接口”,它提供了對某種持久化存儲的描述,但不提供具體的實現;而這個持久化存儲的實現部分則由 PV 負責完成。

這樣做的好處是,作爲應用開發者,我們只需要跟 PVC 這個“接口”打交道,而不必關心具體的實現是 NFS 還是 Ceph。畢竟這些存儲相關的知識太專業了,應該交給專業的人去做。

而在上面的講述中,其實還有一個比較棘手的情況。

比如,你在創建 Pod 的時候,系統裏並沒有合適的 PV 跟它定義的 PVC 綁定,也就是說此時容器想要使用的 Volume 不存在。這時候,Pod 的啓動就會報錯。

但是,過了一會兒,運維人員也發現了這個情況,所以他趕緊創建了一個對應的 PV。這時候,我們當然希望 Kubernetes 能夠再次完成 PVC 和 PV 的綁定操作,從而啓動 Pod。

所以在 Kubernetes 中,實際上存在着一個專門處理持久化存儲的控制器,叫作 Volume Controller。這個 Volume Controller 維護着多個控制循環,其中有一個循環,扮演的就是撮合 PV 和 PVC 的“紅娘”的角色。它的名字叫作 PersistentVolumeController。

PersistentVolumeController 會不斷地查看當前每一個 PVC,是不是已經處於 Bound(已綁定)狀態。如果不是,那它就會遍歷所有的、可用的 PV,並嘗試將其與這個“單身”的 PVC 進行綁定。這樣,Kubernetes 就可以保證用戶提交的每一個 PVC,只要有合適的 PV 出現,它就能夠很快進入綁定狀態,從而結束“單身”之旅。

而所謂將一個 PV 與 PVC 進行“綁定”,其實就是將這個 PV 對象的名字,填在了 PVC 對象的 spec.volumeName 字段上。所以,接下來 Kubernetes 只要獲取到這個 PVC 對象,就一定能夠找到它所綁定的 PV。

那麼,這個 PV 對象,又是如何變成容器裏的一個持久化存儲的呢?

Volume 的掛載機制。用一句話總結,所謂容器的 Volume,其實就是將一個宿主機上的目錄,跟一個容器裏的目錄綁定掛載在了一起。所謂的“持久化 Volume”,指的就是這個宿主機上的目錄,具備“持久性”。即:這個目錄裏面的內容,既不會因爲容器的刪除而被清理掉,也不會跟當前的宿主機綁定。這樣,當容器被重啓或者在其他節點上重建出來之後,它仍然能夠通過掛載這個 Volume,訪問到這些內容。

顯然,我們前面使用的 hostPath 和 emptyDir 類型的 Volume 並不具備這個特徵:它們既有可能被 kubelet 清理掉,也不能被“遷移”到其他節點上。

所以,大多數情況下,持久化 Volume 的實現,往往依賴於一個遠程存儲服務,比如:遠程文件存儲(比如,NFS、GlusterFS)、遠程塊存儲(比如,公有云提供的遠程磁盤)等等。

而 Kubernetes 需要做的工作,就是使用這些存儲服務,來爲容器準備一個持久化的宿主機目錄,以供將來進行綁定掛載時使用。而所謂“持久化”,指的是容器在這個目錄裏寫入的文件,都會保存在遠程存儲中,從而使得這個目錄具備了“持久性”。

這個準備“持久化”宿主機目錄的過程,我們可以形象地稱爲“兩階段處理”。

接下來,我通過一個具體的例子爲你說明。

當一個 Pod 調度到一個節點上之後,kubelet 就要負責爲這個 Pod 創建它的 Volume 目錄。默認情況下,kubelet 爲 Volume 創建的目錄是如下所示的一個宿主機上的路徑:

/var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 類型 >/<Volume 名字 >

接下來,kubelet 要做的操作就取決於你的 Volume 類型了。

如果你的 Volume 類型是遠程塊存儲,比如 Google Cloud 的 Persistent Disk(GCE 提供的遠程磁盤服務),那麼 kubelet 就需要先調用 Goolge Cloud 的 API,將它所提供的 Persistent Disk 掛載到 Pod 所在的宿主機上。

備註:你如果不太瞭解塊存儲的話,可以直接把它理解爲:一塊磁盤。

這相當於執行:

$ gcloud compute instances attach-disk < 虛擬機名字 > --disk < 遠程磁盤名字 >

這一步爲虛擬機掛載遠程磁盤的操作,對應的正是“兩階段處理”的第一階段。在 Kubernetes 中,我們把這個階段稱爲 Attach。

Attach 階段完成後,爲了能夠使用這個遠程磁盤,kubelet 還要進行第二個操作,即:格式化這個磁盤設備,然後將它掛載到宿主機指定的掛載點上。不難理解,這個掛載點,正是我在前面反覆提到的 Volume 的宿主機目錄。所以,這一步相當於執行:

# 通過 lsblk 命令獲取磁盤設備 ID
$ sudo lsblk
# 格式化成 ext4 格式
$ sudo mkfs.ext4 -m 0 -F -E lazy_itable_init=0,lazy_journal_init=0,discard /dev/< 磁盤設備 ID>
# 掛載到掛載點
$ sudo mkdir -p /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 類型 >/<Volume 名字 >

這個將磁盤設備格式化並掛載到 Volume 宿主機目錄的操作,對應的正是“兩階段處理”的第二個階段,我們一般稱爲:Mount。

Mount 階段完成後,這個 Volume 的宿主機目錄就是一個“持久化”的目錄了,容器在它裏面寫入的內容,會保存在 Google Cloud 的遠程磁盤中。

而如果你的 Volume 類型是遠程文件存儲(比如 NFS)的話,kubelet 的處理過程就會更簡單一些。

因爲在這種情況下,kubelet 可以跳過“第一階段”(Attach)的操作,這是因爲一般來說,遠程文件存儲並沒有一個“存儲設備”需要掛載在宿主機上。

所以,kubelet 會直接從“第二階段”(Mount)開始準備宿主機上的 Volume 目錄。

在這一步,kubelet 需要作爲 client,將遠端 NFS 服務器的目錄(比如:“/”目錄),掛載到 Volume 的宿主機目錄上,即相當於執行如下所示的命令:

$ mount -t nfs <NFS 服務器地址 >:/ /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 類型 >/<Volume 名字 >

通過這個掛載操作,Volume 的宿主機目錄就成爲了一個遠程 NFS 目錄的掛載點,後面你在這個目錄裏寫入的所有文件,都會被保存在遠程 NFS 服務器上。所以,我們也就完成了對這個 Volume 宿主機目錄的“持久化”。

到這裏,你可能會有疑問,Kubernetes 又是如何定義和區分這兩個階段的呢?

其實很簡單,在具體的 Volume 插件的實現接口上,Kubernetes 分別給這兩個階段提供了兩種不同的參數列表:

  • 對於“第一階段”(Attach),Kubernetes 提供的可用參數是 nodeName,即宿主機的名字。
  • 而對於“第二階段”(Mount),Kubernetes 提供的可用參數是 dir,即 Volume 的宿主機目錄。

所以,作爲一個存儲插件,你只需要根據自己的需求進行選擇和實現即可。在後面關於編寫存儲插件的文章中,我會對這個過程做深入講解。

而經過了“兩階段處理”,我們就得到了一個“持久化”的 Volume 宿主機目錄。所以,接下來,kubelet 只要把這個 Volume 目錄通過 CRI 裏的 Mounts 參數,傳遞給 Docker,然後就可以爲 Pod 裏的容器掛載這個“持久化”的 Volume 了。其實,這一步相當於執行了如下所示的命令:

$ docker run -v /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 類型 >/<Volume 名字 >:/< 容器內的目標目錄 > 我的鏡像 ...

以上,就是 Kubernetes 處理 PV 的具體原理了。

備註:對應地,在刪除一個 PV 的時候,Kubernetes 也需要 Unmount 和 Dettach 兩個階段來處理。這個過程我就不再詳細介紹了,執行“反向操作”即可。

實際上,你可能已經發現,這個 PV 的處理流程似乎跟 Pod 以及容器的啓動流程沒有太多的耦合,只要 kubelet 在向 Docker 發起 CRI 請求之前,確保“持久化”的宿主機目錄已經處理完畢即可。

所以,在 Kubernetes 中,上述關於 PV 的“兩階段處理”流程,是靠獨立於 kubelet 主控制循環(Kubelet Sync Loop)之外的兩個控制循環來實現的。

其中,“第一階段”的 Attach(以及 Dettach)操作,是由 Volume Controller 負責維護的,這個控制循環的名字叫作:AttachDetachController。而它的作用,就是不斷地檢查每一個 Pod 對應的 PV,和這個 Pod 所在宿主機之間掛載情況。從而決定,是否需要對這個 PV 進行 Attach(或者 Dettach)操作。

需要注意,作爲一個 Kubernetes 內置的控制器,Volume Controller 自然是 kube-controller-manager 的一部分。所以,AttachDetachController 也一定是運行在 Master 節點上的。當然,Attach 操作只需要調用公有云或者具體存儲項目的 API,並不需要在具體的宿主機上執行操作,所以這個設計沒有任何問題。

而“第二階段”的 Mount(以及 Unmount)操作,必須發生在 Pod 對應的宿主機上,所以它必須是 kubelet 組件的一部分。這個控制循環的名字,叫作:VolumeManagerReconciler,它運行起來之後,是一個獨立於 kubelet 主循環的 Goroutine。

通過這樣將 Volume 的處理同 kubelet 的主循環解耦,Kubernetes 就避免了這些耗時的遠程掛載操作拖慢 kubelet 的主控制循環,進而導致 Pod 的創建效率大幅下降的問題。實際上,kubelet 的一個主要設計原則,就是它的主控制循環絕對不可以被 block。這個思想,我在後續的講述容器運行時的時候還會提到。

在瞭解了 Kubernetes 的 Volume 處理機制之後,我再來爲你介紹這個體系裏最後一個重要概念:StorageClass。

我在前面介紹 PV 和 PVC 的時候,曾經提到過,PV 這個對象的創建,是由運維人員完成的。但是,在大規模的生產環境裏,這其實是一個非常麻煩的工作。

這是因爲,一個大規模的 Kubernetes 集羣裏很可能有成千上萬個 PVC,這就意味着運維人員必須得事先創建出成千上萬個 PV。更麻煩的是,隨着新的 PVC 不斷被提交,運維人員就不得不繼續添加新的、能滿足條件的 PV,否則新的 Pod 就會因爲 PVC 綁定不到 PV 而失敗。在實際操作中,這幾乎沒辦法靠人工做到。

所以,Kubernetes 爲我們提供了一套可以自動創建 PV 的機制,即:Dynamic Provisioning。

相比之下,前面人工管理 PV 的方式就叫作 Static Provisioning。

Dynamic Provisioning 機制工作的核心,在於一個名叫 StorageClass 的 API 對象。

而 StorageClass 對象的作用,其實就是創建 PV 的模板。

具體地說,StorageClass 對象會定義如下兩個部分內容:

  • 第一,PV 的屬性。比如,存儲類型、Volume 的大小等等。
  • 第二,創建這種 PV 需要用到的存儲插件。比如,Ceph 等等。
    有了這樣兩個信息之後,Kubernetes 就能夠根據用戶提交的 PVC,找到一個對應的 StorageClass 了。然後,Kubernetes 就會調用該 StorageClass 聲明的存儲插件,創建出需要的 PV。

舉個例子,假如我們的 Volume 的類型是 GCE 的 Persistent Disk 的話,運維人員就需要定義一個如下所示的 StorageClass:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: block-service
provisioner: kubernetes.io/gce-pd
parameters:
  type: pd-ssd

在這個 YAML 文件裏,我們定義了一個名叫 block-service 的 StorageClass。

這個 StorageClass 的 provisioner 字段的值是:kubernetes.io/gce-pd,這正是 Kubernetes 內置的 GCE PD 存儲插件的名字。

而這個 StorageClass 的 parameters 字段,就是 PV 的參數。比如:上面例子裏的 type=pd-ssd,指的是這個 PV 的類型是“SSD 格式的 GCE 遠程磁盤”。

需要注意的是,由於需要使用 GCE Persistent Disk,上面這個例子只有在 GCE 提供的 Kubernetes 服務裏才能實踐。如果你想使用我們之前部署在本地的 Kubernetes 集羣以及 Rook 存儲服務的話,你的 StorageClass 需要使用如下所示的 YAML 文件來定義:

apiVersion: ceph.rook.io/v1beta1
kind: Pool
metadata:
  name: replicapool
  namespace: rook-ceph
spec:
  replicated:
    size: 3
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: block-service
provisioner: ceph.rook.io/block
parameters:
  pool: replicapool
  #The value of "clusterNamespace" MUST be the same as the one in which your rook cluster exist
  clusterNamespace: rook-ceph

在這個 YAML 文件中,我們定義的還是一個名叫 block-service 的 StorageClass,只不過它聲明使的存儲插件是由 Rook 項目。

有了 StorageClass 的 YAML 文件之後,運維人員就可以在 Kubernetes 裏創建這個 StorageClass 了:

$ kubectl create -f sc.yaml

這時候,作爲應用開發者,我們只需要在 PVC 裏指定要使用的 StorageClass 名字即可,如下所示:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: claim1
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: block-service
  resources:
    requests:
      storage: 30Gi

可以看到,我們在這個 PVC 裏添加了一個叫作 storageClassName 的字段,用於指定該 PVC 所要使用的 StorageClass 的名字是:block-service。

以 Google Cloud 爲例。

當我們通過 kubectl create 創建上述 PVC 對象之後,Kubernetes 就會調用 Google Cloud 的 API,創建出一塊 SSD 格式的 Persistent Disk。然後,再使用這個 Persistent Disk 的信息,自動創建出一個對應的 PV 對象。

我們可以一起來實踐一下這個過程(如果使用 Rook 的話下面的流程也是一樣的,只不過 Rook 創建出的是 Ceph 類型的 PV):

$ kubectl create -f pvc.yaml

可以看到,我們創建的 PVC 會綁定一個 Kubernetes 自動創建的 PV,如下所示:

$ kubectl describe pvc claim1
Name: claim1
Namespace: default
StorageClass: block-service
Status: Bound
Volume: pvc-e5578707-c626-11e6-baf6-08002729a32b
Labels:
Capacity: 30Gi
Access Modes: RWO
No Events.
而且,通過查看這個自動創建的 PV 的屬性,你就可以看到它跟我們在 PVC 裏聲明的存儲的屬性是一致的,如下所示:

$ kubectl describe pv pvc-e5578707-c626-11e6-baf6-08002729a32b
Name:            pvc-e5578707-c626-11e6-baf6-08002729a32b
Labels:          <none>
StorageClass:    block-service
Status:          Bound
Claim:           default/claim1
Reclaim Policy:  Delete
Access Modes:    RWO
Capacity:        30Gi
...
No events.

此外,你還可以看到,這個自動創建出來的 PV 的 StorageClass 字段的值,也是 block-service。這是因爲,Kubernetes 只會將 StorageClass 相同的 PVC 和 PV 綁定起來。

有了 Dynamic Provisioning 機制,運維人員只需要在 Kubernetes 集羣裏創建出數量有限的 StorageClass 對象就可以了。這就好比,運維人員在 Kubernetes 集羣裏創建出了各種各樣的 PV 模板。這時候,當開發人員提交了包含 StorageClass 字段的 PVC 之後,Kubernetes 就會根據這個 StorageClass 創建出對應的 PV。

Kubernetes 的官方文檔裏已經列出了默認支持 Dynamic Provisioning 的內置存儲插件。而對於不在文檔裏的插件,比如 NFS,或者其他非內置存儲插件,你其實可以通過kubernetes-incubator/external-storage這個庫來自己編寫一個外部插件完成這個工作。像我們之前部署的 Rook,已經內置了 external-storage 的實現,所以 Rook 是完全支持 Dynamic Provisioning 特性的。

需要注意的是,StorageClass 並不是專門爲了 Dynamic Provisioning 而設計的。

比如,在本篇一開始的例子裏,我在 PV 和 PVC 裏都聲明瞭 storageClassName=manual。而我的集羣裏,實際上並沒有一個名叫 manual 的 StorageClass 對象。這完全沒有問題,這個時候 Kubernetes 進行的是 Static Provisioning,但在做綁定決策的時候,它依然會考慮 PV 和 PVC 的 StorageClass 定義。

而這麼做的好處也很明顯:這個 PVC 和 PV 的綁定關係,就完全在我自己的掌控之中。

這裏,你可能會有疑問,我在之前講解 StatefulSet 存儲狀態的例子時,好像並沒有聲明 StorageClass 啊?

實際上,如果你的集羣已經開啓了名叫 DefaultStorageClass 的 Admission Plugin,它就會爲 PVC 和 PV 自動添加一個默認的 StorageClass;否則,PVC 的 storageClassName 的值就是“”,這也意味着它只能夠跟 storageClassName 也是“”的 PV 進行綁定。

總結
在今天的分享中,我爲你詳細解釋了 PVC 和 PV 的設計與實現原理,併爲你闡述了 StorageClass 到底是幹什麼用的。這些概念之間的關係,可以用如下所示的一幅示意圖描述:

從圖中我們可以看到,在這個體系中:

  • PVC 描述的,是 Pod 想要使用的持久化存儲的屬性,比如存儲的大小、讀寫權限等。
  • PV 描述的,則是一個具體的 Volume 的屬性,比如 Volume 的類型、掛載目錄、遠程存儲服務器地址等。
  • 而 StorageClass 的作用,則是充當 PV 的模板。並且,只有同屬於一個 StorageClass 的 PV 和 PVC,纔可以綁定在一起。

當然,StorageClass 的另一個重要作用,是指定 PV 的 Provisioner(存儲插件)。這時候,如果你的存儲插件支持 Dynamic Provisioning 的話,Kubernetes 就可以自動爲你創建 PV 了。

基於上述講述,爲了統一概念和方便敘述,在本專欄中,我以後凡是提到“Volume”,指的就是一個遠程存儲服務掛載在宿主機上的持久化目錄;而“PV”,指的是這個 Volume 在 Kubernetes 裏的 API 對象。

需要注意的是,這套容器持久化存儲體系,完全是 Kubernetes 項目自己負責管理的,並不依賴於 docker volume 命令和 Docker 的存儲插件。當然,這套體系本身就比 docker volume 命令的誕生時間還要早得多。

發佈了56 篇原創文章 · 獲贊 11 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章