← Kubernetes← Kubernetes
KubernetesKubernetes19 Th7, 2026Jul 19, 202617 phút đọc15 min read

StorageStorage

Thuộc bộ tài liệu kiến thức theo Kubernetes Roadmap.

Tổng quan

Container vốn ephemeral theo thiết kế: khi một Pod chết, lớp container ghi được của nó chết theo. Điều đó ổn với service stateless, nhưng hệ thống thực cần giữ dữ liệu — database, message queue, file upload, cache, artifact build. Storage của Kubernetes là tập các lớp trừu tượng cho phép Pod dùng storage sống lâu hơn chính nó, có thể gắn vào bất kỳ node nào Pod đáp xuống, và được cấp phát theo yêu cầu từ cloud disk, network filesystem, hay media cục bộ. Hệ thống được thiết kế để người viết ứng dụng yêu cầu storage theo nhu cầu (“tôi cần 20Gi storage nhanh, read-write-once”) mà không cần biết backend nào đáp ứng.

Trọng tâm của thiết kế là tách bạch giữa đòi (claim) và cấp phát (provision). Một workload khai báo cái gì nó cần bằng một PersistentVolumeClaim (PVC); một StorageClass mô tả làm thế nào để tạo ra nó, và một CSI driver thực sự cấp phát một PersistentVolume (PV) — một cloud disk, một NFS export, bất cứ thứ gì. Sự tách rời này cho phép cùng một manifest chạy y nguyên trên AWS, GKE, on-prem, hay laptop: workload yêu cầu một lớp storage, và các driver được cấu hình của cluster đáp ứng nó. Hiểu chuỗi PVC → StorageClass → PV → CSI, cùng với access mode và reclaim policy, là thứ phân biệt “dữ liệu của tôi sống sót qua sự cố node” với “database của tôi mất rồi.”

Một mô hình tư duy

Storage trong Kubernetes là một chợ yêu cầu-đáp ứng. PVC là đơn đặt hàng (“20Gi, ReadWriteOnce, class=fast”) do team ứng dụng viết. StorageClass là mục trong catalog (“fast” = SSD do AWS EBS CSI driver cấp phát, với các tham số này). PV là hàng đã giao — một volume thật bound tới đúng một PVC. CSI driver là nhà cung cấp sản xuất và gắn nó. App không bao giờ gọi tên một disk cụ thể; nó gọi tên một yêu cầu, và cluster đáp ứng. Sự gián tiếp này chính là điểm cốt lõi: storage portable, self-service, cấp phát động. Cấp phát tĩnh (admin tạo sẵn PV bằng tay) vẫn tồn tại, nhưng cấp phát động qua StorageClass mới là chuẩn mực.

Kiến thức nền tảng

Ephemeral vs persistent storage

Quyết định đầu tiên là tuổi thọ. Kubernetes cung cấp nhiều tầng, từ ephemeral nhất tới ít nhất:

StorageTuổi thọSống sót quaDùng điển hình
Filesystem của containerRestart container hủy nóKhông gìScratch trong một container
Volume emptyDirGắn với PodRestart container (không phải xóa Pod)Scratch dùng chung giữa các container của Pod, cache
Volume configMap / secretGắn với PodFile config/secret được tiêmConfig và credential chỉ đọc
PersistentVolume (qua PVC)Độc lập với mọi PodXóa Pod, sự cố node, rescheduleDatabase, dữ liệu người dùng — bất cứ gì phải tồn tại lâu

Quy tắc kinh nghiệm: nếu mất dữ liệu khi Pod bị xóa là chấp nhận được, dùng ephemeral volume; nếu không, bạn cần PersistentVolume. Đừng cố lưu dữ liệu thật bằng hostPath (nó buộc dữ liệu vào một node và là rủi ro bảo mật) — hãy dùng PVC.

Volume: lớp trừu tượng trong Pod

Một Volume là một thư mục, được backing bởi một media nào đó, mount vào một hay nhiều container của Pod tại một mountPath được chọn. Volume được khai báo trong spec.volumes của Pod và mount qua containers[].volumeMounts. Các loại volume phổ biến:

