Quick Summary: Node.js http.Agent on Alpine facing stalled connections due to `tcp_tw_recycle` interaction with FIN-WAIT. Resolve `ECONNRESET` & `ETIMEDOUT` in h...
Alright, listen up. You've got a Node.js service. It's probably running in a Docker container, almost certainly on Alpine Linux, because everyone loves tiny images until they hate them. And suddenly, your seemingly healthy service is throwing a fit. Not a full meltdown, just these insidious, intermittent errors when trying to talk to other internal services.
You see them: ECONNRESET. Sometimes ETIMEDOUT. Sometimes Socket hang up. But here's the kicker: the backend service is fine. You can curl it from the host, from another container. You restart your misbehaving Node.js container, and poof, everything's hunky-dory for a bit. Then, like a digital ghost, it reappears. You're losing your mind, aren't you? Good. Because I've been there. And it's not your code, not your DNS (probably), and not your firewall (definitely not).
You've probably already wasted hours. You checked the network. You checked the CPU/memory on the host. You increased Node's --max-old-space-size just in case. You even dug into Node's HTTP agent configuration, fiddling with maxSockets or keepAlive, because that’s the usual suspect when Node gets connection-y. You might have even thought it was related to other Alpine-specific network traps, like the phantom `getaddrinfo` ENOTFOUND that haunts `musl` libc users. No, this one's different.
This isn't a Node.js bug, not really. It’s a Linux kernel "feature" interacting disastrously with modern container networking and Node's default connection pooling. And it’s a silent killer.
This particular brand of hell often manifests under these conditions:
| Component | Version/OS | Notes |
|---|---|---|
| Client OS | Linux Kernel 3.x to 5.x | Specifically systems with net.ipv4.tcp_tw_recycle=1 enabled. Often seen on older/default cloud instances. |
| Container Host | Docker Engine (any recent version) | Utilizing NAT for container networking. |
| Application Runtime | Node.js 12.x, 14.x, 16.x, 18.x, 20.x | All versions leveraging http.Agent for connection pooling. |
| Container Base Image | Alpine Linux (any recent version) | Due to small footprint, commonly used in production, exacerbating symptoms under specific load patterns. |
The Root Cause
The culprit? A Linux kernel parameter called net.ipv4.tcp_tw_recycle. When this is set to 1 (enabled), the kernel aggressively reuses TCP sockets that are in the TIME_WAIT state. Sounds good, right? Reduce resource consumption, free up ports faster. In a simpler world, maybe. But we don't live in a simpler world. We live in a world of Docker, NAT, and ephemeral connections.
Here’s the breakdown of the architectural flaw:
TIME_WAITand Connection Closure: When a TCP connection closes, one side (usually the client, if the server closes first) enters theTIME_WAITstate for a period (typically 2 * Maximum Segment Lifetime). This is to ensure all packets for that connection are drained and to prevent issues with delayed or retransmitted segments from previous connections.tcp_tw_recycle's Aggression: Withtcp_tw_recycleenabled, the kernel tries to accelerate the cleanup ofTIME_WAITsockets. It does this by checking TCP timestamps. If a new connection request comes in from the same client IP address to the same server IP and port, and the timestamp on the new SYN packet is older than or equal to the timestamp of a recently closed connection from that same client, the kernel silently drops the new SYN packet. It assumes it's a "delayed" packet from an old connection.- Docker NAT's Deception: Here's where it gets insidious. When your Node.js container talks to another service, its traffic goes through the Docker host's NAT layer. From the perspective of the backend service (and more critically, the *client's kernel* when it's managing outbound connections from *itself* through NAT), all connections originating from different containers on the same Docker host appear to come from the same source IP address – the Docker host's IP.
- Node.js
http.Agent's Dilemma: Node's defaulthttp.Agentuses connection pooling. It keeps connections alive and reuses them. But under load, especially with transient network issues, connections might drop and need to be re-established. When these re-establishment attempts occur rapidly from different containers on the same host (all sharing the same NAT'd source IP) to the same backend, they can hit thetcp_tw_recycletimestamp check. The kernel sees a SYN from "the Docker host's IP" with an old timestamp, silently drops it, and Node.js just hangs there, waiting for a response that will never come, eventually timing out or getting an `ECONNRESET` when something eventually gives up.
This isn't just theory. This is the bitter truth. It's a classic case of kernel-level optimizations designed for a simpler, pre-NAT world clashing violently with modern container orchestration. You'll find similar headaches in complex distributed systems, where seemingly innocent network configurations can ripple through an entire architecture, causing problems that would make an Orchestrion operator weep.
The worst part? Because the packets are silently dropped by the client's own kernel, you don't even see them hit the wire with a packet capture from the client perspective. The server never gets the SYN. It's a black hole.
The fix is surprisingly simple, once you've diagnosed this particular hell.
You MUST disable tcp_tw_recycle on your Docker hosts.
Seriously. This parameter has been deprecated and is even removed in some newer kernels precisely because of these NAT interaction issues. Just turn it off. Do it now. Don't think about it. If it's enabled, you're living on borrowed time.
Here’s how you do it on your Linux Docker host (or any VM running your Node.js services directly):
sudo sysctl -w net.ipv4.tcp_tw_recycle=0
To make this change persistent across reboots, you need to add or modify the line in /etc/sysctl.conf or a file under /etc/sysctl.d/ (e.g., /etc/sysctl.d/99-custom-net.conf):
# Add this line to /etc/sysctl.conf or a new file like /etc/sysctl.d/99-custom-net.conf
net.ipv4.tcp_tw_recycle = 0
Then apply the changes:
sudo sysctl -p
A note on tcp_tw_reuse: You might hear about net.ipv4.tcp_tw_reuse. This parameter is generally safer. It allows new connections to reuse TIME_WAIT sockets, but only if the new connection's timestamp is strictly greater than the old one. It does not suffer from the same NAT issues because it doesn't drop packets based on perceived "oldness" but rather allows reuse only if time has truly advanced. If you're concerned about TIME_WAIT consumption, enable tcp_tw_reuse=1, but disable tcp_tw_recycle=0.
Seriously, tcp_tw_recycle is a landmine in any NAT-heavy environment. Just disable it. Your Node.js services, your sanity, and your on-call engineers will thank you. Now go fix it and get some sleep.
Comments
Post a Comment