blog.stackframe.dev

NGINX 원거리 리버스 프록시 지연 문제

최근 웹 서버 최적화를 진행하는 도중 엄청난 딜레이를 발생시키는 요청을 발견하였다.

2.10MB 크기의 이미지를 가져오는데 97ms의 대기 시간과 297ms의 다운로드 시간이 걸렸다.

먼저 서버와 네트워크 구성에 대해서 설명하자면 해외 서버와 홈 서버가 있고 이 두 서버는 wireguard VPN으로 연결되어 있다. 홈 서버에서 NGINX와 PHP-FPM으로 암호화가 없는 웹 서버를 돌리고, 해외 서버에서 NGINX의 리버스 프록시 기능을 사용하여 사용자와 HTTP2 연결을 하면서 모든 요청을 홈 서버로 보내고 응답을 사용자에게 그대로 반환한다. 그리고 해외 서버와 홈 서버 사이의 RTT는 평균 31ms 정도 나온다.

해외 서버는 하는게 없어보이지만 존재 자체에 의미가 있으므로 서버 구성의 문제점은 넘어가자.

여기서 문제가 발생하는 것은 그리 크지 않은 파일(1 ~ 5MB)에 대한 요청이 상당히 느린 속도로 전송된다는 점이다. 그렇다고 홈 서버에서 다이렉트로 다운받아보면 긴 시간이 걸리지 않는다.

홈 서버에서 직접 다운받으면 대기 시간 71ms와 다운로드 시간 53ms가 걸렸다.

단지 해외 서버를 경유한 것으로 다운로드 시간이 6배 가까이 길어진 것을 확인할 수 있었다. 서버 사이의 RTT를 고려하여도 도저히 용납할 수 없는 결과이다.

위의 두 결과를 종합해보면 문제는 분명 해외 서버 쪽에 있음이 틀림없었다. 그런데 해외 서버에 접속해서 이런저런 테스트를 해보다 두 가지 벽에 부딫혔다.

  • 해외 서버는 NGINX 설정이 매우 단순하여 어떤 요청이든 모두 동일한 로직으로 홈 서버로 보내는데 JS, CSS 같은 파일들은 RTT에 가까운 지연 시간만 추가되었다.
  • 네트워크 대역폭은 충분히 넓게 측정되고 있었다.

약 260mbps의 대역폭이 측정되었다.

대역폭은 널널하고 RTT도 31ms에 이미지 같은 적당한 크기의 파일들만 영향을 받는다? 도저히 이해가 되지 않는 상황이 발생하고 있었다. 분명 해외 서버에서 무언가가 잘못된 것인데 정확한 원인을 파악하지 못하고 애꿎은 NGINX 설정만 건드렸다.

그러다 아예 내 컴퓨터를 홈 서버와 비슷하게 구성하여 와이어샤크로 패킷 덤프를 해보니 그 원인을 알 수 있었다.

패킷 덤프 결과

내 컴퓨터가 192.168.1.8이고 해외 서버가 192.168.1.1이다. 2,3번 패킷의 시간을 보면 RTT가 31ms인 것을 알 수 있다.

중요한 부분은 10과 11사이이다. 6 ~ 10까지 서버에서 2788 바이트 크기의 패킷을 해외 서버로 전송하였다. MTU가 1420이므로 네트워크 상에서는 그보다 작은 1300바이트 정도의 2개의 패킷으로 분할되어 10개의 패킷이 전송되었을 것이다. 그런데 그 이후 서버에서 ACK가 올 때까지 다음 데이터를 전송하지 않고 대기하여 30ms를 아무런 작업도 하지 않고 날려버렸다. 밑의 패킷들을 보면 이렇게 ACK를 기다리며 몇 번이나 시간을 허비하였고 결과적으로 지연시간이 매우 늘어났다.

TCP는 흐름 제어와 혼잡 제어를 하기 때문에 무작정 패킷을 보내지 않는다. 상대방이 받을 수 있다고 할 때만 전송하기 때문에 TCP 연결 초반의 윈도우가 작거나 네트워크가 혼잡 상황인지 분별이 불가능할 때는 조금만 전송하고 상대방의 응답을 대기하게 된다. 위의 패킷 덤프를 보면 리눅스의 초기 혼잡 윈도우(initcwnd) 설정에 따라 10개의 패킷만 전송하고 ACK를 기다리게 되었다.

상대방에게서 ACK가 빠르게 도착한다면 그 즉시 다음 데이터를 전송할테니 문제가 없다. 하지만 늦게 도착하게 된다면 그만큼 허비하는 시간이 길어지게 된다. 지금 내 서버 구성에서는 31ms의 RTT가 이런 상황을 초래하게 되었다.

이 문제를 완화하기 위해서는 연결 초기에 최대한 많은 패킷을 전송해야한다. 네트워크에 연결된 다른 장비들에겐 미안한 일이겠지만 초기 혼잡 윈도우 값을 올려서 초반에 전송될 패킷의 수를 늘릴 수 있다.

# ip route change 192.168.1.0/24 dev wg0 proto kernel scope link src 192.168.1.8 initcwnd 400

initcwnd는 라우팅 레코드마다 설정된다. 기본값이 10인데 나는 400으로 올렸다. 아래에서 패킷 덤프를 보면 알겠지만 초반에 400개의 패킷은 전송이 불가능하다. 단순히 초반에 패킷을 갖다 밖으면 어떻게 되는지 보여주기 위해 설정한 값이니 무작정 따라서 적용하면 안된다.

