一文讀懂 Kubernetes 儲存設計

2023-01-10 18:00:44

在 Docker 的設計中,容器內的檔案是臨時存放的,並且隨著容器的刪除,容器內部的資料也會一同被清空。不過,我們可以通過在 docker run 啟動容器時,使用 --volume/-v 引數來指定掛載卷,這樣就能夠將容器內部的路徑掛載到主機,後續在容器內部存放資料時會就被同步到被掛載的主機路徑中。這樣做可以保證保證即便容器被刪除,儲存到主機路徑中的資料也仍然存在。

與 Docker 通過掛載卷的方式就可以解決持久化儲存問題不同,K8s 儲存要面臨的問題要複雜的多。因為 K8s 通常會在多個主機部署節點,如果 K8s 編排的 Docker 容器崩潰,K8s 可能會在其他節點上重新拉起容器,這就導致原來節點主機上掛載的容器目錄無法使用。

當然也是有辦法解決 K8s 容器儲存的諸多限制,比如可以對儲存資源做一層抽象,通常大家將這層抽象稱為卷(Volume)。

K8s 支援的卷基本上可以分為三類:設定資訊、臨時儲存、持久儲存。

設定資訊

無論何種型別的應用,都會用到組態檔或啟動引數。而 K8s 將設定資訊進行了抽象,定義成了幾種資源,主要有以下三種:

  • ConfigMap

  • Secret

  • DownwardAPI

ConfigMap

ConfigMap 卷通常以一個或多個 key: value 形式存在,主要用來儲存應用的設定資料。其中 value 可以是字面量或組態檔。

不過,因為ConfigMap 在設計上不是用來儲存大量資料的,所以在 ConfigMap 中儲存的資料不可超過 1 MiB(兆位元組)。

ConfigMap 有兩種建立方式:

  • 通過命令列建立

  • 通過 yaml 檔案建立

通過命令列建立

在建立 Configmap 的時候可以通過 --from-literal 引數來指定 key: value,以下範例中 foo=bar 即為字面量形式,bar=bar.txt 為組態檔形式。

$ kubectl create configmap c1 --from-literal=foo=bar --from-literal=bar=bar.txt

bar.txt 內容如下:

baz

通過 kubectl describe 命令檢視新建立的名稱為 c1 的這個 Configmap 資源內容。

$ kubectl describe configmap c1
Name:         c1
Namespace:    default
Labels:       <none>
Annotations:  <none>

Data
====
bar:
----
baz
foo:
----
bar
Events:  <none>

通過 yaml 檔案建立

建立 configmap-demo.yaml 內容如下:

kind: ConfigMap
apiVersion: v1
metadata:
  name: c2
  namespace: default
data:
  foo: bar
  bar: baz

通過 kubectl apply 命令應用這個檔案。

$ kubectl apply -f configmap-demo.yaml

$ kubectl get configmap c2
NAME   DATA   AGE
c2     2      11s

$ kubectl describe configmap c2
Name:         c2
Namespace:    default
Labels:       <none>
Annotations:  <none>

Data
====
foo:
----
bar
bar:
----
baz
Events:  <none>

得到的結果跟通過命令列方式建立的 Configmap 沒什麼兩樣。

使用範例

完成 Configmap 建立後,來看下如何使用。

建立好的Configmap 有兩種使用方法:

  • 通過環境變數將 Configmap 注入到容器內部

  • 通過卷掛載的方式直接將 Configmap 以檔案形式掛載到容器。

通過環境變數方式參照

建立 use-configmap-env-demo.yaml 內容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "use-configmap-env"
  namespace: default
spec:
  containers:
    - name: use-configmap-env
      image: "alpine"
      # 一次參照單個值
      env:
        - name: FOO
          valueFrom:
            configMapKeyRef:
              name: c2
              key: foo
      # 一次參照所有值
      envFrom:
        - prefix: CONFIG_  # 設定參照字首
          configMapRef:
            name: c2
      command: ["echo", "$(FOO)", "$(CONFIG_bar)"]

可以看到我們建立了一個名為 use-configmap-env 的 Pod,Pod 的容器將使用兩種方式參照 Configmap 的內容。

第一種是指定 spec.containers.env,它可以為容器參照一個 Configmap 的 key: value 對。其中valueFrom. configMapKeyRef 表明我們要參照 Configmap ,Configmap 的名稱為 c2 ,參照的 key 為 foo 。

第二種是指定 spec.containers.envFrom ,只需要通過 configMapRef.name 指定 Configmap 的名稱,它就可以一次將 Configmap 中的所有 key: value 傳遞給容器。其中 prefix 可以給參照的 key 前面增加統一字首。

Pod 的容器啟動命令為 echo $(FOO) $(CONFIG_bar) ,可以分別列印通過 env 和 envFrom 兩種方式參照的 Configmap 的內容。

