🔗 PV, PVC: binding, access modes và reclaim policy

Bài này là phần thực hành. Mình sẽ không giảng lý thuyết dài, mà đi thẳng vào 5 bài tập nhỏ. Làm xong 5 bài này là bạn xử lý được gần như mọi câu PV/PVC trong đề CKA.

Mục tiêu bài học

Sau khoảng 30 phút, bạn làm được:

  • Phân biệt PV (cluster-scoped) và PVC (namespaced), hiểu ai "xin" và ai "cấp".
  • Tạo một cặp PV + PVC khớp nhau và đưa PVC sang trạng thái Bound.
  • Đọc đúng 4 access modes, và nhớ rằng ReadWriteOnce không phải "một pod".
  • Chọn persistentVolumeReclaimPolicy phù hợp và biết cách đưa PV từ Released về Available.

Ôn nhanh từ bài trước

Bài trước bạn đã dùng emptyDir, hostPath và volume từ ConfigMap/Secret. Cả ba đều buộc vào vòng đời pod: pod bị xóa, volume bị xóa hoặc mồ côi.

PV/PVC ra đời để tách vòng đời dữ liệu khỏi vòng đời pod. Pod chết, PVC còn, dữ liệu còn. Đó là toàn bộ lý do cặp đôi này tồn tại.

Ví dụ mẫu từng bước

Cách mình hay hình dung: PV là ổ đĩa có sẵn, PVC là đơn xin cấp ổ đĩa, control plane (PersistentVolume controller) là người se duyên. Bạn viết đơn, nó đi tìm ổ đĩa khớp, khớp thì gắn hai bên lại.

flowchart TD
    A["📝 Tạo PVC"] --> B{"🔍 Có PV khớp?"}
    B -->|"storageClassName + accessModes + capacity"| C["🔗 PVC Bound"]
    B -->|"lệch một điều kiện"| D["⏳ PVC Pending"]
    D --> E["🛠️ Sửa PVC hoặc tạo PV to hơn"]
    E --> B

Bước 1: tạo PV

Không có lệnh imperative kiểu kubectl create pv, nên bạn phải viết YAML. Tạo file pv-demo.yaml:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-demo
spec:
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: ""
  hostPath:
    path: /mnt/data/pv-demo
    type: DirectoryOrCreate
kubectl apply -f pv-demo.yaml
kubectl get pv
NAME      CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM   STORAGECLASS   AGE
pv-demo   1Gi        RWO            Retain           Available                        10s

Bước 2: tạo PVC

Tạo file pvc-demo.yaml. Xin ít hơn PV đang có, đây là chuyện bình thường:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-demo
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 500Mi
  storageClassName: ""
kubectl apply -f pvc-demo.yaml
kubectl get pvc
NAME       STATUS   VOLUME    CAPACITY   ACCESS MODES   STORAGECLASS   AGE
pvc-demo   Bound    pv-demo   1Gi        RWO                           5s

Để ý: PVC xin 500Mi nhưng nhận cả 1Gi. ==Capacity của PV phải lớn hơn hoặc bằng requests.storage của PVC==, không cần bằng nhau.

Bước 3: dùng PVC trong pod

apiVersion: v1
kind: Pod
metadata:
  name: pod-demo
spec:
  containers:
    - name: app
      image: busybox
      command: ["sh", "-c", "sleep 3600"]
      volumeMounts:
        - name: data
          mountPath: /data
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: pvc-demo
kubectl apply -f pod-demo.yaml
kubectl exec pod-demo -- sh -c "echo hello-cka > /data/note.txt && cat /data/note.txt"

Nếu in ra hello-cka, PVC đã được mount đúng.

Bước 4: xem reclaim policy hành động

kubectl delete pod pod-demo
kubectl delete pvc pvc-demo
kubectl get pv
NAME      CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS     CLAIM             STORAGECLASS   AGE
pv-demo   1Gi        RWO            Retain           Released   default/pvc-demo                 3m

