Running a FUSE filesystem inside gVisor: what broke
· by Plori Engineering
TL;DR. Inside gVisor, the runsc builds we ran capped FUSE writes at 4 KiB and returned ENOSYS for mmap on FUSE files. fsync returned before the daemon replied. Ordinary signals failed FUSE calls with EINTR, which broke git. We patched all four in our runsc fork. A small-file git checkout on the FUSE mount was still twice as slow under gVisor as on the host kernel. On upstream master, mmap on FUSE files still returned ENOSYS on October 11, 2026.
In September 2026, each plori agent ran in a gVisor sandbox, and its files lived on its own JuiceFS volume. Our previous post explains why we later replaced JuiceFS.
FUSE is a Linux interface that lets an ordinary program, the FUSE daemon, serve a filesystem. gVisor is a sandbox that implements Linux system calls in its own kernel, which runs as a normal host process. runsc is its container runtime. The sentry is the part of gVisor that handles system calls, including FUSE. We built our own runsc from a fork, with releases named .plori.1 to .plori.5.
Why run the FUSE daemon inside the gVisor sandbox?
We wanted to attach a user's volume to a sandbox that was already running. On runsc release-20260803.0, a host mount made after the sandbox started did not appear inside it. gVisor's file proxy receives its mounts when the sandbox starts.
So we ran the JuiceFS client inside the sandbox, on gVisor's /dev/fuse. On staging, the mount was ready in 0.3 s. A cold session start with a host mount took 4.6 s on staging and 8.0 s in production. All three are medians, and each staging figure comes from five runs.
Why are FUSE writes only 4 KiB inside gVisor?
The sentry sends a FUSE daemon writes larger than one page only if it offers FUSE_BIG_WRITES at FUSE_INIT and the daemon accepts. The sentry in upstream release-20260803.0 and release-20260907.0 did not offer it.
On staging, a 64 MiB write took 16,447 FUSE WRITE requests and ran at 34.6 MB/s. Our .plori.1 release added only the flag, and the same write took 575 requests at 160.2 MB/s. Each figure is the median of three runs. The object store was a local directory, so only the ratio is valid.
Pull request #14844 added the flag upstream on September 22, 2026. It first shipped in release-20260928.0.
Does mmap work on a FUSE file in gVisor?
No. On .plori.1, mmap on a regular FUSE file returned ENOSYS. That release changed only the write flag, so the gap came from upstream. Git maps files such as .git/config and pack files, so git could fail while every file was present.
On upstream master on October 11, 2026, mmap on a FUSE regular file still returned ENOSYS. Upstream issue #3234 has been open since July 2020. Our .plori.3 release added mmap.
Does fsync wait for the FUSE daemon in gVisor?
Not in the runsc builds we ran: fsync returned success before the daemon replied. A fake FUSE server delayed an EIO reply by 500 ms. On .plori.1, fsync returned in 0 to 1 ms and never reported the error.
Upstream master still sends FUSE_FSYNC without waiting for the reply. Our .plori.3 release made FSYNC and FSYNCDIR wait and return the daemon's error.
Why do FUSE system calls return EINTR inside gVisor?
The sentry waited for FUSE replies in an interruptible wait, as upstream did. A handled signal could end it with EINTR after the daemon had the request. Linux sends the daemon an INTERRUPT request instead, and the daemon decides whether the call ends with EINTR.
On September 18, 2026, git reset --hard on about 11,000 files failed in production with "Interrupted system call". Git's progress display uses a timer signal.
On staging, a probe made filesystem calls under concurrent writes and a 10 ms timer signal whose handler lacked SA_RESTART. In one run on .plori.3, 591 of 1,414 calls returned EINTR.
The same defect caused two more failures. Calls returned ENOENT or EEXIST on the wrong paths, because the daemon had already applied the interrupted request. git clone also died with SIGBUS. A page fault on a mapped file makes the sentry send a FUSE READ. An interrupted READ became a bus error, which SA_RESTART cannot restart.
Resending is unsafe, since the daemon may have applied the request. Our .plori.4 release made three FUSE waits killable, so only a fatal signal ends them. In the same probe, 0 of 4,200 calls returned EINTR, and both git clones completed.
On October 8, 2026, upstream master began returning ERESTARTSYS, so an interrupted call can restart with a new request. No release tag had this on October 11. An open pull request, #15523, adds FUSE_INTERRUPT and a killable wait. We have not tested either change.
Why is git checkout slow on a FUSE mount inside gVisor?
Creating one small file takes about six FUSE requests: LOOKUP, CREATE, SETATTR, WRITE, FLUSH and RELEASE. Only the WRITE carries file bytes. We measured 6.12 requests per 2 KiB file under gVisor and 6.18 on the host kernel.
We profiled a git checkout of 9,749 files on staging with .plori.5, using the same node, image and JuiceFS build in each case.
| Where the checkout wrote | Runs | Time |
|---|---|---|
| Local directory, host kernel | 1 | 0.78 s |
| Local directory, under gVisor | 1 | 7.00 s |
| JuiceFS FUSE mount, host kernel | 3 | 39.7 to 43.5 s |
| JuiceFS FUSE mount, under gVisor | 3 | 83.8 to 90.1 s |
Under gVisor, the JuiceFS daemon used about 80 percent of the checkout time.
In a create-and-write test, the daemon took about twice as long per file under gVisor (7.27 ms against 3.60 ms). We think its own system calls inside the sandbox cause most of the extra time. We did not prove this.
How did it end?
On September 25, 2026, we moved agent sessions to Firecracker microVMs. Each microVM runs a Linux guest kernel with its own FUSE client, so these runsc patches no longer apply.
What do these numbers cover?
The late mount, the in-sandbox mount and the 4 KiB baseline used upstream release-20260803.0. The rest used our fork, based on release-20260907.0. From .plori.3 on, it also had upstream changes up to September 15.
Two results come from production: the 8.0 s cold start and the git reset failure. The 8.0 s is the median of 976 starts from September 7 to 11, with a tail inflated by a load test. The mmap and fsync findings come from qualification tests of our fork. Everything else ran on our one staging node, which also ran CI.
We did not measure concurrent mounts, checkpoint and restore, or cache invalidation between clients. The checkout test used an in-cluster object store and no metadata replication, unlike production.
What should you test before you run FUSE under gVisor?
- Write 64 MiB in 1 MiB blocks. About 16,400 FUSE WRITE requests means 4 KiB writes.
- Run
git clone --progressonto the mount and watch for SIGBUS, exit code 135. - Delay a test daemon's EIO reply to
FSYNC. Check that fsync waits and fails. - Make filesystem calls under a 10 ms timer signal without
SA_RESTART. Count EINTR, ENOENT and EEXIST. - Repeat each test with the same daemon on the host kernel.
- If you attach volumes to running sandboxes, check that the mount appears.