mountx logomountx

Virtual machines

#Mounting into a VM

Serve the driver on your host, mount it inside the guest.

Linux already has both clients this needs built into the kernel — 9P and NFS — and mountx serves either one over an ordinary TCP socket. So one mount command inside the VM turns your driver into a real folder there, with nothing installed in the guest: no agent, no shared-folder daemon, no guest additions.

#Which transport?

your VMusewhy
QEMU9Pits default networking reaches the host already
FirecrackerNFSv4.1its guest kernels have no 9P client
Anything elseeitherwhichever client the guest kernel has

Serving never needs root, as usual. Only the mount inside the guest does — and root in a throwaway VM is free.

#QEMU

QEMU's default networking routes 10.0.2.2 to your host's loopback. That one fact does all the work: no tap device, no bridge, no ip commands on the host, no root on the host, and the server can stay bound to 127.0.0.1 where nothing else on your network can reach it.

#1. Serve on the host

serve.ts
import { createP9Server } from "mountx/9p";
import { createMemoryDriver } from "mountx/drivers/memory";

const driver = createMemoryDriver();

const server = await createP9Server(driver, { port: 5640 }).listen();
console.log(`serving 9P on 127.0.0.1:${server.port}`);

Leave it running. The memory driver is just a stand-in — any driver works, which is the whole point.

#2. Boot a guest

Any Linux with a 9P client, which is nearly all of them. Alpine's virt image is a good tiny one to start with:

qemu-system-x86_64 -enable-kvm -m 512 -nographic \
  -cdrom alpine-virt-3.21.3-x86_64.iso \
  -nic user

-nic user is the default networking, spelled out. Log in as root with no password.

#3. Mount, inside the guest

mkdir -p /mnt/x
mount -t 9p -o trans=tcp,port=5640,version=9p2000.L 10.0.2.2 /mnt/x

That is the entire client side. ls /mnt/x is now your driver.

Three options, and nothing else is needed:

  • trans=tcp,port= — 9P over a socket. The address is the mount source and the port is its own option, which is why there is no :5640 after 10.0.2.2.
  • version=9p2000.L — the Linux dialect, the only one mountx speaks. Note the capital P.
  • No modprobe: mount -t 9p loads the modules itself. The cache and access defaults are already what you want.

Caution

The source must be a dotted-quad IPv4 address. The kernel accepts nothing else here — 10.0.2.2 works, localhost and ::1 do not. More.

Note

If the guest has no network yet. Minimal images sometimes boot with eth0 down. One line gets you to the host:

ip addr add 10.0.2.15/24 dev eth0 && ip link set eth0 up

Add ip route add default via 10.0.2.2 too if you want the guest online generally — the mount itself does not need it.

#Making it faster

msize is the knob that matters for bulk I/O. It defaults to 128 KiB; 1 MiB is the maximum both mountx and the kernel accept:

mount -t 9p -o trans=tcp,port=5640,version=9p2000.L,msize=1048576 10.0.2.2 /mnt/x

See Tuning for the rest. Read cache=none before raising the cache mode — 9P cannot invalidate a guest's cache, so anything above none assumes nothing but the guest changes your driver.

#This is not -virtfs

The names collide, so it is worth separating. QEMU has its own built-in 9P server behind -virtfs/-fsdev, and it can only export a directory on your host — there is no way to point it at mountx.

So mountx connects over TCP instead. You give up virtio's shared memory and get the thing you wanted: a filesystem that is your JavaScript rather than a host directory.

#Firecracker

Firecracker cannot do 9P at all — it has no virtio-9p device, and its published guest kernels are built without the 9P client. No option turns it on.

Those kernels do have the NFS client, NFSv4.1 included (but not NFSv3). So Firecracker means NFSv4.1.

Kernels vary, so check yours if you are not using Firecracker's own:

zcat /proc/config.gz 2>/dev/null || cat "/boot/config-$(uname -r)"

and grep for CONFIG_NET_9P, CONFIG_NFS_V4_1 and CONFIG_NFS_V3.

#1. Set up a tap device

Firecracker has no user-mode networking, so every guest network is a tap device. This is the one step QEMU does not need, and the only one that needs privileges on the host:

ip tuntap add dev tap0 mode tap
ip addr add 172.20.0.1/24 dev tap0
ip link set tap0 up

The host is 172.20.0.1, the guest will be 172.20.0.2.

#2. Serve on the host

The guest arrives from the tap subnet rather than loopback, so the server has to be told to accept it:

serve.ts
import { createNfsServer } from "mountx/nfs";
import { createMemoryDriver } from "mountx/drivers/memory";

const server = await createNfsServer(createMemoryDriver(), {
  port: 2049,
  host: "172.20.0.1", // the tap address, not 0.0.0.0
  allowRemote: true, // the guest is not on loopback
}).listen();

Caution

allowRemote: true exports your driver to anything that can reach the socket — NFS authenticates nothing here. Bind to the tap address rather than 0.0.0.0, and treat the network as your only boundary. Same posture as the NFS page.

#3. Boot the guest

Let the kernel configure the network with an ip= boot argument and the guest needs no network commands of its own:

vm.json
{
  "boot-source": {
    "kernel_image_path": "vmlinux",
    "boot_args": "console=ttyS0 reboot=k panic=1 pci=off ip=172.20.0.2::172.20.0.1:255.255.255.0::eth0:off"
  },
  "drives": [
    {
      "drive_id": "rootfs",
      "path_on_host": "rootfs.ext4",
      "is_root_device": true,
      "is_read_only": false
    }
  ],
  "network-interfaces": [{ "iface_id": "eth0", "host_dev_name": "tap0" }],
  "machine-config": { "vcpu_count": 2, "mem_size_mib": 1024 }
}
firecracker --no-api --config-file vm.json

#4. Mount, inside the guest

mount -t nfs4 -o vers=4.1,port=2049,proto=tcp,sec=sys,hard,addr=172.20.0.1,clientaddr=172.20.0.2 \
  172.20.0.1:/ /mnt/x

vers=4.1 is what mountx implements and what that kernel has. There is no rpcbind to run and no MOUNT service to set up, which is part of why v4 is the easier half to serve into a VM.

Note

No nfs-common needed in the guest either. Firecracker's own root image has no mount.nfs at all, and the command above still works: spelling out addr= and clientaddr= leaves nothing for a helper to resolve, so mount goes straight to the kernel. That is what lets a microVM image stay empty.

#Other hypervisors

The rule generalises: serve over TCP, mount with whatever client the guest kernel has.

  • Cloud Hypervisor, or QEMU with a tap or bridge — the same 9P recipe, with your host's address on that network in place of 10.0.2.2. Still a dotted-quad literal.
  • WSL2, Multipass, Lima, UTM, VirtualBox, Proxmox — a Linux guest with a route to the host is all it takes. 9P if the kernel has it, NFSv4.1 if not.
  • macOS guestsNFS only; no BSD kernel has a 9P client.

Two things to keep in mind wherever you land:

  • Bind deliberately. Both servers bind 127.0.0.1 and refuse non-loopback peers by default. Reaching them from a guest on a real network means allowRemote: true, which exposes the driver to everything that can reach the socket.
  • The guest is a second client. Both servers accept several connections, but 9P grants every lock and only serialises renames per connection. Mounting one driver from the host and a guest at the same time is not what either transport is built for.

#Next

mountx logo

mountx  Write a filesystem in JavaScript, mount it as a real folder.