Skip to content

Split View: Connection refused와 timeout의 차이 — TCP 수준에서 원인 좁히는 법

✨ Learn with Quiz
|

Connection refused와 timeout의 차이 — TCP 수준에서 원인 좁히는 법

들어가며 — 같은 "연결 실패"인데 메시지가 두 종류입니다

배포 직후 헬스 체크가 실패했습니다. 한 파드는 이렇게 말합니다.

dial tcp 10.0.3.11:8080: connect: connection refused

옆 파드는 이렇게 말합니다.

dial tcp 10.0.3.11:8080: i/o timeout

많은 팀이 이 둘을 "어쨌든 연결이 안 되는 상황"으로 묶어서 다룹니다. 그리고 두 경우 모두 보안 그룹과 방화벽 규칙부터 뒤집니다. 이것이 시간을 가장 많이 낭비하는 지점입니다. 두 메시지는 TCP 수준에서 정반대의 사실을 말하고 있고, 그래서 봐야 할 곳도 정반대입니다.

이 글은 두 오류가 각각 어떤 패킷 교환의 결과인지, 그리고 그 사실로부터 원인 후보를 어디까지 잘라낼 수 있는지를 다룹니다. 명령과 출력은 그대로 복사해서 비교할 수 있게 실제 형태로 실었습니다.

Connection refused는 RST를 받았다는 뜻입니다

connect() 시스템 콜이 ECONNREFUSED를 반환하는 조건은 하나입니다. 내가 보낸 SYN에 대해 상대가 RST 플래그가 세워진 TCP 세그먼트를 돌려준 경우입니다.

리스너가 없는 포트로 SYN이 도착하면 커널이 애플리케이션과 무관하게 즉시 RST를 만들어 보냅니다. 이것은 TCP 명세가 요구하는 동작이며, 프로세스가 죽어 있어도 커널이 살아 있는 한 계속 일어납니다.

# 리스너가 없는 포트 — 왕복 시간 안에 즉시 끝난다
time nc -vz 10.0.3.11 8080
nc: connect to 10.0.3.11 port 8080 (tcp) failed: Connection refused

real    0m0.004s
user    0m0.001s
sys     0m0.002s

여기서 중요한 것은 실패 자체가 아니라 걸린 시간입니다. 4밀리초는 왕복 시간입니다. 내 SYN이 10.0.3.11까지 갔고, 그 호스트가 답을 만들어 나에게 돌려보냈습니다. 즉 라우팅, 보안 그룹, 네트워크 정책, NAT까지 경로상의 모든 관문을 이미 통과했다는 증거입니다.

그래서 남는 원인 후보는 목적지 호스트 안쪽으로 좁혀집니다.

  • 프로세스가 죽어 있거나 아직 시작되지 않았습니다.
  • 프로세스는 살아 있지만 다른 포트에 바인딩되어 있습니다.
  • 프로세스가 0.0.0.0이 아니라 127.0.0.1에만 바인딩되어 있습니다.
  • 목적지가 로드밸런서나 kube-proxy이고, 그 뒤에 정상 백엔드가 하나도 없습니다.

여기서 흔한 오해를 하나 정정해야 합니다. "connection refused가 나오니 방화벽이 막은 것"이라는 설명은 대부분 틀렸습니다. 실무에서 쓰는 방화벽은 거의 언제나 DROP 정책입니다. AWS 보안 그룹, 쿠버네티스 NetworkPolicy, 클라우드 방화벽은 규칙에 없는 트래픽을 조용히 버립니다. 버려진 패킷은 응답이 없으므로 refused가 아니라 timeout으로 나타납니다. refused를 보고 방화벽을 뒤지는 것은 이미 통과한 관문을 다시 확인하는 일입니다.

예외는 명시적으로 거절을 응답하도록 설정한 경우입니다.

# 이 규칙은 RST를 돌려주므로 refused처럼 보인다
sudo iptables -A INPUT -p tcp --dport 8080 -j REJECT --reject-with tcp-reset

# 이 규칙은 응답이 없으므로 timeout이 된다
sudo iptables -A INPUT -p tcp --dport 8080 -j DROP

로컬 iptables/nftables를 직접 쓰는 환경이라면 REJECT 규칙이 있는지만 한 번 확인하면 됩니다. 그 외에는 refused를 프로세스 문제로 다루는 편이 거의 항상 맞습니다.

Timeout은 SYN에 아무도 답하지 않았다는 뜻입니다

