skip to content

Why are bind mounts from macOS or Windows into a Docker Desktop container slow?

level: middleimportance: should knowfreq 47%

answer

  1. The files never left your laptop
  2. Two filesystems, one boundary between them
  3. Latency per call, not bandwidth
  4. Count the stat calls a build makes
  5. Move hot directories into named volumes

basics

~20 s

Files stay on the host filesystem, so every open, stat and read crosses the VM boundary over a file-sharing protocol. A native Linux bind mount is a kernel operation with no crossing, so metadata-heavy trees suffer most.

solid answer

~50 s

Docker Desktop cannot bind-mount a macOS or Windows directory directly, because the container's kernel is inside the VM and the files are outside it. Desktop shares the directory across the boundary with a file-sharing protocol — VirtioFS on current macOS versions, earlier gRPC-FUSE and osxfs; on the WSL 2 backend, Windows drives appear inside Linux at `/mnt/c` over a translation layer. The cost is per-operation latency, not bandwidth, so workloads that do millions of small `stat`, `open` and `readdir` calls (compilers walking headers, dependency trees, test watchers) are hit hardest, while copying one large file looks fine. The practical fixes all move hot data to the VM side: on Windows keep the repository inside the WSL 2 distribution's own ext4 filesystem rather than under `/mnt/c`; on macOS choose VirtioFS and put build outputs, object files and dependency caches in named volumes; share only the directories you actually need.

code

bash · 5 lines
bash
docker run --rm \
  -v "$PWD/src":/work/src:ro \
  -v digest-objs:/work/build \
  -w /work cpp-toolchain:1.4 \
  cmake --build build -j 8

go deeper

for a junior

Know that mounting your project folder into a container is slower on a Mac or Windows laptop than on Linux, and that the usual advice is to keep dependency and build directories out of the shared folder.

for a middle

Explain the mechanism: the files stay on the host, the kernel is in the VM, and each filesystem call becomes a request across the boundary — so the cost scales with the number of small operations, not the bytes moved.

for a senior

Demonstrate that you measure before you tune: time the same build inside and outside the shared mount, then restructure so only human-edited files cross while output and caches live on the VM side, and know which historical flags are now inert.

for a principal

Weigh the developer-experience cost against parity: decide how much local-loop speed is worth diverging from the production layout, and whether the team's answer is remote or containerised development environments rather than more laptop tuning.

