리버스 프록시를 오래 운영하다 보면 프런트 Nginx는 멀쩡한데 백엔드 앞단에서 TIME_WAIT 소켓이 수만 개씩 쌓이는 장면을 마주한다. WAS 튜닝을 아무리 해도 안 잡히는데, 알고 보면 Nginx가 백엔드로 요청을 넘길 때마다 TCP 커넥션을 새로 열고 바로 닫고 있어서다. 이 글은 그 커넥션을 재사용하도록 upstream keepalive를 켜는 방법을 정리한다.
결론부터 말하면, upstream 블록에 keepalive를 넣고 프록시 위치에서 proxy_http_version 1.1과 proxy_set_header Connection ""를 함께 설정하면 Nginx가 백엔드 커넥션을 풀에 넣고 재사용한다. 이 세 가지가 세트다. 하나라도 빠지면 keepalive가 동작하지 않고, 특히 Connection 헤더를 비우지 않으면 매 응답 뒤 커넥션이 닫힌다.
keepalive가 뭐고 왜 필요한가
HTTP keepalive는 한 번 맺은 TCP 커넥션을 여러 요청에 재사용하는 방식이다. 커넥션을 새로 열려면 SYN, SYN-ACK, ACK 3-way 핸드셰이크를 거치고, 닫을 때는 능동적으로 닫은 쪽이 TIME_WAIT 상태로 한동안(리눅스 기본 60초) 소켓을 붙잡고 있는다. 요청량이 많으면 이 짧은 커넥션이 초당 수백, 수천 개씩 생겼다 사라진다.
브라우저와 Nginx 사이(클라이언트측) keepalive는 대부분 기본으로 켜져 있다. 문제는 Nginx와 백엔드 사이(upstream측)다. 여기는 명시적으로 켜지 않으면 Nginx가 proxy_pass로 요청을 넘길 때마다 백엔드에 새 커넥션을 열고, 응답을 받으면 닫는다. 백엔드가 Tomcat이든 Node이든 uWSGI든 마찬가지다.
구체적으로 이런 그림이 된다. 초당 1000 요청을 처리하는 프록시라면 upstream keepalive가 없을 때 초당 1000개의 백엔드 커넥션이 열리고 닫힌다. 능동적으로 닫는 쪽이 Nginx이므로 Nginx 쪽 소스 포트가 TIME_WAIT으로 쌓인다. 로컬 포트 범위(net.ipv4.ip_local_port_range, 기본 32768~60820, 약 2만8천 개)를 다 소진하면 그때부터 connect()가 실패하기 시작한다.
먼저 증상을 확인한다
백엔드가 127.0.0.1:8080이라고 하자. keepalive를 켜기 전에 현재 상태를 ss로 본다. ss는 소켓 상태를 조회하는 도구이고, -t는 TCP, -a는 모든 상태, state 필터로 특정 상태만 추릴 수 있다.
$ ss -tan state time-wait '( dport = :8080 or sport = :8080 )' | wc -l
14231
$ ss -tan '( sport = :8080 )' | awk '{print $1}' | sort | uniq -c
9852 TIME-WAIT
41 ESTAB
TIME-WAIT이 압도적으로 많고 ESTAB(연결 유지 중)은 몇 개 안 된다. 매 요청마다 커넥션을 열고 바로 닫는 전형적인 모습이다. 여기서 숫자는 트래픽과 환경에 따라 다르지만, TIME-WAIT이 ESTAB보다 수십, 수백 배 많다면 커넥션 재사용이 전혀 안 되고 있다는 신호다.
upstream 블록에 keepalive를 켠다
핵심 설정은 세 곳으로 나뉜다. upstream 블록, 그리고 proxy_pass가 있는 location의 두 지시어다.
upstream app_backend {
server 127.0.0.1:8080;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
server {
listen 80;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
각 지시어를 하나씩 본다.
keepalive
upstream 블록 안에 두는 지시어로, 각 워커 프로세스가 캐시에 유지할 유휴 keepalive 커넥션의 최대 개수를 정한다. 여기서 오해하기 쉬운데, 이 값은 동시 커넥션 총량 제한이 아니다. 일을 마치고 놀고 있는 커넥션을 몇 개까지 풀에 담아둘지의 숫자다. 이 한도를 넘으면 가장 오래 쓰지 않은 커넥션부터 닫힌다.
공식 문서는 이 값을 백엔드가 새 커넥션도 처리할 수 있을 만큼 작게 잡으라고 못박는다. upstream 서버 하나에 워커 프로세스 수를 곱한 만큼 유휴 커넥션이 상시 유지될 수 있기 때문이다. 워커가 4개이고 keepalive 32면 백엔드는 유휴 상태로도 최대 128개 커넥션을 물고 있게 된다. 시작값으로는 16~32 정도가 무난하고, 백엔드의 최대 커넥션 수용량을 보며 조정한다.
Nginx 1.29.7부터는 upstream keepalive가 워커당 32개 기본으로 켜져 있다. 하지만 현재 널리 쓰이는 안정 버전(1.24, 1.26, 1.28 계열)에서는 기본이 꺼져 있으므로 명시해야 한다. nginx -v로 버전을 먼저 확인하라.
keepalive_requests
커넥션 하나로 처리할 수 있는 요청 수의 상한이다. 이 수를 채우면 커넥션을 닫는다. 커넥션마다 붙는 메모리 할당을 주기적으로 풀어주기 위한 장치다. 기본값은 1000이고, Nginx 1.19.10 이전에는 100이었다. 트래픽이 많으면 이 값을 올려 재사용 구간을 늘릴 수 있다.
keepalive_timeout
유휴 keepalive 커넥션을 열어둔 채 유지하는 시간이다. 기본값은 60초다. 이 값은 백엔드의 keepalive 타임아웃보다 작게 두는 편이 낫다. 백엔드가 먼저 커넥션을 닫아버리면 Nginx가 이미 닫힌 커넥션을 재사용하려다 502를 낼 수 있기 때문이다. Tomcat이라면 connectionTimeout, keepAliveTimeout 값과 맞춰서 본다.
proxy_http_version 1.1
keepalive는 HTTP/1.1의 지속 커넥션 위에서 동작한다. 그런데 Nginx의 프록시 기본 프로토콜은 오래 HTTP/1.0이었다(1.29.7부터 기본이 1.1로 바뀌었다). HTTP/1.0은 요청마다 커넥션을 닫는 것이 기본이라, 버전을 명시하지 않으면 upstream 블록에 keepalive를 아무리 적어도 재사용이 안 된다. 그래서 proxy_http_version 1.1을 반드시 함께 준다.
proxy_set_header Connection ""
이게 가장 자주 빠뜨리는 부분이다. Nginx는 프록시 요청에 기본으로 Connection: close 헤더를 붙인다. 백엔드는 이 헤더를 보고 "응답 주고 커넥션 닫으라"는 뜻으로 받아들여 커넥션을 끊는다. 그러면 keepalive가 무력화된다.
proxy_set_header Connection ""는 이 헤더를 빈 값으로 덮어써서 백엔드로 전달되지 않게 한다. 결과적으로 커넥션이 유지된다. 여기서 흔히 하는 실수가 하나 있다. WebSocket 프록시 예제에서 복사해온 proxy_set_header Connection "upgrade"를 그대로 두는 것이다. 일반 HTTP 프록시에 upgrade를 넣으면 keepalive가 깨진다. WebSocket location과 일반 API location을 분리해서 설정하라.
참고로 proxy_set_header는 같은 레벨에 하나라도 지시어가 있으면 상위 레벨 설정을 상속하지 않는다. location 안에서 헤더를 여러 개 세팅한다면 Connection 지시어도 그 location 안에 같이 넣어야 한다.
적용하고 재사용을 검증한다
설정 문법을 검사하고 무중단으로 리로드한다.
$ sudo nginx -t nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful $ sudo systemctl reload nginx
이제 부하를 조금 준 뒤 다시 ss로 본다. 재사용이 되고 있으면 ESTAB이 keepalive 값 부근에서 안정적으로 유지되고, TIME-WAIT 증가 속도가 눈에 띄게 떨어진다.
$ ss -tan '( sport = :8080 )' | awk '{print $1}' | sort | uniq -c
28 ESTAB
173 TIME-WAIT
keepalive 적용 전 ESTAB 41개에 TIME-WAIT 9852개였던 것과 비교하면, 유지 커넥션 대비 TIME-WAIT 비율이 확 낮아진 것을 볼 수 있다. 이렇게 말이다. 정확한 수치는 트래픽과 keepalive 값에 따라 다르지만, ESTAB이 keepalive 설정값 근처에서 유지되고 TIME-WAIT이 더는 폭증하지 않으면 재사용이 걸린 것이다.
커넥션을 낮은 수준에서 직접 확인하고 싶으면 특정 커넥션이 얼마나 오래 살아 있는지 -o(타이머 정보)로 본다.
$ ss -tano '( sport = :8080 and dst 127.0.0.1 )' state established Recv-Q Send-Q Local Address:Port Peer Address:Port Process 0 0 127.0.0.1:8080 127.0.0.1:41022 timer:(keepalive,18sec,0) 0 0 127.0.0.1:8080 127.0.0.1:41008 timer:(keepalive,42sec,0)
같은 커넥션이 여러 요청에 걸쳐 ESTABLISHED로 살아 있으면 재사용이 되는 것이다.
흔히 걸리는 함정
설정을 다 넣었는데도 TIME-WAIT이 안 줄면 대개 다음 중 하나다.
- proxy_pass에 변수를 쓰는 경우.
proxy_pass http://$backend;처럼 변수로 업스트림을 지정하면 upstream 블록의 keepalive 풀을 타지 않을 수 있다. 정적 이름의 upstream 블록을proxy_pass에 직접 지정하라. - 백엔드가 먼저 커넥션을 닫는 경우. 백엔드의 keepalive 타임아웃이 Nginx의
keepalive_timeout보다 짧으면 백엔드가 먼저 끊는다. 이때 능동 종료 주체가 백엔드로 바뀌어 TIME-WAIT이 백엔드 쪽에 쌓이고, Nginx는 닫힌 커넥션 재사용으로 간헐적 502를 겪을 수 있다. 양쪽 타임아웃을 맞춘다. - Connection 헤더를 안 비운 경우. 앞서 말한
proxy_set_header Connection ""가 빠졌거나upgrade로 남아 있는지 확인한다. - HTTP/1.0으로 나가는 경우.
proxy_http_version 1.1이 해당 location에 실제로 적용됐는지 본다. server 블록과 location 블록의 상속 관계 때문에 엉뚱한 곳에 걸려 있을 수 있다.
TIME_WAIT 회피 커널 파라미터는 keepalive를 대체하지 못한다
net.ipv4.tcp_tw_reuse 같은 커널 파라미터로 TIME_WAIT 소켓을 재활용하는 방법도 있지만, 이건 증상 완화이지 원인 제거가 아니다. 커넥션을 여전히 매번 새로 여는 것이고, 핸드셰이크 비용도 그대로다. 근본 해결은 커넥션 자체를 재사용하는 upstream keepalive다. 커널 튜닝은 그 위에 얹는 보조 수단으로 생각하는 편이 맞다.
정리
Nginx와 백엔드 사이 커넥션 재사용은 세 가지 세트로 완성된다. upstream 블록의 keepalive, location의 proxy_http_version 1.1, 그리고 proxy_set_header Connection ""다. 적용 뒤에는 반드시 ss로 ESTAB이 유지되고 TIME-WAIT 증가가 멎었는지 눈으로 확인한다. keepalive 개수와 타임아웃은 백엔드 수용량, 백엔드측 타임아웃과 맞춰가며 조정하면 된다.