타임아웃은 정보가 훨씬 적은 실패입니다. 내가 SYN을 보냈고, 정해진 횟수만큼 재전송했고, 그 동안 SYN-ACK도 RST도 ICMP도 돌아오지 않았습니다. 커널이 알 수 있는 사실은 "응답이 없다"뿐입니다.

# 패킷이 조용히 버려지는 포트 — 커널 기본 재시도를 다 소진한다
time nc -vz 10.0.3.11 9090
nc: connect to 10.0.3.11 port 9090 (tcp) failed: Connection timed out

real    2m7.213s
user    0m0.002s
sys     0m0.003s

2분 7초라는 숫자는 뒤에서 계산해 보겠습니다. 지금 중요한 것은 원인 후보입니다.

  • 경로상의 방화벽이나 보안 그룹이 패킷을 버리고 있습니다.
  • 목적지 IP가 잘못됐고, 그 주소를 쓰는 호스트가 아예 없습니다.
  • 라우팅이 잘못되어 패킷이 엉뚱한 곳으로 갔습니다.
  • 응답 경로가 비대칭이라 SYN-ACK가 나에게 돌아오지 못합니다.
  • 목적지가 살아 있고 리스너도 있지만 accept 큐가 넘쳐 SYN이 버려지고 있습니다.

마지막 항목이 가장 자주 놓치는 경우입니다.

리스너가 있는데도 timeout이 나는 경우

핸드셰이크가 끝난 연결은 accept 큐에 쌓이고, 애플리케이션이 accept()로 꺼내 갑니다. 애플리케이션이 느려서 이 큐가 가득 차면 리눅스는 기본 설정에서 새 SYN을 조용히 버립니다. 클라이언트 입장에서는 포트가 닫힌 것과 구분되지 않고, 다만 refused가 아니라 timeout으로 보입니다.

ss -ltn 'sport = :8080'
State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port
LISTEN  129     128            0.0.0.0:8080         0.0.0.0:*

리스닝 소켓에서 Recv-Q는 현재 accept 큐 길이, Send-Q는 큐의 최대 크기입니다. 129가 128을 넘었다는 것은 큐가 이미 포화 상태라는 뜻입니다. 누적 카운터로 확인하면 더 확실합니다.

nstat -az | grep -E 'ListenOverflows|ListenDrops'
TcpExtListenOverflows           18437              0.0
TcpExtListenDrops               18437              0.0

이 값이 증가하고 있으면 네트워크가 아니라 애플리케이션 처리량이 원인입니다. 방화벽을 아무리 뒤져도 나오지 않습니다.

나머지 오류 메시지가 가리키는 지점

connect 계열 실패는 사실 네 종류가 더 있습니다. 각각이 서로 다른 계층에서 발생하므로, 메시지만 정확히 읽어도 절반은 끝납니다.

메시지errno실제로 일어난 일패킷이 나갔나먼저 볼 곳
Connection refusedECONNREFUSED목적지가 RST를 돌려줌나갔고 응답도 받음대상 호스트의 프로세스와 바인딩 주소
Connection timed outETIMEDOUTSYN 재전송을 모두 소진하도록 무응답나갔지만 응답 없음방화벽, 보안 그룹, 라우팅, accept 큐
No route to hostEHOSTUNREACHICMP host unreachable 수신 또는 ARP 응답 없음같은 서브넷까지만 나감대상 호스트 전원, ARP 테이블, REJECT
Network is unreachableENETUNREACH라우팅 테이블에 해당 목적지로 가는 경로가 없음나가지 못함ip route, 특히 IPv6 경로 유무
Name or service not knownEAI_NONAME이름 해석 단계에서 실패, 소켓을 열지도 않음대상으로는 하나도 안 나감nsswitch.conf, resolv.conf, /etc/hosts

두 가지를 강조하겠습니다.

첫째, Network is unreachable은 커널 라우팅 테이블에 경로가 없어 패킷이 인터페이스 밖으로 나가지도 못한 상태입니다. 요즘 이 오류의 절대다수는 IPv6입니다. DNS가 AAAA 레코드를 돌려줬는데 호스트에 IPv6 기본 경로가 없으면, 애플리케이션이 먼저 IPv6를 시도하다 즉시 실패합니다.

ip -6 route show default
# 출력이 비어 있으면 AAAA 결과로는 연결을 시도할 수 없다