apiVersion: v1
kind: Pod
metadata: { name: demo }
spec:
  containers:
    - name: app
      image: nginx
      volumeMounts:
        - { name: cache, mountPath: /var/cache }      # scratch ephemeral
        - { name: config, mountPath: /etc/app, readOnly: true }
        - { name: data, mountPath: /var/lib/app }      # bền
  volumes:
    - name: cache
      emptyDir: {}
    - name: config
      configMap: { name: app-config }
    - name: data
      persistentVolumeClaim: { claimName: app-data }   # → PV → disk thật

Tam giác PV / PVC / StorageClass

Ba object phối hợp để cung cấp storage bền:

Vòng đời binding: một PVC được tạo → control plane tìm hoặc (động) cấp phát một PV khớp → chúng bind một-một → một Pod mount PVC được gắn PV vào node của nó. Cặp PVC/PV đã bound là độc quyền; PV không thể bound tới một claim khác.

Từ PVC tới disk thật
  1. 01PVC "app-data"namespace: prod, yêu cầu: 20Gi, RWO, class: fast
  2. khớp
    02StorageClass "fast"provisioner: ebs.csi.aws.com, type: gp3
  3. cấp phát động
    03PV "pvc-8f3c…"20Gi, RWO, bound tới app-data
  4. backing bởi
    04AWS EBS volume vol-0abc…disk thật, gắn vào node của Pod

Cấp phát tĩnh vs động

Khái niệm chính

Access mode

Access mode mô tả bao nhiêu node có thể mount một volume và ở chế độ nào. Chúng là thuộc tính của cả PV lẫn yêu cầu PVC, và được thực thi lúc attach:

ModeViết tắtÝ nghĩa
ReadWriteOnceRWOMount read-write bởi một node duy nhất (nhiều Pod trên node đó dùng chung được)
ReadOnlyManyROXMount read-only bởi nhiều node
ReadWriteManyRWXMount read-write bởi nhiều node đồng thời
ReadWriteOncePoolRWOPRead-write bởi một Pod duy nhất (chặt hơn RWO; cần CSI hỗ trợ)

Sắc thái then chốt: RWO là theo node, không phải theo Pod. Đa số block storage (AWS EBS, GCE PD, Azure Disk) là RWO — nó chỉ attach được vào một node tại một thời điểm, nên một Deployment 2 replica đáp xuống các node khác nhau sẽ để một Pod kẹt ContainerCreating chờ một volume không attach được. Để read-write đa node thực sự bạn cần RWX, thứ đòi một backend file/mạng (NFS, CephFS, AWS EFS, Azure Files, GlusterFS) — block volume không làm RWX được. Chọn topology của workload mà không khớp access mode với backend là lỗi storage phổ biến nhất.

Volume mode: Filesystem vs Block

volumeMode của một PV là Filesystem (mặc định) — volume được format với một filesystem và mount như một thư mục — hoặc Block — thiết bị block thô được phơi cho container tại một device path (/dev/xvda) không có filesystem. Block thô dành cho database và phần mềm storage tự quản định dạng trên đĩa và muốn bỏ qua tầng filesystem để tăng hiệu năng.

Reclaim policy

Khi một PVC bị xóa, persistentVolumeReclaimPolicy của PV quyết định số phận dữ liệu bên dưới:

Hướng dẫn thực dụng: StorageClass production cho dữ liệu stateful nên dùng reclaimPolicy: Retain (hoặc chụp snapshot), để một kubectl delete pvc vô ý không xóa sạch database. Mặc định Delete là một cái bẫy cho dữ liệu thật.

Ví dụ thực tế: StorageClass + PVC

# StorageClass: SSD trên AWS, reclaim an toàn, mở rộng được, bind muộn
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: { name: fast }
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  iops: "5000"
  fsType: ext4