# 建立 Pod
$ kubectl apply -f use-configmap-env-demo.yaml
# 通過檢視 Pod 紀錄檔來觀察容器內部參照 Configmap 結果
$ kubectl logs use-configmap-env
bar baz

結果表明,容器內部可以通過環境變數的方式參照到 Configmap 的內容。

通過卷掛載方式參照

建立 use-configmap-volume-demo.yaml 內容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "use-configmap-volume"
  namespace: default
spec:
  containers:
    - name: use-configmap-volume
      image: "alpine"
      command: ["sleep", "3600"]
      volumeMounts:
        - name: configmap-volume
          mountPath: /usr/share/tmp  # 容器掛載目錄
  volumes:
    - name: configmap-volume
      configMap:
        name: c2

這裡建立一個名為 use-configmap-volume 的 Pod。通過 spec.containers.volumeMounts 指定容器掛載,name 指定掛載的卷名,mountPath 指定容器內部掛載路徑(也就是將 Configmap 掛載到容器內部的指定目錄下)。spec.volumes 宣告一個卷,而configMap.name 表明了這個卷要參照的 Configmap 名稱。

然後可通過如下命令建立 Pod 並驗證容器內部能否參照到 Configmap。

# 建立 Pod
$ kubectl apply -f use-configmap-volume-demo.yaml
# 進入 Pod 容器內部
$ kubectl exec -it use-configmap-volume -- sh
# 進入容器掛載目錄
/ # cd /usr/share/tmp/
# 檢視掛載目錄下的檔案
/usr/share/tmp # ls
bar  foo
# 檢視檔案內容
/usr/share/tmp # cat foo
bar
/usr/share/tmp # cat bar
baz

建立完成後,通過 kubectl exec 命令可以進入容器內部。檢視容器 /usr/share/tmp/ 目錄,可以看到兩個以 Configmap 中 key 為名稱的文字檔案(foo 、bar), key 所對應的 value 內容即為檔案內容。

以上就是兩種將 Configmap 的內容注入到容器內部的方式。容器內部的應用則可以分別通過讀取環境變數、檔案內容的方式使用設定資訊。

Secret

熟悉了 Configmap 的用法,接下來看下 Secret 的使用。Secret 卷用來給 Pod 傳遞敏感資訊,例如密碼、SSH 金鑰等。因為雖然Secret 與 ConfigMap 非常類似,但是它會對儲存的資料進行 base64 編碼。

Secret 同樣有兩種建立方式:

  • 通過命令列建立

  • 通過 yaml 檔案建立

通過命令列建立

Secret 除了通過 --from-literal 引數來指定 key: value,還有另一種方式。即通過 --form-file 引數直接從檔案載入設定,檔名即為 key,檔案內容作為 value。

# generic 引數對應 Opaque 型別,既使用者定義的任意資料
$ kubectl create secret generic s1 --from-file=foo.txt

foo.txt 內容如下:

foo=bar
bar=baz

可以看到與 Configmap 不同,建立 Secret 需要指明型別。上面的範例為命令通過指定 generic 引數來建立型別為 Opaque 的 Secret ,這也是 Secret 預設型別。需要注意的是除去預設型別,Secret 還支援其他型別,可以通過官方檔案檢視。不過初期學習階段只使用預設型別即可,通過預設型別就能夠實現其他幾種型別的功能

$ kubectl describe secret s1
Name:         s1
Namespace:    default
Labels:       <none>
Annotations:  <none>

Type:  Opaque

Data
====
foo.txt:  16 bytes

另外一點與 Configmap 不同的是,Secret 僅展示 value 的位元組大小,而不直接展示資料,這是為了儲存密文,也是Secret 名稱的含義。

通過 yaml 檔案建立

建立 secret-demo.yaml 內容如下:

apiVersion: v1
kind: Secret
metadata:
  name: s2
  namespace: default
type: Opaque  # 預設型別
data:
  user: cm9vdAo=
  password: MTIzNDU2Cg==

通過 kubectl apply 命令應用這個檔案。


$ kubectl apply -f secret-demo.yaml

$ kubectl get secret s2
NAME   TYPE     DATA   AGE
s2     Opaque   2      59s

$ kubectl describe secret s2
Name:         s2
Namespace:    default
Labels:       <none>
Annotations:  <none>

Type:  Opaque

Data
====
password:  7 bytes
user:      5 bytes

同樣能夠正確建立出 Secret 資源。但是可以看到通過 yaml 檔案建立 Secret 時,指定的 data 內容必須經過 base64 編碼,比如我們指定的 user 和 password 都是編碼後的結果。

data:
  user: cm9vdAo=
  password: MTIzNDU2Cg==

除此外也可以使用原始字串方式,這兩種方式是等價,範例如下:

data:
  stringData:
   user: root
   password: "123456"

相對而言,我更推薦使用 base64 編碼的方式。