getent ahosts api.example.com
2606:4700:4700::1111 STREAM api.example.com
1.1.1.1              STREAM

둘째, Name or service not known은 TCP 문제가 아닙니다. 대상 서버로는 패킷이 단 한 개도 나가지 않았습니다. 이 경우 방화벽 로그를 아무리 봐도 아무것도 없는 것이 정상입니다.

tcpdump로 SYN과 RST를 눈으로 확인하기

추론이 확실하지 않을 때는 패킷을 직접 보는 것이 가장 빠릅니다. 핸드셰이크만 보면 되므로 필터를 좁게 잡습니다.

sudo tcpdump -nn -i any "host 10.0.3.11 and tcp port 8080"

거부되는 경우의 출력입니다.

14:02:11.104512 IP 10.0.2.7.54312 > 10.0.3.11.8080: Flags [S], seq 1829301744, win 62727, options [mss 8961,sackOK,TS val 913442 ecr 0,nop,wscale 7], length 0
14:02:11.104698 IP 10.0.3.11.8080 > 10.0.2.7.54312: Flags [R.], seq 0, ack 1829301745, win 0, length 0

두 줄이면 끝입니다. [S]가 나가고 [R.]가 돌아왔습니다. 0.2밀리초 만에 응답이 왔다는 것은 같은 호스트이거나 아주 가깝다는 뜻입니다.

타임아웃되는 경우는 이렇게 보입니다.

14:05:31.220114 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1113209 ecr 0,nop,wscale 7], length 0
14:05:32.235880 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1114225 ecr 0,nop,wscale 7], length 0
14:05:34.251874 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1116241 ecr 0,nop,wscale 7], length 0
14:05:38.315876 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1120305 ecr 0,nop,wscale 7], length 0

같은 시퀀스 번호의 SYN이 1초, 2초, 4초 간격으로 반복됩니다. 응답 줄이 아예 없습니다.

패킷 캡처 권한이 없는 환경이라면 소켓 상태만으로도 판별할 수 있습니다.

ss -tanp state syn-sent
Recv-Q  Send-Q   Local Address:Port    Peer Address:Port   Process
0       1             10.0.2.7:54390       10.0.3.11:9090   users:(("curl",pid=31544,fd=5))

SYN-SENT 상태에 몇 초 이상 머물러 있고 Send-Q가 1이면, 응답을 못 받은 SYN이 하나 떠 있다는 뜻입니다. refused는 이 상태가 눈에 잡히지 않을 만큼 짧게 지나갑니다.

SYN 재전송 간격으로 타임아웃 길이를 예측하기

앞의 2분 7초는 우연한 숫자가 아닙니다. 리눅스는 net.ipv4.tcp_syn_retries 값만큼 SYN을 재전송하고, 간격을 매번 두 배로 늘립니다.

sysctl net.ipv4.tcp_syn_retries
net.ipv4.tcp_syn_retries = 6

최초 전송 후 1, 2, 4, 8, 16, 32초 간격으로 6번 재전송하고, 마지막 재전송 뒤 64초를 더 기다린 다음 포기합니다. 합이 1 + 2 + 4 + 8 + 16 + 32 + 64 = 127초입니다. 앞의 측정값 2분 7초와 정확히 일치합니다.

이 계산이 실무에서 유용한 이유는 두 가지입니다.

하나, 정확히 127초 근처에서 끊기는 실패는 커널이 SYN을 포기한 것이므로 응답이 아예 없었다는 뜻입니다. 애플리케이션 로그의 타임아웃 값이 30초인데 실제로는 127초 만에 실패했다면, 타임아웃 설정이 적용되지 않고 있다는 신호이기도 합니다.

둘, 연결 타임아웃을 짧게 잡고 싶다면 커널 값을 바꾸기보다 애플리케이션이나 소켓 단위로 지정하는 편이 안전합니다.

# 이 호스트 전체에 적용 — 재전송 3회, 약 15초
sudo sysctl -w net.ipv4.tcp_syn_retries=3

# 도구 단위로 제한하는 편이 부작용이 적다
curl --connect-timeout 3 -sS http://10.0.3.11:8080/health
nc -w 3 -vz 10.0.3.11 8080

macOS는 이 동작이 다릅니다. 기본 연결 타임아웃이 약 75초이고 sysctl 이름도 다르므로, 로컬 맥에서 잰 시간을 리눅스 서버 기준으로 해석하면 안 됩니다.