reclaimPolicy: Retain               # giữ dữ liệu nếu PVC bị xóa
allowVolumeExpansion: true          # PVC của class này grow được
volumeBindingMode: WaitForFirstConsumer   # cấp phát trong zone/node của Pod
---
# PVC: yêu cầu 20Gi, RWO, từ class "fast"
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: app-data, namespace: prod }
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: fast
  resources:
    requests: { storage: 20Gi }

volumeBindingMode: WaitForFirstConsumer quan trọng và ít được dùng: với mặc định Immediate, một PV được cấp phát ngay khi PVC được tạo — có thể trong một zone mà Pod không bao giờ được schedule tới (một lỗi cross-AZ kinh điển trên cloud block storage). WaitForFirstConsumer hoãn cấp phát tới khi một Pod thực sự dùng PVC, nên volume được tạo trong đúng zone/node. Dùng nó cho block storage theo zone.

Container Storage Interface (CSI)

CSI là chuẩn interface plugin giữa Kubernetes và các hệ storage. Trước đây, storage driver (“in-tree volume plugin”) được biên dịch thẳng vào Kubernetes; mỗi backend mới đồng nghĩa với đổi code lõi. CSI tách toàn bộ đó thành các out-of-tree driver — thành phần độc lập (thường là một controller Deployment + một node DaemonSet) cài đặt provisioning, attach/detach, mount/unmount, snapshot, và expansion. Kubernetes đã gỡ các plugin in-tree cũ để ưu tiên CSI.

Thực tế: để dùng AWS EBS bạn cài aws-ebs-csi-driver; cho GCE PD là driver pd.csi.storage.gke.io; cho on-prem là ceph-csi, nfs-csi, democratic-csi, v.v. CSI driver tự đăng ký, provisioner của StorageClass gọi tên nó, và nó cũng mở khóa các tính năng nâng cao — volume snapshot, online volume expansion, topology awareness, và cloning — do driver cài đặt. Khi “PVC kẹt Pending,” một CSI driver thiếu hoặc không khỏe là nghi phạm hàng đầu.

StatefulSet và volumeClaimTemplates

Một Deployment chia sẻ một PVC cho mọi replica (hoặc, thường hơn, không thể — vì RWO block storage không attach vào nhiều node được). Workload stateful cần một volume riêng, ổn định cho mỗi replica. Đó chính xác là điều volumeClaimTemplates của StatefulSet cung cấp: cho mỗi Pod, controller StatefulSet tạo một PVC riêng (tên <template>-<statefulset>-<ordinal>, ví dụ data-postgres-0), và PVC đó đi theo Pod qua các lần reschedule.

apiVersion: apps/v1
kind: StatefulSet
metadata: { name: postgres }
spec:
  serviceName: postgres            # headless Service cho DNS Pod ổn định
  replicas: 3
  selector: { matchLabels: { app: postgres } }
  template:
    metadata: { labels: { app: postgres } }
    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports: [{ containerPort: 5432 }]
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
  volumeClaimTemplates:            # một PVC MỖI replica, tạo tự động
    - metadata: { name: data }
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: fast
        resources: { requests: { storage: 50Gi } }

Điều này cho mỗi replica (postgres-0, postgres-1, postgres-2) một PVC 50Gi riêng tồn tại qua restart và reschedule, và — ghép với một headless Service — một tên DNS ổn định. Lưu ý một tính năng an toàn có chủ đích: xóa một StatefulSet không xóa các PVC của nó, nên scale down hay gỡ set sẽ không hủy dữ liệu trừ khi bạn xóa PVC một cách rõ ràng.

Volume snapshot

Các CSI driver hỗ trợ cho phép bạn chụp snapshot một PVC tại một thời điểm và khôi phục PVC mới từ chúng — nền tảng cho backup và workflow clone-để-test. Nó dùng ba object: một VolumeSnapshotClass (giống StorageClass, cho snapshot), một VolumeSnapshot (yêu cầu snapshot một PVC), và một VolumeSnapshotContent (resource snapshot thật).

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata: { name: pg-snap-2026-07-19, namespace: prod }
spec:
  volumeSnapshotClassName: csi-snapclass
  source:
    persistentVolumeClaimName: data-postgres-0
