Tomcat이 뒤쪽 DB나 외부 API로 연결을 열고 바로바로 닫는 구조, 또는 Nginx가 upstream으로 짧은 연결을 계속 맺는 구조에서 자주 보게 되는 증상이다. 평소엔 멀쩡하다가 트래픽이 조금만 올라가면 java.net.BindException: Cannot assign requested address 같은 로그가 찍히기 시작한다. 막상 서버에 붙어 ss로 세어 보면 TIME_WAIT 소켓이 수만 개 쌓여 있다. 포트가 모자라서 나가는 연결 자체를 못 만드는 상태다.
결론부터 말하자면, 밖으로 나가는 연결(아웃바운드) 때문에 포트가 고갈된 거라면 net.ipv4.tcp_tw_reuse를 켜고 net.ipv4.ip_local_port_range를 넓히는 게 정석이다. 들어오는 연결(인바운드)의 수락 대기열이 넘치는 문제라면 net.core.somaxconn과 WAS의 backlog 설정을 같이 올려야 한다. 두 문제는 원인도 해법도 다르니 먼저 어느 쪽인지부터 가려야 한다.
이 글은 커널 5.4 이상(RHEL 9, Ubuntu 20.04 이상 계열)을 기준으로 한다. 기본값은 커널 버전에 따라 다를 수 있으니 실제 값은 각 항목에서 sysctl로 확인하는 명령을 함께 적는다.
1. TIME_WAIT가 무엇이고 왜 쌓이는가
TCP 연결을 끝낼 때 먼저 FIN을 보내는 쪽, 즉 연결을 능동적으로 닫는 쪽이 TIME_WAIT 상태로 들어간다. 뒤늦게 도착하는 패킷이 다음 연결에 섞여 들어가는 것을 막기 위해, 소켓을 바로 없애지 않고 한동안 붙잡아 둔다. 리눅스에서 이 시간은 커널에 TCP_TIMEWAIT_LEN으로 60초가 박혀 있고, sysctl로는 바꿀 수 없다.
흔히 tcp_fin_timeout으로 TIME_WAIT 시간을 줄일 수 있다고 알려져 있는데, 이 값은 FIN_WAIT_2 상태에 적용되는 것이지 TIME_WAIT에는 영향이 없다. 혼동하기 쉬운 지점이다.
문제는 아웃바운드 연결에서 생긴다. Tomcat이 DB 커넥션 풀 없이 매 요청마다 연결을 열고 닫거나, Nginx가 upstream으로 keepalive 없이 짧은 연결을 반복하면, 로컬 쪽이 능동적으로 닫는 입장이 되어 TIME_WAIT가 로컬 포트에 쌓인다. 나가는 연결은 (로컬 IP, 로컬 포트, 원격 IP, 원격 포트) 네 값으로 구분되는데, 로컬 포트는 커널이 ip_local_port_range 범위에서 임시로 하나 골라 쓴다. 이 임시 포트(ephemeral port)가 TIME_WAIT로 다 묶이면 새 연결에 배정할 포트가 없어 EADDRNOTAVAIL, 자바에서는 BindException: Cannot assign requested address로 터진다.
2. 어느 쪽 문제인지 ss로 확인한다
ss는 소켓 상태를 보는 도구로 예전 netstat을 대체한다. 먼저 요약으로 TIME_WAIT 개수부터 본다.
$ ss -s Total: 5821 TCP: 5604 (estab 142, closed 5312, orphaned 0, timewait 5301) Transport Total IP IPv6 TCP 292 280 12
timewait 수치가 수천, 수만으로 올라가 있으면 TIME_WAIT 누적이 맞다. 다음으로 어느 목적지로 쌓이는지 본다. 상태 필터로 TIME_WAIT만 추려서 세어 본다.
$ ss -tan state time-wait | wc -l 5301 $ ss -tan state time-wait dst :3306 | head State Recv-Q Send-Q Local Address:Port Peer Address:Port TIME-WAIT 0 0 10.0.0.5:41022 10.0.0.9:3306 TIME-WAIT 0 0 10.0.0.5:41044 10.0.0.9:3306 TIME-WAIT 0 0 10.0.0.5:41050 10.0.0.9:3306
Local Address의 포트가 전부 임시 포트 범위(32768 이상)이고 Peer가 DB 포트(3306)라면, 이 서버가 능동적으로 닫는 아웃바운드 연결이 원인이다. 3절과 4절의 튜닝이 바로 듣는 경우다.
반대로 Local Address 포트가 서비스 포트(8080 등)로 고정돼 있고 TIME_WAIT가 쌓인다면, 클라이언트 쪽이 아니라 이 서버가 받은 연결을 닫는 상황이다. 이때는 애플리케이션이 연결을 재사용하도록 keepalive를 손보는 게 먼저다. 수락 대기열이 넘치는지는 5절에서 본다.
3. 임시 포트 범위를 넓힌다
현재 범위부터 확인한다.
$ sysctl net.ipv4.ip_local_port_range net.ipv4.ip_local_port_range = 32768 60999
기본값은 32768부터 60999까지, 약 2만 8천 개다. 60초 동안 2만 8천 개를 다 소진할 만큼 짧은 연결을 쏟아내면 고갈된다. 시작 포트를 낮춰 범위를 넓히면 여유가 늘어난다.
$ sudo sysctl -w net.ipv4.ip_local_port_range="10240 65535" net.ipv4.ip_local_port_range = 10240 65535
10240 아래는 잘 알려진 서비스 포트와 겹칠 수 있으니 그보다 낮추지 않는다. 범위를 넓히는 건 증상을 완화할 뿐 근본 해결은 아니다. 연결 재사용이 안 되는 구조라면 범위를 넓혀도 트래픽이 더 오르면 다시 고갈된다. 그래서 4절을 같이 적용한다.
4. tcp_tw_reuse로 TIME_WAIT 소켓을 재사용한다
net.ipv4.tcp_tw_reuse는 TIME_WAIT 상태의 소켓을 새 아웃바운드 연결에 안전하게 재사용하도록 허용하는 값이다. TCP 타임스탬프를 이용해 옛 연결과 새 연결을 구분할 수 있을 때만 재사용하므로, 아무 때나 포트를 돌려쓰는 것과는 다르다.
값은 세 가지다.
0- 재사용 안 함1- 전역 활성화2- 루프백 트래픽에만 활성화(커널 5.x대 기본값)
현재 값을 확인한다.
$ sysctl net.ipv4.tcp_tw_reuse net.ipv4.tcp_tw_reuse = 2 $ sysctl net.ipv4.tcp_timestamps net.ipv4.tcp_timestamps = 1
기본값 2는 루프백에만 적용되므로, 다른 호스트의 DB나 API로 나가는 연결에는 효과가 없다. 아웃바운드 포트 고갈을 풀려면 1로 올린다.
$ sudo sysctl -w net.ipv4.tcp_tw_reuse=1 net.ipv4.tcp_tw_reuse = 1
전제 조건이 하나 있다. tcp_tw_reuse는 TCP 타임스탬프가 켜져 있어야(tcp_timestamps = 1, 기본값) 동작한다. 타임스탬프를 끈 환경이라면 reuse도 듣지 않으니 먼저 확인한다.
한 가지 더. 예전에 쓰던 tcp_tw_recycle은 NAT 뒤 클라이언트의 연결을 깨뜨리는 문제가 있어 커널 4.12에서 제거됐다. 지금 커널엔 아예 없으니 설정하려 하지 말고 tcp_tw_reuse만 쓴다. reuse는 아웃바운드 연결에만 작용하고 수신 측 연결을 건드리지 않아 recycle 같은 부작용이 없다.
TIME_WAIT 소켓 수 자체에 상한을 두고 싶으면 tcp_max_tw_buckets를 조정한다. 이 상한을 넘으면 TIME_WAIT 소켓이 즉시 파기되고 커널 로그에 경고가 찍힌다. 안전장치로는 쓸 수 있지만, 상한을 낮추는 것이 포트 고갈의 해법은 아니다. 근본은 reuse와 연결 재사용이다.
5. 인바운드라면 somaxconn과 backlog를 맞춘다
2절에서 수신 측 문제로 판명됐거나, 순간 접속 폭주로 연결이 거부된다면 수락 대기열(accept queue)을 본다. net.core.somaxconn은 listen() 백로그의 상한이다. 애플리케이션이 backlog를 아무리 크게 요청해도 이 값으로 잘린다.
$ sysctl net.core.somaxconn net.core.somaxconn = 4096
기본값은 커널 5.4부터 4096이다. 그 이전 커널에서는 128이었으니, 오래된 서버라면 128에 묶여 있을 수 있다. 대기열이 넘치는지는 ss -ltn의 Send-Q로 본다. 리스닝 소켓에서 Send-Q는 백로그 상한, Recv-Q는 현재 대기 중인 연결 수다.
$ ss -ltn State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 0 128 *:8080 *:*
Send-Q가 128로 보이면 Tomcat이 backlog 128로 열었다는 뜻이다. Tomcat은 server.xml의 Connector에서 acceptCount가 이 백로그에 해당한다. 기본값이 100이므로, 커널만 4096으로 올려도 Tomcat이 100으로 여는 한 100에 묶인다. 양쪽을 같이 올려야 한다.
<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol"
connectionTimeout="20000"
acceptCount="4096"
maxConnections="8192" />
Nginx는 listen 지시어의 backlog 파라미터로 지정한다. 리눅스 기본은 511이다.
server {
listen 80 backlog=4096;
...
}
커널의 somaxconn과 애플리케이션의 backlog 중 작은 쪽이 실제 상한이 된다는 점만 기억하면 된다.
6. 영구 적용과 검증
sysctl -w로 바꾼 값은 재부팅하면 사라진다. 영구 적용은 /etc/sysctl.d/ 아래에 파일로 남긴다.
$ sudo tee /etc/sysctl.d/99-tcp-tuning.conf <<'EOF' net.ipv4.tcp_tw_reuse = 1 net.ipv4.ip_local_port_range = 10240 65535 net.core.somaxconn = 4096 EOF $ sudo sysctl --system
적용 뒤 부하가 올라간 시점에 다시 ss로 센다. TIME_WAIT가 늘어도 포트 고갈 에러가 사라지고, 임시 포트가 재사용되며 수치가 평평하게 유지되면 튜닝이 듣는 것이다.
$ watch -n1 'ss -tan state time-wait | wc -l'
마지막으로, 커널 튜닝은 증상을 버티게 해 주는 것이고 가장 확실한 해결은 애플리케이션 쪽 연결 재사용이다. Tomcat이라면 DB 커넥션 풀을 제대로 쓰고, 외부 API 호출에는 HTTP 클라이언트의 keepalive를 켠다. Nginx라면 upstream 블록에 keepalive를 두고 proxy_http_version 1.1, proxy_set_header Connection ""를 설정해 연결을 재사용한다. 연결을 재사용하면 애초에 TIME_WAIT가 덜 생긴다. 커널 값은 그 위에 얹는 여유분으로 보면 된다.