Kubernetes Storage
PV / PVC / StorageClass — The Three-Layer Model
graph TD
classDef sc fill:#e67e22,stroke:#d35400,color:#fff
classDef pv fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef pvc fill:#3498db,stroke:#2980b9,color:#fff
classDef pod fill:#2ecc71,stroke:#27ae60,color:#fff
classDef disk fill:#34495e,stroke:#2c3e50,color:#fff
SC["StorageClass: gp3-encrypted provisioner: ebs.csi.aws.com parameters: type=gp3, encrypted=true reclaimPolicy: Delete volumeBindingMode: WaitForFirstConsumer"]:::sc
PV["PersistentVolume: pv-ebs-abc123 50Gi, ReadWriteOnce status: Bound claimRef: postgres/postgres-data-pvc"]:::pv
PVC["PersistentVolumeClaim: postgres-data-pvc requests: 50Gi, ReadWriteOnce storageClassName: gp3-encrypted status: Bound --> pv-ebs-abc123"]:::pvc
POD["Pod: postgres-0 volumeMounts: - name: data mountPath: /var/lib/postgresql/data"]:::pod
DISK["EBS Volume: vol-0abc123 us-east-1b 50 GiB gp3"]:::disk
SC -->|"dynamic provisioning: creates PV + EBS volume automatically"| PV
PV -->|"bound"| PVC
PVC --> POD
PV --> DISK
The three layers:
- StorageClass — describes the type of storage (provisioner, disk type, encryption, reclaim policy). Created once by an admin, used by many PVCs.
- PersistentVolume (PV) — represents an actual piece of storage. Can be pre-provisioned by admin or dynamically created by the CSI driver when a PVC is created.
- PersistentVolumeClaim (PVC) — a pod's request for storage. Declares size and access mode. Kubernetes binds it to a matching PV.
Dynamic Provisioning Flow
sequenceDiagram
participant USER as Developer
participant API as API Server
participant CTRL as PV Controller
participant CSI as EBS CSI Driver
participant AWS as AWS EBS API
USER->>API: Create PVC (50Gi, gp3-encrypted)
API->>CTRL: Watch: new PVC with storageClass=gp3-encrypted
CTRL->>CSI: CreateVolume(50Gi, gp3, encrypted, us-east-1b)
CSI->>AWS: ec2:CreateVolume
AWS-->>CSI: vol-0abc123 created
CSI-->>CTRL: Volume ready
CTRL->>API: Create PV bound to this PVC
API-->>USER: PVC status: Bound
Note over USER: Pod using the PVC is scheduled
CTRL->>CSI: ControllerPublishVolume (attach to node i-xyz)
CSI->>AWS: ec2:AttachVolume(vol-0abc123, i-xyz)
AWS-->>CSI: Attached at /dev/xvdba
CSI->>CSI: NodeStageVolume (format + mount to staging path)
CSI->>CSI: NodePublishVolume (bind-mount into pod path)
volumeBindingMode: WaitForFirstConsumer — PV/EBS not provisioned until a pod using the PVC is scheduled. Prevents EBS volumes in the wrong AZ. Always use this for EBS.
Access Modes
graph LR
classDef rwo fill:#e74c3c,stroke:#c0392b,color:#fff
classDef rwx fill:#2ecc71,stroke:#27ae60,color:#fff
classDef rox fill:#3498db,stroke:#2980b9,color:#fff
classDef rwop fill:#9b59b6,stroke:#8e44ad,color:#fff
RWO["ReadWriteOnce (RWO) One node can read+write EBS, local disk Most common for databases"]:::rwo
ROX["ReadOnlyMany (ROX) Many nodes can read EFS, NFS with read-only data Config files, assets"]:::rox
RWX["ReadWriteMany (RWX) Many nodes can read+write EFS, NFS, CephFS Shared workspace, logs"]:::rwx
RWOP["ReadWriteOncePod (RWOP) One POD can read+write (K8s 1.22+) Stronger than RWO Guarantees single-writer"]:::rwop
| Access Mode | Storage backends | Use case |
|---|---|---|
ReadWriteOnce |
EBS, local disk | Single-pod databases (Postgres, MySQL) |
ReadOnlyMany |
EFS, NFS, S3 (via CSI) | Shared config, ML model serving |
ReadWriteMany |
EFS, NFS, CephFS, Portworx | Shared workspaces, legacy apps |
ReadWriteOncePod |
EBS, CSI drivers | Strict single-writer guarantee |
Reclaim Policies
What happens to the PV (and underlying disk) when the PVC is deleted:
graph TD
classDef delete fill:#e74c3c,stroke:#c0392b,color:#fff
classDef retain fill:#2ecc71,stroke:#27ae60,color:#fff
classDef recycle fill:#f39c12,stroke:#d68910,color:#000
PVC_DEL["PVC deleted"] --> RP{ReclaimPolicy}
RP -->|"Delete (default for dynamic)"| DEL["PV deleted EBS volume deleted Data gone permanently"]:::delete
RP -->|"Retain"| RET["PV status: Released EBS volume kept Admin must manually reclaim or delete Data preserved"]:::retain
RP -->|"Recycle (deprecated)"| REC["PV scrubbed (rm -rf /) Made Available for new PVC"]:::recycle
Production rule: Use Retain for databases in production. Use Delete for ephemeral/dev workloads. Never lose data accidentally.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3-retain
provisioner: ebs.csi.aws.com
parameters:
type: gp3
encrypted: "true"
reclaimPolicy: Retain # keep EBS volume after PVC deletion
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true # allow resize without recreating
CSI (Container Storage Interface)
CSI is the standard interface between Kubernetes and storage vendors. Any storage system (EBS, EFS, Ceph, NetApp, etc.) can implement the CSI spec to work with Kubernetes.
graph TD
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef csi fill:#e67e22,stroke:#d35400,color:#fff
classDef storage fill:#2ecc71,stroke:#27ae60,color:#fff
subgraph K8s["Kubernetes"]
KUBELET["kubelet calls CSI Node Service"]:::k8s
CTRL_MGR["External Provisioner calls CSI Controller Service"]:::k8s
end
subgraph CSIDriver["CSI Driver (e.g. aws-ebs-csi-driver)"]
CTRL_SVC["Controller Service CreateVolume, DeleteVolume AttachVolume, DetachVolume CreateSnapshot"]:::csi
NODE_SVC["Node Service NodeStageVolume (format+mount to staging) NodePublishVolume (bind-mount to pod path) NodeUnpublishVolume"]:::csi
end
subgraph StorageBackend["Storage Backend"]
EBS["AWS EBS"]:::storage
EFS["AWS EFS"]:::storage
S3["S3 (Mountpoint CSI)"]:::storage
end
CTRL_MGR --> CTRL_SVC
KUBELET --> NODE_SVC
CTRL_SVC --> EBS & EFS & S3
NODE_SVC --> EBS & EFS
Why CSI replaced in-tree drivers: Before CSI, storage drivers were compiled into the Kubernetes binary. Updating a storage driver required a Kubernetes upgrade. CSI drivers are out-of-tree — deployed as pods, updated independently.
Required CSI drivers in EKS (as add-ons):
aws-ebs-csi-driver— PVCs backed by EBS (gp3, io2). Required since K8s 1.23 (in-tree deprecated).aws-efs-csi-driver— PVCs backed by EFS (ReadWriteMany across AZs).mountpoint-s3-csi-driver— mount S3 buckets as a filesystem (read-heavy workloads, ML data).
Volume Snapshots
# Create a snapshot of a PVC
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: postgres-snapshot-2026-01-15
spec:
volumeSnapshotClassName: csi-aws-vsc
source:
persistentVolumeClaimName: postgres-data-pvc
---
# Restore from snapshot into a new PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data-restored
spec:
dataSource:
name: postgres-snapshot-2026-01-15
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 50Gi
storageClassName: gp3-retain
Use snapshots for: pre-upgrade database backups, cloning production data to staging, disaster recovery checkpoints.
ConfigMap vs Secret
graph LR
CM["ConfigMap<br/>non-sensitive config<br/>app.properties, nginx.conf<br/>feature flags, URLs<br/>stored in etcd as plaintext"] --> POD
SEC["Secret<br/>sensitive data<br/>passwords, tokens, TLS certs<br/>stored in etcd base64-encoded<br/>(NOT encrypted by default)"] --> POD["Pod"]
POD -->|"mount as volume"| VOL["File in container<br/>/etc/config/app.properties"]
POD -->|"inject as env var"| ENV["ENV DB_PASS=s3cr3t"]
Key differences:
| ConfigMap | Secret | |
|---|---|---|
| Data type | Non-sensitive | Sensitive (passwords, tokens, certs) |
| etcd storage | Plaintext | Base64-encoded (NOT encrypted without extra config) |
| K8s RBAC | get configmaps |
get secrets (separate permission) |
| Mounted as | File or env var | File, env var, or imagePullSecret |
| Max size | 1MB | 1MB |
Base64 ≠ encryption. A Secret's value is base64-encoded in etcd — anyone with kubectl get secret -o yaml can decode it immediately. Real protection requires encryption at rest.
Encryption at Rest
# /etc/kubernetes/encryption-config.yaml (on control plane)
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: ["secrets"]
providers:
- aescbc: # AES-CBC encryption
keys:
- name: key1
secret: <base64-encoded-32-byte-key> # generated: head -c 32 /dev/urandom | base64
- identity: {} # fallback: unencrypted (for existing secrets)
# Enable on kube-apiserver
--encryption-provider-config=/etc/kubernetes/encryption-config.yaml
# Encrypt all existing secrets (re-writes them with the new provider)
kubectl get secrets --all-namespaces -o json | kubectl replace -f -
# Verify a secret is encrypted in etcd
ETCDCTL_API=3 etcdctl get /registry/secrets/default/my-secret | hexdump -C | head
# If encrypted: shows random bytes, not recognizable base64
# If not encrypted: shows "k8s:enc:aescbc:v1:key1:" prefix if encrypted
EKS/GKE managed encryption:
# EKS: enable envelope encryption with KMS
aws eks create-cluster --name my-cluster \
--encryption-config '[{"provider":{"keyArn":"arn:aws:kms:..."},"resources":["secrets"]}]'
# GKE: application-layer encryption (CMEK)
gcloud container clusters create my-cluster \
--database-encryption-key projects/PROJECT/locations/REGION/keyRings/RING/cryptoKeys/KEY
Using Secrets Safely
# Mount as file (preferred for large secrets, certificates)
spec:
volumes:
- name: tls-cert
secret:
secretName: my-tls-secret
containers:
- volumeMounts:
- name: tls-cert
mountPath: /etc/ssl/certs
readOnly: true
# Env var (avoid for multi-line secrets, visible in process list)
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: password
# Never: hardcode in container spec or ConfigMap
# Never: commit Secret YAML with real values to git
# Better: use External Secrets Operator (ESO) → pull from AWS SM/Vault at runtime