使用範例

同 Configmap 使用方式一樣,我們也可以通過環境變數或卷掛載的方式來使用 Secret 。以卷掛載方式為例。首先建立 use-secret-volume-demo.yaml 內容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "use-secret-volume-demo"
  namespace: default
spec:
  containers:
    - name: use-secret-volume-demo
      image: "alpine"
      command: ["sleep", "3600"]
      volumeMounts:
        - name: secret-volume
          mountPath: /usr/share/tmp # 容器掛載目錄
  volumes:
    - name: secret-volume
      secret:
        secretName: s2

即建立一個名為 use-secret-volume-demo 的 Pod,而 Pod 的容器通過卷掛載方式參照 Secret 的內容。

# 建立 Pod
$ kubectl apply -f use-secret-volume-demo.yaml

# 進入 Pod 容器內部
$ kubectl exec -it use-secret-volume-demo -- sh
# 進入容器掛載目錄
/ # cd /usr/share/tmp/
# 檢視掛載目錄下的檔案
/usr/share/tmp # ls
password  user
# 檢視檔案內容
/usr/share/tmp # cat password 
123456
/usr/share/tmp # cat user 
root

可以發現被掛載到容器內部以後,Secret 的內容將變成明文儲存。容器內部應用可以同使用 Configmap 一樣來使用 Secret 。

作為可以儲存設定資訊的 Configmap 和 Secret , Configmap 通常存放普通設定, Secret 則存放敏感設定。

值得一提的是,使用環境變數方式參照 Configmap 或 Secret ,當 Configmap 或 Secret 內容變更時,容器內部參照的內容不會自動更新;使用卷掛載方式參照 Configmap 或 Secret ,當 Configmap 或 Secret 內容變更時,容器內部參照的內容會自動更新。如果容器內部應用支援組態檔熱載入,那麼通過卷掛載對的方式參照 Configmap 或 Secret 內容將是推薦方式。

DownwardAPI

DownwardAPI 可以將 Pod 物件自身的資訊注入到 Pod 所管理的容器內部。

使用範例

建立 downwardapi-demo.yaml 內容如下:

apiVersion: v1
kind: Pod
metadata:
  name: downwardapi-volume-demo
  labels:
    app: downwardapi-volume-demo
  annotations:
    foo: bar
spec:
  containers:
    - name: downwardapi-volume-demo
      image: alpine
      command: ["sleep", "3600"]
      volumeMounts:
        - name: podinfo
          mountPath: /etc/podinfo
  volumes:
    - name: podinfo
      downwardAPI:
        items:
          # 指定參照的 labels
          - path: "labels"
            fieldRef:
              fieldPath: metadata.labels
          # 指定參照的 annotations
          - path: "annotations"
            fieldRef:
              fieldPath: metadata.annotations
# 建立 Pod
$ kubectl apply -f downwardapi-demo.yaml
pod/downwardapi-volume-demo created

# 進入 Pod 容器內部
$ kubectl exec -it downwardapi-volume-demo -- sh
# 進入容器掛載目錄
/ # cd /etc/podinfo/
# 檢視掛載目錄下的檔案
/etc/podinfo # ls
annotations  labels
# 檢視檔案內容
/etc/podinfo # cat annotations 
foo="bar"
kubectl.kubernetes.io/last-applied-configuration="{\"apiVersion\":\"v1\",\"kind\":\"Pod\",\"metadata\":{\"annotations\":{\"foo\":\"bar\"},\"labels\":{\"app\":\"downwardapi-volume-demo\"},\"name\":\"downwardapi-volume-demo\",\"namespace\":\"default\"},\"spec\":{\"containers\":[{\"command\":[\"sleep\",\"3600\"],\"image\":\"alpine\",\"name\":\"downwardapi-volume-demo\",\"volumeMounts\":[{\"mountPath\":\"/etc/podinfo\",\"name\":\"podinfo\"}]}],\"volumes\":[{\"downwardAPI\":{\"items\":[{\"fieldRef\":{\"fieldPath\":\"metadata.labels\"},\"path\":\"labels\"},{\"fieldRef\":{\"fieldPath\":\"metadata.annotations\"},\"path\":\"annotations\"}]},\"name\":\"podinfo\"}]}}\n"
kubernetes.io/config.seen="2022-03-12T13:06:50.766902000Z"
/etc/podinfo # cat labels
app="downwardapi-volume-demo"

不難發現,DownwardAPI 的使用方式同 Configmap 和 Secret 一樣,都可以通過卷掛載方式掛載到容器內部以後,在容器掛載的目錄下生成對應檔案,用來儲存 key: value。不同的是 ,因為DownwardAPI能參照的內容已經都在當前 yaml 檔案中定義好了,所以DownwardAPI 不需要預先定義,可以直接使用。

小結