---
# Khôi phục: một PVC mới có dataSource là snapshot
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: data-postgres-restore, namespace: prod }
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: fast
  resources: { requests: { storage: 50Gi } }
  dataSource:
    name: pg-snap-2026-07-19
    kind: VolumeSnapshot
    apiGroup: snapshot.storage.k8s.io

Snapshot là crash-consistent, không phải application-consistent — với database, hãy quiesce/flush trước khi snapshot (hoặc dùng công cụ backup của chính DB). Chúng là một khối xây dựng, không phải giải pháp backup hoàn chỉnh; các công cụ như Velero dàn dựng snapshot cộng với storage ngoài cluster.

Mở rộng volume

Hết dung lượng không còn nghĩa là phải tạo lại volume. Nếu StorageClass đặt allowVolumeExpansion: true và CSI driver hỗ trợ, bạn grow một PVC bằng cách sửa spec.resources.requests.storage lên giá trị lớn hơn. CSI driver mở rộng volume backing và (với filesystem mode) mở rộng filesystem, thường online không downtime. Bạn chỉ grow được, không bao giờ shrink. Nếu expansion cần restart Pod, PVC hiện điều kiện FileSystemResizePending.

kubectl patch pvc app-data -n prod -p '{"spec":{"resources":{"requests":{"storage":"40Gi"}}}}'
kubectl get pvc app-data -n prod -w    # theo dõi capacity cập nhật

Best Practices

  1. Dùng PVC cho bất cứ gì phải sống sót qua một Pod — không bao giờ hostPath hay filesystem container. hostPath ghim dữ liệu vào một node và là lỗ hổng bảo mật; lớp container bị hủy lúc restart. Dữ liệu bền thuộc về PersistentVolume.
  2. Đặt reclaimPolicy: Retain (hoặc snapshot đều đặn) cho dữ liệu có giá trị. Mặc định cấp-phát-động Delete hủy volume backing khi PVC bị xóa; một kubectl delete pvc vô ý có thể xóa sạch database. Retain giữ dữ liệu cho khôi phục thủ công.
  3. Khớp access mode với backend và topology. RWO block storage chỉ attach vào một node — read-write đa node đòi RWX trên backend file (EFS/NFS/CephFS). Yêu cầu RWX từ một class chỉ-block sẽ âm thầm mắc kẹt Pod.
  4. Ưu tiên binding WaitForFirstConsumer cho block storage theo zone. Binding Immediate có thể cấp phát volume trong một zone Pod không tới được; hoãn tới khi scheduling đảm bảo volume nằm nơi Pod nằm.
  5. Dùng StatefulSet với volumeClaimTemplates cho app stateful. Mỗi replica có PVC ổn định riêng đi theo nó qua các lần reschedule — primitive đúng cho database và kho cluster, khác với việc chia sẻ một PVC cho cả Deployment.
  6. Đặt allowVolumeExpansion: true trên StorageClass production. Nó cho phép grow volume online không phải tạo lại; retrofit khả năng mở rộng vào một class đã tồn tại sau khi hết chỗ đau đớn hơn nhiều.
  7. Giữ secret khỏi volume thường và bật encryption at rest. Mount credential qua Secret volume (hoặc một external secrets operator), và bật encryption cho cả Secret trong etcd lẫn backend storage (CSI encryption / cloud KMS).
  8. Snapshot trước thao tác rủi ro và test khôi phục. Chụp VolumeSnapshot trước upgrade/migration; một backup bạn chưa từng khôi phục không phải là backup. Tự động hóa bằng Velero và diễn tập recovery.
  9. Right-size request và đặt ResourceQuota cho storage. Over-request lãng phí tiền vào cloud disk; dùng quota requests.storage mỗi namespace để giới hạn tiêu thụ và ngăn cấp phát mất kiểm soát.
  10. Chọn StorageClass có ý thức; định nghĩa một mặc định rõ ràng. Các class khác nhau (SSD vs HDD, replicated vs single, tầng IOPS) có chi phí/hiệu năng rất khác. Đừng dựa vào bất kỳ mặc định nào tình cờ tồn tại — đặt một cái có chủ đích và chọn theo từng workload.
  11. Hiểu vòng đời volume vs dữ liệu của StatefulSet. Xóa một StatefulSet để lại PVC theo thiết kế; PVC khi scale-down cũng được giữ. Dọn chúng có chủ đích, và đừng bao giờ giả định kubectl delete statefulset giải phóng storage.
  12. Đảm bảo CSI driver được cài, khỏe, và khớp version. PVC Pending và attach kẹt thường truy về một CSI controller hoặc node driver thiếu/hỏng; giám sát nó như bất kỳ thành phần hệ thống tới hạn nào và giữ tương thích với version Kubernetes.
  13. Dùng emptyDir (với giới hạn kích thước) cho scratch, không cho nhu cầu bền. Nó nhanh và đơn giản cho cache và chia sẻ liên-container, nhưng chết theo Pod; đặt sizeLimit để một tiến trình mất kiểm soát không lấp đầy disk của node.
  14. Giám sát capacity volume và đặt alert. Một PVC đầy có thể crash hoặc corrupt database mà không tự khắc phục. Theo dõi mức dùng PV (qua metric kubelet_volume_stats_*) và cảnh báo trước khi volume đầy.
  15. Với database, ưu tiên local NVMe hoặc class IOPS cao và backup application-consistent. Network block storage thêm độ trễ; chọn tầng IOPS/throughput phù hợp, và dựa vào backup nhất quán của chính database thay vì chỉ snapshot crash-consistent thô.

