connect()는 성공했는데 첫 요청이 멈추는 이유 — Linux listen backlog와 accept 큐 오버플로

seonwooj0810·3일 전

1. 도입 — 거절이 아니라 멈춤

트래픽이 몰리면 클라이언트 로그에 connection refused가 찍힐 거라고 생각했다. 그런데 실제로 보이는 건 connect()는 성공했는데 첫 요청이 1초, 3초씩 멈추다가 응답이 오거나 타임아웃 나는 현상이다. 커널 소스를 따라가 보니 원인은 서버의 accept 큐가 넘쳤을 때 커널이 3번째 ACK를 RST 없이 그냥 무시한다는 데 있었다. 이 글은 listen(fd, backlog)의 backlog가 실제로 무엇을 세는지에서 시작해, 그 ACK가 어디서 버려지는지까지 따라간 기록이다.

2. backlog는 "완성된 연결" 큐의 길이다

listen(2) man page에 따르면 Linux 2.2부터 backlog는 반쯤 열린(SYN_RECV) 연결이 아니라 핸드셰이크가 끝났지만 아직 accept()되지 않은 소켓 수의 상한이다. 3-way handshake 자체의 흐름은 TCP 3-way/4-way handshake 글에서 정리했고, 여기서는 서버 쪽 큐만 본다.

listen()에 넘긴 값은 net.core.somaxconn(5.4부터 기본 4096, 이전은 128)으로 조용히 잘려 sk->sk_max_ack_backlog에 저장된다. 커널은 이 한 값을 두 관문에 쓴다.

카운터의미비교 대상
qlenSYN_RECV 상태 요청 수 ("SYN 큐")sk_max_ack_backlog → 넘으면 syncookie 전환
sk_ack_backlogaccept 대기 중인 완성 소켓 수sk_max_ack_backlog → 넘으면 overflow

헷갈렸던 지점은 tcp_max_syn_backlog다. 이 sysctl은 syncookies가 꺼져 있을 때 "마지막 1/4은 살아 있음이 입증된 목적지에게만"을 판단할 때만 쓰인다. 기본값(tcp_syncookies=1)에서 SYN 단계의 실질 임계값은 앱의 backlog다. 그래서 tcp_max_syn_backlog만 올리는 튜닝은 효과가 없다.

하나 더 있다. 두 비교가 모두 >=가 아니라 >라서 실제로는 backlog+1개까지 들어간다.

// include/net/sock.h
static inline bool sk_acceptq_is_full(const struct sock *sk)
{
	return READ_ONCE(sk->sk_ack_backlog) > READ_ONCE(sk->sk_max_ack_backlog);
}

sock.h에는 ">=여야 한다고 생각하면 commit 64a146513f8f를 보라"는 주석이 붙어 있다. off-by-one처럼 보이지만 의도적으로 되돌린 결과다.

3. accept 큐가 꽉 찼을 때 — 관문이 두 번 있다

accept 큐가 꽉 찬 상태에서 새 SYN이 오면 tcp_conn_request()가 ListenOverflows를 올리고 SYN 자체를 버린다. 클라이언트는 SYN_SENT에 머물며 SYN을 재전송한다. 이 경우는 connect()가 늦어질 뿐 성공처럼 보이지는 않는다.

문제는 SYN 시점엔 자리가 있었는데 3번째 ACK가 도착한 시점엔 큐가 찬 경우다. burst에서 여러 핸드셰이크가 동시에 진행되면 이렇게 된다. tcp_check_req()가 child 소켓을 만들려다 실패하면 이 분기로 간다.

// net/ipv4/tcp_minisocks.c — listen_overflow
if (!READ_ONCE(sock_net(sk)->ipv4.sysctl_tcp_abort_on_overflow)) {
	inet_rsk(req)->acked = 1;   // ACK를 받았다고 표시만 하고
	return NULL;                // 세그먼트는 버린다 — RST 없음
}

이때 양쪽 상태가 어긋난다.