ConfigMap 、Secret 、DownwardAPI 這三種 Volume 存在的意義不是為了儲存容器中的資料,而是為了給容器傳遞預先定義好的資料。

臨時卷

接下來我們要關注的是臨時卷,即臨時儲存。K8s 支援的臨時儲存中最常用的就是如下兩種:

  • EmptyDir

  • HostPath

臨時儲存卷會遵從 Pod 的生命週期,與 Pod 一起建立和刪除。

EmptyDir

先來看 emptyDir 如何使用。EmptyDir 相當於通過 --volume/-v 掛載時的隱式 Volume 形式使用 Docker。K8s 會在宿主機上建立一個臨時目錄,被掛載到容器所宣告的 mountPath 目錄上,即不顯式的宣告在宿主機上的目錄。

使用範例

建立 emptydir-demo.yaml 內容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "emptydir-nginx-pod"
  namespace: default
  labels:
    app: "emptydir-nginx-pod"
spec:
  containers:
    - name: html-generator
      image: "alpine:latest"
      command: ["sh", "-c"]
      args:
       - while true; do
          date > /usr/share/index.html;
          sleep 1;
         done
      volumeMounts:
        - name: html
          mountPath: /usr/share
    - name: nginx
      image: "nginx:latest"
      ports:
        - containerPort: 80
          name: http
      volumeMounts:
        - name: html
          # nginx 容器 index.html 檔案所在目錄
          mountPath: /usr/share/nginx/html
          readOnly: true
  volumes:
    - name: html
      emptyDir: {}

這裡建立一個名為 emptydir-nginx-pod 的 Pod,它包含兩個容器 html-generator 和 nginx 。html-generator 用來不停的生成 html 檔案,nginx 則是用來展示 html-generator 生成的 index.html 檔案的 Web 服務。

具體流程為,html-generator 不停的將當前時間寫入到 /usr/share/index.html 下,並將 /usr/share 目錄掛載到名為 html 的卷中,而 nginx 容器則將 /usr/share/nginx/html 目錄掛載到名為 html 的卷中,這樣兩個容器通過同一個卷 html 掛載到了一起。

現在通過 kubectl apply 命令應用這個檔案:

# 建立 Pod
$ kubectl apply -f emptydir-demo.yaml
pod/emptydir-nginx-pod created

# 進入 Pod 容器內部
$ kubectl exec -it pod/emptydir-nginx-pod -- sh
# 檢視系統時區
/ # curl 127.0.0.1
Sun Mar 13 08:40:01 UTC 2022
/ # curl 127.0.0.1
Sun Mar 13 08:40:04 UTC 2022

根據 nginx 容器內部 curl 127.0.0.1 命令輸出結果可以發現,nginx 容器 /usr/share/nginx/html/indedx.html 檔案內容即為 html-generator 容器 /usr/share/index.html 檔案內容。

能夠實現此效果的原理是,當我們宣告卷型別為 emptyDir: {} 後,K8s 會自動在主機目錄上建立一個臨時目錄。然後將 html-generator 容器 /usr/share/ 目錄和 nginx 容器 /usr/share/nginx/html/ 同時掛載到這個臨時目錄上。這樣兩個容器的目錄就能夠實現資料同步。

需要注意的是,容器崩潰並不會導致 Pod 被從節點上移除,因此容器崩潰期間 emptyDir 卷中的資料是安全的。另外,emptyDir.medium 除了可以設成 {},還可以設成 Memory 表示記憶體掛載。

HostPath

與 emptyDir 不同,hostPath 卷能將主機節點檔案系統上的檔案或目錄掛載到指定的 Pod 中。並且當 Pod 刪除時,與之繫結的 hostPath 並不會隨之刪除。新建立的 Pod掛載到上一個 Pod 使用過的 hostPath時,原 hostPath 中的內容仍然存在。但這僅限於新的 Pod 和已經刪除的 Pod 被排程到同一節點上,所以嚴格來講 hostPath 仍然屬於臨時儲存。

hostPath 卷的典型應用是將主機節點上的時區通過卷掛載的方式注入到容器內部, 進而保證啟動的容器和主機節點時間同步。

使用範例

建立 hostpath-demo.yaml 內容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "hostpath-volume-pod"
  namespace: default
  labels:
    app: "hostpath-volume-pod"
spec:
  containers:
    - name: hostpath-volume-container
      image: "alpine:latest"
      command: ["sleep", "3600"]
      volumeMounts:
        - name: localtime
          mountPath: /etc/localtime
  volumes:
    - name: localtime
      hostPath:
        path: /usr/share/zoneinfo/Asia/Shanghai

要實現時間同步,只需要將主機目錄 /usr/share/zoneinfo/Asia/Shanghai 通過卷掛載的方式掛載到容器內部的 /etc/localtime 目錄即可。