PV không bị xóa, nó chuyển sang Released. Dữ liệu vẫn nằm trên đĩa nhưng PV chưa bind lại được, vì spec.claimRef vẫn trỏ tới PVC đã chết. Bước 5 là cách cứu:

kubectl patch pv pv-demo -p '{"spec":{"claimRef":null}}'
kubectl get pv pv-demo
NAME      CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM   STORAGECLASS   AGE
pv-demo   1Gi        RWO            Retain           Available                         4m

Luyện tập có hướng dẫn

Làm đúng thứ tự, mỗi bài tự bấm giờ. Bài 1 và 2 là ví dụ trên, bài 3 đến 5 là phần ăn điểm trong đề thi.

Bài 1 (5 phút): tạo cặp PV/PVC Bound

Tạo pv-web 2Gi, ReadWriteOnce, reclaim policy Retain, storageClassName: "", hostPath /mnt/data/pv-web, và pvc-web xin 1Gi cùng access mode, cùng storageClassName.

Kiểm tra:

kubectl get pvc pvc-web -o wide
kubectl get pvc pvc-web -o jsonpath='{.spec.volumeName}{"\n"}'

Kỳ vọng: STATUS = Bound, và lệnh jsonpath in ra pv-web. Đây là cách nhanh nhất để biết PVC đang cắm vào PV nào.

Bài 2 (5 phút): ghi dữ liệu qua PVC

Tạo pod pod-web dùng image nginx, mount pvc-web vào /usr/share/nginx/html, sau đó ghi một file:

kubectl exec pod-web -- sh -c "echo 'CKA storage ok' > /usr/share/nginx/html/index.html"
kubectl exec pod-web -- cat /usr/share/nginx/html/index.html

Xóa pod, tạo lại pod mới cùng PVC, đọc lại file. Dữ liệu vẫn còn, đó chính là điểm khác biệt so với emptyDir ở bài trước.

Bài 3 (7 phút): cố tình lệch accessModes

Tạo pvc-rwx.yaml xin ReadWriteMany, 1Gi, storageClassName: "":

kubectl apply -f pvc-rwx.yaml
kubectl get pvc pvc-rwx
kubectl describe pvc pvc-rwx | tail -n 8

Kỳ vọng: STATUS = Pending, event kiểu:

Warning  ProvisioningFailed  persistentvolume-controller  no persistent volumes available for this claim and no storage class is set

Giờ sửa. Bẫy ở đây: bạn không thể kubectl edit để đổi accessModes, API sẽ trả về Forbidden: updates to persistentvolumeclaims spec are forbidden. Cách đúng là xóa và tạo lại:

kubectl delete pvc pvc-rwx
# sửa accessModes thành ReadWriteOnce rồi apply lại

Tự kiểm tra: nếu xóa PVC rồi apply lại với ReadWriteOnce và 1Gi, PVC có tự bind vào pv-web (2Gi, RWO) không? Đáp án là có.

Bài 4 (6 phút): lệch capacity

Tạo PVC pvc-big xin 3Gi, ReadWriteOnce, storageClassName: "". Không có PV nào đủ lớn, nên PVC Pending.

Đừng sửa PVC. Hãy tạo thêm một PV đủ lớn:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-big
spec:
  capacity:
    storage: 5Gi
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: ""
  hostPath:
    path: /mnt/data/pv-big
    type: DirectoryOrCreate
kubectl apply -f pv-big.yaml
kubectl get pvc pvc-big

PV mới xuất hiện và binder tự gắn ngay, không cần can thiệp. Bài học: bạn chỉ cần làm cho phía PV khớp, không nhất thiết phải sửa đơn xin. Và nếu có nhiều PV cùng khớp, Kubernetes chọn PV nhỏ nhất vẫn đủ lớn.

Bài 5 (7 phút): Retain, Released và claimRef

kubectl delete pod pod-web --ignore-not-found
kubectl delete pvc pvc-web
kubectl get pv

Kỳ vọng: pv-web ở trạng thái Released, cột CLAIM vẫn ghi default/pvc-web.

Giờ thử bind lại mà chưa xử lý gì:

kubectl apply -f pvc-web.yaml
kubectl get pvc pvc-web

PVC mới sẽ Pending, vì PV đang Released chứ không phải Available. Sửa bằng cách gỡ claimRef:

kubectl patch pv pv-web -p '{"spec":{"claimRef":null}}'
kubectl get pv pv-web

PV về Available, và PVC Pending ở trên sẽ tự bind trong vài giây. Đây là mẹo rất hay bị hỏi trong đề: Retain + Released + xóa claimRef là bộ ba đi liền nhau.

Lỗi thường gặp và cách xử lý

Triệu chứng Nguyên nhân Cách xử lý
PVC Pending, event no persistent volumes available for this claim and no storage class is set Không có PV nào khớp accessModes, capacity hoặc storageClassName So kubectl describe pv với kubectl describe pvc, sửa bên lệch
PVC Pending dù đã tạo PV đúng PV có storageClassName: manual nhưng PVC để trống, hoặc ngược lại Hai bên phải ghi giống hệt nhau, kể cả chuỗi rỗng ""
kubectl edit pvc báo Forbidden: updates to persistentvolumeclaims spec are forbidden spec.accessModes, storageClassName gần như immutable Xóa PVC và tạo lại, hoặc tạo PV đúng ý
PV Released mãi, tạo PVC mới vẫn Pending spec.claimRef còn trỏ tới PVC cũ kubectl patch pv <ten> -p '{"spec":{"claimRef":null}}'
Xóa PVC báo lỗi hoặc treo ở Terminating Finalizer kubernetes.io/pvc-protection vì còn pod đang dùng Xóa pod trước, rồi xóa PVC
Pod Pending dù PVC đã Bound Pod có thể đang thiếu node hoặc sai claimName kubectl describe pod xem event, đối chiếu tên PVC

[!WARNING] Có một trường hợp rất dễ sửa sai: PVC Pending không phải lúc nào cũng do PV. Nếu cluster của bạn có StorageClass mặc định, PVC không ghi storageClassName sẽ bị dynamic provisioning "cướp" và bind vào một PV khác hoàn toàn. Trong bài này mình luôn ghi storageClassName: "" để tránh chuyện đó.

[!NOTE] ReadWriteOnce nghĩa là một node mount read-write, không phải một pod. Hai pod trên cùng một node vẫn dùng chung một PVC RWO bình thường. Chỉ ReadWriteOncePod mới giới hạn đúng một pod trong toàn cluster.

Góc chuyên gia

Bảng access modes, học đúng 4 dòng này là đủ:

Access mode Viết tắt Ý nghĩa thực tế
ReadWriteOnce RWO Một node mount read-write
ReadOnlyMany ROX Nhiều node mount read-only
ReadWriteMany RWX Nhiều node mount read-write, cần backend hỗ trợ (NFS, CephFS)
ReadWriteOncePod RWOP Đúng một pod trong cluster mount read-write

Bảng reclaim policy:

Policy Sau khi PVC bị xóa Dùng khi
Retain PV sang Released, dữ liệu còn, phải xử lý tay Dữ liệu quan trọng, an toàn khi thi
Delete Xóa PV object và backing storage Lab, dữ liệu tạm
Recycle Xóa nội dung rồi về Available Đã deprecated, đừng dùng

Mặc định: PV tạo thủ công là Retain, PV do dynamic provisioning tạo là Delete. Cứ ghi rõ ra trong YAML cho chắc.

Mẹo thi:

  • kubectl explain pv.spec --recursive là phao cứu sinh khi quên field. Không cần nhớ hết, chỉ cần nhớ lệnh này.
  • Muốn chắc chắn PVC bind đúng PV, thêm volumeName: pv-demo vào spec của PVC. Nó ép binder chỉ xét đúng PV đó, tránh bị PV khác "giành".
  • Thứ tự khớp khi bind: storageClassName → accessModes → capacity → selector. Lệch ở bước nào, describe pvc sẽ dừng ở bước đó.
  • Trước khi nộp, luôn chạy kubectl get pvc xem STATUS. Bound mới là xong, Pending là mất điểm.
  • Đừng dùng hostPath cho câu hỏi mô tả production. NHƯNG trong lab CKA thì hostPath là cách nhanh nhất để có một PV hợp lệ, vì bạn không có NFS server.