컨테이너와 쿠버네티스의 127.0.0.1 바인딩 사고

컨테이너 환경에서 connection refused의 절반은 같은 원인입니다. 프로세스가 루프백에만 바인딩되어 있는 것입니다.

로컬 개발에서는 127.0.0.1:8080으로 잘 뜨고 잘 접속됩니다. 그대로 컨테이너에 넣고 Service를 붙이면, 파드는 Running이고 프로세스도 살아 있는데 다른 파드에서 접근하면 connection refused가 납니다.

이유는 목적지 주소에 있습니다. kube-proxy가 Service ClusterIP를 파드 IP로 DNAT하면, 도착하는 패킷의 목적지 주소는 10.244.1.23 같은 파드 IP입니다. 소켓은 127.0.0.1에만 열려 있으므로 커널은 이 목적지에 맞는 리스너를 찾지 못하고 RST를 돌려줍니다.

kubectl exec -it deploy/api -- ss -ltnp
State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port  Process
LISTEN  0       4096        127.0.0.1:8080         0.0.0.0:*      users:(("api",pid=1,fd=7))

정상이라면 이렇게 나와야 합니다.

State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port  Process
LISTEN  0       4096          0.0.0.0:8080         0.0.0.0:*      users:(("api",pid=1,fd=7))
LISTEN  0       4096             [::]:8080            [::]:*      users:(("api",pid=1,fd=8))

파드 안에서는 헬스 체크가 성공한다는 점이 이 사고를 오래 끌게 만듭니다. kubectl exec로 들어가서 curl localhost:8080을 해 보면 잘 됩니다. 목적지 주소가 127.0.0.1이니 당연합니다. 반드시 파드 IP로 확인해야 합니다.

POD_IP=$(kubectl get pod -l app=api -o jsonpath='{.items[0].status.podIP}')
kubectl exec -it deploy/api -- curl -sS --connect-timeout 2 "http://${POD_IP}:8080/health"
curl: (7) Failed to connect to 10.244.1.23 port 8080 after 1 ms: Connection refused

고치는 지점은 애플리케이션 설정입니다. Node의 Express는 app.listen(8080)으로 두면 모든 주소에 붙지만 app.listen(8080, '127.0.0.1')은 아닙니다. Go는 http.ListenAndServe(":8080", ...)가 맞고 "localhost:8080"은 틀립니다. Spring Boot는 server.address 속성, Rails는 -b 0.0.0.0, Gunicorn은 --bind 0.0.0.0:8000, Postgres는 listen_addresses 설정입니다. 도커에서 포트 매핑을 걸었는데도 접속이 안 되는 사례도 원인이 같습니다.

같은 논리를 뒤집으면 판별법이 됩니다. 파드 IP로는 refused인데 파드 안에서 localhost로는 성공한다면 원인은 네트워크 정책이 아니라 바인딩 주소입니다. 반대로 파드 IP로도 timeout이라면 그때부터 NetworkPolicy와 CNI를 봐야 합니다.

Service를 거쳤을 때만 refused가 나는 또 다른 경우도 기억해 둘 만합니다. Endpoints가 비어 있으면 kube-proxy가 보낼 곳이 없어 즉시 거부합니다.

kubectl get endpoints api
NAME   ENDPOINTS   AGE
api    <none>      12m

이때는 Service의 selector와 파드 라벨이 어긋났거나, readinessProbe가 실패해 준비된 파드가 하나도 없는 상태입니다.

마치며 — refused는 나쁜 소식이 아닙니다

정리하면 판단의 뼈대는 짧습니다.

Connection refused를 봤다면 패킷이 목적지까지 갔다가 돌아왔습니다. 경로는 이미 검증된 것이므로 방화벽과 라우팅은 후보에서 지우고, 대상 호스트의 프로세스와 바인딩 주소, 포트 번호, 백엔드 존재 여부를 보면 됩니다. 이것은 문제 범위가 한 대의 호스트 안으로 줄어들었다는 뜻이고, 그래서 반가운 신호입니다.

Timeout을 봤다면 아무 정보도 돌아오지 않았습니다. 방화벽, 보안 그룹, 라우팅, 비대칭 경로, 그리고 accept 큐 넘침까지 넓게 열어 두고 tcpdump로 SYN이 실제로 나가는지, 재전송이 반복되는지부터 확인해야 합니다. 여기서 127초라는 숫자를 기억해 두면 커널이 포기한 것인지 애플리케이션 타임아웃이 먼저 걸린 것인지를 즉시 구분할 수 있습니다.

