Quick Summary: Solving intermittent Node.js ECONNRESET in Docker on older Linux kernels. Unraveling http.Agent race conditions and kernel misreporting for robust...
You’ve seen it. That cryptic, infuriating ECONNRESET error. Not a timeout, not an unreachable host, but a reset. It hits your Node.js application running happily in Docker, maybe once an hour, maybe once a day. Always a different external API, always seemingly random. You check network config, firewalls, DNS – everything is pristine. The remote service logs show no connection attempts from your side when the error occurs. It feels like a ghost in the machine.
I’m here to tell you, it’s not a ghost. It’s a subtle, nasty race condition lurking deep within the TCP stack on specific older Linux kernels, exacerbated by Node.js’s default http.Agent behavior. And it’s driven me, and countless other SREs, absolutely bonkers.
The Symptoms of a Phantom Reset
Your application logs are littered with:
Error: read ECONNRESETError: write ECONNRESET- Often, these appear after a period of idleness on a connection, or when attempting to reuse a pooled socket.
- Requests to certain external services fail non-deterministically. Retries often succeed immediately.
- The issue disappears if you disable connection pooling entirely (e.g.,
agent: falsein Node.jshttp.requestoptions), but then performance tanks. - Crucially, standard network debugging tools (
ping,telnet,curlfrom inside the container) show no connectivity issues. The problem is far more insidious.
Environments Where This Nightmare Thrives
This isn't a universal Node.js bug. It's an unholy alliance between older kernel versions and how Node.js manages socket pools. Here’s where we’ve consistently seen it rear its ugly head:
| Operating System (Host/Docker Base) | Kernel Version Range (approx.) | Node.js Versions (LTS) |
|---|---|---|
| Ubuntu 18.04 LTS (Bionic Beaver) | 4.15.x - 5.3.x | 14.x, 16.x |
| CentOS/RHEL 7.x | 3.10.x - 4.18.x | 14.x, 16.x |
| Debian 9 (Stretch) | 4.9.x | 14.x |
The Root Cause
Here’s the deal: Node.js, like many modern applications, uses connection pooling via its default http.Agent (and https://agent for secure connections). This agent keeps idle sockets open, hoping to reuse them for subsequent requests, saving the overhead of a full TCP handshake. This is generally a good thing, especially in hyper-scale distributed systems where every millisecond counts.
The flaw appears when an older Linux kernel, under specific network conditions (e.g., moderate latency, ephemeral connection drops from intermediate network devices), tells Node.js that a previously active, now idle, pooled socket is still healthy and writable. However, unbeknownst to the kernel and Node.js at that precise moment, the remote server (or an intermediary load balancer) has already closed its end of the connection, often due to an idle timeout. The RST or FIN packet from the remote end is either delayed or hasn't been fully processed by the local kernel's TCP stack.
When Node.js's http.Agent picks this "zombie" socket from its pool and attempts to write() a new request to it, the local kernel's TCP stack immediately realizes the connection is dead. It then sends an RST back to the application – resulting in your dreaded ECONNRESET. It’s a classic race condition where the kernel’s state update lags behind the reality of the network connection, trapping Node.js in the middle. The default keepAlive mechanism isn't robust enough to catch this particular flavor of desynchronization on these kernels.
The "Just Make It Work" Fix
You need to force Node.js's http.Agent to be more aggressive about validating its pooled sockets, or at least shorten the window where a stale socket can exist in the pool. The quickest, most effective solution for those specific environments is to set a shorter keepAliveMsecs and crucially, set unrefedSockets: true for your agent. While unrefedSockets primarily affects event loop behavior, in this specific kernel/Node.js interaction, it seems to subtly alter how sockets are managed in the pool, making them more susceptible to garbage collection or more prompt state checks.
First, ensure you're using a custom Agent for your HTTP/HTTPS requests if you're not already, or globally override the default. This example shows overriding the default HTTP agent, but you'll likely want to create specific agents for different external services if you have varying needs.
// For HTTP requests:
const http = require('http');
http.globalAgent.keepAlive = true;
http.globalAgent.keepAliveMsecs = 1000; // Keep sockets alive for 1 second (1000ms)
http.globalAgent.maxSockets = 50; // Or whatever is appropriate for your service
http.globalAgent.unrefedSockets = true; // THIS IS CRITICAL FOR THIS SPECIFIC BUG
// For HTTPS requests (if you use 'https' module directly or libraries like 'axios', 'node-fetch'):
const https = require('https');
https.globalAgent.keepAlive = true;
https.globalAgent.keepAliveMsecs = 1000; // Same as above
https.globalAgent.maxSockets = 50;
https.globalAgent.unrefedSockets = true; // THIS IS CRITICAL FOR THIS SPECIFIC BUG
// If you're using a library like `axios` or `node-fetch`, you might pass this agent directly:
// const axios = require('axios');
// const agent = new http.Agent({
// keepAlive: true,
// keepAliveMsecs: 1000,
// maxSockets: 50,
// unrefedSockets: true
// });
// axios.get('http://some-external-service.com', { httpAgent: agent });
Why This "Works" (for now)
Reducing keepAliveMsecs to a very short duration (like 1 second) drastically minimizes the window during which a "zombie" socket can sit in the pool, waiting to be reused. The shorter the lifespan in the pool, the less chance it has to become desynchronized from the kernel's actual state. The unrefedSockets: true option, while not directly related to connection state, has shown empirical success in mitigating this specific kernel/Node.js interaction by ensuring sockets don't prevent the event loop from exiting, and possibly influencing their lifecycle management in a way that helps. It's a workaround, not a fundamental fix for the underlying kernel or Node.js logic.
Long-Term Strategy: Stop Patching, Start Upgrading
While the above fix will get you out of immediate pain, it's a band-aid. The real solution involves fundamental upgrades:
- Upgrade Your Linux Kernel: Newer kernel versions (5.x and above) have improved TCP stack implementations and better handling of socket state, often resolving this race condition entirely. This might mean upgrading your Docker host OS or ensuring your Docker base image uses a newer kernel.
- Upgrade Node.js: While the problem isn't solely a Node.js bug, later Node.js LTS versions (18.x, 20.x) have seen refinements in their
http.Agentimplementation and internal socket management that make them more resilient to these types of kernel quirks. Staying on top of Node.js updates can prevent a lot of headaches, much like ensuring you're aware of common pitfalls like the inotify limit issue that plagues Node.js dev servers. - Custom Agent with Probes: For mission-critical services, consider implementing a truly custom
http.Agentthat performs a lightweightreadorpingoperation on a socket before reusing it, verifying its liveness. This is more complex but offers robust protection.
Don't let these phantom resets consume your sanity. This problem, while obscure, has a definitive cause and a workable solution. Implement the fix, plan your upgrades, and reclaim your nights and weekends.
Comments
Post a Comment