Kubernetes CSI Driver
zerofs-csi exposes a ZeroFS filesystem to Kubernetes as dynamically provisioned persistent volumes. A ZeroFS gateway (a leader/standby pair) backs a StorageClass; each volume is a directory on that filesystem, and pods mount their directory over 9P.
The driver registers as csi.zerofs.net. It provisions mount volumes only (no
block mode) and passes csi-sanity v5.4.0 (one spec requiring per-volume
capacity tracking is skipped; capacity is not enforced, see
Limitations). Manifests live in
zerofs/zerofs-csi/deploy/;
the project's k3s end-to-end CI applies the driver manifests as-is, overriding
only the container image (the gateway it tests against is a CI-specific
manifest).
How It Works
ZeroFS is single-writer per bucket/prefix, so the driver shares one gateway across all volumes of a StorageClass rather than running an instance per volume. The gateway is a leader/standby pair: a leader serves the filesystem while a standby replicates it and takes over through a bounded handoff if the leader fails, with at most one node writing at any time (see High Availability).
- Two single-replica StatefulSets run
zerofs runagainst one bucket. The leader serves 9P to the nodes and a gRPC admin API to the controller; the standby receives the leader's writes and stands ready. Manifest-backed serving authority and the post-fence handoff grace prevent overlapping successful reads/acknowledgments, while writer-epoch fencing independently guarantees that only one writer term can commit durable database state. - The controller plugin translates
CreateVolumeinto aCreateDirectorycall for/volumes/pvc-<uuid>on the admin API, andDeleteVolumeintoRemoveDirectory. It points at both nodes and provisions against whichever is leading; a standby is not serving, so it is skipped. Removal renames the directory into a trash area and returns immediately; a background task deletes the contents and the garbage collector reclaims the extents. - The node plugin publishes a volume by spawning
zerofs mount <both-gateways> <target> --aname /volumes/pvc-<uuid>, the userspace 9P client described in 9P File Access, attached directly to the volume's directory. The attach is rooted at that directory: the mount cannot name anything above it, and..at its root resolves to itself.
Each published volume is its own zerofs mount process pointed at both nodes, so mounts inherit the client's failover behavior: when the leader fails over (or a node restarts), the client follows the serving node, operations block through the takeover gap, then linked open file handles resume by inode id. A failover does not normally invalidate an existing mount. The exception is an open file whose final link was removed: that handle is connection-local, and a disconnect makes the mount's entire logical session terminal with ESTALE; recreate the pod or mount to establish a new session.
| Kubernetes access mode | Supported |
|---|---|
ReadWriteOnce | yes |
ReadOnlyMany | yes |
ReadWriteMany | yes |
ReadWriteOncePod | no |
ReadWriteMany works because any number of pods on any number of nodes each run their own client against the same directory; the gateway arbitrates, including POSIX byte-range locks.
Installation
Prerequisites: an object store bucket with credentials for the gateway, and /dev/fuse on the nodes (the node plugin runs privileged and mounts it from the host).
1. The gateway
The example manifest creates the zerofs namespace and, for each node, a config Secret, a per-node Service (9P on 5564, admin RPC on 7000, replication on 9000), and a single-replica StatefulSet. The two nodes form the leader/standby pair over one bucket:
curl -fsSLO https://raw.githubusercontent.com/Barre/ZeroFS/main/zerofs/zerofs-csi/deploy/gateway-example.yaml
# Edit both nodes' Secrets: identical storage url and encryption_password, plus credentials.
kubectl apply -f gateway-example.yaml
Each node is configured like any other ZeroFS instance (see Configuration) plus a [replication] section naming its role and its peer; sizing the cache is the main throughput lever for everything mounted through it. The two nodes must share the same storage url and encryption password.
Pod readiness is gated on the replication port, not 9P. A standby binds the replication port early (to receive the leader's writes and answer heartbeats) but binds 9P only once it takes over, so a 9P readiness probe would drop the standby from its Service and cut off the leader's replication. The example StatefulSets are configured this way.
2. The driver
DEPLOY=https://raw.githubusercontent.com/Barre/ZeroFS/main/zerofs/zerofs-csi/deploy
kubectl apply -f $DEPLOY/csidriver.yaml -f $DEPLOY/rbac.yaml \
-f $DEPLOY/controller.yaml -f $DEPLOY/node.yaml
The controller is a Deployment running the CSI controller service next to the external-provisioner sidecar. The node plugin is a DaemonSet on every node (it tolerates all taints, since any node can run a pod with a volume). The ghcr.io/barre/zerofs-csi image is published for amd64 and arm64 and carries the same zerofs binary as the standalone image.
3. The StorageClass
kubectl apply -f $DEPLOY/storageclass-example.yaml
| Parameter | Required | Meaning |
|---|---|---|
adminEndpoint | yes | Both nodes' admin RPC URLs, comma-separated, e.g. http://zerofs-gateway-a.zerofs.svc:7000,http://zerofs-gateway-b.zerofs.svc:7000. The controller uses whichever node leads. |
gateway | yes | Both nodes' 9P addresses, comma-separated, e.g. zerofs-gateway-a.zerofs.svc:5564,zerofs-gateway-b.zerofs.svc:5564. The mount follows the serving leader. |
volumesRoot | no | Directory holding the volume directories (default /volumes) |
adminEndpoint and gateway each carry the pair's node addresses, comma-separated; the data-plane mount and the controller probe the list and follow the serving leader, rerouting on failover.
DeleteVolume receives no StorageClass parameters (the CSI specification gives it only the volume id and secrets), so the StorageClass must also reference a Secret carrying adminEndpoint through csi.storage.k8s.io/provisioner-secret-name and -namespace. The example includes it.
One StorageClass per gateway. Several StorageClasses, each with its own gateway pair, bucket, and encryption password, can coexist in one cluster.
Using Volumes
Nothing ZeroFS-specific appears in workloads:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
spec:
storageClassName: zerofs
accessModes: ['ReadWriteMany']
resources:
requests:
storage: 10Gi
Creating the claim provisions the directory; scheduling a pod that uses it mounts the directory at the pod's mountPath. There is no attach step (attachRequired: false), so pods start as soon as the node plugin has the mount up.
Requested capacity is recorded on the PV but not enforced; every volume shares
the gateway filesystem. Cap total usage with the gateway's max_size_gb
(Configuration).
Failure Behavior
- Leader failover: when the leader fails, heartbeat silence triggers a durable marker-claim attempt; the winner remains in
Claimingfor the nine-second pre-open grace, then entersOpening, opens the database to epoch-fence the old writer, reconciles the retained tail, and activates. Published mounts block through the gap and resume linked open handles by inode id, following the new leader without a remount; the controller's next provisioning call routes to it too. An open-unlinked handle is the exception described above: it cannot be replayed, and its loss terminally fails that mount session withESTALE. Writer-epoch fencing prevents the deposed leader from committing after takeover. This is the same model documented under High Availability; the data-plane client behavior is Client access. - Node plugin restart: the FUSE clients are children of the node plugin container, so a restart breaks the published mounts on that node until the affected pods are recreated. The DaemonSet therefore uses an
OnDeleteupdate strategy: upgrades are deliberate, per-node, coordinated with draining. - Node loss: volume directories live on the gateway, so rescheduled pods mount the same data from any other node.
The pair is two nodes, one standby: availability through failover, not write scale-out. See High Availability for the replication model, guarantees, and limits.
Security
None of the gateway ports is authenticated; the trust boundary is network reachability, the same model as NFS with AUTH_SYS. Each must be restricted to its legitimate clients:
- 9P (5564) is the data plane. The attach aname is namespacing, not authentication: any client that reaches this port can attach to any directory, including another volume's or the filesystem root. Pods never speak 9P themselves (they see only a kernel mountpoint), so this port is open to the node plugins.
- Admin RPC (7000) is a root-equivalent control plane. It runs every call as uid 0 and can trash any volume (
RemoveDirectory). Its only client is the controller, so it is locked to the controller pod alone. - Replication (9000) is the leader → standby stream, heartbeats, and role discovery. Its only clients are the gateway pods themselves, so it is open just to them (each node ships to the other).
networkpolicy-example.yaml ships in the deploy bundle and enforces all three. Apply it alongside the gateway (adapt the labels to your manifests):
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: zerofs-gateway
namespace: zerofs
spec:
podSelector:
matchLabels:
app: zerofs-gateway
policyTypes: [Ingress]
ingress:
- ports: [{ port: 5564 }] # data plane, from every node plugin
from:
- podSelector:
matchLabels:
app: zerofs-csi-node
- ports: [{ port: 7000 }] # control plane, from the controller only
from:
- podSelector:
matchLabels:
app: zerofs-csi-controller
- ports: [{ port: 9000 }] # replication, between the gateway pods
from:
- podSelector:
matchLabels:
app: zerofs-gateway
A NetworkPolicy is a no-op on a CNI that does not enforce them (vanilla flannel, default k3s); there, no port has in-cluster protection, so do not expose the gateway to untrusted pods. Data is still encrypted before it reaches the object store; the encryption password is per gateway, i.e. per StorageClass.
Limitations
- Capacity is not enforced. PVC sizes are labels; the gateway's
max_size_gbcaps the filesystem as a whole. - No per-volume snapshots. Checkpoints cover the whole gateway filesystem, not one volume directory.
- StorageClass
mountOptionsare rejected. The mount takes no pass-through options; a capability carrying them fails withInvalidArgumentrather than being silently ignored. - Mount volumes only.
volumeMode: Blockis not supported and not planned. A workload that wants a fixed-size disk image can create one inside its filesystem volume (truncate -s 10G /data/disk.img) and consume it directly (e.g. a file-backed VM disk) or, given sufficient privilege, attach it as a loop device. A first-class block volume would only add a raw device in exchange for giving up the filesystem'sReadWriteManyand reconnect-tolerant recovery.