initcwnd 400을 적용한 결과

초기 혼잡 윈도우를 높게 잡았더니 이번에는 TCP Window Full이 보인다. 전송된 데이터 크기는 31516 + 31516 + 1420 + 652 = 65104로, 하위 계층과 헤더 크기를 빼버리면 약 64KB가 전송된 것을 확인할 수 있다. 그 이유는 3번 패킷에서 상대의 수신 윈도우 크기가 64896이라 전달받았기 때문에 그 이상 전송하지 못하고 ACK를 기다리게 되었기 때문이다.

연결 초기에 데이터를 대량으로 전송할 수 있게 되었으니 이제 수신 윈도우의 크기를 키우면 된다. 대역폭 x RTT 크기의 수신 윈도우를 가지고 있다면 송신 측은 계속 데이터를 전송하고 윈도우가 다 채워질 즈음에 상대의 ACK를 받게되고 이후의 데이터를 이어서 보낼 수 있기에 대역폭을 최대한 활용할 수 있다.

문제는 나는 리눅스에서 TCP 연결 초반에 수신 윈도우 크기를 64KB 이상 설정하는 방법을 찾지 못했다. net.core.rmem_default, net.core.rmem_max, net.ipv4.tcp_rmem sysctl 값을 모두 조정해봐도 초반 윈도우 크기는 그 이상으로 올라가지 않았다. 리눅스 소스코드를 대충 읽어보니 /net/ipv4/tcp_output.ctcp_select_initial_window(), tcp_select_window() 함수에서 수신 윈도우를 설정하는 것 같은데 사용자가 조정할 만한 요소가 보이지 않았다. 검색을 하다보니 이 문제에 대한 메일링 리스트를 찾을 수 있었는데 유용한 해결 방법은 없었다.

Stack is conservative about RWIN increase, it wants to receive packets to have an idea of the skb->len/skb->truesize ratio to convert a memory budget to RWIN.

Some drivers have to allocate 16K buffers (or even 32K buffers) just to hold one segment (of less than 1500 bytes of payload), while others are able to pack memory more efficiently.

I guess that you could use eBPF code to precisely tweak stack behavior to your needs.

Eric Dumazet

그리고 저 문제에 대해 메일을 보내신 분이 BPF에서 setsockopt()rcv_ssthresh 값을 설정할 수 있도록 하는 패치를 보냈었는데, 위험할 수 있다는 평가를 받았다.

It's not so much that I don't think your backbone can handle this...

... it's the prospect of handing whiskey, car keys and excessive initcwnd to teenage boys on a saturday night.

Dave Taht

다만 sysctl의 rmem 관련 값들을 크게 설정했더니 수신 윈도우 크기가 매우 빠르게 증가하는 것을 확인할 수 있었다. 수신 윈도우의 크기가 커질수록 ACK를 기다리지 않고 더 많은 데이터를 전송할 수 있다는 의미이므로 지연 시간은 이전보다 개선되었다.

다운로드 시간이 약간 단축되었다.

결국 initcwnd를 조정하여 처음 64KB를 대량으로 보내고 수신 버퍼를 키워서 수신 윈도우도 빠르게 커지게 하는거로는 만족스러울 정도의 결과를 내지 못했다. 하지만 생각을 조금 바꾸어보면 약간의 희망이 보인다.

TCP 연결 초기의 수신 윈도우를 키울 수는 없지만 이미 확립된 TCP 연결은 윈도우가 커져있다. 그렇다면 NGINX의 upstream 설정의 keepalive를 잘 사용한다면 이미 수신 윈도우가 커진 TCP 연결로 요청을 보내는 것이 가능할 것이다. 참고로 일정 시간동안 TCP 통신을 하지 않으면 윈도우를 축소시키는 net.ipv4.tcp_slow_start_after_idle sysctl 설정이 있는데 이를 끄는 것이 도움이 될지도 모른다.

그래도 결국 홈 서버에 연결하여 데이터를 받아오므로 만족스러운 속도가 아닐 수 있다. 그렇다면 과거의 명언을 따라야한다.

캐싱, 캐싱 그리고 또 캐싱

화자 미상

웹 개발을 하면 캐싱에 대해 귀에 딱지가 앉을 정도로 들을 것이다. 그만큼 캐싱을 사용했을 때 성능 향상이 대단하다는 의미이기도 하다. 물론 이 블로그는 내 취향이 듬뿍 들어가 거의 대부분을 실시간 처리하여 캐싱과는 거리가 멀지만.

NGINX의 proxy_cache를 사용하면 업스트림에서 왔던 요청을 일정 시간 캐싱할 수 있다. 캐싱이 되면 ExpiresCache-Control 헤더에 지정한 시간이 지나기 전까지 프록시에서 해당 응답을 사용자들에게 그대로 전송한다. 그러므로 업스트림에 접속할 필요가 없어지기 때문에 응답 속도가 향상된다.

서버 측 캐싱 이외에도 Cache-Control이나 service worker를 활용하여 브라우저에도 캐싱할 수 있으므로 이 모든 것을 잘 활용한다면 일부 요청이 오래 걸리더라도 이후의 동일한 요청은 금방 처리할 수 있게 만들 수 있다.

댓글