Tóm tắt và bước tiếp theo

Năm dòng để bạn ghi ra giấy nháp:

  1. PV là cluster-scoped, PVC là namespaced.
  2. Bind thành công khi storageClassName, accessModes khớp và capacity PV lớn hơn hoặc bằng request.
  3. RWO là per-node, không phải per-pod.
  4. Retain để lại PV ở Released, muốn dùng lại thì xóa claimRef.
  5. PVC gần như immutable: sai access mode thì xóa tạo lại.

Kiểm tra miệng 60 giây:

PV là resource cấp {{c1::cluster}}, còn PVC là resource cấp {{c2::namespace}}.
Access mode cho phép nhiều node mount read-write là {{c3::ReadWriteMany}}.
Reclaim policy mặc định của PV tạo thủ công là {{c4::Retain}}, còn PV do StorageClass tạo động mặc định là {{c5::Delete}}.
PVC ở trạng thái {{c6::Pending}} nghĩa là chưa tìm được PV phù hợp.
Muốn PV đang ở trạng thái Released bind lại được, ta xóa {{c7::claimRef}} trong spec của PV.

Bộ thẻ ôn tập:

[
  { "front": "PV là resource cấp cluster hay namespace?", "back": "Cluster-scoped. PVC mới là namespaced." },
  { "front": "Kể tên 4 access modes của PV", "back": "ReadWriteOnce, ReadOnlyMany, ReadWriteMany, ReadWriteOncePod." },
  { "front": "ReadWriteOnce giới hạn theo node hay theo pod?", "back": "Theo node. Nhiều pod cùng node vẫn dùng chung được." },
  { "front": "PVC chuyển sang Bound khi nào?", "back": "Khi controller tìm được PV khớp storageClassName, accessModes và capacity." },
  { "front": "PV 1Gi có bind được với PVC xin 2Gi không?", "back": "Không. Capacity PV phải lớn hơn hoặc bằng requests.storage." },
  { "front": "Có nhiều PV cùng khớp thì chọn cái nào?", "back": "PV nhỏ nhất vẫn đủ lớn." },
  { "front": "Ba reclaim policy của PV?", "back": "Retain, Delete và Recycle (Recycle đã deprecated)." },
  { "front": "Reclaim policy mặc định của PV tạo thủ công?", "back": "Retain." },
  { "front": "Reclaim policy mặc định của PV do dynamic provisioning tạo?", "back": "Delete." },
  { "front": "PV ở trạng thái Released nghĩa là gì?", "back": "PVC đã bị xóa, dữ liệu còn nhưng PV chưa bind lại được." },
  { "front": "Làm sao đưa PV từ Released về Available?", "back": "Gỡ spec.claimRef bằng kubectl patch pv <ten> -p '{\"spec\":{\"claimRef\":null}}'." },
  { "front": "Sửa accessModes của PVC đã tạo được không?", "back": "Không. Spec gần như immutable, phải xóa và tạo lại." },
  { "front": "storageClassName rỗng (\"\") trên PVC có ý nghĩa gì?", "back": "Chỉ bind với PV không có StorageClass, bỏ qua StorageClass mặc định." },
  { "front": "Lệnh xem PVC đang bind vào PV nào?", "back": "kubectl get pvc <ten> -o jsonpath='{.spec.volumeName}'." }
]

Bài tiếp theo: bạn đã tự tay tạo PV, nghĩa là đang làm static provisioning. Bài 5.3 sẽ bỏ hẳn bước đó: chỉ cần một StorageClass, PVC tự sinh PV. Bạn sẽ thấy vì sao hầu hết cluster thật không ai viết PV bằng tay, và vì sao WaitForFirstConsumer là field khiến nhiều người mất điểm oan.