一文讀懂 Kubernetes 存儲設計

語言: CN / TW / HK

在 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