Tài liệu tham khảo

Part of the Kubernetes Roadmap knowledge base.

Overview

Containers are ephemeral by design: when a Pod dies, its writable container layer dies with it. That is fine for stateless services, but real systems need to keep data — databases, message queues, uploaded files, caches, build artifacts. Kubernetes storage is the set of abstractions that let Pods consume storage that outlives them, that can be attached to whichever node a Pod lands on, and that is provisioned on demand from cloud disks, network filesystems, or local media. The system is designed so application authors ask for storage by requirement (“I need 20Gi of fast, read-write-once storage”) without knowing which backend fulfills it.

The heart of the design is a claim-and-provision separation of concerns. A workload declares what it needs with a PersistentVolumeClaim (PVC); a StorageClass describes how to make it, and a CSI driver does the actual provisioning of a PersistentVolume (PV) — a cloud disk, an NFS export, whatever. This decoupling is what lets the same manifest run unchanged on AWS, GKE, on-prem, or a laptop: the workload asks for a class of storage, and the cluster’s configured drivers satisfy it. Understanding the PVC → StorageClass → PV → CSI chain, plus access modes and reclaim policies, is what separates “my data survived a node failure” from “my database is gone.”

A mental model

Storage in Kubernetes is a request-fulfillment marketplace. The PVC is a purchase order (“20Gi, ReadWriteOnce, class=fast”) written by the app team. The StorageClass is a catalog entry (“fast” = SSD provisioned by the AWS EBS CSI driver, with these parameters). The PV is the delivered goods — an actual volume bound to exactly one PVC. The CSI driver is the supplier that manufactures and attaches it. The app never names a specific disk; it names a requirement, and the cluster fulfills it. This indirection is the whole point: portable, self-service, dynamically provisioned storage. Static provisioning (an admin pre-creating PVs by hand) still exists, but dynamic provisioning through StorageClasses is the norm.

Fundamentals

Ephemeral vs persistent storage

The first decision is lifetime. Kubernetes offers several tiers, from most to least ephemeral:

StorageLifetimeSurvivesTypical use
Container filesystemContainer restart destroys itNothingScratch inside one container
emptyDir volumeTied to the PodContainer restart (not Pod deletion)Shared scratch between a Pod’s containers, cache
configMap / secret volumeTied to the PodInjected config/secret filesRead-only config and credentials
PersistentVolume (via PVC)Independent of any PodPod deletion, node failure, reschedulingDatabases, user data — anything that must persist

The rule of thumb: if losing the data when the Pod is deleted is acceptable, use an ephemeral volume; if not, you need a PersistentVolume. Do not attempt to persist real data with hostPath (it ties data to one node and is a security risk) — use a PVC.

Volumes: the in-Pod abstraction

A Volume is a directory, backed by some medium, mounted into one or more of a Pod’s containers at a chosen mountPath. Volumes are declared in the Pod spec.volumes and mounted via containers[].volumeMounts. Common volume types:

apiVersion: v1
kind: Pod
metadata: { name: demo }
spec:
  containers:
    - name: app
      image: nginx
      volumeMounts:
        - { name: cache, mountPath: /var/cache }      # ephemeral scratch
        - { name: config, mountPath: /etc/app, readOnly: true }
        - { name: data, mountPath: /var/lib/app }      # durable
  volumes:
    - name: cache
      emptyDir: {}
    - name: config
      configMap: { name: app-config }
    - name: data
      persistentVolumeClaim: { claimName: app-data }   # → PV → real disk

The PV / PVC / StorageClass triangle

Three objects cooperate to deliver durable storage:

The binding lifecycle: a PVC is created → the control plane finds or (dynamically) provisions a matching PV → they bind one-to-one → a Pod mounting the PVC gets the PV attached to its node. A bound PVC/PV pair is exclusive; the PV cannot be bound to another claim.

From a PVC to a real disk
  1. 01PVC "app-data"namespace: prod, request: 20Gi, RWO, class: fast
  2. matches
    02StorageClass "fast"provisioner: ebs.csi.aws.com, type: gp3
  3. dynamically provisions
    03PV "pvc-8f3c…"20Gi, RWO, bound to app-data
  4. backed by
    04AWS EBS volume vol-0abc…real disk, attached to the Pod's node

Static vs dynamic provisioning

Key Concepts

Access modes

Access modes describe how many nodes can mount a volume and in what mode. They are a property of both the PV and the PVC request, and are enforced at attach time:

ModeAbbrevMeaning
ReadWriteOnceRWOMounted read-write by a single node (multiple Pods on that node can share it)
ReadOnlyManyROXMounted read-only by many nodes
ReadWriteManyRWXMounted read-write by many nodes simultaneously
ReadWriteOncePoolRWOPRead-write by a single Pod (stricter than RWO; requires CSI support)

The crucial nuance: RWO is per-node, not per-Pod. Most block storage (AWS EBS, GCE PD, Azure Disk) is RWO — it can attach to only one node at a time, so a Deployment with 2 replicas landing on different nodes will leave one Pod stuck ContainerCreating waiting for a volume it can’t attach. For genuine multi-node shared read-write you need RWX, which requires a file/network backend (NFS, CephFS, AWS EFS, Azure Files, GlusterFS) — block volumes cannot do RWX. Choosing a workload topology without matching the access mode to the backend is the single most common storage mistake.

Volume modes: Filesystem vs Block

A PV’s volumeMode is either Filesystem (default) — the volume is formatted with a filesystem and mounted as a directory — or Block — the raw block device is exposed to the container at a device path (/dev/xvda) with no filesystem. Raw block is for databases and storage software that manage their own on-disk format and want to skip the filesystem layer for performance.

Reclaim policies

When a PVC is deleted, the PV’s persistentVolumeReclaimPolicy decides the fate of the underlying data:

The practical guidance: production StorageClasses for stateful data should use reclaimPolicy: Retain (or take snapshots), so an accidental kubectl delete pvc doesn’t wipe your database. The default Delete is a footgun for real data.

Worked example: StorageClass + PVC

# StorageClass: SSD on AWS, safe reclaim, expandable, bind late
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: { name: fast }
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  iops: "5000"
  fsType: ext4