두 메시지를 "연결 실패"로 뭉뚱그리는 순간 진단 시간이 몇 배로 늘어납니다. 반대로 이 한 줄만 정확히 읽으면 확인해야 할 곳의 절반 이상이 처음부터 사라집니다.

Connection refused vs. timeout — narrowing the cause at the TCP level

Introduction — the same "connection failure", two kinds of message

Right after a deploy the health check failed. One pod says this.

dial tcp 10.0.3.11:8080: connect: connection refused

The pod next to it says this.

dial tcp 10.0.3.11:8080: i/o timeout

Many teams lump the two together as "the connection does not work either way". And in both cases they start by digging through security groups and firewall rules. That is where the most time gets wasted. At the TCP level the two messages state opposite facts, which means the places you should look are opposite as well.

This post covers which packet exchange each error is the result of, and how far that single fact lets you cut down the list of candidate causes. Commands and outputs are shown in their real form so you can copy and compare them directly.

Connection refused means you received an RST

There is exactly one condition under which the connect() system call returns ECONNREFUSED: the peer answered the SYN you sent with a TCP segment that has the RST flag set.

When a SYN arrives at a port with no listener, the kernel builds and sends an RST immediately, independently of any application. This is behaviour the TCP specification requires, and it keeps happening even when the process is dead, as long as the kernel is alive.

# A port with no listener — this finishes within one round trip
time nc -vz 10.0.3.11 8080
nc: connect to 10.0.3.11 port 8080 (tcp) failed: Connection refused

real    0m0.004s
user    0m0.001s
sys     0m0.002s

What matters here is not the failure itself but the elapsed time. Four milliseconds is a round trip. My SYN travelled to 10.0.3.11, and that host built an answer and sent it back to me. In other words, this is proof that the packet already passed every gate on the path: routing, security groups, network policy and NAT.

That leaves a set of candidate causes narrowed down to the inside of the destination host.

  • The process is dead, or has not started yet.
  • The process is alive but bound to a different port.
  • The process is bound to 127.0.0.1 rather than 0.0.0.0.
  • The destination is a load balancer or kube-proxy with no healthy backend behind it.

One common misunderstanding needs correcting here. The explanation "connection refused means a firewall blocked it" is wrong most of the time. Firewalls used in practice are almost always DROP policies. AWS security groups, Kubernetes NetworkPolicy and cloud firewalls silently discard traffic that no rule allows. A discarded packet produces no reply, so it shows up as a timeout, not as refused. Digging through firewalls because you saw refused means re-checking a gate the packet already passed.

The exception is a configuration that was explicitly told to answer with a rejection.

# This rule returns an RST, so it looks like refused
sudo iptables -A INPUT -p tcp --dport 8080 -j REJECT --reject-with tcp-reset

# This rule sends no reply, so it becomes a timeout
sudo iptables -A INPUT -p tcp --dport 8080 -j DROP

In an environment where you drive local iptables/nftables yourself, all you need is one check for the presence of a REJECT rule. Beyond that, treating refused as a process problem is almost always the right call.

Timeout means nobody answered the SYN

A timeout is a failure that carries far less information. I sent a SYN, retransmitted it the configured number of times, and during that whole window no SYN-ACK, no RST and no ICMP came back. The only fact available to the kernel is "there is no answer".

# A port where packets are silently discarded — this burns through the kernel default retries
time nc -vz 10.0.3.11 9090
nc: connect to 10.0.3.11 port 9090 (tcp) failed: Connection timed out

real    2m7.213s
user    0m0.002s
sys     0m0.003s

We will work out where the number two minutes and seven seconds comes from later. What matters right now is the candidate causes.

  • A firewall or security group on the path is discarding the packets.
  • The destination IP is wrong, and no host uses that address at all.
  • Routing is wrong and the packet went somewhere else entirely.
  • The return path is asymmetric, so the SYN-ACK cannot get back to me.
  • The destination is alive and a listener exists, but the accept queue is overflowing and SYNs are being dropped.

That last item is the one people miss most often.

When a listener exists and you still get a timeout

Connections whose handshake has completed pile up in the accept queue, and the application pulls them out with accept(). If the application is slow and that queue fills up, Linux in its default configuration silently drops new SYNs. From the client side this is indistinguishable from a closed port, except that it appears as a timeout rather than as refused.

