Kubernetes for beginners. Part 25: What other storage is there for the cluster?
All parts of the series “Kubernetes for beginners”The three previous parts were about network file systems: an NFS server for the cluster (part 22), csi-driver-nfs (part 23) and csi-driver-smb (part 24). They are good for shared files, but we have already seen what they cannot do: databases are not put on them, the volume size limits nothing (df in the pod shows the whole export), and when the server is unreachable every pod with its volumes stalls at once. Today is an overview of the rest of what you meet in clusters without a cloud: local node disks (local-path-provisioner, the local volume, TopoLVM), S3 object storage mounted as a file system, and the distributed storage systems Longhorn, Ceph and OpenEBS. At the end comes the answer to the question all five parts of this block lead to: where to keep databases and which data goes where. The mechanics of PV, PVC, StorageClass and CSI are from part 21.
Local volumes and S3 were tested on the lab cluster: local-path-provisioner, a static local volume on a separate node disk, k8s-csi-s3 with the Garage S3 server. The distributed systems do not fit there even within their minimum requirements (more on that below), so their section follows the projects' documentation, without output.
Local node disks
The fastest disk for a pod is the one in its own node: no network, no server, no protocol, the fsync latency is the SSD's latency. The price is single but large: the data lives on one node. A pod bound to such a volume can only run there, and if the node dies for good, the data dies with it unless something higher up replicates it.
local-path-provisioner
local-path-provisioner is a small controller from the k3s developers that implements dynamic provisioning of volumes in local node directories. It is not a CSI driver but an old-style external provisioner: it watches PVCs of its class (rancher.io/local-path) itself and creates PVs, while kubelet mounts them with built-in means. It is installed with a single release manifest:
$ kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.37/deploy/local-path-storage.yaml
namespace/local-path-storage created
serviceaccount/local-path-provisioner-service-account created
role.rbac.authorization.k8s.io/local-path-provisioner-role created
clusterrole.rbac.authorization.k8s.io/local-path-provisioner-role created
rolebinding.rbac.authorization.k8s.io/local-path-provisioner-bind created
clusterrolebinding.rbac.authorization.k8s.io/local-path-provisioner-bind created
deployment.apps/local-path-provisioner created
storageclass.storage.k8s.io/local-path created
configmap/local-path-config created
$ kubectl get storageclass local-path
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
local-path rancher.io/local-path Delete WaitForFirstConsumer false 25s
How it works. When a pod with a PVC of this class is assigned to a node, the controller starts a short-lived helper pod helper-pod-create-<PV name> on that node (in the local-path-storage namespace), which runs the setup script from the local-path-config ConfigMap: mkdir -m 0777 of the directory /opt/local-path-provisioner/<PV name>_<namespace>_<PVC name>. Then the controller creates a PV — of type hostPath by default, or of type local with the annotation volumeType: local on the PVC (or defaultVolumeType: local on the class) — with nodeAffinity to that node. kubelet mounts the directory into the pod with a bind mount. When the PVC is deleted (policy Delete), a similar helper-pod-delete-… pod runs teardown — rm -rf of the directory.
All of this is configured in the same ConfigMap: nodePathMap sets the directories for each node (they are worth moving to a separate disk rather than leaving on the root partition), helperPod.yaml is the helper pod template. The release template uses the image docker.io/library/busybox without a tag, that is latest from Docker Hub: in a cluster without internet access or with a "pinned versions only" rule it has to be replaced with an image from your own registry.
WaitForFirstConsumer. The mode from part 21: the class postpones creating the volume until the scheduler has chosen a node for the pod. For a local volume there is no other way: if the volume were created right away on k8s-node-1 while only k8s-node-2 had room for the pod, the pod would never start. Once created, the binding is permanent: the PV gets nodeAffinity, and every later start of the pod happens only on that node.
Limitations.
- The PVC size is not enforced: the project documentation says plainly that the capacity limit is ignored. The volume is a directory on a shared file system, and it grows as long as the node disk has room. There is no expansion either (
ALLOWVOLUMEEXPANSION false) — there is nothing to expand. - Access modes are only
ReadWriteOnceandReadWriteOncePod: a directory on one node. The controller rejects a PVC withReadWriteMany(NodePath only supports ReadWriteOnce and ReadWriteOncePod (1.22+) access modes). The exception issharedFileSystemPath: if a shared file system is mounted at the same directory on every node, the provisioner hands out volumes in it with any mode, but that is no longer local storage. - If the node is unreachable or taken out of service, the pod will not restart elsewhere; it stays
Pendinguntil the node returns.kubectl drainevicts the pod, but the new one waits foruncordon. If the node is lost for good, the PVC has to be deleted and created again, and the data restored from a backup or a replica.
When it fits. Lab and test clusters; caches and temporary data you would not miss; and — most importantly — applications that replicate data between their instances themselves: a PostgreSQL cluster with streaming replication, the RabbitMQ message broker with quorum queues (a queue stored on several broker nodes and agreed on by voting), the Elasticsearch search engine with its own shard copies. They do not need reliability from storage, they need the speed of a local disk, and they provide reliability themselves.
The local volume and static provisioning
The volume type local built into Kubernetes is the same thing without a controller: the administrator creates a PV for each disk or partition, usually mounted separately (for example, an NVMe disk for a database).
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-disk
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: k8s-node-1-disk1
spec:
capacity:
storage: 200Mi
accessModes: ["ReadWriteOncePod"]
persistentVolumeReclaimPolicy: Retain
storageClassName: local-disk
local:
path: /mnt/disks/disk1
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: ["k8s-node-1"]
kubernetes.io/no-provisioner means the class has no provisioner: PVCs of this class bind only to PVs created in advance. WaitForFirstConsumer means something different here than with local-path: the scheduler takes into account which nodes have free matching PVs, picks a node and only then binds the PVC to a PV on that node. nodeAffinity is mandatory on a local PV — it is the binding to the node. Here the size is real: the volume is a separate file system, and writing past it ends with No space left on device. ReadWriteOncePod (the mode from part 21: exactly one pod in the whole cluster uses the volume) has to be declared in the static PV itself — a PVC with ReadWriteOncePod will not bind to a PV with only ReadWriteOnce.
The difference from hostPath that matters: local is a full PV that the scheduler understands, while hostPath in a pod is just a path on whatever node the pod happened to land on. Creating a PV for each disk by hand is automated by sig-storage-local-static-provisioner — a DaemonSet that finds mounted disks in a given node directory (for example, /mnt/disks/) and creates PVs for them, and after a volume is released wipes the disk and creates the PV again.
TopoLVM
TopoLVM is a CSI driver that carves volumes as LVM logical volumes (the Linux logical volume manager) out of a volume group on each node. Unlike local-path, the volume gets a real size (it is a separate block device with an ext4, XFS or Btrfs file system), supports expansion and block mode, and snapshots for thin logical volumes (thin provisioning). The scheduler learns each node's free space through storage capacity tracking (CSIStorageCapacity, part 21) or the TopoLVM scheduler extender and will not place a pod where there is no room. If a cluster has many local volumes, this is the next step after local-path.
S3 through CSI
S3 is an object storage HTTP protocol introduced by Amazon that became a standard: data lives in buckets as objects with a key name; they are put (PUT), read (GET, including ranges), deleted and listed by prefix (ListObjectsV2). Every request is signed with an access key: the client computes a request signature from the secret key (the AWS Signature Version 4 scheme, an HMAC over the method, path, headers and time), and the secret itself never crosses the network. S3 has no directories: reports/2026/10.pdf is a key with slashes, and a "directory" is a common prefix or an empty object whose name ends with /.
Compatible servers people run themselves: Ceph RGW (the S3 gateway of the distributed storage system Ceph, see below), Garage (a lightweight open-source distributed S3 server designed for small deployments on several machines) and others. MinIO, long the most popular of them, no longer maintains its open-source repository: it has been archived, and the company offers a separate product with its own license instead.
A bucket can be mounted as a volume through CSI drivers based on FUSE (Filesystem in Userspace) — a kernel mechanism that hands file system operations to a user-space process. The process on the node turns open, read, write into S3 requests.
- k8s-csi-s3 is a driver that mounts buckets through GeeseFS, a fast FUSE file system for S3 (it can also use s3fs and rclone, but the authors strongly recommend GeeseFS). It supports dynamic provisioning: a new PVC is a new bucket or a
pvc-<uid>prefix in the bucket named in the class; deleting the PVC removes the prefix. Any access mode works, includingReadWriteMany. It does not store file owners and modes on third-party S3 servers: everything appears to belong to one user with the same mode from the mount options. - Mountpoint for Amazon S3 CSI Driver is a driver based on Mountpoint, the AWS FUSE client optimized for large sequential reads. Static provisioning only: a PV points to an existing bucket. A compatible server's address can be set with
--endpoint-url, but it is designed primarily for Amazon S3 and is installed with a Helm chart or an EKS add-on.
k8s-csi-s3 is installed with three release manifests; the server address and the keys live in a Secret, not in the class:
apiVersion: v1
kind: Secret
metadata:
name: csi-s3-secret
namespace: storage-credentials
stringData:
accessKeyID: GK51446d5c5b4635172249f367
secretAccessKey: <secret key>
endpoint: http://s3.example.com:3900
region: garage
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: s3
provisioner: ru.yandex.s3.csi
parameters:
mounter: geesefs
options: "--memory-limit 1000 --dir-mode 0770 --file-mode 0660 --uid 10001 --gid 10001"
bucket: k8s-volumes
csi.storage.k8s.io/provisioner-secret-name: csi-s3-secret
csi.storage.k8s.io/provisioner-secret-namespace: storage-credentials
csi.storage.k8s.io/controller-publish-secret-name: csi-s3-secret
csi.storage.k8s.io/controller-publish-secret-namespace: storage-credentials
csi.storage.k8s.io/node-stage-secret-name: csi-s3-secret
csi.storage.k8s.io/node-stage-secret-namespace: storage-credentials
csi.storage.k8s.io/node-publish-secret-name: csi-s3-secret
csi.storage.k8s.io/node-publish-secret-namespace: storage-credentials
The Secret lives in the separate storage-credentials namespace — by the same logic as the SMB password in part 24: the key should have rights to this bucket only, not to the whole storage. --uid, --gid, --dir-mode, --file-mode set the owner and mode for every file of the volume at once; --memory-limit is GeeseFS's memory ceiling for the data cache in megabytes, and it applies to each volume on each node: ten S3 volumes on a node with the default 1000 means up to 10 GB of memory outside pod accounting.
Where GeeseFS runs. By default the node plugin starts GeeseFS not in its own container but on the node itself — as a transient systemd service geesefs-<volume name>.service (for that /run/systemd is mounted into the container, and the binary is copied to /var/lib/kubelet/plugins/ru.yandex.s3.csi/). This way an upgrade or restart of the driver does not break mounted volumes. With the --no-systemd option GeeseFS lives in the driver container, and when it restarts every volume on the node answers Transport endpoint is not connected — both cases are tested below.
Security. The driver controller gets a ClusterRole with get and list on all secrets in the cluster, and the node plugin runs privileged with access to the node's systemd. The service receives the S3 keys through environment variables, and the Environment property of a transient systemd service is served over D-Bus to any user on the node: systemctl show without sudo prints the secret key (shown below). So the volume key should have rights to one bucket, and the nodes should have no outside users. One more difference from NFS and SMB: the driver controller talks to the S3 server from the pod network (its pod is not hostNetwork), so closing pods' path to the server with an address-based network policy, as in part 22, is only possible with an exception for kube-system; the main boundary here is the key's rights.
It is not POSIX
A bucket mounted as a directory looks like a file system but behaves differently:
- No atomic
rename. S3 has no rename operation; the FUSE client copies the object under the new key and deletes the old one, and renaming a directory means copying every object with its prefix. Applications that write a temporary file and then atomically rename it into place (almost every database and many configuration systems do this) lose that guarantee on S3. Mountpoint supports renaming files only in the S3 Express One Zone storage class and cannot rename directories at all. - No locks between nodes. GeeseFS does not support locking:
flockworks only among processes of one node (the kernel handles it), while a pod on another node gets the same lock at the same time. Two writers of one object do not know about each other, and the last one wins. - No hard links, and GeeseFS keeps symbolic links, owners and modes only in Yandex Object Storage: on another S3 server a symbolic link looks like an empty file on the neighbouring node, and
chmodandchownsucceed and change nothing. - Partial overwrites are expensive. An S3 object is written whole. Changing 4 KiB in the middle of a 1 GiB file means assembling the object again (GeeseFS does it with a server-side copy, but the volume is the same). Mountpoint by default only does sequential writes of new files; overwriting needs
--allow-overwriteand only with truncation, deletion needs--allow-delete. - Metadata consistency. The object list and attributes are cached by the client (GeeseFS
--stat-cache-ttldefaults to one minute), and another node may not see a just-created file for a minute. - The volume size means nothing.
dfin the pod shows a petabyte, and the PVCcapacityis just a number in the object.
When it fits. Large files written once and then read: media, archives, exports, datasets for processing; passing files between systems where one works with S3 through the API and the other only knows files. It does not fit: databases, append-only logs, anything that relies on rename and locks.
Better without a file system. If you write the application, it is better to talk to S3 through its API with an SDK (a library for your programming language): the application decides what to upload and when, gets explicit errors, can do multipart uploads and presigned URLs, and the FUSE layer with its non-obvious behaviour disappears. Then Kubernetes needs not a volume but a Secret with the keys and the storage address in the configuration.
Distributed storage
The next level is storage that lives in the cluster itself: it uses node disks and replicates data between them over the network. A pod gets a block volume that survives the loss of a node: a replica exists on another one.
node 1 node 2 node 3
┌────────────┐ ┌────────────┐ ┌────────────┐
│ pod │ │ │ │ │
│ │ │ │ │ │ │
│ ▼ │ │ │ │ │
│ volume ────┼───────►│ replica 2 │ │ replica 3 │
│ replica 1 │◄───────┼────────────┼───────►│ │
│ node disk │ │ node disk │ │ node disk │
└────────────┘ └────────────┘ └────────────┘
a write is acknowledged once all healthy replicas have stored it
- Longhorn is distributed block storage from the authors of Rancher (release 1.13). Each volume has its own engine process that synchronously writes data to several replicas on disks of different nodes. In the main data engine (V1) the engine exposes the volume to the node as a block device over iSCSI — a protocol that carries SCSI disk commands over TCP — so every node needs
open-iscsiwith a runningiscsid; the newer V2 engine is built on SPDK (a library of user-space storage drivers) and exposes volumes over NVMe/TCP. It has a web UI, snapshots, backups to S3 or NFS,ReadWriteManythrough a separate share-manager pod with an NFSv4 server for the volume (the nodes need an NFSv4 client). The documented minimum is three nodes with 4 vCPUs and 4 GiB of memory each, SSD or NVMe, and a 10 Gbit/s network between nodes. The simplest of the distributed systems to install. - Ceph is a distributed storage system that provides block devices (RBD, RADOS Block Device), a file system (CephFS) and S3-compatible object storage (RGW). Data is spread across OSD daemons (one per disk) by the CRUSH algorithm, which computes an object's location from the cluster map with no central directory; the cluster state is kept by monitors (MON), which need at least three for a quorum. In Kubernetes it is deployed by the Rook operator (release 1.21; an operator is a controller that manages a complex application through its own resources, covered in detail in part 28), and volumes are attached by the ceph-csi driver. An OSD needs a disk, partition or LVM logical volume without a file system (or a PVC in block mode), and the nodes need the
rbdkernel module. The most powerful and most complex option: industrial storage that whole clouds run on. - OpenEBS (release 4.6) is a set of engines under one name: local volumes (on directories, LVM or ZFS — a file system with a built-in volume manager; the counterpart of local-path and TopoLVM) and replicated volumes, Replicated PV Mayastor, which creates an NVMe over TCP target for each volume — the NVMe-oF protocol that carries NVMe disk commands over the network — and requires dedicated CPU cores and huge pages. It lets you start with local volumes and add replication where needed.
All three are installed with Helm charts (Helm is covered in part 27) or their own manifests; on the lab cluster of two workers with 2 vCPUs and 4 GB each none of them meets even the minimum requirements, so here it is description only.
The operating cost is the same in kind and different in size:
- Resources. At least three nodes with dedicated disks, memory and CPU for storage daemons on each node, and network: every write goes over the network to 2–3 replicas, and 1 Gbit/s quickly becomes the bottleneck — Longhorn names 10 Gbit/s as the basic requirement and recommends a dedicated storage network.
- Latency. Synchronous replication adds a network round trip to every write. A distributed volume is slower than a local disk.
- Failure complexity. Storage that lives in the cluster falls with the cluster: a failed Kubernetes upgrade, a node network outage or memory pressure on the nodes hit the data too. Recovering from a double failure, rebalancing, replacing disks is a separate skill set.
- Upgrades. The storage has its own release cycle and compatibility with Kubernetes and kernel versions, and it has to be upgraded more carefully than applications.
If the organization has no people ready to own Ceph, distributed storage in a small cluster often brings more risk than it removes. A sensible path for three to five nodes: local volumes for what replicates itself, NFS or SMB for shared files, S3 for large objects, and Longhorn only if you truly need a block volume that survives node loss for an application that cannot replicate itself.
Databases in the cluster
At this point the storage block comes down to this: a database needs fast fsync, correct locking, POSIX semantics and protection from losing a node. Network file systems give the last one at the cost of the first three (as discussed in part 23: Prometheus explicitly does not support NFS, SQLite warns about broken locking on network file systems, every fsync gets a network round trip longer). Distributed block storage gives everything, but at the cost of complexity and one more copy of the data on top of what the database makes itself.
So the commonly accepted scheme is different: local disks and replication by the database itself — a shared-nothing architecture that the CloudNativePG documentation recommends for PostgreSQL.
k8s-node-1 k8s-node-2
┌─────────────────────────┐ ┌─────────────────────────┐
│ postgres-1 (primary) │ WAL │ postgres-2 (replica) │
│ PVC local-disk ──► disk ├─────►│ PVC local-disk ──► disk │
└─────────────────────────┘ └─────────────────────────┘
operator: primary election, failover, backups to S3
Each instance is a pod with its own local volume; the primary sends its change log (WAL, the write-ahead log in PostgreSQL) to the replicas over the network; when a node is lost, the operator promotes one of the replicas to primary and creates a new replica from scratch on another node. Backups and the log go to S3 — outside the cluster and outside the nodes. All this is managed by operators: for PostgreSQL, for example, CloudNativePG (an open-source PostgreSQL operator; it backs up to S3 through the Barman Cloud plugin, the built-in upload has been deprecated since 1.26), for RabbitMQ its official cluster operator; how operators work is covered in part 28.
What you get: the speed of local NVMe, the database's own consistency guarantees, surviving node loss without distributed storage, and point-in-time recovery from S3. What it costs: at least two or three instances, each holding a full copy of the data.
Which data goes where
| Data | Storage | Access mode | Why |
|---|---|---|---|
| Replicated databases (PostgreSQL, MySQL, broker clusters) | local PV, local-path, TopoLVM | RWO (better RWOP) | local disk speed; reliability comes from database replication |
| Non-replicated databases that must not be lost | distributed block (Longhorn, Ceph RBD) | RWO | survives node loss |
| Prometheus metrics (a monitoring system), the local part of Loki logs (a log store, part 33) | local disk | RWO | the TSDB needs POSIX; long-term storage goes to S3 through the application itself |
| etcd | local SSD | — (not a cluster PV) | fsync latency; more in part 34 |
| Files shared by several replicas: uploads, reports, static assets | NFS (csi-driver-nfs) | RWX | a natural shared FS, permissions by UID |
| Files that Windows users need too | SMB (csi-driver-smb) | RWX | the same share people use; SMB3 authentication and encryption |
| Ready-made reference data, read only | static NFS or SMB PV | ROX | ro on the server and in the PV |
| Large objects, media, archives, backups | S3 through the API; a CSI S3 driver if needed | RWX (through FUSE) | cheap and scalable; not POSIX |
| Caches, intermediate data | emptyDir or local-path | RWO | fine to lose |
| Configuration and secrets | ConfigMap, Secret | — | these are not data volumes (part 12) |
RWOP (ReadWriteOncePod) is safer than RWO for databases: RWO limits the volume to one node, but two pods on that node will both mount it, while RWOP rules out the second pod entirely. local-path supports it; a static PV has to declare it in the PV itself.
Hands-on
The experiments run in the demo namespace from part 11, the pods use busybox.
local-path: a directory on the node
A 100 MiB PVC and a Deployment that writes its start time into it:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: cache
namespace: demo
spec:
storageClassName: local-path
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 100Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cache
namespace: demo
labels:
app.kubernetes.io/name: cache
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: cache
template:
metadata:
labels:
app.kubernetes.io/name: cache
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "date >> /data/started.log; sleep infinity"]
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: cache
strategy: Recreate because the RWO volume is on one node: during an update the old pod has to leave before the new one comes. Until there is a pod, the PVC waits — as intended:
$ kubectl -n demo get pvc cache
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
cache Pending local-path <unset> 5s
$ kubectl -n demo describe pvc cache | sed -n '/Events/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal WaitForFirstConsumer 2s (x2 over 6s) persistentvolume-controller waiting for first consumer to be created before binding
A pod appeared — the scheduler chose k8s-node-2, and a helper pod started there:
$ kubectl -n local-path-storage get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
helper-pod-create-pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 0/1 ContainerCreating 0 2s <none> k8s-node-2 <none> <none>
local-path-provisioner-54745b98cf-rj4p5 1/1 Running 0 50s 172.20.211.62 k8s-node-1 <none> <none>
$ kubectl -n demo get pvc cache
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
cache Bound pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 100Mi RWO local-path <unset> 34s
$ kubectl -n demo describe pvc cache | sed -n '/Events/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal WaitForFirstConsumer 30s (x2 over 34s) persistentvolume-controller waiting for first consumer to be created before binding
Normal Provisioning 22s rancher.io/local-path_local-path-provisioner-54745b98cf-rj4p5_27abeaf4-ef01-47f0-885a-be13148abbdc External provisioner is provisioning volume for claim "demo/cache"
Normal ExternalProvisioning 15s (x2 over 22s) persistentvolume-controller Waiting for a volume to be created either by the external provisioner 'rancher.io/local-path' or manually by the system administrator. If volume creation is delayed, please verify that the provisioner is running and correctly registered.
Normal ProvisioningSucceeded 9s rancher.io/local-path_local-path-provisioner-54745b98cf-rj4p5_27abeaf4-ef01-47f0-885a-be13148abbdc Successfully provisioned volume pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531
Of the 13 seconds between Provisioning and ProvisioningSucceeded the helper pod spent almost all in ContainerCreating: the node was pulling busybox:latest. The provisioner log shows what happened:
$ kubectl -n local-path-storage logs deploy/local-path-provisioner | grep -v event.go
time="2026-10-30T13:19:15Z" level=info msg="Health server started on :8080"
time="2026-10-30T13:19:15Z" level=debug msg="Applied config: {\"nodePathMap\":[{\"node\":\"DEFAULT_PATH_FOR_NON_LISTED_NODES\",\"paths\":[\"/opt/local-path-provisioner\"]}],\"storageClassConfigs\":null}"
time="2026-10-30T13:19:15Z" level=debug msg="Provisioner started"
I1030 13:19:15.347581 1 controller.go:838] "Starting provisioner controller" component="rancher.io/local-path_local-path-provisioner-54745b98cf-rj4p5_27abeaf4-ef01-47f0-885a-be13148abbdc"
I1030 13:19:15.448643 1 controller.go:887] "Started provisioner controller" component="rancher.io/local-path_local-path-provisioner-54745b98cf-rj4p5_27abeaf4-ef01-47f0-885a-be13148abbdc"
time="2026-10-30T13:19:52Z" level=debug msg="config doesn't contain node k8s-node-2, use DEFAULT_PATH_FOR_NON_LISTED_NODES instead"
time="2026-10-30T13:19:52Z" level=info msg="Creating volume pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 at k8s-node-2:/opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache"
time="2026-10-30T13:19:52Z" level=info msg="create the helper pod helper-pod-create-pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 into local-path-storage"
time="2026-10-30T13:20:05Z" level=info msg="Volume pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 has been created on k8s-node-2:/opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache"
time="2026-10-30T13:20:05Z" level=info msg="Start of helper-pod-create-pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 logs"
time="2026-10-30T13:20:05Z" level=info msg="End of helper-pod-create-pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 logs"
The PV is a plain hostPath bound to the node:
$ kubectl get pv pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 -o yaml
apiVersion: v1
kind: PersistentVolume
metadata:
annotations:
local.path.provisioner/selected-node: k8s-node-2
pv.kubernetes.io/provisioned-by: rancher.io/local-path
...
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 100Mi
...
hostPath:
path: /opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache
type: DirectoryOrCreate
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- k8s-node-2
persistentVolumeReclaimPolicy: Delete
storageClassName: local-path
volumeMode: Filesystem
With the annotation volumeType: local on the PVC the same directory is presented as a local PV (spec.local.path instead of spec.hostPath) — tested with a second PVC, nothing else changes.
We write 200 MiB into a 100 MiB volume:
$ kubectl -n demo exec deploy/cache -- sh -c 'dd if=/dev/zero of=/data/big bs=1M count=200 2>&1 | tail -1; ls -l /data; df -h /data'
209715200 bytes (200.0MB) copied, 0.036870 seconds, 5.3GB/s
total 204804
-rw-r--r-- 1 root root 209715200 Oct 30 13:20 big
-rw-r--r-- 1 root root 29 Oct 30 13:20 started.log
Filesystem Size Used Available Use% Mounted on
/dev/sda2 30.3G 5.9G 22.8G 21% /data
No error, no warning: the pod sees the node's whole root partition. On the node it is a plain directory with mode 0777:
user@k8s-node-2:~$ sudo ls -la /opt/local-path-provisioner/
total 12
drwxr-xr-x 3 root root 4096 Oct 30 13:20 .
drwxr-xr-x 5 root root 4096 Oct 30 13:19 ..
drwxrwxrwx 2 root root 4096 Oct 30 13:20 pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache
user@k8s-node-2:~$ sudo du -sh /opt/local-path-provisioner/*
201M /opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache
user@k8s-node-2:~$ df -h /opt/local-path-provisioner
Filesystem Size Used Avail Use% Mounted on
/dev/sda2 31G 6.0G 23G 21% /
The directory is on the root partition: that is local-path's default, and exactly why its path is moved to a separate disk through nodePathMap. One pod without limits can fill the root partition and cause disk pressure for the whole node.
What happens when the node is taken out of service. We close k8s-node-2 to new pods and delete the pod — the same thing kubectl drain does:
$ kubectl cordon k8s-node-2
node/k8s-node-2 cordoned
$ kubectl -n demo delete pod -l app.kubernetes.io/name=cache
pod "cache-7dc5df85bc-ktqpg" deleted from demo namespace
$ kubectl -n demo get pods -l app.kubernetes.io/name=cache -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
cache-7dc5df85bc-9lhkb 0/1 Pending 0 40s <none> <none> <none> <none>
$ kubectl -n demo describe pod cache-7dc5df85bc-9lhkb | sed -n '/Events/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 39s default-scheduler 0/3 nodes are available: 1 node(s) didn't match PersistentVolume's node affinity, 1 node(s) had untolerated taint(s), 1 node(s) were unschedulable. no new claims to deallocate, preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.
Three nodes, three reasons: k8s-node-1 does not match the volume's nodeAffinity, the master has the control-plane taint (part 13), k8s-node-2 is closed. The pod will wait forever; we bring the node back:
$ kubectl uncordon k8s-node-2
node/k8s-node-2 uncordoned
$ kubectl -n demo get pods -l app.kubernetes.io/name=cache -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
cache-7dc5df85bc-9lhkb 1/1 Running 0 51s 172.20.172.63 k8s-node-2 <none> <none>
$ kubectl -n demo exec deploy/cache -- cat /data/started.log
Fri Oct 30 13:20:09 UTC 2026
Fri Oct 30 13:22:41 UTC 2026
The data is in place — the second start appended a line. It is not a bug but a property of a local volume that has to be weighed when choosing: had the node not come back, the pod would have stayed Pending.
Deleting the PVC starts a second helper pod, and the directory disappears:
$ kubectl delete -f cache.yaml
persistentvolumeclaim "cache" deleted from demo namespace
deployment.apps "cache" deleted from demo namespace
$ kubectl -n local-path-storage logs deploy/local-path-provisioner --since=2m | grep -v event.go
time="2026-10-30T13:23:29Z" level=info msg="Deleting volume pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 at k8s-node-2:/opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache"
time="2026-10-30T13:23:29Z" level=info msg="create the helper pod helper-pod-delete-pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 into local-path-storage"
time="2026-10-30T13:23:35Z" level=info msg="Volume pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 has been deleted on k8s-node-2:/opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache"
...
user@k8s-node-2:~$ sudo ls -la /opt/local-path-provisioner/
total 8
drwxr-xr-x 2 root root 4096 Oct 30 13:23 .
drwxr-xr-x 5 root root 4096 Oct 30 13:19 ..
A local volume on a separate disk
For the experiment, instead of NVMe, a 256 MiB image file attached as a loop device (a block device on top of a file) and mounted on k8s-node-1:
user@k8s-node-1:~$ sudo fallocate -l 256M /var/tmp/disk1.img
user@k8s-node-1:~$ LOOP=$(sudo losetup --find --show /var/tmp/disk1.img); echo $LOOP
/dev/loop5
user@k8s-node-1:~$ sudo mkfs.ext4 -q -L disk1 $LOOP
user@k8s-node-1:~$ sudo mkdir -p /mnt/disks/disk1 && sudo mount $LOOP /mnt/disks/disk1
user@k8s-node-1:~$ df -h /mnt/disks/disk1
Filesystem Size Used Avail Use% Mounted on
/dev/loop5 224M 152K 206M 1% /mnt/disks/disk1
The class and the PV are the manifest from the beginning of the part. The access mode trap: the first time the PV was declared with ReadWriteOnce, while the PVC and the pod asked for ReadWriteOncePod:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db
namespace: demo
spec:
storageClassName: local-disk
accessModes: ["ReadWriteOncePod"]
resources:
requests:
storage: 200Mi
---
apiVersion: v1
kind: Pod
metadata:
name: db
namespace: demo
spec:
containers:
- name: app
image: busybox:1.37
command: ["sleep", "infinity"]
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: db
$ kubectl -n demo describe pod db | sed -n '/Events/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 68s default-scheduler 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't find available persistent volumes to bind. no new claims to deallocate, preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.
didn't find available persistent volumes to bind — the scheduler found no matching free PV on any worker; the PVC meanwhile is Pending with the same WaitForFirstConsumer event. The answer is the same if the PV is smaller than the request. We add the required mode to the PV, and binding goes through:
$ kubectl patch pv k8s-node-1-disk1 --type=merge -p '{"spec":{"accessModes":["ReadWriteOncePod"]}}'
persistentvolume/k8s-node-1-disk1 patched
$ kubectl -n demo get pvc db
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
db Bound k8s-node-1-disk1 200Mi RWOP local-disk <unset> 89s
$ kubectl -n demo get pod db -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
db 1/1 Running 0 89s 172.20.211.9 k8s-node-1 <none> <none>
Here the size is real — the volume has its own file system:
$ kubectl -n demo exec db -- df -h /data
Filesystem Size Used Available Use% Mounted on
/dev/loop5 223.7M 152.0K 205.7M 0% /data
$ kubectl -n demo exec db -- dd if=/dev/zero of=/data/big bs=1M count=300
dd: error writing '/data/big': No space left on device
219+0 records in
218+0 records out
228589568 bytes (218.0MB) copied, 2.515043 seconds, 86.7MB/s
command terminated with exit code 1
user@k8s-node-1:~$ findmnt -n -o TARGET,SOURCE,FSTYPE -S /dev/loop5
/mnt/disks/disk1 /dev/loop5 ext4
/var/lib/kubelet/pods/9f3f0845-d7d9-4b30-ac5a-0ba986ac6e7f/volumes/kubernetes.io~local-volume/k8s-node-1-disk1 /dev/loop5 ext4
kubelet bind-mounts the disk directory into the pod's volume directory (the kubernetes.io~local-volume plugin). After the pod and the PVC are deleted, Retain kicks in: the PV moves to Released with the old claimRef, the data stays on the disk, and a new PVC will not bind to it until the administrator wipes the disk and removes the claimRef (kubectl patch pv k8s-node-1-disk1 --type=json -p '[{"op":"remove","path":"/spec/claimRef"}]') — exactly the work sig-storage-local-static-provisioner takes over:
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
k8s-node-1-disk1 200Mi RWOP Retain Released demo/db local-disk <unset> 2m21s
user@k8s-node-1:~$ sudo ls -la /mnt/disks/disk1
total 223260
drwxr-xr-x 3 root root 4096 Oct 30 13:26 .
drwxr-xr-x 3 root root 4096 Oct 30 13:24 ..
-rw-r--r-- 1 root root 228589568 Oct 30 13:26 big
drwx------ 2 root root 16384 Oct 30 13:24 lost+found
S3: the Garage server and k8s-csi-s3
The S3 server for the experiment is Garage 2.5 on a separate machine s3.example.com (192.168.10.33): one node, one copy of the data (replication_factor = 1 — the documentation says plainly it is for test deployments only), metadata in SQLite. Garage is a single static binary; the configuration:
# /etc/garage.toml
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "127.0.0.1:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "<output of openssl rand -hex 32>"
[s3_api]
s3_region = "garage"
api_bind_addr = "0.0.0.0:3900"
[admin]
api_bind_addr = "127.0.0.1:3903"
Port 3900 is the S3 API, 3901 is communication between Garage nodes (local only for a single node), 3903 is the admin API. Garage does not do TLS itself: on the lab network it answers over HTTP; request signing does not reveal the secret, but the data travels in the clear. In a production setup a TLS reverse proxy goes in front of it, and endpoint gets an https:// address.
For the experiment a transient systemd-run service is enough (the Garage documentation has a unit file for a permanent setup); then the node gets a role in the cluster layout, and a bucket and a key with rights to it only are created:
user@s3:~$ sudo systemd-run --unit=garage /usr/local/bin/garage -c /etc/garage.toml server
Running as unit: garage.service; invocation ID: 5b947ec15de847bfb9ed5764c294f9c2
user@s3:~$ sudo garage -c /etc/garage.toml status
==== HEALTHY NODES ====
ID Hostname Address Tags Zone Capacity DataAvail ResyncQueue Version
42df63bf219b0dad s3 127.0.0.1:3901 NO ROLE ASSIGNED 0 v2.5.0
user@s3:~$ sudo garage -c /etc/garage.toml layout assign -z dc1 -c 2G 42df63bf219b0dad
user@s3:~$ sudo garage -c /etc/garage.toml layout apply --version 1
...
New cluster layout with updated role assignment has been applied in cluster.
Data will now be moved around between nodes accordingly.
user@s3:~$ sudo garage -c /etc/garage.toml bucket create k8s-volumes
user@s3:~$ sudo garage -c /etc/garage.toml key create k8s-csi-s3
==== ACCESS KEY INFORMATION ====
Key ID: GK51446d5c5b4635172249f367
Key name: k8s-csi-s3
Created: 2026-10-30 13:28:25.940 +00:00
Validity: valid
Expiration: never
Can create buckets: false
...
user@s3:~$ sudo garage -c /etc/garage.toml bucket allow --read --write --owner k8s-volumes --key k8s-csi-s3
==== KEYS FOR THIS BUCKET ====
Permissions Access key Local aliases
RWO GK51446d5c5b4635172249f367 k8s-csi-s3
user@s3:~$ for ip in 192.168.10.17 192.168.10.20 192.168.10.21; do
> sudo ufw allow proto tcp from $ip to any port 3900 comment 'S3: k8s nodes'
> done
Rule added
Rule added
Rule added
The secret key is printed once at creation (and later with garage key info --show-secret); it does not appear in this text. The region is part of the SigV4 signature, and Garage only accepts requests addressed to the region from s3_region (to others it answers AuthorizationHeaderMalformed with a hint about the right region), hence region: garage in the driver Secret.
The k8s-csi-s3 v0.43.7 driver is three release manifests (the controller-provisioner, the CSIDriver object, the node plugin DaemonSet), all in kube-system:
$ U=https://raw.githubusercontent.com/yandex-cloud/k8s-csi-s3/v0.43.7/deploy/kubernetes
$ kubectl apply -f $U/provisioner.yaml -f $U/driver.yaml -f $U/csi-s3.yaml
serviceaccount/csi-s3-provisioner-sa created
clusterrole.rbac.authorization.k8s.io/csi-s3-external-provisioner-runner created
clusterrolebinding.rbac.authorization.k8s.io/csi-s3-provisioner-role created
service/csi-s3-provisioner created
statefulset.apps/csi-s3-provisioner created
csidriver.storage.k8s.io/ru.yandex.s3.csi created
serviceaccount/csi-s3 created
clusterrole.rbac.authorization.k8s.io/csi-s3 created
clusterrolebinding.rbac.authorization.k8s.io/csi-s3 created
daemonset.apps/csi-s3 created
$ kubectl -n kube-system get pods -l 'app in (csi-s3,csi-s3-provisioner)' -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
csi-s3-nntbp 2/2 Running 0 97s 172.20.172.20 k8s-node-2 <none> <none>
csi-s3-provisioner-0 2/2 Running 1 (23s ago) 99s 172.20.172.15 k8s-node-2 <none> <none>
csi-s3-v7j98 2/2 Running 0 97s 172.20.211.7 k8s-node-1 <none> <none>
$ kubectl get csidriver ru.yandex.s3.csi
NAME ATTACHREQUIRED PODINFOONMOUNT STORAGECAPACITY TOKENREQUESTS REQUIRESREPUBLISH MODES AGE
ru.yandex.s3.csi false true false <unset> false Persistent 61s
The driver and sidecar images (provisioner v6.2.0, registrar v2.16.0) live in the cr.yandex registry. The node plugin landed only on the workers — the manifest has no toleration for the master's taint, so pods with S3 volumes cannot run on the master. The csi-provisioner restart on first start is the same as with csi-driver-nfs: the sidecar started before the driver opened its socket. The manifest has no fsGroupPolicy field, the API fills in ReadWriteOnceWithFSType, and with it kubelet applies fsGroup only to RWO volumes with a specified fsType — the pod's fsGroup does not change an S3 volume, the owner is set by the --uid/--gid options.
We create the Secret substituting the key from a file so it lands neither in the output nor in the shell history ($(…) also strips the trailing newline of the file — with it the signature would not match, so --from-file does not work here), then the class from the beginning of the part and a PVC with a Deployment across two nodes:
$ kubectl create namespace storage-credentials
namespace/storage-credentials created
$ kubectl -n storage-credentials create secret generic csi-s3-secret \
--from-literal=accessKeyID=GK51446d5c5b4635172249f367 \
--from-literal=secretAccessKey="$(cat ./garage-k8s-csi-s3.secret)" \
--from-literal=endpoint=http://s3.example.com:3900 \
--from-literal=region=garage
secret/csi-s3-secret created
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: media
namespace: demo
spec:
storageClassName: s3
accessModes: ["ReadWriteMany"]
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: media
namespace: demo
labels:
app.kubernetes.io/name: media
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: media
template:
metadata:
labels:
app.kubernetes.io/name: media
spec:
securityContext:
runAsUser: 10001
runAsGroup: 10001
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: media
containers:
- name: app
image: busybox:1.37
command: ["sleep", "infinity"]
volumeMounts:
- name: media
mountPath: /media
volumes:
- name: media
persistentVolumeClaim:
claimName: media
$ kubectl -n demo get pvc media
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
media Bound pvc-f2b79471-30fc-4c02-9008-49899e70f87e 1Gi RWX s3 <unset> 26s
$ kubectl -n demo get pods -l app.kubernetes.io/name=media -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
media-7f79bbf67d-4jr2n 1/1 Running 0 25s 172.20.172.18 k8s-node-2 <none> <none>
media-7f79bbf67d-cghcr 1/1 Running 0 25s 172.20.211.8 k8s-node-1 <none> <none>
$ kubectl get pv pvc-f2b79471-30fc-4c02-9008-49899e70f87e -o yaml
...
csi:
...
driver: ru.yandex.s3.csi
...
volumeAttributes:
bucket: k8s-volumes
capacity: "1073741824"
mounter: geesefs
options: --memory-limit 1000 --dir-mode 0770 --file-mode 0660 --uid 10001 --gid
10001
storage.kubernetes.io/csiProvisionerIdentity: 1793367013849-1750-ru.yandex.s3.csi
volumeHandle: k8s-volumes/pvc-f2b79471-30fc-4c02-9008-49899e70f87e
persistentVolumeReclaimPolicy: Delete
volumeHandle is bucket/prefix. On the node there are two FUSE mounts (staging and the pod's volume directory), and behind them a systemd service:
user@k8s-node-1:~$ findmnt -t fuse.geesefs -o TARGET,SOURCE,FSTYPE,OPTIONS
TARGET SOURCE FSTYPE OPTIONS
/var/lib/kubelet/plugins/kubernetes.io/csi/ru.yandex.s3.csi/a0978cd9ba03768c44659165dd0bc3f0a40595d4597aeb9a3c3484fdce698bbd/globalmount k8s-volumes fuse.geesefs rw,nosuid,nodev,relatime,user_id=65534,group_id=65534,default_permissions,allow_other
/var/lib/kubelet/pods/152f88ad-2f11-473d-b3ca-c0f0f0e87ab5/volumes/kubernetes.io~csi/pvc-f2b79471-30fc-4c02-9008-49899e70f87e/mount k8s-volumes fuse.geesefs rw,nosuid,nodev,relatime,user_id=65534,group_id=65534,default_permissions,allow_other
user@k8s-node-1:~$ systemctl status 'geesefs-*' --no-pager -n 0
● geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service - GeeseFS mount for Kubernetes volume k8s-volumes/pvc-f2b79471-30fc-4c02-9008-49899e70f87e
Loaded: loaded (/run/systemd/transient/geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service; transient)
Transient: yes
Drop-In: /run/systemd/system/geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service.d
└─50-StopProps.conf
Active: active (running) since Fri 2026-10-30 13:30:52 UTC; 44s ago
Invocation: abd7fcbeb574439e985b9aeaa847c6a9
Main PID: 957629 (geesefs)
Tasks: 7 (limit: 1699)
Memory: 6.9M (peak: 7.4M)
CPU: 17ms
CGroup: /system.slice/geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service
└─957629 /var/lib/kubelet/plugins/ru.yandex.s3.csi/geesefs -f -o allow_other --endpoint http://s3.example.com:3900 --region garage --setuid 65534 --setgid 65534 --memory-limit 1000 --dir-mode 0770 --file-mode 0660 --uid 10001 --gid 10001 k8s-volumes:pvc-f2b79471-30fc-4c02-9008-49899e70f87e /var/lib/kubelet/plugins/kubernetes.io/csi/ru.yandex.s3.csi/a0978cd9ba03768c44659165dd0bc3f0a40595d4597aeb9a3c3484fdce698bbd/globalmount
GeeseFS runs as nobody (--setuid 65534), and the staging directory is the same SHA-256 of volumeHandle as in part 21. And here is what an ordinary node user sees without sudo (I replaced the 64-character secret key):
user@k8s-node-1:~$ systemctl show 'geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service' -p Environment
Environment=AWS_ACCESS_KEY_ID=GK51446d5c5b4635172249f367 AWS_SECRET_ACCESS_KEY=<secret key>
Now the behaviour. The pod on k8s-node-1 creates files and directories:
$ kubectl -n demo exec media-7f79bbf67d-cghcr -- sh -c 'id; df -h /media; echo hello > /media/a.txt; mkdir -p /media/reports/2026; echo r > /media/reports/2026/10.txt; ls -la /media'
uid=10001 gid=10001 groups=10001
Filesystem Size Used Available Use% Mounted on
k8s-volumes 1.0P 0 1.0P 0% /media
total 9
drwxrwx--- 2 10001 10001 4096 Oct 30 13:31 .
drwxr-xr-x 1 root root 4096 Oct 30 13:30 ..
-rw-rw---- 1 10001 10001 6 Oct 30 13:31 a.txt
drwxrwx--- 2 10001 10001 0 Oct 30 13:31 reports
$ kubectl -n demo exec media-7f79bbf67d-cghcr -- sh -c 'chmod 0600 /media/a.txt; echo chmod=$?; ls -l /media/a.txt; ln /media/a.txt /media/hard.txt; echo ln=$?; ln -s a.txt /media/sym.txt; echo symlink=$?'
chmod=0
-rw-rw---- 1 10001 10001 6 Oct 30 13:31 /media/a.txt
ln: /media/hard.txt: Operation not supported
ln=1
symlink=0
$ kubectl -n demo exec media-7f79bbf67d-cghcr -- sh -c 'mv /media/a.txt /media/b.txt; mv /media/reports /media/archive; ls -R /media'
/media:
archive
b.txt
sym.txt
/media/archive:
2026
/media/archive/2026:
10.txt
A petabyte in df, chmod "succeeds" but the mode stays, hard links are not supported. On the neighbouring node the symbolic link turns out to be an empty file — on Garage GeeseFS does not restore it:
$ kubectl -n demo exec media-7f79bbf67d-4jr2n -- ls -l /media
total 1
drwxrwx--- 2 10001 10001 0 Oct 30 13:32 archive
-rw-rw---- 1 10001 10001 6 Oct 30 13:32 b.txt
-rw-rw---- 1 10001 10001 0 Oct 30 13:32 sym.txt
Consistency: the pod on k8s-node-2 has already read the directory, the pod on k8s-node-1 creates c.txt at 13:33:06, and the second node lists the directory every five seconds:
13:33:06 archive b.txt sym.txt
13:33:11 archive b.txt sym.txt
...
13:34:01 archive b.txt sym.txt
13:34:06 archive b.txt c.txt sym.txt
Exactly one minute — --stat-cache-ttl. Locks: the pod on k8s-node-1 takes a flock for 20 seconds, and meanwhile a pod on the other node and a second process on the same node try:
$ kubectl -n demo exec media-7f79bbf67d-cghcr -- flock -n /media/lock -c 'echo A got lock; sleep 20' &
A got lock
$ kubectl -n demo exec media-7f79bbf67d-4jr2n -- sh -c 'flock -n /media/lock -c "echo B got lock too"; echo rc=$?'
B got lock too
rc=0
$ kubectl -n demo exec media-7f79bbf67d-cghcr -- sh -c 'flock -n /media/lock -c "echo A2 got lock"; echo rc=$?'
rc=1
On one node the kernel holds the lock, and the second process is refused; the pod on the other node knows nothing about it.
Driver restart. We delete the node plugin pod on k8s-node-1 — the DaemonSet creates a new one, and GeeseFS keeps running in its own service:
$ kubectl -n kube-system delete pod csi-s3-v7j98
pod "csi-s3-v7j98" deleted from kube-system namespace
$ kubectl -n demo exec media-7f79bbf67d-cghcr -- sh -c 'echo after-restart > /media/d.txt; cat /media/d.txt'
after-restart
user@k8s-node-1:~$ ps -o pid,etime,cmd -C geesefs | cut -c1-80
PID ELAPSED CMD
957629 04:37 /var/lib/kubelet/plugins/ru.yandex.s3.csi/geesefs -f -o allo
The same experiment with a class where --no-systemd is added to options, and a pod nosd on k8s-node-1:
$ kubectl -n kube-system delete pod csi-s3-nlqqr
pod "csi-s3-nlqqr" deleted from kube-system namespace
$ kubectl -n demo exec nosd -- ls /data
ls: /data: Transport endpoint is not connected
command terminated with exit code 1
$ kubectl -n demo exec nosd -- sh -c 'echo y > /data/y.txt'
sh: can't create /data/y.txt: Transport endpoint is not connected
command terminated with exit code 1
$ kubectl -n demo get pod nosd
NAME READY STATUS RESTARTS AGE
nosd 1/1 Running 0 38s
The GeeseFS process died with the driver container, the volume is dead, and the pod stays Running — it has no probe that would notice. Moreover, after such a pod is deleted, kubelet logs GetMountRefs found corrupted mount … treating as unmounted path, the driver does not remove the dead mount on NodeUnstageVolume, and staging has to be unmounted on the node by hand (sudo umount …/globalmount). So --no-systemd only if the nodes have no systemd.
What is in the bucket — a ListObjectsV2 request signed with SigV4 straight from curl (the key is substituted from a file so it stays out of the history and the process list):
user@s3:~$ printf 'user = "GK51446d5c5b4635172249f367:%s"\n' "$(sudo cat /root/garage-k8s-csi-s3.secret)" \
| curl -sS -K - --aws-sigv4 aws:amz:garage:s3 'http://127.0.0.1:3900/k8s-volumes?list-type=2' \
| grep -oE '<Key>[^<]*</Key>|<Size>[^<]*</Size>' | paste - -
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/</Key> <Size>0</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/archive/</Key> <Size>0</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/archive/2026/</Key> <Size>0</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/archive/2026/10.txt</Key> <Size>2</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/b.txt</Key> <Size>6</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/c.txt</Key> <Size>4</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/d.txt</Key> <Size>14</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/lock</Key> <Size>0</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/sym.txt</Key> <Size>0</Size>
Directories are empty objects with a trailing /, the renamed directory reports became a set of new keys archive/…, the symbolic link is an empty object (GeeseFS keeps its target in object metadata, which Garage does not return in listings). d.txt is the write after the driver restart; the nosd volume's prefix is already gone: its PVC was deleted, and the driver removed the prefix.
Stopping a volume. When the Deployment is scaled to zero, the driver stops the service on NodeUnstageVolume; GeeseFS running as nobody cannot unmount itself, after 20 seconds (TimeoutStopSec from 50-StopProps.conf) systemd kills it, and ExecStopPost runs umount -f -l:
user@k8s-node-2:~$ sudo journalctl -u 'geesefs-*' -o cat --since '-5min'
...
2026/10/30 13:38:30.580733 main.INFO Received terminated, attempting to unmount...
Stopping geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service - GeeseFS mount for Kubernetes volume k8s-volumes/pvc-f2b79471-30fc-4c02-9008-49899e70f87e...
2026/10/30 13:38:50.617082 main.ERROR Failed to unmount in response to terminated: exit status 1: /usr/bin/fusermount3: failed to unmount /var/lib/kubelet/plugins/kubernetes.io/csi/ru.yandex.s3.csi/a0978cd9ba03768c44659165dd0bc3f0a40595d4597aeb9a3c3484fdce698bbd/globalmount: Permission denied
geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service: State 'stop-sigterm' timed out. Killing.
geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service: Killing process 329074 (geesefs) with signal SIGKILL.
geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service: Main process exited, code=killed, status=9/KILL
geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service: Failed with result 'timeout'.
Stopped geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service - GeeseFS mount for Kubernetes volume k8s-volumes/pvc-f2b79471-30fc-4c02-9008-49899e70f87e.
The mount is gone and the service has disappeared, but the drop-in directory /run/systemd/system/geesefs-….service.d stays until the node reboots. Here the files were written long before the stop, but GeeseFS uploads data asynchronously, and the SIGKILL after 20 seconds is dangerous for a volume whose writes have not reached S3 yet: the application must close its files (fsync, close) before the pod stops. Deleting the PVC removes the prefix in the bucket:
$ kubectl -n kube-system logs csi-s3-provisioner-0 -c csi-s3 | grep -E 'DeleteVolume|removed'
I1030 13:39:22.351104 1 driver.go:119] GRPC call: /csi.v1.Controller/DeleteVolume
I1030 13:39:22.359356 1 controllerserver.go:130] Prefix pvc-f2b79471-30fc-4c02-9008-49899e70f87e removed
user@s3:~$ sudo garage -c /etc/garage.toml bucket info k8s-volumes | sed -n '/Size/,/Objects/p'
Size: 0 B (0 B)
Objects: 0
Cleanup
The drivers are removed with the same manifests (kubectl delete -f), taking their StorageClasses and the local-path-storage and storage-credentials namespaces with them; the Retain PV (k8s-node-1-disk1) is deleted by hand. On the nodes k8s-csi-s3 leaves the directory /var/lib/kubelet/plugins/ru.yandex.s3.csi with a copy of the GeeseFS binary and the service drop-ins in /run/systemd/system, local-path leaves an empty /opt/local-path-provisioner, plus the driver images and busybox:latest — all removed by hand (crictl rmi with exact names). The loop disk: umount, losetup -d, delete the image file.
Sources
- Kubernetes: Volumes — local and Persistent Volumes — the
localvolume, mandatorynodeAffinity, access modes,RetainandReleased. - Kubernetes: Storage Classes — Local and Storage Capacity —
kubernetes.io/no-provisioner,WaitForFirstConsumer, free space tracking by the scheduler. - Local Path Provisioner (release v0.0.37) — installation,
nodePathMap,sharedFileSystemPath,volumeType, the helper pod, ignored capacity. - sig-storage-local-static-provisioner — automatic
localPVs for node disks. - TopoLVM — an LVM-based CSI driver, capacity tracking, snapshots of thin volumes.
- Amazon S3 API Reference: ListObjectsV2 and Signature Version 4 — listing by prefix and request signing; curl: --aws-sigv4 — signing from the command line.
- Linux kernel: FUSE — file systems in user space.
- k8s-csi-s3 (release v0.43.7) and GeeseFS — installation, buckets and prefixes, running GeeseFS through systemd and
--no-systemd, the POSIX compatibility matrix and the list of deviations. - Mountpoint for Amazon S3 CSI Driver and Mountpoint file system behavior — static provisioning only, sequential writes,
renameonly in S3 Express One Zone. - Garage: Quick Start and Configuration file format — installation, cluster layout, keys and buckets,
s3_region; the MinIO repository — project status. - Longhorn: Architecture and Concepts, Installation Requirements and Best Practices — engine and replicas, iSCSI and NVMe/TCP, minimum requirements.
- Rook: Prerequisites and Ceph: Architecture — devices for OSDs, the
rbdmodule, CRUSH, monitors. - OpenEBS Documentation — local and replicated volumes, NVMe over TCP.
- CloudNativePG: Storage and Backup — local storage and shared-nothing, backups to S3 through the Barman Cloud Plugin; RabbitMQ Cluster Operator and Quorum Queues.
- Prometheus: Storage and SQLite: How To Corrupt An SQLite Database File — why databases are not put on network and non-POSIX file systems.
Next — Kustomize: how to stop copying manifests between environments, base and overlays, patches, ConfigMap and Secret generators, and kubectl apply -k.
Only registered users can leave a comment.
Sign in or register to leave a comment.