reclaimPolicy: Retain               # keep data if the PVC is deleted
allowVolumeExpansion: true          # PVCs of this class can be grown
volumeBindingMode: WaitForFirstConsumer   # provision in the Pod's zone/node
---
# PVC: request 20Gi, RWO, from the "fast" class
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: app-data, namespace: prod }
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: fast
  resources:
    requests: { storage: 20Gi }

volumeBindingMode: WaitForFirstConsumer is important and underused: with the default Immediate, a PV is provisioned as soon as the PVC is created — possibly in a zone where the Pod can never be scheduled (a classic cross-AZ failure on cloud block storage). WaitForFirstConsumer delays provisioning until a Pod actually consumes the PVC, so the volume is created in the right zone/node. Use it for zonal block storage.

The Container Storage Interface (CSI)

CSI is the standard plugin interface between Kubernetes and storage systems. Historically, storage drivers (“in-tree volume plugins”) were compiled into Kubernetes itself; every new backend meant changing core code. CSI extracted all of that into out-of-tree drivers — independent components (typically a controller Deployment + a node DaemonSet) that implement provisioning, attach/detach, mount/unmount, snapshots, and expansion. Kubernetes has removed the old in-tree plugins in favor of CSI.

Practically: to use AWS EBS you install the aws-ebs-csi-driver; for GCE PD the pd.csi.storage.gke.io driver; for on-prem, ceph-csi, nfs-csi, democratic-csi, etc. The CSI driver registers itself, the StorageClass’s provisioner names it, and it also unlocks advanced features — volume snapshots, online volume expansion, topology awareness, and cloning — that the driver implements. When “PVCs are stuck Pending,” a missing or unhealthy CSI driver is a prime suspect.

StatefulSets and volumeClaimTemplates

A Deployment shares one PVC across all replicas (or, more often, can’t — because RWO block storage won’t attach to multiple nodes). Stateful workloads need one dedicated, stable volume per replica. That is exactly what a StatefulSet’s volumeClaimTemplates provides: for each Pod, the StatefulSet controller creates a separate PVC (named <template>-<statefulset>-<ordinal>, e.g., data-postgres-0), and that PVC follows the Pod across rescheduling.

apiVersion: apps/v1
kind: StatefulSet
metadata: { name: postgres }
spec:
  serviceName: postgres            # headless Service for stable Pod DNS
  replicas: 3
  selector: { matchLabels: { app: postgres } }
  template:
    metadata: { labels: { app: postgres } }
    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports: [{ containerPort: 5432 }]
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
  volumeClaimTemplates:            # one PVC PER replica, created automatically
    - metadata: { name: data }
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: fast
        resources: { requests: { storage: 50Gi } }

This gives each replica (postgres-0, postgres-1, postgres-2) its own 50Gi PVC that persists across restarts and reschedules, and — paired with a headless Service — a stable DNS name. Note a deliberate safety feature: deleting a StatefulSet does not delete its PVCs, so scaling down or removing the set won’t destroy data unless you delete the PVCs explicitly.

Volume snapshots

CSI drivers that support it let you take point-in-time snapshots of a PVC and restore new PVCs from them — the basis of backups and clone-for-test workflows. It uses three objects: a VolumeSnapshotClass (like a StorageClass, for snapshots), a VolumeSnapshot (the request to snapshot a PVC), and a VolumeSnapshotContent (the actual snapshot resource).

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata: { name: pg-snap-2026-07-19, namespace: prod }
spec:
  volumeSnapshotClassName: csi-snapclass
  source:
    persistentVolumeClaimName: data-postgres-0
---
# Restore: a new PVC whose dataSource is the snapshot
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: data-postgres-restore, namespace: prod }
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: fast
  resources: { requests: { storage: 50Gi } }
  dataSource:
    name: pg-snap-2026-07-19
    kind: VolumeSnapshot
    apiGroup: snapshot.storage.k8s.io

