Kubernetes Unbound PVC: Fix Pending Pods
Describe the PVC to read its Events, align storageClass, size, and accessModes, then fix provisioning.
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
- ✓A Kubernetes cluster you can inspect (minikube, kind, or a dev namespace)
- ✓kubectl installed and pointing at that cluster
- ✓Basic comfort reading a Deployment manifest and pod events
- Unbound means the claim found no volume: the pod stays Pending because the scheduler won't place a pod whose storage doesn't exist yet
- Read the reason directly: kubectl describe pvc
prints Events like storageclass not found, no matching volumes, or provisioner errors - Match all three binding inputs: storageClassName must exist, requested size must fit a volume, and accessModes (RWO/RWX) must be supported
- If binding should be automatic, check the StorageClass provisioner and its controller logs — typos and missing drivers block dynamic provisioning
Imagine a hotel request slip: non-smoking double, third floor. The desk matches your slip to a free room — or leaves you waiting in the lobby if none fits. A PVC is your slip, a PersistentVolume is the room, and the provisioner is the clerk. Unbound means you're still in the lobby: no room matches, the type was misspelled, or the clerk never showed. Your pod waits with you — Pending — until the paperwork resolves.
Your Deployment is applied, the pods exist, and every one sits in Pending with "0/3 nodes are available: 3 pod has unbound immediate PersistentVolumeClaims." No container started, no logs exist, and kubectl logs returns nothing because there's nothing to log. Storage failures are uniquely frustrating: the workload is correct, the images are fine, and the cluster refuses to schedule over paperwork — a claim with no matching volume.
The error compresses several distinct causes into one sentence. The StorageClass may not exist (a typo, or a class from another cloud's tutorial). The claim may ask for more than any volume offers, or an access mode the driver can't provide. Binding may be delayed by design (WaitForFirstConsumer) while you expected instant. Or dynamic provisioning is broken — the provisioner pod is missing, misconfigured, or erroring — so no volume ever gets created.
This guide works the chain in order: read the PVC events, verify class and static volumes, reconcile size and access modes, understand the binding mode, and debug the provisioner. You'll get the kubectl commands that reveal each link and manifests that bind correctly.
Start at the Claim: Events Name the Cause
kubectl describe pod tells you the pod is waiting; kubectl describe pvc tells you why. The claim's Events section is the single richest diagnostic in storage debugging: the controller writes exactly what blocked binding — a StorageClass it can't find, a provisioner call that failed with the cloud error attached, or a static match that found no candidates. Teams that start at the pod burn hours on node and image theories; teams that start at the claim read the answer in thirty seconds. Make describe pvc your reflex for every Pending pod with a volume.
Pair it with the wide view: kubectl get pvc shows phase (Pending vs Bound), age (how long it's waited), and volume (empty until bound). A claim Pending for 3 hours with FailedProvisioning events repeating is a provisioner problem; Pending with no events at all suggests the class doesn't exist (no controller watches it) or the binding mode is waiting on scheduling. kubectl get events --sort-by=.lastTimestamp -n <ns> | grep -i -E 'pvc|provision|volume' gives the namespace timeline when multiple claims fail together.
Practice reading the three canonical messages until they're instant pattern matches. storageclass.storage.k8s.io X not found means fix the name. no persistent volumes available means static binding found nothing (check the PV table). ProvisioningFailed with a cloud error means the driver called the API and lost — read the attached message, not the prefix. Each message maps to exactly one section below.
StorageClass Names: Spelling Is the API
The storageClassName field is a strict string match against a cluster-scoped object — one wrong character and no provisioner engages, with no fuzzy matching and no helpful suggestion. Cross-environment copies are the top source: fast-ssd-prod in a cluster that defines fast-ssd, gp2 from an AWS tutorial applied to GKE, or a Helm values file overriding the class per environment with a stale entry. kubectl get sc lists the actual vocabulary; the claim must use exactly one of those words or nothing happens.
Mind the empty-string edge. A claim with no storageClassName requests the cluster's default class (the one annotated storageclass.kubernetes.io/is-default-class). If no default exists, an empty class means static-only binding — dynamic provisioning silently never starts. Conversely, an explicit class on the claim and a different (or empty) class on a static PV never bind: both sides must name the same class or both omit it. Check both objects, not just the claim.
Fixes are small and permanent. Correct the name in the manifest or values file, set a default class per cluster so bare claims provision, and standardize class names across environments so overlays can't diverge. The pipeline gate from the incident — every rendered storageClassName must exist in the target cluster — turns this entire section into a CI failure instead of a 3 AM page. Spelling is the API; lint it like one.
Static Binding: Capacity, Modes, and the PV Table
Without dynamic provisioning, binding is a three-way match between claim and a pre-created PersistentVolume: the volume's capacity must be greater than or equal to the request (a 5Gi volume never satisfies a 10Gi claim, and Kubernetes won't partially fill), the claim's accessModes must be a subset of the volume's (claiming ReadWriteMany against RWO-only volumes fails), and storageClassName must agree on both sides. One mismatch and the claim waits forever — silently, since static matching emits few events. Read the PV table, not your assumptions.
Reclaim policy and phase add two more traps. A volume in Released phase (previously bound, claim deleted without wiping the volume) never rebinds automatically — it needs manual reclaim (retain-and-clean) or deletion and recreation. And accessModes semantics are driver-real: ReadWriteOnce means one node, so a claim needing multi-node writers genuinely needs RWX-capable storage (NFS, EFS, CephFS), not a bigger EBS volume. Asking EBS for RWX is asking the impossible, however the manifest phrases it.
The one-table diagnosis: kubectl get pv with custom columns for class, capacity, modes, and phase shows every candidate and its disqualifier at a glance. Fix the actual column — resize the request, align the modes with what the driver supports, match the class names, or recycle Released volumes. Static binding rewards table reading; every other approach is guessing.
WaitForFirstConsumer vs Immediate: Timing, Not Failure
VolumeBindingMode splits provisioning into two philosophies. Immediate binds at claim creation — the volume exists before any pod, which is simple but zone-blind: the provisioner may carve storage in zone A while the pod later schedules in zone B, stranding both. WaitForFirstConsumer waits for a pod to need the claim, then provisions near the chosen node — correct topology, but the claim reads Unbound and the pod waits during the delay. Newcomers misread that wait as breakage and start deleting things that were working.
Know which mode your class uses before debugging: kubectl get sc <class> -o jsonpath for volumeBindingMode. Immediate plus unbound means a real failure (provisioner error or no candidates) — dig in. WaitForFirstConsumer plus unbound means scheduling is mid-flight — check the pod's scheduler events for the actual constraint (node affinity, taints, zone limits) rather than the storage. The storage resolves itself once scheduling picks a home.
Choose deliberately per workload. Stateful sets with node affinity and multi-zone clusters want WaitForFirstConsumer (it's the default in most provisioners' recommended classes). Single-zone dev clusters and pre-provisioned static volumes are fine with Immediate. Document the mode per class in your cluster README so the next on-call doesn't debug a normal delay — and set the Pending alert threshold past the normal bind window so it pages for stuck, not for slow.
Dynamic Provisioning: When the Clerk Never Shows
Dynamic provisioning needs a running provisioner: the CSI external-provisioner sidecar watching for claims in its class and calling the cloud API to carve volumes. If the driver isn't installed, the sidecar is crash-looping, IAM denies the CreateVolume call, or the cloud quota is exhausted, claims wait with ProvisioningFailed events — or no events if the class points at a provisioner name nothing serves. Reinstalling random drivers (the Wednesday response) fixes nothing when the installed one lacks permissions.
Debug the chain outside-in. The class's provisioner field names the expected driver; the driver namespace should show running controller pods; their logs carry the cloud-side verdict — AccessDenied, quota exceeded, invalid parameter — verbatim. IAM and quota dominate in cloud: a fresh cluster with an old node role, or a region that hit its volume cap, fails every claim identically. Cross-check by provisioning manually (a test claim) while tailing controller logs: the error appears within seconds.
Harden the path once it works. Pin CSI driver versions in cluster bootstrapping (not latest-at-install), grant least-privilege storage IAM with a documented policy, alert on provisioner pod restarts, and keep a test-claim job that binds and deletes weekly — a synthetic probe proving the whole chain end to end. Provisioning is a clerk, a phone line (API), and permission to order rooms; monitor all three and the lobby stays empty.
Bind It and Keep It Bound
Resolution order never changes: fix the named cause (class spelling, size/modes, provisioner health), then watch binding complete on its own — no pod restarts needed, since the scheduler notices the bound claim and places the waiting pod within seconds. Verify with kubectl get pvc showing Bound plus a volume name, then kubectl get pods showing the workload leaving Pending. If binding completes but pods still wait, the remaining constraint is elsewhere (node resources, affinity) — storage did its job.
The deployment manifest below shows the correct end state for the common dynamic case: claim and pod agreeing on name, namespace, and modes, against a class that exists. Note the claim lives in the same namespace as the pod — cross-namespace claims don't exist, and a typo'd namespace orphans the reference silently. Apply, watch Bound, watch Running: that's the whole ceremony when inputs are right.
Lock it with four guards. The CI class-existence gate from the incident. Standardized class names across environments. A Pending-pods alert (10 minutes, with describe output) so waits page before they become outages. And a weekly test-claim probe proving provisioning end to end. Storage binding is paperwork with a clerk — verify the names, keep the clerk healthy, and pods stop waiting in the lobby.
A StorageClass Typo Left 14 Pods Pending for 3 Hours
- Read PVC events, not pod events. Pods say unbound; claims say why — the missing class name was printed the whole time, one describe away.
- Copied manifests aren't identical manifests. Overlays and values files mutate inputs silently; diff rendered output against the target cluster, not against the source environment.
- Gate deploys on referenced storage names. A 5-second StorageClass existence check in the pipeline beats 3 hours of driver reinstalls.
| File | Command / Code | Purpose |
|---|---|---|
| storageclass-name-check.sh | kubectl get sc -o custom-columns=NAME:.metadata.name,PROVISIONER:.provisioner,MO... | StorageClass Names |
| static-pv-claim-match.yaml | apiVersion: v1 | Static Binding |
| binding-mode-check.sh | kubectl get sc fast-ssd -o jsonpath='mode={.volumeBindingMode} provisioner={.pro... | WaitForFirstConsumer vs Immediate |
| provisioner-chain-debug.sh | kubectl get sc fast-ssd -o jsonpath='{.provisioner}{"\n"}' | Dynamic Provisioning |
| bound-claim-and-pod.yaml | apiVersion: v1 | Bind It and Keep It Bound |
Key takeaways
Common mistakes to avoid
6 patternsReading pod events instead of PVC events
Deleting and recreating the pod to fix storage
Assuming copied manifests match the new cluster
Treating WaitForFirstConsumer delay as failure
Reinstalling the CSI driver for an IAM problem
Claiming RWX from storage that can't do it
Interview Questions on This Topic
A pod is Pending with unbound claims. What's your first command?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
That's Kubernetes. Mark it forged?
6 min read · try the examples if you haven't