Repository navigation
redis: enforce read/write liveness timeout on Redis connections - #1271
Conversation
Since the move to radix v4, REDIS_TIMEOUT only bounds dialing. radix v4
has no read or write timeout: once a command is written it waits for the
reply for as long as the socket stays open. When a Redis server goes
away without resetting its connections (e.g. a managed Redis failover
that keeps the IP), every pooled connection blocks until the kernel
gives up retransmitting, about 15 minutes with default Linux settings.
Meanwhile every rate limit call hangs, nothing is logged and no error
is counted.
Dial Redis connections through our own NetDialer that wraps each
connection so that every read radix waits on, and every write, is
bounded by an I/O timeout. radix clears the read deadline only right
before it reads a reply, so idle connections are never timed out. When
the timeout fires the read fails with an error radix treats as an I/O
failure, so the connection is closed, the commands in flight on it fail
and the pool dials a replacement. The dialer also does the TLS
handshake itself so TLS connections are bounded the same way.
The I/O timeout defaults to REDIS_TIMEOUT, which restores its
documented meaning ("connection and I/O operations"), and can be set
separately with REDIS_IO_TIMEOUT / REDIS_PERSECOND_IO_TIMEOUT (0
disables it). Sentinel connections are not bounded, since radix keeps
an idle pubsub connection to the sentinels.
Signed-off-by: Ignasi Barrera <ignasi@tetrate.io>
|
We hit exactly this in production and independently traced it down to the same root cause (filed as #1282 / a competing patch as #1283, which we're closing in favor of this one — see below for why this PR's approach is more complete than ours). In our case the dead connections were caused by a CNI (kube-router/iptables conntrack) silently dropping the NAT entry for the ratelimit→Redis TCP connection under load — no FIN/RST exchanged, so the connection looks ESTABLISHED forever on both ends. That's functionally the same failure mode described here (peer disappears without resetting the connection); conntrack eviction is just another way to get there besides a failover that keeps the IP. Correlating the service's own metrics across two incidents confirmed the mechanism precisely:
On why we're closing our own patch (#1283) in favor of this one: we initially bounded |
radix v4 hard-codes a 10s TCP keepalive on the connections it dials, and nothing sets TCP_USER_TIMEOUT. Keepalive probes are only sent on idle connections, so they cannot detect a dead peer while there is unacknowledged data in flight, which is exactly the state of a busy connection to a Redis server that vanished. The kernel then only closes it after net.ipv4.tcp_retries2 retransmissions, about 15 minutes by default. Add REDIS_TCP_USER_TIMEOUT / REDIS_PERSECOND_TCP_USER_TIMEOUT (Linux only, off by default) to bound how long sent data may stay unacknowledged, and REDIS_TCP_KEEPALIVE / REDIS_PERSECOND_TCP_KEEPALIVE (default 10s, as radix did; negative disables it). These complement the I/O timeout: TCP_USER_TIMEOUT catches a peer that stopped acknowledging, the I/O timeout catches one that acknowledges but never replies. golang.org/x/sys becomes a direct dependency. Also restore the "Connection Timeout" README intro, which envoyproxy#1272 reverted to "connection establishment" only while REDIS_TIMEOUT also bounds reads and writes since envoyproxy#1271. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ignasi Barrera <ignasi@tetrate.io>
radix v4 hard-codes a 10s TCP keepalive on the connections it dials, and nothing sets TCP_USER_TIMEOUT. Keepalive probes are only sent on idle connections, so they cannot detect a dead peer while there is unacknowledged data in flight, which is exactly the state of a busy connection to a Redis server that vanished. The kernel then only closes it after net.ipv4.tcp_retries2 retransmissions, about 15 minutes by default. Add REDIS_TCP_USER_TIMEOUT / REDIS_PERSECOND_TCP_USER_TIMEOUT (Linux only, off by default) to bound how long sent data may stay unacknowledged, and REDIS_TCP_KEEPALIVE / REDIS_PERSECOND_TCP_KEEPALIVE (default 10s, as radix did; negative disables it). These complement the I/O timeout: TCP_USER_TIMEOUT catches a peer that stopped acknowledging, the I/O timeout catches one that acknowledges but never replies. golang.org/x/sys becomes a direct dependency. Also restore the "Connection Timeout" README intro, which #1272 reverted to "connection establishment" only while REDIS_TIMEOUT also bounds reads and writes since #1271. Signed-off-by: Ignasi Barrera <ignasi@tetrate.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Since the move to radix v4,
REDIS_TIMEOUTonly bounds dialing. radix v4 has no read or write timeout: once a command is written it waits for the reply for as long as the socket stays open. When a Redis server goes away without resetting its connections (e.g. a managed Redis failover that keeps the IP), every pooled connection blocks until the kernel gives up retransmitting, about 15 minutes with default Linux settings. Meanwhile every rate limit call hangs, nothing is logged and no error is counted.Dial Redis connections through our own NetDialer that wraps each connection so that every read radix waits on, and every write, is bounded by an I/O timeout. radix clears the read deadline only right before it reads a reply, so idle connections are never timed out. When the timeout fires the read fails with an error radix treats as an I/O failure, so the connection is closed, the commands in flight on it fail and the pool dials a replacement. The dialer also does the TLS handshake itself so TLS connections are bounded the same way.
The I/O timeout defaults to REDIS_TIMEOUT, which restores its documented meaning ("connection and I/O operations"), and can be set separately with REDIS_IO_TIMEOUT / REDIS_PERSECOND_IO_TIMEOUT (0 disables it). Sentinel connections are not bounded, since radix keeps an idle pubsub connection to the sentinels.