Quick Summary: Fix intermittent ECONNRESET/EPIPE errors in Node.js PM2 clusters on CentOS 7 talking to local Redis. Diagnose and resolve subtle ulimit propagatio...
Alright, listen up. You’ve hit one of those 'why-the-hell-is-this-happening' problems. The kind that makes you question your career choices, especially if you're stuck maintaining legacy Node.js apps on older Linux kernels. This isn't your average 'Redis connection refused' error. This is a phantom, a whisper of `ECONNRESET` or `EPIPE` that appears under load, vanishes when you stare at it, and only strikes your PM2-managed Node.js cluster.
I've seen this specific flavor of hell far too often. You've got your Node.js app, humming along, talking to a local Redis instance. Everything’s fine in development. You push to production, run it with `pm2 start app.js -i max`, and suddenly, under moderate load, your Node.js workers start randomly dropping connections to Redis. Not all of them. Not consistently. Just enough to cause chaos, data inconsistencies, and a slow, agonizing death for your services.
You've checked Redis logs, Node.js app logs, system logs. Nothing screams 'I AM THE PROBLEM!' Redis looks healthy. Node.js processes aren't crashing. It’s just this intermittent, maddening connection drop. You might even switch to a remote Redis instance, and poof, problem gone. That's a huge clue, but also a red herring if you need local performance.
Before you blame the network, Redis itself, or your Node.js code, let’s narrow down the environment where this particular nightmare thrives:
| Component | Version/Condition | Notes |
|---|---|---|
| Operating System | CentOS Linux 7 (Core) | Specifically kernels < 3.10.0-957.el7.x86_64 |
| Node.js | v14.x.x, v16.x.x | LTS versions commonly deployed on older systems |
| PM2 | v4.x.x, v5.x.x | Using cluster mode (-i max or -i N) |
| Redis | Any local installation (e.g., v5.x, v6.x) | Connections are to 127.0.0.1 or localhost |
The Root Cause
The core problem isn't Redis, isn't Node.js, and it's not even PM2 itself in isolation. It's a nasty, subtle interaction. On these older CentOS 7 kernels, when PM2 forks worker processes in cluster mode, it sometimes fails to consistently inherit or apply the system's `ulimit -n` (no-file) settings to its child Node.js processes. Specifically, the default hard limit for open files for user processes in some older `systemd` configurations or shell environments can be surprisingly low (e.g., 1024).
When your Node.js workers hit this hard limit on file descriptors – which includes network sockets – they can't open new connections to Redis or even maintain existing ones properly under stress. The kernel silently rejects the request, but your Node.js application (or the underlying Redis client) doesn't always see a clear `EMFILE` (Too many open files) error. Instead, it gets a generic `ECONNRESET` (Connection reset by peer) or `EPIPE` (Broken pipe) as the local connection abruptly terminates or fails to establish.
Why does it work with `systemd`? Because `systemd` service files (e.g., `LimitNOFILE=65536`) apply these limits much more robustly at the service level, ensuring all child processes inherit them. Why does it work with a single PM2 instance? Lower resource contention, less likelihood of hitting the low limit. Why with a remote Redis? The connections are often fewer, or the network stack behaves differently, masking the underlying `ulimit` problem. This scenario is a prime example of how engineering for ultra-low latency often means obsessing over system-level configurations.
The Fix: Stop the Bleeding
There are two primary ways to tackle this, from a quick band-aid to a more robust, long-term solution.
Option 1: Explicitly tell PM2 the `ulimit` (The immediate relief)
The quickest way to verify this theory and get immediate relief is to explicitly set the `ulimit` within PM2's ecosystem. You can do this by using a PM2 ecosystem file (e.g., `ecosystem.config.js`) and adding an `env` variable or a `pm_exec_path` trick, but the cleanest is often through the `max_memory_restart` configuration, which can include `exec_interpreter` options, or by ensuring the shell PM2 runs in has the proper limits. However, for a quick, copy-paste solution directly to the application startup, we can modify the application's environment. Better yet, let PM2 manage its limits where possible.
The robust PM2 way is to define it in your ecosystem file. If you’re not using one, now is the time to start. Create an `ecosystem.config.js`:
module.exports = {
apps: [
{
name: 'your-nodejs-app',
script: 'app.js',
instances: 'max',
exec_mode: 'cluster',
// Crucial: Set ulimit for the PM2 worker processes
// This tells PM2 to launch Node.js with this specific ulimit.
// Ensure this value is appropriate for your system and needs.
// A value of 65536 is common for high-concurrency apps.
// Check system-wide limits (e.g., /etc/security/limits.conf) first.
env:
{
NODE_ENV: 'production'
},
args: [],
max_memory_restart: '1G', // Or whatever your app needs
// THE FIX IS HERE:
// This is a common workaround when PM2's internal ulimit propagation fails.
// It forces the shell that launches Node.js to set the ulimit.
// Alternatively, set the global ulimit for the user PM2 runs as.
exec_interpreter: 'bash',
exec_args: '-c "ulimit -n 65536 && node app.js"' // Adjust '65536' as needed
},
],
};
Then, start your application with `pm2 start ecosystem.config.js`. You might need to adjust the `ulimit -n` value. Make sure the system itself (/etc/security/limits.conf) allows this value for the user PM2 is running under. Restart PM2 and monitor. This brutal, direct injection of `ulimit` often stomps out the `ECONNRESET` ghosts immediately.
Option 2: System-level `ulimit` configuration (The proper SRE way)
For a more permanent, system-wide solution, configure `ulimit` correctly for the user running PM2. Edit `/etc/security/limits.conf` (or add a file in `/etc/security/limits.d/`):
# /etc/security/limits.conf (or a new file like /etc/security/limits.d/nodejs.conf)
# User running PM2 (e.g., 'deployuser' or 'webuser')
deployuser soft nofile 65536
deployuser hard nofile 65536
# Or for all users
# * soft nofile 65536
# * hard nofile 65536
You’ll also need to ensure `systemd` itself respects these limits for the user. Sometimes, on older CentOS versions, a reboot is required for these changes to take full effect, or at least a full logout/login for the user running PM2. You can verify the limits by logging in as the `deployuser` and running `ulimit -n`.
If you're deploying with a `systemd` service file for PM2 itself, ensure that service file explicitly sets `LimitNOFILE`. This is typically what I recommend for production environments, as it's more robust than relying on shell environments alone. Speaking of robust deployments, you might find value in understanding how other ecosystems handle high concurrency and resource management, especially when comparing performance characteristics like those discussed in Backend Bloodbath: Go (Gin) Annihilates Node.js (NestJS) for Enterprise Dominance, which often touch upon these low-level system interactions.
After applying either fix, restart PM2 completely (`pm2 kill` then `pm2 start ecosystem.config.js`) and monitor your application closely under load. The intermittent `ECONNRESET`s should disappear. This isn't a Node.js bug, or a Redis bug. It's an environmental interaction that silently kills your performance and drives you mad. Get it fixed, move on.