The NFS DaemonSet
This is the part that took me the longest. NFSv3 in a containerised, hostNetwork pod-on-Kubernetes context is genuinely difficult, and the init containers in this manifest exist for very specific reasons that I’m going to spell out because I forgot all of them once already and had to re-debug from scratch.
Explanation
Section titled “Explanation”NFSv3 has multiple processes (rpcbind, mountd, nfsd, statd, lockd) that talk to each other via RPC. They register themselves with rpcbind on UDP/TCP 111 so clients can ask “where’s mountd?” and get back a port number.
Two things make this messy:
rpcbindis a singleton. Only one process can own port 111 on a given network namespace. If the host is runningrpcbind(which Ubuntu does by default), and the pod is also runningrpcbindon the same hostNetwork… one of them loses, and worse, which one loses is non-deterministic.mountdregisters with whateverrpcbindanswered first. If hostrpcbindis running and the pod’srpcbindregistersmountdto the host’s instance, NFS clients will hit the hostrpcbind(port 111), be toldmountdlives on port X, but actuallymountdis bound to the pod’s namespace and isn’t reachable from the host’s. The whole thing fails silently.
So: the pod must own port 111. That means stopping/masking the host’s rpcbind from inside the pod. Which is a privileged operation. Which is why this pod is the way it is.
About the container image
Section titled “About the container image”The image is andrewkidd/nfs-server, which is a fork of ehough/docker-nfs-server. I went the fork route because the upstream image had two issues that mattered for me:
- No ARM build. ehough/docker-nfs-server#77 tracked the request for ARM/arm64 images, which was open and unresolved when I needed it. The Raspberry Pi obviously can’t run an x86 image, so a multi-arch build was non-negotiable.
- rpcbind, mountd, and statd were not configurable enough for a hostNetwork pod. ehough/docker-nfs-server#34 covered the lack of port pinning for the RPC services. Without pinned
MOUNTD_PORT/STATD_PORT/LOCKD_*env vars, every restart picks fresh ephemeral ports, which is a mess on hostNetwork where you want predictable port numbers across nodes. I submitted ehough/docker-nfs-server#95 with the changes that finally let me run it inside a DaemonSet sanely.
The fork is andrewkidd/nfs-server; the image tag in this manifest (v2.2.7) is from that fork. If you trace this back to the original Pi-netboot guide you’ll notice the same image was used in the Docker Compose version — turns out a container that works on hostNetwork in Docker also works on hostNetwork in Kubernetes, with most of the same env vars and the same privileged-mode requirements.
Implementation
Section titled “Implementation”Reading nfs-deployment.yaml top to bottom:
Three init containers, doing three different unpleasant things
Section titled “Three init containers, doing three different unpleasant things”initContainers: - name: tune-inotify # bumps fs.inotify limits via nsenter into pid 1's namespace - name: disable-host-rpcbind # stops/disables/masks the host's rpcbind via nsenter + systemctl - name: load-nfsd # modprobes nfsd/nfs/lockd, sets up /var/lib/nfs state, mounts /proc/fs/nfsdEach of these uses nsenter --mount=/proc/1/ns/mnt --pid=/proc/1/ns/pid to break out into the host’s mount and PID namespaces. Without that, systemctl inside the pod would just talk to its own (empty) systemd; we need it to talk to the host’s systemd to disable host services.
tune-inotify
Section titled “tune-inotify”- name: tune-inotify image: alpine:3.20 securityContext: privileged: true command: ["/bin/sh","-c"] args: - | nsenter --mount=/proc/1/ns/mnt --pid=/proc/1/ns/pid -- sysctl -w \ fs.inotify.max_user_instances=512 \ fs.inotify.max_user_watches=524288 || truerpc.mountd opens inotify watches on /etc/exports.d/ and a few related paths. On a default Ubuntu Pi, the kernel inotify limits are low enough that mountd can fail with EMFILE (“too many open files”) on a busy cluster. Bumping these values on the host makes the failure go away.
I tried setting these via a securityContext.sysctls on the pod first; turns out fs.inotify.* aren’t namespaced and have to be set on the host, hence the nsenter dance.
disable-host-rpcbind
Section titled “disable-host-rpcbind”- name: disable-host-rpcbind image: alpine:3.20 securityContext: privileged: true command: ["/bin/sh","-c"] args: - | nsenter --mount=/proc/1/ns/mnt --pid=/proc/1/ns/pid -- sh -c ' systemctl stop rpcbind.socket rpcbind.service || true systemctl disable rpcbind.socket rpcbind.service || true systemctl mask rpcbind.socket rpcbind.service || true 'This is the load-bearing one. Stop the host’s rpcbind, disable it (so it doesn’t restart on next boot), and mask it (so nothing accidentally starts it).
Once this runs, port 111 on the host network namespace is free for the pod’s rpcbind to claim. Which it does as soon as the main container starts.
load-nfsd
Section titled “load-nfsd”- name: load-nfsd image: alpine:3.20 securityContext: privileged: true command: ["/bin/sh","-c"] args: - | apk add --no-cache kmod util-linux >/dev/null 2>&1 || true modprobe nfsd || true modprobe nfs || true modprobe lockd || true mkdir -p /var/lib/nfs/rpc_pipefs /var/lib/nfs/sm /var/lib/nfs/sm.bak /var/lib/nfs/v4recovery : > /var/lib/nfs/state chown 0:0 /var/lib/nfs/state && chmod 600 /var/lib/nfs/state || true nsenter --mount=/proc/1/ns/mnt -- sh -c ' if ! mountpoint -q /proc/fs/nfsd 2>/dev/null; then mount -t nfsd nfsd /proc/fs/nfsd || true fi if [ -w /proc/fs/nfsd/versions ]; then printf -- "-2 +3 -4 -4.1 -4.2\n" > /proc/fs/nfsd/versions 2>/dev/null || true fi ' volumeMounts: - name: modules mountPath: /lib/modules readOnly: true - name: nfs-state mountPath: /var/lib/nfsThree jobs here:
- Load kernel modules. The NFS server runs in userspace but
nfsditself is a kernel facility.modprobe nfsd/nfs/lockdmakes sure the kernel is ready. - Initialise
/var/lib/nfson the persistent volume. This is the state directory NFS uses forsm/sm.bak/v4recovery. Putting it on a PVC means recovery state survives pod restarts. - Pin NFSv3 only. Writing
-2 +3 -4 -4.1 -4.2to/proc/fs/nfsd/versionsdisables NFSv2 (long-deprecated) and the v4 family. The Pi firmware can’t boot from NFSv4 anyway. Disabling v4 also silences the “Unsupported version” warnings that would otherwise spam dmesg.
The main container
Section titled “The main container”containers: - name: nfs image: andrewkidd/nfs-server:v2.2.7 securityContext: privileged: true capabilities: add: [SYS_ADMIN] lifecycle: postStart: exec: command: ["/bin/sh","-c","mkdir -p /var/lib/nfs/rpc_pipefs"] env: - { name: NFS_VERSION, value: "3" } - { name: NFS_EXPORT_0, value: "/mnt/nfsshare *(rw,fsid=0,async,no_subtree_check,no_auth_nlm,insecure,no_root_squash)" } - { name: NFS_LOG_LEVEL, value: "DEBUG" } - { name: MOUNTD_PORT, value: "32767" } - { name: STATD_PORT, value: "32765" } - { name: LOCKD_TCPPORT, value: "4045" } - { name: LOCKD_UDPPORT, value: "4045" } ports: # 111, 2049, 4045, 32765, 32767 both TCP and UDP volumeMounts: - { name: os, mountPath: /mnt/nfsshare } - { name: nfs-state, mountPath: /var/lib/nfs }volumes: - name: os persistentVolumeClaim: { claimName: pi-netboot-os } - name: modules hostPath: { path: /lib/modules, type: Directory } - name: nfs-state persistentVolumeClaim: { claimName: pi-netboot-nfs-state }Key bits:
privileged: true+SYS_ADMIN— needed to mountnfsd, manage exports, listen on privileged ports.fsid=0in the export — makes/mnt/nfssharethe root of the NFS namespace. The Pi cmdline can then mount it directly withnfsroot=192.168.1.59:/.no_auth_nlmandinsecure— disable client port restrictions (clients can come from non-privileged ports), and disable lock manager auth. Necessary because the Pi firmware is not running with a particularly opinionated NFS client.no_root_squash— UID 0 inside the NFS share is real UID 0. Required because the Pi runs as root during early boot.- Pinned ports for mountd/statd/lockd — without pinning, these would pick ephemeral ports on each startup, and any firewall rules you wrote would go stale.
/lib/moduleshostPath — the pod uses the host’s kernel modules tomodprobe nfsd. Without this, the modprobe would fail because the container image has no modules for the host kernel.
What you should see after this lands
Section titled “What you should see after this lands”$ kubectl -n ainur-netboot get ds nfsNAME DESIRED CURRENT READY UP-TO-DATE AVAILABLEnfs 2 2 2 2 2
$ rpcinfo -p 192.168.1.59 program vers proto port service 100000 4 tcp 111 portmapper 100000 4 udp 111 portmapper 100005 3 udp 32767 mountd 100005 3 tcp 32767 mountd 100003 3 udp 2049 nfs 100003 3 tcp 2049 nfs 100024 1 udp 32765 status 100024 1 tcp 32765 status
$ showmount -e 192.168.1.59Export list for 192.168.1.59:/mnt/nfsshare *If rpcinfo only shows portmapper and no mountd/nfs, then the host’s rpcbind is still running. Confirm with ssh node 'systemctl status rpcbind' — should be inactive (dead). If it’s running, the disable-host-rpcbind initContainer didn’t do its job; check the pod’s init container logs.
Next Steps
Section titled “Next Steps”HTTP + Bootstrap → — the third hostNetwork pod, plus the clever k3s leader-token share.