可以使用 kubectl apply 命令應用這個檔案,然後進入 Pod 容器內部使用 date 命令檢視容器當前時間。

# 建立 Pod
$ kubectl apply -f hostpath-demo.yaml
pod/hostpath-volume-pod created
# 進入 Pod 容器內部
$ kubectl exec -it hostpath-volume-pod -- sh
# 執行 date 命令輸出當前時間
/ # date
Sun Mar 13 17:00:22 CST 2022  # 上海時區

看到輸出結果為 Sun Mar 13 17:00:22 CST 2022 ,其中 CST 代表了上海時區,也就是主機節點的時區。如果不通過卷掛載的方式將主機時區掛載到容器內部,則容器預設時區為 UTC 時區。

小結

臨時卷內容介紹了 K8s 的臨時儲存方案以及應用,其中emptyDir 適用範圍較少,可以當作臨時快取或者耗時任務檢查點等。

需要注意的是,絕大多數 Pod 應該忽略主機節點,不應該存取節點上的檔案系統。儘管有時候 DaemonSet 可能需要存取主機節點的檔案系統,而且hostPath 可以用來同步主機節點時區到容器,但其他情況下使用較少,特別hostPath 的最佳實踐就是儘量不使用 hostPath。

持久卷

臨時卷的生命週期與 Pod 相同,當 Pod 被刪除時,K8s 會自動刪除 Pod 掛載的臨時卷。而當 Pod 中的應用需要將資料儲存到磁碟,且即使 Pod 被排程到其他節點資料也應該存在時,我們就需要一個真正的持久化儲存了。

K8s 支援的持久卷型別非常多,以下是 v1.24 版本支援的卷型別的一部分:

  • awsElasticBlockStore - AWS 彈性塊儲存(EBS)

  • azureDisk - Azure Disk

  • azureFile - Azure File

  • cephfs - CephFS volume

  • csi - 容器儲存介面 (CSI)

  • fc - Fibre Channel (FC) 儲存

  • gcePersistentDisk - GCE 持久化盤

  • glusterfs - Glusterfs 卷

  • iscsi - iSCSI (SCSI over IP) 儲存

  • local - 節點上掛載的本地儲存裝置

  • nfs - 網路檔案系統 (NFS) 儲存

  • portworxVolume - Portworx 卷

  • rbd - Rados 塊裝置 (RBD) 卷

  • vsphereVolume - vSphere VMDK 卷

看到這麼多持久卷型別不必恐慌,因為 K8s 為了讓開發者不必關心這背後的持久化儲存型別,所以對持久卷有一套獨有的思想,即開發者無論使用哪種持久卷,其用法都是一致的。

K8s 持久卷設計架構如下:

Node1 和 Node2 分別代表兩個工作節點,當我們在工作節點建立 Pod 時,可以通過 spec.containers.volumeMounts 來指定容器掛載目錄,通過 spec.volumes 來指定掛載卷。之前我們用掛載卷掛載了設定資訊和臨時卷,而掛載持久卷也可以採用同樣的方式。每個 volumes 則指向的是下方儲存叢集中不同的儲存型別。

為了保證高可用,我們通常會搭建一個儲存叢集。通常通過 Pod 來操作儲存, 因為 Pod 都會部署在 Node 中,所以儲存叢集最好跟 Node 叢集搭建在同一內網,這樣速度更快。而儲存叢集內部可以使用任何 K8s 支援的持久化儲存,如上圖的 NFS 、CephFS 、CephRBD 。

使用NFS

持久化掛載方式與臨時卷大同小異,我們同樣使用一個 Nginx 服務來進行測試。這次我們用 NFS 儲存來演示 K8s 對持久卷的支援(NFS 測試環境搭建過程可以參考文章結尾的附錄部分),建立 nfs-demo.yaml 內容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "nfs-nginx-pod"
  namespace: default
  labels:
    app: "nfs-nginx-pod"
spec:
  containers:
    - name: nfs-nginx
      image: "nginx:latest"
      ports:
        - containerPort: 80
          name: http
      volumeMounts:
        - name: html-volume
          mountPath: /usr/share/nginx/html/
  volumes:
    - name: html-volume
      nfs:
        server: 192.168.99.101  # 指定 nfs server 地址
        path: /nfs/data/nginx  # 目錄必須存在

將容器 index.html 所在目錄 /usr/share/nginx/html/ 掛載到 NFS 服務的 /nfs/data/nginx 目錄下,在 spec.volumes 設定項中指定 NFS 服務。其中server 指明瞭 NFS 伺服器地址,path 指明瞭 NFS 伺服器中掛載的路徑,當然這個路徑必須是已經存在的路徑。然後通過 kubectl apply 命令應用這個檔案。

$ kubectl apply -f nfs-demo.yaml

接下來我們檢視這個 Pod 使用 NFS 儲存的結果:

