Container Storage Interface
The Container Storage Interface (CSI) is the gRPC contract that decouples Kubernetes from any specific storage backend — block, file, object, or anything else a vendor can expose through three small gRPC services. It is the storage-layer sibling of the Container Runtime Interface: same out-of-tree plugin philosophy, same UNIX-domain-socket transport, same “let third parties ship and version their plugins independently of Kubernetes” design goal. CSI was promoted to GA in Kubernetes 1.13 (January 2019) per the Kubernetes 2019-01-15 announcement, having entered alpha in 1.9 and beta in 1.10. The corresponding CSI spec hit v1.0 alongside the K8s GA. Every modern storage driver in the Kubernetes ecosystem — AWS EBS, Google Persistent Disk, Azure Disk/File, Ceph RBD/CephFS, Portworx, Pure Storage, NetApp Trident, Longhorn, OpenEBS, Rook, vSphere CSI — is a CSI driver. The historical in-tree volume plugins (the
kubernetes.io/aws-ebs,kubernetes.io/gce-pd, etc. provisioners) have been migrated to CSI under KEP-625; the core migration framework reached GA in 1.25, and in-tree code is being removed in subsequent releases.
Mental Model
flowchart TB subgraph "Kubernetes control plane" APISVR[kube-apiserver] CTRLMGR[kube-controller-manager<br/>PV/PVC binder] end subgraph "CSI driver Deployment (controller-side, runs on any node)" PROV[external-provisioner<br/>watches PVCs<br/>calls CreateVolume] ATT[external-attacher<br/>watches VolumeAttachments<br/>calls ControllerPublishVolume] RES[external-resizer<br/>watches PVC resize<br/>calls ControllerExpandVolume] SNAP[external-snapshotter<br/>watches VolumeSnapshot<br/>calls CreateSnapshot] DRIVER1[CSI driver container<br/>Identity + Controller services] end subgraph "CSI driver DaemonSet (node-side, runs on every node)" REG[node-driver-registrar<br/>registers driver socket with kubelet] DRIVER2[CSI driver container<br/>Identity + Node services] KUBELET[kubelet<br/>calls NodeStageVolume<br/>and NodePublishVolume] end BACKEND[(Storage backend<br/>AWS EBS / GCE PD / Ceph / NetApp / ...)] APISVR <--> PROV APISVR <--> ATT APISVR <--> RES APISVR <--> SNAP APISVR <--> REG PROV -- "gRPC over UDS" --> DRIVER1 ATT --> DRIVER1 RES --> DRIVER1 SNAP --> DRIVER1 KUBELET -- "gRPC over UDS" --> DRIVER2 REG --> KUBELET DRIVER1 -- "vendor API" --> BACKEND DRIVER2 -- "vendor API + mount syscalls" --> BACKEND
What this diagram shows. A CSI driver in Kubernetes is not a single binary — it is a driver container packaged alongside several K8s-maintained sidecar containers that translate between the K8s API (PVCs, VolumeAttachments, VolumeSnapshots) and the driver’s CSI gRPC calls. The split is deliberate: the K8s community owns the API watchers (so the K8s ↔ gRPC translation logic isn’t re-implemented per vendor); the vendor owns only the actual storage-backend interaction. The driver typically ships as two workloads: a controller-side Deployment (one replica is enough; runs the CreateVolume / CreateSnapshot / Expand calls that don’t need to be on a specific node) and a node-side DaemonSet (one pod per node; runs the mount/unmount calls that must be on the node where the pod will run). The insight to extract: K8s does not itself know how to talk to any specific storage. The K8s control plane only knows the CSI sidecars and the kubelet only knows the node-side driver socket. Every actual attach, mount, or snapshot is a vendor-supplied gRPC handler.
Why CSI Exists
Before CSI, Kubernetes shipped storage support as in-tree volume plugins: one Go package per backend (pkg/volume/aws_ebs, pkg/volume/gce_pd, pkg/volume/azure_disk, pkg/volume/cinder, pkg/volume/vsphere, …), each compiled into the kubelet and kube-controller-manager binaries. The CSI GA announcement lists three concrete pains that drove the rewrite:
- Lifecycle coupling. Every storage driver shipped on the K8s release cadence — vendors couldn’t fix bugs or add features without waiting for a K8s minor release, and K8s couldn’t refactor internal volume interfaces without breaking every driver.
- Maintenance burden. Vendor code lived in the K8s repository; K8s maintainers had to test, build, and ship code they couldn’t fully understand. Many drivers were under-maintained because the original vendor employees had moved on.
- Reliability and security. Bugs (or worse, vulnerabilities) in a third-party storage driver crashed or compromised the kubelet itself. There was no isolation boundary.
CSI was the FlexVolume-then-CSI generation answer: define a versioned gRPC contract (the CSI spec), require every driver to implement that contract, and let the driver run out-of-tree in its own pods. Vendors gained release independence; K8s gained a stable seam; users gained the ability to install a CSI driver as just-another-Helm-chart instead of waiting for the next K8s release. The architectural rhyme with Container Network Interface (CNI, networking) and Container Runtime Interface (CRI, runtimes) is intentional: K8s’s three biggest plug points (network, storage, runtime) all use the same pattern — versioned external contract, vendor-supplied implementation, lifecycle independent of K8s itself.
The Three CSI Services
The CSI spec defines three gRPC services. A driver must implement Identity; it may implement Controller and/or Node depending on its capabilities. Per the spec:
Identity Service
Tiny — three RPCs:
service Identity {
rpc GetPluginInfo(GetPluginInfoRequest) returns (GetPluginInfoResponse) {}
rpc GetPluginCapabilities(GetPluginCapabilitiesRequest) returns (GetPluginCapabilitiesResponse) {}
rpc Probe(ProbeRequest) returns (ProbeResponse) {}
}GetPluginInfo returns the driver name (e.g. ebs.csi.aws.com, pd.csi.storage.gke.io) and version. GetPluginCapabilities reports which capabilities the driver supports — CONTROLLER_SERVICE (does it have a controller service?), VOLUME_ACCESSIBILITY_CONSTRAINTS (does it understand topology?), etc. Probe is a health check.
Controller Service
The volume-lifecycle API — called by the K8s-side sidecars, not by the kubelet:
service Controller {
// Volume lifecycle
rpc CreateVolume(...) returns (...); // called by external-provisioner on PVC create
rpc DeleteVolume(...) returns (...); // called by external-provisioner on PV delete
// Volume attach/detach (only for drivers that support PUBLISH_UNPUBLISH_VOLUME)
rpc ControllerPublishVolume(...) returns (...); // "attach": called by external-attacher
rpc ControllerUnpublishVolume(...) returns (...); // "detach"
// Volume expansion
rpc ControllerExpandVolume(...) returns (...); // called by external-resizer
// Snapshots (only for drivers that support CREATE_DELETE_SNAPSHOT)
rpc CreateSnapshot(...) returns (...);
rpc DeleteSnapshot(...) returns (...);
// Cloning is implicit in CreateVolume with a content source
// Listing
rpc ListVolumes(...) returns (...);
rpc ListSnapshots(...) returns (...);
rpc GetCapacity(...) returns (...);
// Capability reporting
rpc ControllerGetCapabilities(...) returns (...);
}The controller service is centralized: it doesn’t care which node will eventually mount the volume; it talks to the storage backend’s management API (the EBS API, the Ceph monitor, the NetApp ONTAP API). This is why it can run as a Deployment with a single replica — there’s no per-node work here.
Node Service
The per-node operations — called exclusively by the kubelet on the same node:
service Node {
// The two-stage mount sequence
rpc NodeStageVolume(...) returns (...); // mount to a node-global staging path
rpc NodeUnstageVolume(...) returns (...);
rpc NodePublishVolume(...) returns (...); // bind-mount into the Pod's mount namespace
rpc NodeUnpublishVolume(...) returns (...);
// Online expansion (the file-system half)
rpc NodeExpandVolume(...) returns (...);
// Stats
rpc NodeGetVolumeStats(...) returns (...);
// Self-identification
rpc NodeGetCapabilities(...) returns (...);
rpc NodeGetInfo(...) returns (...); // returns the node ID the backend uses
}Stage versus Publish — the canonical CSI distinction
The two-RPC mount sequence is the most-tested-in-CSI-interviews detail. NodeStageVolume is called once per node: it formats the device (if needed) and mounts it to a global staging path (something like /var/lib/kubelet/plugins/kubernetes.io/csi/pv/<volume-id>/globalmount). NodePublishVolume is called once per Pod consuming the volume on that node: it bind-mounts the staging path into the Pod’s specific mount directory (/var/lib/kubelet/pods/<pod-uid>/volumes/kubernetes.io~csi/<name>/mount).
Why the split? ReadWriteMany (RWX) and ReadOnlyMany (ROX) access modes — a single backing volume mounted once per node and then bind-mounted into many Pods on that node. Without the stage step you’d be re-mounting the underlying device once per Pod, which would either fail (for filesystems that don’t tolerate multi-mount) or churn the kernel mount table unnecessarily. The spec also allows drivers to advertise STAGE_UNSTAGE_VOLUME as a capability they don’t support (typical for block-mode volumes that go straight to the Pod); in that case the kubelet skips the stage step.
Spec evolution since 1.0
The CSI spec at v1.0 (January 2019) defined the surface area above and has accrued capabilities since then in a backwards-compatible way. Recent milestones from the container-storage-interface/spec releases: v1.5 (2021) added VolumeExpansion online/offline distinctions; v1.7–v1.9 added ControllerModifyVolume for in-place storage-class parameter changes; v1.10 broadened that and clarified topology semantics; v1.11 (November 2024) moved VolumeGroupSnapshot to GA — the long-awaited consistency-group-snapshot RPCs that take a coordinated, point-in-time snapshot across multiple volumes in one shot. New capabilities are advertised via ControllerGetCapabilities / NodeGetCapabilities; the kubelet and sidecars feature-gate their use, so a driver pinned to an older spec version still interoperates with newer Kubernetes.
The Sidecar Pattern
Per kubernetes-csi.github.io/docs/sidecar-containers.html, the K8s community ships seven sidecar containers that a vendor packages alongside the driver:
| Sidecar | Watches K8s resource | Calls CSI RPC |
|---|---|---|
| external-provisioner | PVCs needing a PV | CreateVolume, DeleteVolume |
| external-attacher | VolumeAttachment objects | ControllerPublishVolume, ControllerUnpublishVolume |
| external-resizer | PVCs with grown spec.resources.requests.storage | ControllerExpandVolume |
| external-snapshotter | VolumeSnapshot / VolumeSnapshotContent CRDs | CreateSnapshot, DeleteSnapshot |
| node-driver-registrar | (none — local) | registers the driver’s UDS path with kubelet via the plugin-registration directory |
| livenessprobe | (none — local) | calls Identity Probe and exposes an HTTP endpoint kubelet liveness-probes |
| external-health-monitor-controller | PVs | ListVolumes (with volume condition) or ControllerGetVolume; surfaces unhealthy volumes as Events on PVCs/Pods |
Each sidecar is maintained by SIG Storage. The vendor’s container only handles the actual backend interaction. This decomposition is what makes CSI drivers writable in a few hundred lines for simple cases (the hostPath driver is the reference example).
The volume-health story is split in two: the external-health-monitor-controller above runs alongside the controller-side driver and reports backend-detected volume problems. The complementary node-side check is no longer a separate sidecar — per the external-health-monitor README, the node-side agent was folded into kubelet itself in Kubernetes 1.21 behind the CSIVolumeHealth feature gate, which periodically calls NodeGetVolumeStats and emits Events when the driver reports volume_condition.abnormal = true. Older write-ups that show a separate external-health-monitor-agent DaemonSet are out of date.
The kubelet discovers a driver via the plugin registration mechanism (GA in 1.13 alongside CSI): the node-driver-registrar sidecar writes a socket and a registration JSON into /var/lib/kubelet/plugins_registry/, kubelet sees the new file, opens the socket, asks the driver “who are you?” via NodeGetInfo, and registers the driver name in its internal table.
CSI Migration: The Death of In-Tree Volumes
The other side of CSI’s success is getting rid of the old in-tree code. KEP-625 defines the migration framework: for each in-tree plugin (aws-ebs, gce-pd, azure-disk, azure-file, cinder, vsphere, …), provide a feature gate (CSIMigration<X>) that, when on, redirects all in-tree volume API calls to the corresponding CSI driver. Users continue to write kubernetes.io/aws-ebs in their StorageClasses; under the hood, the kubelet and controller-manager call the AWS EBS CSI driver. The combined timeline per the Kubernetes 1.25 migration update and the CSI sidecar changelog:
| Driver | Beta-on-by-default | Migration GA | In-tree plugin status |
|---|---|---|---|
| Core CSIMigration framework | 1.17 (beta) | 1.25 | n/a |
| AWS EBS | 1.23 | 1.25 | In-tree code removed (1.27+) |
| GCE PD | 1.23 | 1.25 | In-tree code removed (1.28); gcePersistentDisk volume type marked deprecated in current docs |
| Azure Disk | 1.23 | 1.24 | Deprecated; removal in 1.26+ |
| OpenStack Cinder | 1.21 | 1.24 | Deprecated; removal in 1.26+ |
| Azure File | 1.24 | 1.26 | Removal staged for 1.28+ |
| vSphere | 1.25 (beta) | 1.26 | Removal staged for 1.28+ |
| Portworx | n/a | 1.25 | Deprecated in current volume-types docs |
The user-facing impact is minimal — old YAML still works — but operators must install the corresponding CSI driver (typically a Helm chart) on any cluster they upgrade to a release where migration is on by default, otherwise PVCs will hang in Pending. The managed K8s services (EKS, GKE, AKS) install the corresponding drivers automatically — AWS enabled CSI migration by default in EKS 1.23 (EBS CSI migration FAQ); GKE 1.22+ enables it transparently; AKS ships AzureDisk and AzureFile CSI drivers as managed addons. The migration’s user-visible “all old YAML still works” property is mediated by the kube-controller-manager and kubelet translating the legacy in-tree volume types into CSI calls at runtime; once the in-tree code is fully removed in a release, those legacy kubernetes.io/aws-ebs PVs still resolve correctly only because the translation shim remains until the deprecation policy fully retires it.
Uncertain
Verify: exact Kubernetes minor versions in which the in-tree volume plugin code (not just deprecation) was finally removed from
kubernetes/kubernetes. The 1.25 status blog and changelog give targets through 1.28, but later releases may have shifted dates by one or two minor versions; for any specific plugin and current cluster version, cross-check the correspondingkubernetes.io/blog/.../storage-in-tree-to-csi-migration-status-update-<ver>post for that release.
Configuration / API Surface
A CSI driver is shipped as YAML. The minimum cluster-facing object is the CSIDriver resource (in storage.k8s.io/v1):
apiVersion: storage.k8s.io/v1
kind: CSIDriver
metadata:
name: ebs.csi.aws.com # MUST match what GetPluginInfo returns
spec:
attachRequired: true # does kubelet wait for VolumeAttachment to be Attached?
podInfoOnMount: false # inject Pod metadata into NodePublishVolume's context?
fsGroupPolicy: File # how to apply fsGroup on mount: None / File / ReadWriteOnceWithFSType
volumeLifecycleModes:
- Persistent # PVC-backed (the normal case)
- Ephemeral # CSI inline ephemeral (rare; see [[Generic Ephemeral Volume]])
storageCapacity: true # advertise per-topology capacity so scheduler can pre-filter
requiresRepublish: false # does kubelet need to periodically re-call NodePublishVolume?
seLinuxMount: true # use mount-time SELinux relabeling for performanceA StorageClass referencing this driver (see StorageClass and Dynamic Volume Provisioning):
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3
provisioner: ebs.csi.aws.com # driver name, matches the CSIDriver.metadata.name
parameters: # vendor-specific; opaque to K8s, forwarded to CreateVolume
type: gp3
iops: "3000"
throughput: "125"
encrypted: "true"
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumerThe parameters block is passed verbatim to the driver’s CreateVolume RPC; the driver’s docs are the source of truth for what keys are accepted. (type here is an AWS EBS volume type; for a Ceph CSI driver it would be pool, clusterID, etc.)
Failure Modes
Driver pod not ready, PVCs stuck in Pending. Most common cause of “storage doesn’t work in my new cluster.” kubectl describe pvc shows waiting for a volume to be created, either by external provisioner ... indefinitely. Check: is the driver Deployment and DaemonSet healthy (kubectl -n kube-system get pods -l app=ebs-csi-controller)? Is the external-provisioner sidecar logging anything? Often the driver lacks IAM permissions (it can’t call the cloud API to create the volume).
Sidecar version skew. Each sidecar has a K8s version-compatibility matrix in its README. A too-old external-provisioner against a newer driver, or vice versa, manifests as silently-not-creating-volumes. The sidecar compatibility tables are mandatory reading before pinning versions.
Node-driver-registrar can’t reach kubelet. Driver DaemonSet pods crash-loop with failed to register with kubelet. Cause: /var/lib/kubelet/plugins_registry/ is not mounted into the pod (the DaemonSet manifest forgot the hostPath). Fix: copy the manifest exactly from the driver’s deployment instructions; the hostPath mounts are non-optional.
RPC timeouts on NodeStageVolume. The kubelet uses a fixed timeout for CSI RPCs (default 2 minutes); if NodeStageVolume takes longer (slow mkfs on a huge volume), the kubelet retries and crictl ps shows the same volume being stage-mounted twice. Long-term fix is in the driver (do the format asynchronously); short-term fix is to pre-format the volumes.
Stuck VolumeAttachment. After a node dies, its VolumeAttachment objects can sit in attached: true indefinitely because the attacher can’t reach the dead node to detach. The volume can’t move to another node until the attachment is gone. The node’s out-of-service taint (since 1.26) is the mechanism to tell the attacher “force-detach this node’s attachments.”
Driver migration breakage. On clusters where CSI migration was newly enabled (1.23–1.25), Pods using in-tree storage broke if the CSI driver wasn’t installed. Symptom: kubectl describe pod shows Volume not attached according to node status. Fix: install the corresponding CSI driver (the migration only redirects to it, it doesn’t ship it).
Alternatives and When to Choose Them
- FlexVolume: the predecessor of CSI; a script-on-disk interface (kubelet
exec’s a binary in a known path with subcommandsinit / attach / mount / ...). Still in K8s but deprecated since 1.23, scheduled for removal. New drivers must be CSI; existing FlexVolume drivers should be migrated. - In-tree plugins: still present but soft-deprecated by CSI migration. New StorageClass usage should reference CSI driver names directly (e.g.
ebs.csi.aws.com), not the legacykubernetes.io/aws-ebs. - HostPath / local volumes: a degenerate alternative for single-node or fixed-mapping use cases. No CSI involvement; the kubelet mounts a path on the node directly. Useful for development clusters and for the local-volume static provisioner on bare metal.
For any new storage integration in K8s, CSI is the only correct choice. The cost of writing a CSI driver (versus other plugin systems) is mostly amortised: the csi-driver-host-path reference, the csi-driver-nfs generic NFS driver, and the csi-test sanity test suite mean a new driver is weeks of work, not months.
Production Notes
- AWS EKS: the AWS EBS CSI driver is the only supported block storage backend; the in-tree plugin is fully migrated. EFS uses a separate
efs.csi.aws.comdriver for RWX. EBS volumes are zonal, sovolumeBindingMode: WaitForFirstConsumeris mandatory or pods will be unschedulable when their PV is in a different AZ. - GKE: the Compute Engine Persistent Disk CSI Driver (
pd.csi.storage.gke.io) ships preinstalled. GKE Autopilot enforces CSI exclusively — no in-tree volumes at all. - Azure AKS: AzureDisk and AzureFile CSI drivers ship preinstalled; migration to CSI was completed via the AKS managed addon.
- On-prem (Ceph, VMware, NetApp): Rook-Ceph operator deploys both
ceph-csi-rbd(block) andceph-csi-cephfs(file) drivers; vSphere CSI Driver (vSphere 7.0+) replaces the legacy vSphere in-tree plugin; NetApp’s Trident is itself a CSI driver. - CSI driver as a security boundary. A misbehaving driver can mount arbitrary host paths into Pods via
NodePublishVolume. Treat CSI driver IAM and admission policy as carefully as you’d treat the kubelet’s. TheCSIDriver.spec.volumeLifecycleModesfield limits whether the driver can be used inline-ephemeral (without cluster-admin PVC creation rights) — leave it set toPersistentonly unless you specifically need ephemeral.
See Also
- Container Runtime Interface — sibling pattern: same gRPC/UDS shape, different layer (runtime vs storage)
- Container Network Interface — sibling pattern for networking
- PersistentVolume — the K8s resource the CSI driver materialises
- PersistentVolumeClaim — the user request that drives the driver’s
CreateVolume - StorageClass — the dynamic-provisioning template the CSI driver consumes
- Dynamic Volume Provisioning — the workflow that ties PVC → external-provisioner → CSI driver
- Volume Snapshot — the snapshot subsystem layered on top of the CSI snapshot RPCs
- Volume Cloning — CSI’s PVC-to-PVC clone, implemented as
CreateVolumewith a content source - Generic Ephemeral Volume — CSI-backed ephemeral storage tied to Pod lifetime
- kubelet — the gRPC client of the Node service
- Kubernetes MOC — parent MOC