client: SYN-ACK 받음 → ESTABLISHED   (connect() 성공, 요청 write)
server: request_sock은 SYN_RECV 그대로, child 소켓 없음
        └ reqsk 타이머 만료 → SYN-ACK 재전송
          → client가 ACK 재전송 → 그때 큐에 자리가 있으면 child 생성
          → tcp_synack_retries 횟수를 넘기면 요청 폐기

클라이언트 입장에선 연결이 열렸으니 바로 요청을 보낸다. 그 사이 서버엔 이 연결을 받을 소켓이 없으므로 요청은 처리되지 않는다. 요청 세그먼트도 ACK를 싣고 있어 같은 검사를 다시 거치지만, 큐가 여전히 차 있으면 똑같이 버려진다. 결국 서버의 SYN-ACK 재전송(또는 클라이언트의 데이터 재전송)이 다시 도착했을 때 큐에 자리가 나 있어야 비로소 연결이 서버에 생긴다. 이 재전송 간격은 초기 1초에서 지수적으로 늘어나는 것으로 알려져 있다. "첫 요청이 1초, 3초씩 멈춘다"는 증상이 이 간격과 맞아떨어진다. 타이머 백오프의 일반 원리는 TCP RTO 글과 같은 구조다.

tcp_abort_on_overflow=1이면 대신 RST를 보내 즉시 실패시킨다. 하지만 ip-sysctl 문서는 기본값 0의 이유를 "burst로 인한 overflow라면 회복된다"고 설명한다. RST로 바꾸면 그 회복 기회를 버리는 셈이다.

4. 직접 확인하기

accept하지 않는 서버를 listen(1)로 띄우고 연결을 여러 개 붙이면 큐가 차는 걸 볼 수 있다.

# backlog_demo.py — listen(1) 후 accept()를 하지 않는 서버
import socket, time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 9999))
s.listen(1)
time.sleep(600)
python3 backlog_demo.py &
nstat -n                                      # 카운터 기준점 리셋
for i in 1 2 3 4; do (sleep 30 | nc 127.0.0.1 9999 &); done
ss -lnt 'sport = :9999'   # LISTEN 행: Recv-Q=sk_ack_backlog, Send-Q=sk_max_ack_backlog
ss -tan 'dport = :9999'   # 클라이언트 상태
nstat -az TcpExtListenOverflows TcpExtListenDrops

LISTEN 행의 Send-Q는 1인데 Recv-Q가 2까지 오르면 backlog+1이 보인다. 순차로 붙였으니 3·4번째는 SYN 단계에서 버려져 SYN-SENT로 남고 ListenOverflows가 오른다. 3번째 ACK 무시 경로는 핸드셰이크가 동시에 겹쳐야 나와서 이 순차 루프로는 재현이 잘 안 된다. 운영에서는 ListenOverflows가 증가하는지를 먼저 보는 편이 확실하다.

5. 정리

backlog는 완성된 연결의 큐 길이이고, 이 큐가 넘치면 Linux는 기본적으로 거절하지 않고 침묵한다 — 그래서 증상이 refused가 아니라 수 초짜리 멈춤으로 나타난다.

"connect는 되는데 첫 응답이 느리다"면 ss -lnt의 Recv-Q/Send-Q와 nstat의 ListenOverflows부터 보고, 앱의 backlog와 somaxconn을 함께 올리는 게 순서다. 다음으로 파고들 만한 건 syncookie가 MSS·wscale을 쿠키에 싣는 방식과, 데이터가 올 때까지 child 생성을 미루는 TCP_DEFER_ACCEPT다.

참고 자료

  • listen(2) — https://man7.org/linux/man-pages/man2/listen.2.html
  • Linux Documentation/networking/ip-sysctl.rst — tcp_abort_on_overflow, tcp_max_syn_backlog, tcp_syncookies, tcp_synack_retries
  • Linux 커널 소스(torvalds/linux) — include/net/sock.h, net/ipv4/tcp_input.c(tcp_conn_request), net/ipv4/tcp_minisocks.c(tcp_check_req), net/ipv4/tcp_diag.c

0개의 댓글