들어가며 — 같은 "연결 실패"인데 메시지가 두 종류입니다
배포 직후 헬스 체크가 실패했습니다. 한 파드는 이렇게 말합니다.
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 refused | ECONNREFUSED | 목적지가 RST를 돌려줌 | 나갔고 응답도 받음 | 대상 호스트의 프로세스와 바인딩 주소 |
| Connection timed out | ETIMEDOUT | SYN 재전송을 모두 소진하도록 무응답 | 나갔지만 응답 없음 | 방화벽, 보안 그룹, 라우팅, accept 큐 |
| No route to host | EHOSTUNREACH | ICMP host unreachable 수신 또는 ARP 응답 없음 | 같은 서브넷까지만 나감 | 대상 호스트 전원, ARP 테이블, REJECT |
| Network is unreachable | ENETUNREACH | 라우팅 테이블에 해당 목적지로 가는 경로가 없음 | 나가지 못함 | ip route, 특히 IPv6 경로 유무 |
| Name or service not known | EAI_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초라는 숫자를 기억해 두면 커널이 포기한 것인지 애플리케이션 타임아웃이 먼저 걸린 것인지를 즉시 구분할 수 있습니다.
두 메시지를 "연결 실패"로 뭉뚱그리는 순간 진단 시간이 몇 배로 늘어납니다. 반대로 이 한 줄만 정확히 읽으면 확인해야 할 곳의 절반 이상이 처음부터 사라집니다.
현재 단락 (1/114)
배포 직후 헬스 체크가 실패했습니다. 한 파드는 이렇게 말합니다.