在 NFS 節點中我們準備一個 index.html 檔案,其內容為 hello nfs。

使用 curl 命令直接存取 Pod 的 IP 地址,即可返回 Nginx 服務的 index.html 內容,結果輸出為 hello nfs ,證明 NFS 持久卷掛載成功。

登入 Pod 容器,通過 df -Th 命令檢視容器目錄掛載資訊。可以發現,容器的 /usr/share/nginx/html/ 目錄被掛載到 NFS 服務的 /nfs/data/nginx 目錄。

現在,當我們執行 kubectl delete -f nfs-demo.yaml 刪除 Pod 後,NFS 伺服器上資料盤中的資料依然存在,這就是持久卷。

持久卷使用痛點

雖然通過使用持久卷,可以解決臨時卷資料易丟失的問題。但目前持久卷的使用方式還存在以下痛點:

  • Pod 開發人員可能對儲存不夠了解,卻要對接多種儲存

  • 安全問題,有些儲存可能需要賬號密碼,這些資訊不應該暴露給 Pod

因此為了解決這些不足,K8s 又針對持久化儲存抽象出了三種資源 PV、PVC、StorageClass。三種資源定義如下:

  • PV 描述的是持久化儲存資料卷

  • PVC 描述的是 Pod 想要使用的持久化儲存屬性,既儲存卷申明

  • StorageClass 作用是根據 PVC 的描述,申請建立對應的 PV

PV 和 PVC 的概念可以對應程式語言中的物件導向思想,PVC 是介面,PV 是具體實現。

有了這三種資源型別後,Pod 就可以通過靜態供應和動態供應這兩種方式來使用持久卷。

靜態供應

靜態供應不涉及 StorageClass,只涉及到 PVC 和 PV。其使用流程圖如下:

使用靜態供應時,Pod 不再直接繫結持久儲存,而是會繫結到 PVC 上,然後再由 PVC 跟 PV 進行繫結。這樣就實現了 Pod 中的容器可以使用由 PV 真正去申請的持久化儲存。

使用範例

建立 pv-demo.yaml 內容如下:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-pv-1g
  labels:
    type: nfs
spec:
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteOnce
  storageClassName: nfs-storage
  nfs:
    server: 192.168.99.101
    path: /nfs/data/nginx1
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-pv-100m
  labels:
    type: nfs
spec:
  capacity:
    storage: 100m
  accessModes:
    - ReadWriteOnce
  storageClassName: nfs-storage
  nfs:
    server: 192.168.99.101
    path: /nfs/data/nginx2
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-500m
  labels:
    app: pvc-500m
spec:
  storageClassName: nfs-storage
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 500m
---
apiVersion: v1
kind: Pod
metadata:
  name: "pv-nginx-pod"
  namespace: default
  labels:
    app: "pv-nginx-pod"
spec:
  containers:
    - name: pv-nginx
      image: "nginx:latest"
      ports:
        - containerPort: 80
          name: http
      volumeMounts:
        - name: html
          mountPath: /usr/share/nginx/html/
  volumes:
    - name: html
      persistentVolumeClaim:
        claimName: pvc-500m

其中 yaml 檔案定義瞭如下內容:

  • 兩個 PV:申請容量分別為 1Gi 、100m ,通過 spec.capacity.storage 指定,並且他們通過 spec.nfs 指定了 NFS 儲存服務的地址和路徑。

  • 一個 PVC :申請 500m 大小的儲存。

  • 一個 Pod:spec.volumes 繫結名為 pvc-500m 的 PVC,而不是直接繫結 NFS 儲存服務。

通過 kubectl apply 命令應用該檔案:

$ kubectl apply -f pv-demo.yaml

以上完成建立,結果檢視操作則如下:

首先通過 kubectl get pod 命令檢視新建立的 Pod,並通過 curl 命令存取 Pod 的 IP 地址,得到 hello nginx1 的響應結果。

然後通過 kubectl get pvc 檢視建立的 PVC:

  • STATUS 欄位:標識 PVC 已經處於繫結(Bound)狀態,也就是與 PV 進行了繫結。

  • CAPACITY 欄位:標識 PVC 繫結到了 1Gi 的 PV 上,儘管我們申請的 PVC 大小是 500m ,但由於我們建立的兩個 PV 大小分別是 1Gi 和 100m ,K8s 會幫我們選擇滿足條件的最優解。因為沒有剛好等於 500m 大小的 PV 存在,而 100m 又不滿足,所以 PVC 會自動與 1Gi 大小的 PV 進行繫結。

通過 kubectl get pv 來查詢建立的 PV 資源,可以發現 1Gi 大小的 PV STATUS 欄位為 Bound 狀態。CLAIM 的值,則標識的是與之繫結的 PVC 的名字。

現在我們登入 NFS 伺服器,確認NFS 儲存上不同持久卷(PV)掛載的目錄下檔案內容。