ss -ltn 'sport = :8080'
State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port
LISTEN  129     128            0.0.0.0:8080         0.0.0.0:*

On a listening socket, Recv-Q is the current accept queue length and Send-Q is the maximum size of that queue. The fact that 129 has gone past 128 means the queue is already saturated. Cumulative counters make it even clearer.

nstat -az | grep -E 'ListenOverflows|ListenDrops'
TcpExtListenOverflows           18437              0.0
TcpExtListenDrops               18437              0.0

If these values are climbing, the cause is application throughput, not the network. No amount of firewall archaeology will turn it up.

What the remaining error messages point at

There are in fact four more failures in the connect family. Each arises at a different layer, so reading the message accurately gets you half the way there.

MessageerrnoWhat actually happenedDid a packet leaveWhere to look first
Connection refusedECONNREFUSEDThe destination returned an RSTLeft and got an answerProcess and bind address on the target
Connection timed outETIMEDOUTNo answer until every SYN retransmission was spentLeft, no answerFirewall, security group, routing, accept queue
No route to hostEHOSTUNREACHICMP host unreachable received, or no ARP replyLeft only as far as the local subnetTarget host power, ARP table, REJECT
Network is unreachableENETUNREACHNo route to that destination in the routing tableNever leftip route, especially whether an IPv6 route exists
Name or service not knownEAI_NONAMEFailed during name resolution, no socket was even openedNothing left toward the targetnsswitch.conf, resolv.conf, /etc/hosts

Two points deserve emphasis.

First, Network is unreachable is the state where the kernel routing table has no route, so the packet never even left the interface. These days the overwhelming majority of this error is IPv6. If DNS returned an AAAA record but the host has no IPv6 default route, the application tries IPv6 first and fails instantly.

ip -6 route show default
# If the output is empty, no connection can be attempted with an AAAA result

getent ahosts api.example.com
2606:4700:4700::1111 STREAM api.example.com
1.1.1.1              STREAM

Second, Name or service not known is not a TCP problem. Not a single packet went out to the target server. In this case, finding nothing at all in the firewall logs is the correct outcome.

Seeing SYN and RST with your own eyes using tcpdump

When reasoning is not conclusive, looking at the packets directly is the fastest route. You only need the handshake, so keep the filter narrow.

sudo tcpdump -nn -i any "host 10.0.3.11 and tcp port 8080"

This is the output in the refused case.

14:02:11.104512 IP 10.0.2.7.54312 > 10.0.3.11.8080: Flags [S], seq 1829301744, win 62727, options [mss 8961,sackOK,TS val 913442 ecr 0,nop,wscale 7], length 0
14:02:11.104698 IP 10.0.3.11.8080 > 10.0.2.7.54312: Flags [R.], seq 0, ack 1829301745, win 0, length 0

Two lines and you are done. [S] went out and [R.] came back. An answer within 0.2 milliseconds means the same host or something very close to it.

The timeout case looks like this.

14:05:31.220114 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1113209 ecr 0,nop,wscale 7], length 0
14:05:32.235880 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1114225 ecr 0,nop,wscale 7], length 0
14:05:34.251874 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1116241 ecr 0,nop,wscale 7], length 0
14:05:38.315876 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1120305 ecr 0,nop,wscale 7], length 0

The same SYN, with the same sequence number, repeats at 1-second, 2-second and 4-second intervals. There is no reply line at all.

In an environment where you have no packet capture privileges, socket state alone is enough to tell them apart.

ss -tanp state syn-sent
Recv-Q  Send-Q   Local Address:Port    Peer Address:Port   Process
0       1             10.0.2.7:54390       10.0.3.11:9090   users:(("curl",pid=31544,fd=5))

If a socket sits in SYN-SENT for more than a few seconds with Send-Q at 1, there is one SYN outstanding that never got an answer. In the refused case this state passes too quickly for the eye to catch.

Predicting the timeout length from the SYN retransmission schedule

The two minutes and seven seconds above is not an accidental number. Linux retransmits the SYN as many times as net.ipv4.tcp_syn_retries says, doubling the interval each time.

sysctl net.ipv4.tcp_syn_retries
net.ipv4.tcp_syn_retries = 6

After the initial transmission it retransmits six times at 1, 2, 4, 8, 16 and 32 second intervals, then waits a further 64 seconds after the last retransmission before giving up. The sum is 1 + 2 + 4 + 8 + 16 + 32 + 64 = 127 seconds. That matches the measured two minutes and seven seconds exactly.

