A CSI driver (csi.daos.io) for DAOS POSIX containers, mounted with dfuse. It targets clusters
where pods run with user namespaces (hostUsers: false), under gVisor, or under the restricted
Pod Security level, and where applications use the libpil4dfs interception library.
Pods with hostUsers: false need a dfuse that allows idmapped mounts. There are two variants:
- The upstream patch
dfuse --allow-idmap(proposed to DAOS; a dfuse option plus a fused update withFUSE_CAP_ALLOW_IDMAP), on DAOS master or ported to 2.8.0. SetdfuseArgs: "--allow-idmap"on the StorageClass. - An earlier, broader version of the patch for DAOS 2.8 (not proposed upstream). It always allows idmapped mounts, and its libpil4dfs connects by itself when dfuse refuses its handles.
With stock DAOS everything else works.
controller (Deployment) node (DaemonSet, privileged)
PVC ──► csi-provisioner ──► CreateVolume kubelet ──► NodeStageVolume
daos container create └► mount pod: dfuse --foreground
<pool> <pvc-name> --type=POSIX on .../globalmount (one per
volume and node)
kubelet ──► NodePublishVolume
└► bind mount into the pod
runc ──► idmaps the bind mount for
hostUsers: false pods
- Volumes are DAOS POSIX containers identified as
<pool>/<container>. Dynamic volumes get a new container named after the PV. Static PVs name an existing one involumeHandle. - Mount pods (
-mount-mode=pod, the default). Each volume used on a node gets one dfuse process, running in its own pod in thedaos-csinamespace. The node plugin can be restarted or upgraded without breaking running pods' volumes.-mount-mode=processruns dfuse inside the plugin container instead. That mode is simpler, but a plugin restart leaves running pods withTransport endpoint is not connected; the plugin remounts the staging path, but those pods have to be restarted. - Idmapped mounts. With
--allow-idmap(or the older always-on build), dfuse lets the container runtime idmap the bind mount for pods withhostUsers: false. This needs Linux 6.12 or later (Talos 1.9+, Ubuntu 24.04 HWE). Without it, or on older kernels, those pods fail withfailed to set MOUNT_ATTR_IDMAP ... invalid argument. - Mount pods run with
hostPID: true. With--allow-idmap, dfuse only shares its pool and container handles with interception libraries in its pid namespace or one nested in it. In the host's pid namespace, that covers every pod. - The
agent-socketinline volume exposes the node'sdaos_agent.sockto pods. libpil4dfs and the daos CLI need it. Unlike a hostPath volume, it is allowed by thebaselineandrestrictedPod Security levels. - Staging lives under
/var/lib/kubelet, so no extra kubelet mounts are needed (on Talos, for example).
StorageClass parameters (dynamic) or PV csi.volumeAttributes (static):
| key | meaning |
|---|---|
pool |
DAOS pool (label or UUID). Required for dynamic volumes |
oclass, dirOclass, fileOclass, chunkSize, properties |
passed to daos container create |
group |
owner group of the container (group@) |
acl |
extra container ACL entries, comma separated, e.g. A::admin@:rwta |
rootUid, rootGid, rootMode |
owner/mode applied to the container root when it is first mounted, so non-root pods can write without fsGroup |
dfuseArgs |
extra dfuse arguments, e.g. --allow-idmap for pods with hostUsers: false, --disable-caching for data written from several nodes, --thread-count=N |
dfuseCpuRequest, dfuseCpuLimit, dfuseMemoryRequest, dfuseMemoryLimit |
resources of this volume's dfuse in its mount pods, overriding the node plugin's defaults (see "dfuse resources") |
DAOS ACLs apply to the credential that daos_agent gives each process. The agent derives it from
the process's uid as seen on the node:
- dfuse in the mount pods runs as root and reaches DAOS as
root@. The controller also creates containers asroot@, so root isOWNER@of dynamic volumes. For static volumes, giveroot@(or the owner you use) an entry in the pool and container ACLs. - Pods using the volume through dfuse do not need DAOS permissions of their own. dfuse
(
--multi-user,default_permissions) enforces POSIX modes against the pod's uid. - Pods using libpil4dfs with dfuse's handles reach DAOS as dfuse does.
- Interception libraries that connect by themselves (libioil after a refusal, the first
version's libpil4dfs, gVisor) need DAOS permissions of their own. With
hostUsers: falsetheir node-side uid is random and has no passwd entry, so the agent needscredential_config.client_user_map.default, and that principal needs an ACL entry (aclparameter). All such pods then look the same to DAOS.
dfuse shares its pool and container handles only with processes of its own uid and gid (root in
the mount pods). With --allow-idmap, it also requires them to be in its pid namespace or one
nested in it.
| Pod | Upstream patch (--allow-idmap) |
First version (patches/) |
|---|---|---|
hostUsers: true, root (the uid of dfuse) |
libpil4dfs and libioil import dfuse's handles (ioctl) | the same |
hostUsers: false, or another uid |
dfuse refuses its handles. libpil4dfs does not intercept (op_sum 0), and I/O goes through dfuse. libioil connects by itself with the pod's credential |
libpil4dfs asks dfuse for the pool and container UUIDs (allowed for everyone) and connects with the pod's own credential |
gVisor (runtimeClassName: gvisor) |
the volume shows up as 9p in the sandbox. libpil4dfs needs D_IL_MOUNT_POINT, D_IL_POOL and D_IL_CONTAINER, and then connects by itself |
the same |
To see why libpil4dfs did not intercept, run with
D_LOG_FILE=/tmp/il.log D_LOG_MASK=DEBUG DD_MASK=all, then
grep retrieve_handles_from_fuse /tmp/il.log. "Operation not permitted" means that dfuse refused
its handles.
Requirements:
-
The pod mounts the
agent-socketinline volume at/var/run/daos_agent. -
Traffic from pods to the DAOS servers must not be NATed. DAOS servers talk back to the client's pod IP, so the servers need a route for the pod CIDR, and the CNI must not masquerade traffic to the storage network.
- With Cilium, use
ipMasqAgent/nonMasqueradeCIDRs. - With Calico, add an IPPool for the storage network with
disabled: true. - With flannel (Talos' default) and other iptables CNIs, deploy
deploy/daos-pod-nonat.yaml.--ip-masq=falsealone does not help: flannel's CNI plugin then turns on per-pod masquerading in the bridge plugin.
Symptoms of NAT:
DER_TIMEDOUTin the pod, andNA_TIMEOUT ... from ofi+tcp://<pod ip>in the engine log. - With Cilium, use
-
For gVisor, runsc needs
host-uds = "open". On Talos this is set in the machine config (see below).
See examples/pil4dfs-restricted.yaml and examples/gvisor.yaml.
-
Run
daos_agenton every node, either as a host service or asdeploy/daos-agent.yaml. Setdisable_auto_evict: true: otherwise restarting the agent evicts dfuse's pool handles and every mount on the node breaks.In a container, the agent turns uids into ACL principals using the container's
/etc/passwd, not the node's.client_user_maponly applies to uids that are unknown there. The DaemonSet therefore replaces/etc/passwdand/etc/groupfrom its ConfigMap. Without that, for example, uid 1000 would becomeubuntu@in an Ubuntu-based image. -
Build the image from
Containerfileon top of an image with the patched DAOS client (BASE), e.g. one built from a client tarball withContainerfile.client-ubuntu. Docker and Podman both work (checked with Docker 24 and 27). Docker needs-f Containerfile, as it only looks for aDockerfileby default. -
Set
DAOS_CSI_IMAGEindeploy/daos-csi.yaml, set the StorageClasspool, then apply.
The chart in helm/daos-csi deploys the same objects as deploy/. The agent DaemonSet, the
no-NAT DaemonSet and StorageClasses are optional, and so is a ServiceMonitor for the Prometheus
Operator. Its values.yaml describes every setting.
The namespace must allow privileged pods: the node plugin creates its mount pods there.
kubectl create namespace daos-csi
kubectl label namespace daos-csi pod-security.kubernetes.io/enforce=privileged
helm install daos-csi helm/daos-csi -n daos-csi -f my-values.yaml
A minimal my-values.yaml, with daos_agent as a DaemonSet:
image: {repository: registry.example.com/daos-csi, tag: "0.1.0"}
agent:
enabled: true
config:
access_points: ['10.0.0.1']
include_fabric_ifaces: [eth0]
storageClasses:
- name: daos
parameters: {pool: tank, dfuseArgs: "--allow-idmap"}agent.config is merged with the defaults of values.yaml (disable_auto_evict,
client_user_map, and so on). To drop a default key, set it to null.
The controller uses the host network and its metrics port, so its rolling update stops the old
pod before starting the new one (maxSurge: 0).
Each volume used on a node has one dfuse, in its mount pod, however many pods use the volume
there. The settings below apply to mount pods (-mount-mode=pod, the default). With libpil4dfs or libioil, I/O runs in the application's own process and is accounted to
its container. Idle dfuse threads wait without polling.
The resources of dfuse are set as follows:
- Defaults come from the node plugin's flags
-mount-pod-cpu-request,-mount-pod-cpu-limit,-mount-pod-memory-requestand-mount-pod-memory-limit(Helm:node.mountPodResources). - A volume can override each of them with the StorageClass parameters
dfuseCpuRequest,dfuseCpuLimit,dfuseMemoryRequestanddfuseMemoryLimit(or PVvolumeAttributes). - When they apply. They are set when the mount pod is created, i.e. when the volume is first used on the node. A running mount pod keeps its resources. Restarting it would break the pods using the volume.
- Validation. The controller checks the syntax and that no request exceeds its limit, so a bad StorageClass fails at provisioning.
Things to know:
- A limit alone reserves nothing. The driver then sets the request to 0, where Kubernetes would
default it to the limit. Mount pods are created with
nodeNameset, so the scheduler does not know about them. kubelet admits them on its own, and as they aresystem-node-critical, it evicts lower-priority pods to make room for their requests ("Preempted in order to admit critical pod"). A limit of 2 CPUs on 50 volumes must not reserve 100 CPUs. Set a request only to reserve on purpose, and keep it small. - Requests that cannot fit fail the mount: kubelet rejects the mount pod
(
UnexpectedAdmissionError ... preemption, orOutOfcpu). The driver deletes the rejected pod, reports the reason in the pod'sFailedMountevent, and tries again with backoff. - A CPU limit caps dfuse with CFS throttling. In the lab, 200m brought one writer from 135–180 to 40–55 MB/s over several runs.
- Thread count. dfuse sizes its thread pool from the CPUs it may run on (up to 16, or one per
CPU of its cpuset), not from a CPU limit. More threads than the limit allows only get throttled.
So with a CPU limit, the driver adds
--thread-count= the limit rounded up plus the event queues (--eq-count, 1 by default): a limit of 2 CPUs gives 3 threads. A--thread-countindfuseArgsis kept as is. - A memory limit kills dfuse when it is exceeded. Its mount pod restarts dfuse, but pods that were using the volume keep a dead mount until they are restarted. Leave headroom.
- Pinning. With kubelet's static CPU manager, requests equal to limits in whole CPUs give dfuse cores of its own, and it then sizes its threads to them.
-max-volumes-per-node=N (Helm: node.maxVolumesPerNode) is reported to Kubernetes as the
node's volume limit for this driver. The scheduler then places pods on a node only while the
distinct DAOS volumes used there stay within N. That is the number of dfuse processes, so N times
the per-volume CPU and memory limits bounds what dfuse can use on the node. 0 (the default) means
no limit.
The node plugin probes every volume staged on its node, by default once a minute
(-health-interval, 0 to disable). The probe is a statfs of the staging path. dfuse answers it
with a DAOS pool query, a round trip to the servers that the kernel never caches, so the probe
fails when dfuse or its network path is stuck, e.g. on a dead network port. Probes run in the
background and never block CSI calls, even on a hung mount.
-
Unhealthy. A volume whose probe fails, goes unanswered for
-health-timeout(30s), or whose staging path is not mounted getsdaos_csi_volume_healthy{volume}0 and a log line. -
Kill and restart. After
-health-kill-after(5m) of that, the dfuse of the volume is killed. Its mount pod starts a new one (in-mount-mode=process, the plugin does).- If the servers are still unreachable, the new dfuse mounts once they are back.
- Every new dfuse gets the whole
-health-kill-afterfrom when its mount appears. Right after an outage it may still be connecting, and answers of probes of the dfuse it replaced do not count. - The kill is the only disruptive step. The default is long enough that a slow but working DAOS system does not lose its mounts. 0 turns it off.
-
Pods' mounts. A running container keeps the mount of the dfuse it started with, and that mount is dead now. The plugin bind-mounts the new dfuse on every pod's mount point of the volume on the node (
daos_csi_targets_rebound_total), and the container's next start uses it. So an application recovers by itself if its liveness probe covers the volume, e.g.:livenessProbe: exec: {command: [stat, -f, /data]} # the volume's mount path periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3
Without such a probe, restart the pod.
-
Lab results. Both a frozen dfuse (
SIGSTOP) and a node cut off from the DAOS servers (iptables) were detected. dfuse was replaced, and a pod with the probe above read its data again without being recreated (lab suitek8s-csi-health). -
Space.
NodeGetVolumeStatsanswers from the last successful probe, without touching the mount, so kubelet'skubelet_volume_stats_*metrics show the pool's space. kubelet must havevolumeStatsAggPeriodabove 0; kubeadm writes 0s, which turns them off. CSI 1.13 dropped the volume condition, and its successor is alpha, so health is reported through the metrics above. -
Cost. Each probe is a pool query with space information, which the pool service gathers from the pool's engines. With many nodes and volumes, raise the interval: 1000 nodes with 10 volumes each and the default interval make about 170 pool queries per second.
-http-endpoint=<host:port> serves three paths. The manifests use port 9810 on the node plugin
and 9811 on the controller, both on the host network, with prometheus.io/* annotations.
/healthzand/readyzare for the liveness and readiness probes./metricsis in the Prometheus text format:
| Metric | Meaning |
|---|---|
daos_csi_operations_total{method,code} |
CSI calls by gRPC result code |
daos_csi_operation_duration_seconds{method} |
Call duration (histogram) |
daos_csi_dfuse_mounts_total{result} |
dfuse mounts of a staging path, ok or error |
daos_csi_dfuse_mount_duration_seconds |
Time until a started dfuse serves its staging path |
daos_csi_stale_mounts_total{where} |
Mounts whose dfuse had died, found and cleared: staging, or target (a pod's mount, replaced when the pod is published again) |
daos_csi_staged_volumes |
dfuse mounts at this node's staging paths (node plugin), read from mountinfo |
daos_csi_volume_healthy{volume} |
1 if the volume's dfuse answered its last health probe in time |
daos_csi_health_probe_failures_total{reason} |
failed probes: timeout, error, dead (dfuse gone) |
daos_csi_dfuse_kills_total |
unresponsive dfuse processes killed |
daos_csi_targets_rebound_total |
pods' mounts switched to a volume's new dfuse |
daos_csi_staged_volumes deliberately does not stat the mounts, because a stat of a hung FUSE mount
blocks. Hung mounts are found by the health checks.
Logging options:
-log-level=debug|info|warn|error. Atdebugthe full request of every call is logged.-log-format=text|json.
Each call is logged with its method, volume, target path and duration.
go vet ./... && go test -race ./...
The unit tests replace the mount layer (mounter) with an in-memory fake. Among them:
- concurrent publishes and unpublishes of one volume by several pods: each call must act on its own target;
- replacing a stale pod mount;
- mount pod resources and the thread count derived from a CPU limit;
- health checks on a fake node (staging area, pods'
vol_data.json, mountinfo,/proc):- a hung dfuse is found and killed, but not before
-health-kill-after, and not when that is 0; - a new dfuse after an outage gets the whole
-health-kill-after; - a dfuse that is gone is left to its mount pod;
- pods' mounts of an old dfuse are mounted again;
- a hung dfuse is found and killed, but not before
- mountinfo parsing, the mount pod spec, and the metrics format.
Mounting itself is tested in a lab cluster (kubeadm and Talos) with Kubernetes test suites.
The driver, the agent DaemonSet and daos-pod-nonat run on Talos unchanged: staging lives under
/var/lib/kubelet, so no kubelet.extraMounts are needed. Tested with Talos v1.14.1 (Linux 6.18,
containerd 2.3) and the siderolabs/gvisor extension. Machine config used in the lab:
user.max_user_namespacessysctl set to a non-zero value (hostUsers: false)- gVisor host UDS: a runsc config under
/varplus a CRI customization pointing therunscruntime at it (machine.files,/etc/cri/conf.d/20-customization.part):[plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runsc.options] TypeUrl = "io.containerd.runsc.v1.options" ConfigPath = "/var/etc/runsc/runsc.toml" # contains: [runsc_config] host-uds = "open"
daos-pod-nonat(or a CNI configured not to masquerade the storage network) for libpil4dfs. The DAOS servers need a route for the pod CIDR.
The same scenario suite (hack/run-scenarios.sh) ran on:
- kubeadm: 1 node, Linux 7.2, Kubernetes 1.37, containerd 2.4, runc 1.5, gVisor 20260921.
- Talos: v1.14.1, Linux 6.18, Kubernetes 1.37, containerd 2.3, gVisor extension 20260831.
Both used images built from Ubuntu 24.04 packages of DAOS 2.8 with the earlier version of the patch.
| Scenario | kubeadm | Talos |
|---|---|---|
dynamic PVC, RWX, pods with and without hostUsers: false sharing it (mount is idmapped in userns pods) |
✅ | ✅ |
| static PV for an existing container | ✅ | – |
non-root pod in a user namespace, volume with rootUid/rootGid/rootMode |
✅ | ✅ |
| node plugin restart, mount-mode=pod: running pods keep their volumes | ✅ | ✅ |
| node plugin restart, mount-mode=process: running pods get ENOTCONN, new pods work | ✅ (as described) | – |
daos_agent restart with disable_auto_evict: true (host service and DaemonSet) |
✅ | ✅ |
libpil4dfs, restricted namespace, hostUsers: false, uid 1000 |
✅ | ✅ (with daos-pod-nonat) |
| gVisor pod, CSI volume (through the gofer) | ✅ | ✅ |
gVisor pod, libpil4dfs with D_IL_* |
✅ | ✅ |
gVisor + hostUsers: false |
❌ runsc cannot join the pod's user namespace (setns ... invalid argument), a gVisor limitation |
❌ same |
| pod deletion unstages (mount pod removed); PVC deletion destroys the container | ✅ | ✅ |
Throughput from hack/bench.sh on the kubeadm lab node (1 GiB, 4 MiB blocks, write with fsync).
The single DAOS server runs in a VM with emulated NVMe, so compare the rows, not the numbers:
| Access path | write MB/s | read MB/s |
|---|---|---|
| runc, dfuse (CSI volume) | 144 | 208 |
runc, hostUsers: false, libpil4dfs |
250 | 417 |
| gVisor, dfuse through the gofer | 142 | 202 |
gVisor, libpil4dfs (D_IL_*, netstack) |
121 | 276 |
With runsc network = "host", libpil4dfs did not connect inside gVisor (0 intercepted calls).
This was not investigated further.