可以看到,/nfs/data/nginx1 目錄下的 index.html 內容為 hello nginx1 ,即為上面通過 curl 命令存取 Pod 服務的響應結果。

到此持久卷完成使用,我們總結下整個持久卷使用流程。首先建立一個 Pod, Pod 的 spec.volumes 中繫結 PVC。這裡的 PVC 只是一個儲存申明,代表我們的 Pod 需要什麼樣的持久化儲存,它不需要標明 NFS 服務地址,也不需要明確要和哪個 PV 進行繫結,只是建立出這個 PVC 即可。接著我們建立兩個 PV,PV 也沒有明確指出要與哪個 PVC 進行繫結,只需要指出它的大小和 NFS 儲存服務地址即可。此時 K8s 會自動幫我們進行 PVC 和 PV 的繫結,這樣 Pod 就和 PV 產生了聯絡,也就可以存取持久化儲存了。

其他

細心的你可能已經發現,前文提到靜態供應不涉及 StorageClass,但是在定義 PVC 和 PV 的 yaml 檔案時,還是都為其指定了 spec.storageClassName 值為 nfs-storage。因為這是一個便於管理的操作方式,只有具有相同 StorageClass 的 PVC 和 PV 才可以進行繫結,這個欄位標識了持久卷的存取模式。在 K8s 持久化中支援四種存取模式:

  • RWO - ReadWriteOnce —— 卷可以被一個節點以讀寫方式掛載

  • ROX - ReadOnlyMany —— 卷可以被多個節點以唯讀方式掛載

  • RWX - ReadWriteMany —— 卷可以被多個節點以讀寫方式掛載

  • RWOP - ReadWriteOncePod —— 卷可以被單個 Pod 以讀寫方式掛載( K8s 1.22 以上版本)

只有具有相同讀寫模式的 PVC 和 PV 才可以進行繫結。

現在我們來繼續實驗,通過命令 kubectl delete pod pv-nginx-pod 刪除 Pod,再次檢視 PVC 和 PV 狀態。