This arithmetic is useful in practice for two reasons.

One, a failure that cuts off at almost exactly 127 seconds means the kernel gave up on the SYN, which in turn means there was no answer at all. If the timeout value in your application log says 30 seconds but the failure actually took 127 seconds, that is also a signal that the timeout setting is not being applied.

Two, if you want a shorter connect timeout, specifying it per application or per socket is safer than changing the kernel value.

# Applies to this entire host — 3 retransmissions, about 15 seconds
sudo sysctl -w net.ipv4.tcp_syn_retries=3

# Limiting it per tool has fewer side effects
curl --connect-timeout 3 -sS http://10.0.3.11:8080/health
nc -w 3 -vz 10.0.3.11 8080

macOS behaves differently here. The default connect timeout is roughly 75 seconds and the sysctl name differs too, so you must not read a time measured on your local Mac as if it applied to a Linux server.

The 127.0.0.1 binding accident in containers and Kubernetes

Half of all connection refused cases in container environments have the same cause: the process is bound only to loopback.

In local development it comes up on 127.0.0.1:8080 and connects fine. Drop the same thing into a container, attach a Service, and the pod is Running and the process is alive, yet reaching it from another pod gives connection refused.

The reason lies in the destination address. When kube-proxy DNATs a Service ClusterIP to a pod IP, the destination address of the arriving packet is a pod IP such as 10.244.1.23. The socket is open only on 127.0.0.1, so the kernel finds no listener matching that destination and returns an RST.

kubectl exec -it deploy/api -- ss -ltnp
State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port  Process
LISTEN  0       4096        127.0.0.1:8080         0.0.0.0:*      users:(("api",pid=1,fd=7))

A healthy result should look like this.

State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port  Process
LISTEN  0       4096          0.0.0.0:8080         0.0.0.0:*      users:(("api",pid=1,fd=7))
LISTEN  0       4096             [::]:8080            [::]:*      users:(("api",pid=1,fd=8))

What drags this incident out is that the health check succeeds from inside the pod. Go in with kubectl exec and run curl localhost:8080 and it works. Naturally so, because the destination address is 127.0.0.1. You must check with the pod IP.

POD_IP=$(kubectl get pod -l app=api -o jsonpath='{.items[0].status.podIP}')
kubectl exec -it deploy/api -- curl -sS --connect-timeout 2 "http://${POD_IP}:8080/health"
curl: (7) Failed to connect to 10.244.1.23 port 8080 after 1 ms: Connection refused

The place to fix it is the application configuration. In Node, Express with app.listen(8080) attaches to every address, but app.listen(8080, '127.0.0.1') does not. In Go, http.ListenAndServe(":8080", ...) is right and "localhost:8080" is wrong. Spring Boot has the server.address property, Rails takes -b 0.0.0.0, Gunicorn takes --bind 0.0.0.0:8000, and Postgres has the listen_addresses setting. Cases where a Docker port mapping is in place and the connection still fails have the same root cause.

Turn the same logic around and it becomes a diagnostic. If the pod IP gives refused while localhost inside the pod succeeds, the cause is the bind address, not network policy. Conversely, if even the pod IP times out, that is when it is time to look at NetworkPolicy and the CNI.

There is one more case worth remembering, where refused only appears when going through the Service. If Endpoints is empty, kube-proxy has nowhere to send and refuses immediately.

kubectl get endpoints api
NAME   ENDPOINTS   AGE
api    <none>      12m

In that situation either the Service selector does not match the pod labels, or the readinessProbe is failing and not a single pod is ready.

Closing — refused is not bad news

To sum up, the skeleton of the judgement is short.

If you saw Connection refused, a packet travelled to the destination and came back. The path is already verified, so cross firewalls and routing off the list and look at the process on the target host, the bind address, the port number and whether any backend exists. That means the problem has shrunk to the inside of a single host, which is a welcome signal.

If you saw a timeout, no information came back at all. Keep firewalls, security groups, routing, asymmetric paths and accept queue overflow all open as candidates, and start by using tcpdump to confirm whether the SYN actually goes out and whether retransmissions repeat. Remembering the number 127 seconds here lets you tell instantly whether the kernel gave up or whether an application timeout fired first.

The moment you blur the two messages together into "connection failure", diagnosis time multiplies. Read that one line accurately instead, and more than half of the places you would have had to check disappear from the start.