Snapshots are crash-consistent, not application-consistent — for databases, quiesce/flush before snapshotting (or use the DB’s own backup tooling). They are a building block, not a complete backup solution; tools like Velero orchestrate snapshotting plus off-cluster storage.

Expanding volumes

Running out of space no longer means recreating the volume. If the StorageClass sets allowVolumeExpansion: true and the CSI driver supports it, you grow a PVC by editing its spec.resources.requests.storage to a larger value. The CSI driver expands the backing volume and (for filesystem mode) the filesystem, often online with no downtime. You can only grow, never shrink. If expansion needs a Pod restart the PVC shows a FileSystemResizePending condition.

kubectl patch pvc app-data -n prod -p '{"spec":{"resources":{"requests":{"storage":"40Gi"}}}}'
kubectl get pvc app-data -n prod -w    # watch capacity update

Best Practices

  1. Use PVCs for anything that must survive a Pod — never hostPath or the container filesystem. hostPath pins data to one node and is a security hole; the container layer is destroyed on restart. Durable data belongs on a PersistentVolume.
  2. Set reclaimPolicy: Retain (or snapshot regularly) for valuable data. The dynamic-provisioning default Delete destroys the backing volume when the PVC is deleted; one accidental kubectl delete pvc can wipe a database. Retain keeps the data for manual recovery.
  3. Match access mode to backend and topology. RWO block storage attaches to one node only — multi-node read-write requires RWX on a file backend (EFS/NFS/CephFS). Requesting RWX from a block-only class silently strands Pods.
  4. Prefer WaitForFirstConsumer binding for zonal block storage. Immediate binding can provision a volume in a zone the Pod can’t reach; delaying until scheduling ensures the volume lands where the Pod does.
  5. Use StatefulSets with volumeClaimTemplates for stateful apps. Each replica gets its own stable PVC that follows it across reschedules — the correct primitive for databases and clustered stores, unlike sharing one PVC across a Deployment.
  6. Set allowVolumeExpansion: true on production StorageClasses. It lets you grow volumes online without recreation; retrofitting expandability onto an existing class after you’re out of space is far more painful.
  7. Keep secrets out of regular volumes and enable encryption at rest. Mount credentials via Secret volumes (or an external secrets operator), and turn on encryption for both etcd Secrets and the storage backend (CSI encryption / cloud KMS).
  8. Snapshot before risky operations and test restores. Take VolumeSnapshots before upgrades/migrations; a backup you have never restored is not a backup. Automate with Velero and rehearse recovery.
  9. Right-size requests and set ResourceQuotas on storage. Over-requesting wastes money on cloud disks; use requests.storage quotas per namespace to cap consumption and prevent runaway provisioning.
  10. Choose the StorageClass consciously; define an explicit default. Different classes (SSD vs HDD, replicated vs single, IOPS tiers) have very different cost/performance. Don’t rely on whichever default happens to exist — set one intentionally and pick per workload.
  11. Understand volume vs data lifecycle for StatefulSets. Deleting a StatefulSet leaves its PVCs behind by design; scale-down PVCs are also retained. Clean them up deliberately, and never assume kubectl delete statefulset frees storage.
  12. Ensure the CSI driver is installed, healthy, and version-matched. Pending PVCs and stuck attaches usually trace to a missing/broken CSI controller or node driver; monitor it like any critical system component and keep it compatible with your Kubernetes version.
  13. Use emptyDir (with size limits) for scratch, not persistent needs. It’s fast and simple for caches and inter-container sharing, but it dies with the Pod; set sizeLimit so a runaway process can’t fill the node’s disk.
  14. Monitor volume capacity and set alerts. A full PVC can crash or corrupt a database with no automatic remediation. Track PV usage (via kubelet_volume_stats_* metrics) and alert before volumes fill.
  15. For databases, prefer local NVMe or high-IOPS classes and app-consistent backups. Network block storage adds latency; pick an IOPS/throughput tier that fits, and rely on the database’s own consistent backup rather than raw crash-consistent snapshots alone.

References