skip to content

Why does a file watcher inside a Docker container miss host edits made over a bind mount?

level: seniorimportance: nice to knowfreq 33%

answer

  1. Visibility and notification are different problems
  2. Watchers wait for the kernel to tell them
  3. Ask which kernel saw the write
  4. Rename-on-save orphans a file watch
  5. Polling ignores events and pays in CPU

basics

~20 s

Watchers rely on kernel inotify events. On a native Linux engine the bind mount is the same filesystem, so events arrive; when the directory reaches the container through a VM's file-sharing layer, host-side changes are often never raised as events. Polling is the fallback.

solid answer

~50 s

A watcher does not scan; it registers inotify watches and waits for the kernel to report changes to those inodes. Three things break that. **The mount path**: on a Linux engine the bind mount is the same filesystem, so events fire, but on a desktop engine the directory crosses a host-to-VM file-sharing layer and changes made by host tools may never be raised as events on the VM side. **Atomic saves**: editors that write a temporary file and rename it over the original replace the inode, so a watch registered on the old file is dead; watch directories, not files. **Watch limits**: `fs.inotify.max_user_watches` is a host kernel setting a container cannot raise for itself, and a large mounted tree can exhaust it, after which adding watches fails. The fixes are polling (`CHOKIDAR_USEPOLLING`, `DOTNET_USE_POLLING_FILE_WATCHER`), narrowing what is watched, or keeping the source on the same kernel as the container.

code

bash · 2 lines
bash
docker run --rm -it -e DOTNET_USE_POLLING_FILE_WATCHER=true \
  -p 8080:8080 -v "$PWD":/src recon:dev

go deeper

for a junior

Take away the distinction: a file being visible inside the container and a program being told it changed are two different things. If a reload is not happening, the content may still be perfectly correct.

for a middle

Be able to name the mechanism — inotify watches registered per directory or file — and the two everyday breakages: a rename-on-save that replaces the inode, and a mount whose changes originate on a different kernel from the one the watcher is asking.

for a senior

Demonstrate the diagnosis: edit from the host and from inside the container to isolate where notification is lost, then choose deliberately between polling, narrowing the watched tree, and removing the boundary — and be able to state what each costs.

for a principal

Decide what local development is allowed to depend on. Uniform tooling across mixed developer platforms, sane defaults for polling intervals and watch scope, and a documented expectation of what the inner loop should cost are the things a lead owns here.

## How a watcher actually learns about a change File watchers do not re-read your project every second — that is the fallback, not the design. They ask the kernel: `inotify_add_watch` registers interest in a directory or file, and the kernel queues events (`IN_MODIFY`, `IN_CREATE`, `IN_MOVED_TO`, ...) as operations happen through that filesystem. Everything about the container inner loop depends on those events surviving the trip from the editor on the host to the process inside the container. ## Cause one: the change never becomes an event on the container's side On a native Linux engine, a bind mount is not a copy or a sync — it is the same filesystem exposed at a second path. A write from your editor and a read from the container touch the same inode, so the kernel raises the event and the watcher sees it. Inner loops on Linux mostly just work. The failure mode appears when the engine is not on the same kernel as your editor. On a desktop engine the container runs inside a Linux virtual machine, and your project directory is shared into it by a file-sharing layer. That layer's job is to make the files *readable and writable*; making a host-side modification appear as an inotify event on the VM's filesystem is a separate problem, and implementations have historically not done it reliably or at all. The result is the classic report: the file content inside the container is right — `cat` shows the new line — but the watcher never fired. Nothing is corrupted; the notification simply never crossed the boundary. ## Cause two: atomic saves move the inode out from under the watch Many editors and build tools save safely: write `Program.cs.tmp`, `fsync`, then `rename()` it over `Program.cs`. The original inode is now unlinked and a *different* inode carries the name. A watcher that registered a watch on the file itself is still watching a file nobody will ever write to again, so the first save works and every later one is silent — or nothing is ever seen at all. Watching the containing directory instead picks up `IN_MOVED_TO` and behaves correctly; most mature watchers do this, which is why the symptom often looks like "only some tools miss changes". ## Cause three: you ran out of watches `fs.inotify.max_user_watches` caps how many watches a user may hold. It is a kernel sysctl on the host, not something a container can raise for itself, and Docker will not let you set it with `--sysctl` because it is not a namespaced parameter the daemon permits. A recursive watch over a large mounted tree — the whole repository including dependency and build-output directories — consumes one watch per directory and can exhaust the limit, after which registration fails with `ENOSPC` and the tool either logs an obscure error or silently degrades. Excluding dependency directories from the watch fixes more of these than raising the sysctl does. ## Diagnosing it quickly Run a trivial check inside the container against the mounted path: touch a file from the host and see whether a watch on the directory reports anything, then touch a file from a shell *inside* the container and see whether that one reports. If changes made inside the container raise events and changes made from the host do not, the boundary is the file-sharing layer, not your tool. If neither raises events, look at watch limits and at whether the tool is watching files rather than directories. ## The fixes, and what each costs * **Polling.** Every ecosystem has a switch: `CHOKIDAR_USEPOLLING=true` for the widely used JavaScript watcher, `DOTNET_USE_POLLING_FILE_WATCHER=true` for .NET tooling, and equivalents elsewhere. Polling asks the kernel nothing and stats files on an interval, so it is immune to the boundary problem. It costs CPU roughly in proportion to the number of files watched — noticeable on a large tree, especially inside a VM — and it adds up to one interval of latency before a reload begins. Tune the interval rather than accepting the default blindly. * **Watch less.** Mount only the directories you edit and exclude dependency and build-output trees. This cuts both inotify pressure and polling cost, and it is the fix that keeps paying. * **Remove the boundary.** Keep the repository on the same kernel the engine runs on — develop against a Linux engine, or hold the source inside the VM's own filesystem — and native events come back. ## Why this belongs in a senior conversation The symptom is deeply confusing: the mount works, the content is correct, the tool is configured properly, and nothing reloads. Candidates who can separate *file visibility* from *change notification* diagnose it in a minute; candidates who cannot will spend a day rewriting the Dockerfile. It is not a screening question — plenty of strong engineers have only ever run a Linux engine and never hit it — but the reasoning it exercises is exactly the reasoning that makes container debugging fast.

  • How do you tell whether the problem is the mount boundary or the watching tool itself?
    Change the same file twice: once from the host and once from a shell inside the container. If the container-side change fires the watcher and the host-side change does not, notification is being lost crossing into the VM. If neither fires, suspect the tool — watching individual files rather than directories — or exhausted inotify watches, which surfaces as a failure to register rather than a missed event.
  • What does polling cost, and how would you keep that cost down?
    The watcher stats every file on each interval, so CPU scales with the number of files watched and reload latency grows by up to one interval. Keep it down by watching less: mount only the directories you edit, exclude dependency and build-output trees from the watch, and set the interval deliberately — a second or two is usually invisible to a human and much cheaper than the default on a large tree.
  • Can you raise fs.inotify.max_user_watches for just one container?
    No. It is a host kernel sysctl and is not namespaced, so the daemon will not accept it via --sysctl on a container; every container inherits the host's value. Raise it on the host (or in the VM, when the engine runs in one) if you genuinely need more watches — but reducing how many directories you watch is usually the better fix.

saying these in an interview costs you the question

  • Says the bind mount failed to copy the file
  • Assumes inotify always crosses a VM file-sharing boundary
  • Enables polling everywhere without considering CPU cost
  • Watches individual files instead of their directories
  • Thinks a container can raise the inotify watch limit itself
  • Blames the application's reload configuration first

context