### What a bind mount has to do here On a Linux engine, `-v /home/me/src:/src` is a kernel bind mount. The same filesystem, the same page cache, the same inode; there is no copying and no protocol. Cost is effectively zero. On Docker Desktop the container's kernel lives in the VM and your files live on APFS or NTFS outside it. To make `/src` exist inside the container, Desktop shares the host directory into the VM and the engine mounts the shared path into the container. Every filesystem call the container makes on that path — `openat`, `stat`, `getdents`, `read`, `write` — becomes a request that leaves the VM's kernel, crosses to a host-side server, is executed against the real filesystem and comes back. The mechanism has changed over the years — osxfs, then gRPC-FUSE, then VirtioFS on macOS; on Windows with the WSL 2 backend, Windows drives are surfaced inside Linux under `/mnt/c` by a translation layer — and each generation has cut the per-call cost, but none of them makes the crossing free. ### Why it feels random Throughput is not the problem; latency multiplied by call count is. `dd` a 400 MB file across a shared mount and it looks respectable. Run a build that opens 38,400 source and header files, stats each one for a timestamp, and re-stats them for every translation unit, and the same mount feels broken. That is why the complaint clusters around a specific set of workloads: C and C++ builds walking include paths, dependency directories with tens of thousands of tiny files, file watchers that poll, and test runners that scan a tree on every change. Anything that writes many small files — object files, coverage output, incremental compiler state — is doubly affected because writes cross the boundary too. ### A worked example A C++ email-digest builder — a daemon that links a handful of runtime shared libraries — is built in a container on a MacBook with the source tree bind-mounted from the Mac and CMake's build directory inside that same tree. An incremental build takes 11 minutes. The identical build in CI on a Linux host takes about 3. Nothing about the compiler flags differs; the difference is that the Linux build never leaves its own filesystem. The fix takes two lines. The source directory stays shared, read-only, because the developer edits it in an IDE on the Mac. The build directory — where the compiler writes objects, dependency files and the linked binary, and where it re-reads them thousands of times — moves to a named volume that lives inside the VM's own filesystem. The same incremental build then finishes in a bit over three minutes, because only the reads of source files still cross the boundary and the write-heavy half no longer does. ### The moves worth knowing **On the WSL 2 backend, location is everything.** A repository under `C:\Users\...` is reached from Linux at `/mnt/c/Users/...` and pays the translation cost on every call. The same repository cloned into the WSL distribution's own ext4 filesystem is native Linux I/O with no crossing, and this is usually a several-fold difference — far larger than any tuning flag. Editors that support a remote/WSL mode can still edit it comfortably from Windows. **On macOS, choose the current sharing implementation.** Desktop's settings expose the file-sharing implementation; VirtioFS is substantially faster than the older ones for metadata-heavy work. Also trim the list of shared directories: sharing your entire home directory gives the host-side server more to watch than sharing one project directory. **Move hot directories into the VM.** Named volumes and BuildKit cache mounts live in the VM's filesystem, so any path you mount from one is native speed. Dependency caches, `node_modules`-style trees, compiler output and database data directories are the usual candidates. Keep only the files a human edits on the host side. **Do not reach for `:cached` and `:delegated`.** Those consistency flags were tuning knobs for the old osxfs implementation. On current sharing implementations they are accepted and do nothing useful; seeing them proposed as a fix is a sign of advice copied from an old blog post. **Consider not mounting at all for builds.** If a container is building rather than serving edits, `COPY` the source into the image and let BuildKit's cache do the incremental work inside the VM. The bind mount exists to give you a live edit loop; where there is no edit loop, it is pure cost. ### The point to land The slowness is not a bug or a bad driver — it is the price of the VM boundary, and it is paid per filesystem operation. The whole optimisation strategy follows from that one sentence: reduce the number of operations that cross, by keeping the write-heavy and scan-heavy directories on the VM side and sharing only what a person edits.

  • How would you prove the boundary is the bottleneck rather than the build itself?
    Run the identical build twice in the same image: once against the bind-mounted tree and once against a copy inside the container or a named volume, timing both. If the in-VM run is several times faster with the same CPU allocation, the crossing is the cost. A cheap secondary check is a metadata-only loop — a recursive `find` or `stat` over the tree — timed on each path, since that isolates per-call latency from compilation work.
  • Are there correctness surprises with shared directories, not just speed?
    Yes. File-change notifications can be unreliable across the boundary, so watchers may need polling mode; case-insensitive host filesystems can hide a filename mismatch that fails on a Linux server; and ownership and permission bits are mapped rather than native, so a container that cares about exact UIDs or modes may behave differently than it will in production.
  • Would you use the same layout in CI on a Linux runner?
    Not necessarily. On Linux the bind mount is free, so the volume-for-output trick buys nothing and mainly adds a moving part. Keep the local layout as a developer-experience choice, and let CI use whatever is simplest there — usually a plain workspace directory — while making sure both paths build the same artefact.

A native bind mount is reading a book on your own desk; a Desktop bind mount is asking a colleague in the next room to read you one line at a time. Fine for a paragraph, ruinous for an index lookup repeated a million times.

saying these in an interview costs you the question

  • Blames the container image or the compiler
  • Suggests :cached or :delegated as the fix
  • Thinks the mount copies files on start
  • Assumes more CPU cores will fix it
  • Believes Linux hosts show the same slowness
  • Keeps the repository under /mnt/c on WSL 2

context