從上圖可以看到, Pod 刪除後 PVC 和 PV 還在,這說明 Pod 刪除並不影響 PVC 的存在。而當 PVC 刪除時 PV 是否刪除,則可以通過設定回收策略來決定。PV 回收策略(pv.spec.persistentVolumeReclaimPolicy)有三種:

  • Retain —— 手動回收,也就是說刪除 PVC 後,PV 依然存在,需要管理員手動進行刪除

  • Recycle —— 基本擦除 (相當於 rm -rf /*)(新版已廢棄不建議使用,建議使用動態供應)

  • Delete —— 刪除 PV,即級聯刪除

現在通過命令 kubectl delete pvc pvc-500m 刪除 PVC,檢視 PV 狀態。

可以看到 PV 依然存在,其 STATUS 已經變成 Released ,此狀態下的 PV 無法再次繫結到 PVC,需要由管理員手動刪除,這是由回收策略決定的。

注意:繫結了 Pod 的 PVC,如果 Pod 正在執行中,PVC 無法刪除。

靜態供應的不足

我們一起體驗了靜態供應的流程,雖然比直接在 Pod 中繫結 NFS 服務更加清晰,但靜態供應依然存在不足。

  • 首先會造成資源浪費,如上面範例中,PVC 申請 500m,而沒有剛好等於 500m 的 PV 存在,這 K8s 會將 1Gi 的 PV 與之繫結

  • 還有一個致命的問題,如果當前沒有滿足條件的 PV 存在,則這 PVC 一直無法系結到 PV 處於 Pending 狀態,Pod 也將無法啟動,所以就需要管理員提前建立好大量 PV 來等待新建立的 PVC 與之繫結,或者管理員時刻監控是否有滿足 PVC 的 PV 存在,如果不存在則馬上進行建立,這顯然是無法接受的

動態供應

因為靜態供應存在不足,K8s 推出一種更加方便的持久卷使用方式,即動態供應。動態供的應核心元件就是 StorageClass——儲存類。StorageClass 主要作用有兩個:

  • 一是資源分組,我們上面使用靜態供應時指定 StorageClass 的目前就是對資源進行分組,便於管理

  • 二是 StorageClass 能夠幫我們根據 PVC 請求的資源,自動建立出新的 PV,這個功能是 StorageClass 中 provisioner 儲存外掛幫我們來做的。

其使用流程圖如下:

相較於靜態供應,動態供應在 PVC 和 PV 之間增加了儲存類。這次 PV 並不需要提前建立好,只要我們申請了 PVC 並且繫結了有 provisioner 功能的 StorageClass,StorageClass 會幫我們自動建立 PV 並與 PVC 進行繫結。

我們可以根據提供的持久化儲存型別,分別建立對應的 StorageClass,比如:

  • nfs-storage

  • cephfs-storage

  • rbd-storage

也可以設定一個預設 StorageClass, 通過在建立 StorageClass 資源時指定對應的 annotations 實現:

apiVersion: storage.K8s.io/v1
kind: StorageClass
metadata:
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
...

當建立 PVC 時不指定 spec.storageClassName ,這個 PVC 就會使用預設 StorageClass。

使用範例

仍然使用 NFS 來作為持久化儲存。

首先需要有一個能夠支援自動建立 PV 的 provisioner ,這可以在 GitHub 中找到一些開源的實現。範例使用 nfs-subdir-external-provisioner 這個儲存外掛,具體安裝方法非常簡單,只需要通過 kubectl apply 命令應用它提供的幾個 yaml 檔案即可。完成儲存外掛安裝後,可以建立如下 StorageClass:

apiVersion: storage.K8s.io/v1
kind: StorageClass
metadata:
  name: nfs-storage
provisioner: K8s-sigs.io/nfs-subdir-external-provisioner
parameters:
  archiveOnDelete: "true"

這個 StorageClass 指定了 provisioner 為我們安裝好的 K8s-sigs.io/nfs-subdir-external-provisioner。
Provisioner 本質上也是一個 Pod,可以通過 kubectl get pod 來檢視。指定了 provisioner 的 StorageClass 就有了自動建立 PV 的能力,因為 Pod 能夠自動建立 PV。

建立好 provisioner 和 StorageClass 就可以進行動態供應的實驗了。首先建立 nfs-provisioner-demo.yaml 內容如下:

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: test-claim
spec:
  storageClassName: nfs-storage
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Mi
---
apiVersion: v1
kind: Pod
metadata:
  name: "test-nginx-pod"
  namespace: default
  labels:
    app: "test-nginx-pod"
spec:
  containers:
    - name: test-nginx
      image: "nginx:latest"
      ports:
        - containerPort: 80
          name: http
      volumeMounts:
        - name: html
          mountPath: /usr/share/nginx/html/
  volumes:
    - name: html
      persistentVolumeClaim:
        claimName: test-claim

這裡我們只定義了一個 PVC 和一個 Pod,並沒有定義 PV。其中 PVC 的 spec.storageClassName 指定為上面建立好的 StorageClass nfs-storage ,然後只需要通過 kubectl apply 命令來建立出 PVC 和 Pod 即可:

$ kubectl apply -f nfs-provisioner-demo.yaml
persistentvolumeclaim/test-claim created
pod/test-nginx-pod created

現在檢視 PV、PVC 和 Pod,可以看到 PV 已經被自動建立出來了,並且它們之間實現了繫結關係。

然後登入 NFS 服務,給遠端掛載的卷寫入 hello nfs 資料。

在 K8s 側,就可以使用 curl 命令驗證掛載的正確性了。

此時如果你通過 kubectl delete -f nfs-provisioner-demo.yaml 刪除 Pod 和 PVC,PV 也會跟著刪除,因為 PV 的刪除策略是 Delete 。不過刪除後NFS 卷中的資料還在,只不過被歸檔成了以 archived 開頭的目錄。這是 K8s-sigs.io/nfs-subdir-external-provisioner 這個儲存外掛所實現的功能,這就是儲存外掛的強大。

完成全部操作後,我們可以發現通過定義指定了 provisioner 的 StorageClass,不僅實現了 PV 的自動化建立,甚至實現了資料刪除時自動歸檔的功能,這就是 K8s 動態供應儲存設計的精妙。也可以說動態供應是持久化儲存最佳實踐。

附錄:NFS 實驗環境搭建

NFS 全稱 Network File System,是一種分散式儲存,它能夠通過區域網實現不同主機間目錄共用。

以下為 NFS 的架構圖:由一個 Server 節點和兩個 Client 節點組成。

下面列出 NFS 在 Centos 系統中的搭建過程。

Server 節點

# 安裝 nfs 工具
yum install -y nfs-utils

# 建立 NFS 目錄
mkdir -p /nfs/data/

# 建立 exports 檔案,* 表示所有網路上的 IP 都可以存取
echo "/nfs/data/ *(insecure,rw,sync,no_root_squash)" > /etc/exports

# 啟動 rpc 遠端繫結功能、NFS 服務功能
systemctl enable rpcbind
systemctl enable nfs-server
systemctl start rpcbind
systemctl start nfs-server

# 過載使設定生效
exportfs -r
# 檢查設定是否生效
exportfs
# 輸出結果如下所示
# /nfs/data      

Client 節點

# 關閉防火牆
systemctl stop firewalld
systemctl disable firewalld

# 安裝 nfs 工具
yum install -y nfs-utils

# 掛載 nfs 伺服器上的共用目錄到本機路徑 /root/nfsmount
mkdir /root/nfsmount
mount -t nfs 192.168.99.101:/nfs/data /root/nfsmount