Quick Summary: Unravel the mystery of Node.js CPU spikes with fs.watch on network mounts. This SRE guide pinpoints a niche kernel/Node.js interaction causing sta...
The Ghost in the Machine: Node.js, NFS, fs.watch, and Your Mysterious CPU Spikes
You've seen it. That inexplicable, intermittent CPU spike on a Node.js service that makes absolutely no sense. Your monitoring shows nothing obvious – no memory leaks, no endless loops, just core after core inexplicably pegged, then dropping back to normal, only to resurface an hour later. It's maddening. You've restarted, you've cursed, you've blamed the cloud, but the ghost persists.
Let's be blunt: This isn't a simple 'oops, I forgot a callback' bug. This is a specific, insidious interaction between Node.js's file system watching, an aging Linux kernel, and the sheer impedance mismatch of network file systems (NFS, SMB). If you're using fs.watch (or any library like chokidar that relies on it) to monitor files on a network mount, and you're seeing these symptoms, congratulations. You've found your demon.
The Scenario: A Slow, Digital Strangulation
Your Node.js application dutifully monitors a directory on, say, an NFS share. It needs to react to config changes, new data files, whatever. Standard stuff, right? Wrong. What starts as a seemingly benign operation slowly turns into a performance drain, then a full-blown CPU hog. You'll observe:
- Intermittent CPU Spikes: Not constant, but regular, unpredictable surges.
- Stale Watchers: File changes aren't always detected. More critically,
lsofor/proc/sys/fs/inotify/max_user_watchesshows an absurd number of inotify handles, many for files that no longer exist or have moved. - I/O That Isn't I/O: Top/htop shows high CPU usage in functions related to
libuv's file system watchers, but actual disk I/O on the NFS mount is minimal. It's thrashing, not processing.
You've checked your application code. You've optimized your loops. You've even considered if your distributed system design is inherently flawed. While the latter is always a good question, in this specific case, the problem is lower-level.
Environments Where This Nightmare Manifests
This isn't universal. This specific hell requires a precise alignment of unfortunate circumstances. Check your stack:
| Component | Version Range (Trigger) | Version Range (Mitigated/Irrelevant) |
|---|---|---|
| Operating System (Kernel) | Linux Kernel 4.4 - 4.15 (e.g., Ubuntu 16.04 LTS, CentOS 7) | Linux Kernel 5.x+ (e.g., Ubuntu 20.04+, CentOS 8+) |
| Node.js Runtime | Node.js 10.x - 12.x (specifically older libuv versions) | Node.js 14.x+ (newer libuv versions, better NFS inode handling) |
| File System Type | NFSv3, NFSv4.0, SMB/CIFS (client-side mounting) | Local ext4, XFS, NFSv4.1+ (with specific mount options, server-side events) |
The Root Cause
The core of the problem lies in how Node.js (via libuv) interacts with the Linux kernel's inotify subsystem for file system watching, specifically when that file system is a network mount. Here's the painful breakdown:
- Inode Inconsistencies:
inotifytracks files using their inode numbers. On local file systems, this is robust. On network file systems like NFS or SMB, inodes are managed by the remote server. The local kernel has a cached view, but changes on the server (especially moves, renames, or deletions by other clients) can change inode mappings or invalidate them without proper notification to the localinotifysubsystem. - Stale Watches Accumulate: Older Linux kernels (particularly in the 4.4-4.15 range) and older
libuvversions in Node.js 10-12 were notoriously bad at reconciling these discrepancies. When a file or directory being watched on an NFS mount disappeared or its inode changed on the server, the localinotifywatch wouldn't be properly cleaned up or invalidated. It became a 'stale' or 'orphaned' watch. - CPU Thrashing: These stale watches accumulate over time. The kernel (and by extension,
libuvand Node.js) periodically attempts to resolve these watches, checking their status against the underlying (now inconsistent) NFS mount. This constant, futile checking of non-existent or invalid inodes consumes CPU cycles, leading to the spikes you observe. It's a continuous, low-level polling operation disguised as an event-driven system, only it's polling for ghosts. - Worker Thread Mayhem: If you're using Node.js worker threads, each thread might establish its own set of
inotifywatches, multiplying the problem and accelerating the accumulation of stale watches and CPU exhaustion. This kind of resource contention is exactly what we try to avoid when designing hyperscale architectures, where every resource counts.
The "Fix": Stop the Bleeding, Then Re-architect
First, the brutal truth: there's no magic bullet for deeply flawed design. The fundamental issue is using a local, kernel-centric file change notification mechanism (inotify) on a distributed, eventually consistent file system (NFS/SMB) with inadequate kernel support. Your long-term solution is architectural.
Option 1: The Only Real Solution (Re-architect)
Do NOT use fs.watch on network mounts. Period.
- Polling (Last Resort): If you absolutely MUST monitor network files, use manual polling with
setIntervalandfs.stat. Check modification times periodically. This is inefficient but avoids theinotifynightmare. Be smart about your polling intervals. - Dedicated Notification Service: If your NFS server offers a native change notification mechanism, use that. Otherwise, move the responsibility to a dedicated service running on a local file system that can then push notifications (e.g., via a message queue) to your Node.js application.
- Object Storage: Can you shift away from a traditional file system to object storage (like S3) with event notifications? Often a better fit for distributed systems.
Option 2: The Temporary Duct Tape (If You're Desperate)
Look, I'm telling you: re-architect. But if you're bleeding out and need time, this might buy you a few hours or days of slightly less terrible performance. This forces Node.js to use a smaller libuv thread pool for ALL asynchronous I/O, including file system operations. It's a blunt instrument, and it can introduce other performance bottlenecks if you have high I/O demands. You've been warned.
# Set this environment variable before starting your Node.js application
export UV_THREADPOOL_SIZE=4
# Or, if running via systemd/pm2, configure it in your service file or ecosystem config:
# Example for systemd:
# [Service]
# Environment="UV_THREADPOOL_SIZE=4"
#
# Example for pm2 ecosystem.config.js:
# module.exports = {
# apps : [{
# name: "my-node-app",
# script: "./app.js",
# env: {
# NODE_ENV: "production",
# UV_THREADPOOL_SIZE: "4"
# }
# }]
# };
The default UV_THREADPOOL_SIZE is 4. Reducing it to, say, 2 or 1 in very specific, low-I/O scenarios might slightly reduce the race conditions and the rate of stale watcher accumulation if your Node.js version and kernel are hitting this precise bug. It effectively limits how many concurrent inotify-related operations libuv can attempt. Increasing it will almost certainly make it worse.
Conclusion
This isn't a problem you debug with simple logs. This is a battle against architectural assumptions and kernel limitations. If you're in the specific nightmare scenario of Node.js 10-12, Linux Kernel 4.x, and fs.watch on NFS, your CPU spikes are likely due to accumulated, stale inotify watches. The UV_THREADPOOL_SIZE tweak is a desperate measure, not a solution. The real fix is to fundamentally change how your application interacts with network file systems.
Stop using fs.watch on network drives with older kernels/Node.js versions. You're welcome. Now go fix your actual architecture.