Quick Summary: Diagnose and fix critical CPU spikes in Node.js fs.watch on NFS mounts with older Linux kernels (4.15-5.3) and Node.js 12.x/14.x. An obscure kerne...
Alright, listen up, because I'm tired of seeing engineers pull their hair out over this one. You've got a Node.js application, usually something that monitors configuration files, caches, or template changes – and it's eating CPU like a starving wolverine. It only happens when that app is deployed onto a host where its working directory, or a significant part of it, is an NFS mount. And here’s the kicker: it doesn’t happen consistently. It’s a ghost, a phantom that only appears when files are modified rapidly.
You’ve checked your application logic, profiled your Node.js code, stared at `top` and `strace` until your eyes blurred. You’ve seen `fs.watch` or `fs.watchFile` (or libraries built on them) show up as the culprit, but the code itself is simple. It's just watching. What could go wrong? Everything, apparently.
This isn't your garden-variety `inotify` queue overflow, and it's definitely not the 'Ghost in the Machine' ECONNRESET issue we battled last year. This is far more insidious, a cross-layer pathological interaction that can bring down a critical service without a clear error message, just pure, unadulterated CPU burn.
The Problem: Node.js Gone Wild on NFS
You’re running a Node.js service. It's using fs.watch to monitor a directory structure for changes. Maybe it's a content management system, a build process, or a hot-reloading development server. The moment someone starts dropping a rapid succession of files into that monitored directory, or making frequent edits, your Node process shoots to 100% CPU, sometimes more across multiple cores if it’s lucky enough to spawn them. Network I/O to the NFS server skyrockets. The application becomes unresponsive, and eventually, if you're lucky, it just crashes. If you're unlucky, it stays alive, silently consuming resources and delaying crucial operations.
This isn't about throughput; it's about system stability and preventing unnecessary resource exhaustion. For systems where microseconds matter, like those discussed in Microsecond Wars: Engineering Ultra-Low Latency, such unpredictable CPU spikes are an absolute nightmare.
Affected Environments
This particular flavor of hell seems to manifest under a very specific set of conditions. Pinpointing it was like chasing smoke.
| Operating System & Kernel | Node.js Version | Filesystem | Conditions |
|---|---|---|---|
| Ubuntu 18.04 LTS (Kernel 4.15 - 5.3) | 12.x, 14.x | NFSv3 / NFSv4 | NFS mount without inotify support (common) |
| CentOS 7 / RHEL 7 (Kernel 3.10 - 4.x) | 12.x, 14.x | NFSv3 / NFSv4 | NFS mount without inotify support (common) |
Note: Newer kernels (5.4+) and Node.js versions (16.x+) generally mitigate this specific interaction due to improvements in both libuv's polling fallback and kernel-level NFS attribute caching. |
|||
The Root Cause
Here’s the deal: When fs.watch is invoked on a Linux filesystem that doesn't support inotify (which is frequently the case for NFS mounts, either by design or configuration), Node.js (specifically its underlying libuv library) falls back to a polling mechanism. This means it repeatedly calls stat() on the watched files/directories to detect changes. Standard stuff, right?
The problem arises from a pathological interaction between Node.js 12.x/14.x's default polling interval (which can be quite aggressive) and the Linux kernel's NFS client attribute caching strategy for kernels in the 4.15-5.3 range. These kernels, by default, have relatively short timeouts for revalidating file attributes (acregmin, acregmax, acdirmin, acdirmax mount options often default to values like 3-60 seconds). When Node.js's aggressive polling detects a change on an NFS file, it triggers a cascade:
- Node.js (via
libuv) issues astat()call to check for changes. - The kernel's NFS client cache, seeing a potential change or an expired attribute, immediately goes to the NFS server to re-validate the file's attributes.
- If a change is confirmed, Node.js processes the event and then (crucially for older Node versions) quickly re-registers its watch, often leading to another immediate
stat()call. - This rapid re-registration, coupled with the kernel's default short attribute cache timeouts and aggressive revalidation logic for fast-changing files on these specific kernel versions, creates a feedback loop. The kernel is constantly forced to hit the NFS server, invalidating and re-validating attributes, consuming excessive CPU cycles on the client and generating significant network traffic, all while Node.js's polling keeps the cycle alive. It's a thundering herd problem for the NFS attribute cache, triggered by Node.js's well-intentioned but overly enthusiastic polling.
The system effectively grinds itself to a halt trying to reconcile file metadata that's changing too fast for its default attribute cache strategy, exacerbated by an application that's asking 'Are we there yet?' too frequently.
The Fix: Slow Down the Kernel's Greed
Since we can't easily configure Node.js's internal fs.watch polling interval when it falls back to polling (without patching libuv, which is a terrible idea for production), we need to address the kernel's side of the equation. We need to tell the NFS client on the Linux host to chill out and trust its cached attributes for longer. This breaks the pathological feedback loop by giving the kernel more breathing room before it hits the NFS server again.
You need to adjust the NFS mount options on the client machine where your Node.js application is running. Specifically, we'll increase the attribute cache timeouts.
# Edit /etc/fstab on your Node.js host
# Find your NFS mount entry, it might look something like this:
# nfs_server_ip:/export/path /mnt/nfs/app_data nfs defaults 0 0
# Modify it to include extended attribute cache settings:
# The crucial part is 'acregmin', 'acregmax', 'acdirmin', 'acdirmax'
# These values are in seconds. We're significantly increasing the default (3-60s).
# Adjust these values based on how frequently you *actually* expect changes to be picked up.
# A 2-minute (120s) minimum and 3-minute (180s) maximum is a good starting point.
# Example for /etc/fstab:
nfs_server_ip:/export/path /mnt/nfs/app_data nfs defaults,acregmin=120,acregmax=180,acdirmin=120,acdirmax=180 0 0
# After modifying /etc/fstab, you must remount the NFS share:
sudo umount /mnt/nfs/app_data
sudo mount /mnt/nfs/app_data
# Alternatively, if you can't unmount, you can try remounting, but fstab is preferred for permanence:
sudo mount -o remount,acregmin=120,acregmax=180,acdirmin=120,acdirmax=180 /mnt/nfs/app_data
Explanation of the fix:
acregmin=120: Minimum time (seconds) to cache attributes of a regular file before asking the server for fresh information.acregmax=180: Maximum time (seconds) to cache attributes of a regular file.acdirmin=120: Minimum time (seconds) to cache attributes of a directory before asking the server.acdirmax=180: Maximum time (seconds) to cache attributes of a directory.
By increasing these values, you're telling the Linux NFS client to trust its local cache for a longer period. This means fewer `stat()` calls will result in an immediate network round trip to the NFS server. Node.js's polling will still happen, but when it asks the kernel for file information, the kernel will often return a cached value without hitting the network, thereby breaking the intense feedback loop and dramatically reducing CPU and network I/O spikes.
Final Thoughts
This fix is a workaround for a legacy interaction. Ideally, you should be on Node.js 16+ and a recent Linux kernel (5.4+) where `libuv`'s polling fallback is more robust and kernel-level NFS optimizations are in place. But for those stuck on older stacks, this is your lifeline. Always test these changes thoroughly in a staging environment before hitting production. Understanding these low-level interactions is what separates the veterans from the newbies. Sometimes, the problem isn't in your code, but in the obscure corners where your code meets the OS and the network.