brunch

You can make anything
by writing

C.S.Lewis

by 이권수 Feb 03. 2020

네트워크 커널 튜닝

할 수 있을 듯 하기 어려운 리눅스 커널 튜닝


대량의 트래픽이 발생할 때를 대비해야 한다면..?


신상품 공개, 콘서트 티켓 세일 등과 같은 이벤트가 있는 경우, 웹사이트에 순간적으로 엄청난 트래픽이 몰려서 웹사이트에 과부하가 발생할 수 있습니다. 이 때 과부하가 발생하는 원인은 다양한데, 가장 유력한 원인 중에 대표적인 것 중 하나가 바로 최적화하지 않은 웹서버입니다. 최적화되지 않은 웹서버를 사용하는 이유는 보통 기본 네트워크 설정값을 그대로 사용하기 때문입니다.


모든 클라이언트는 서버와 연결하기 위해서 3-way handshake를 수행해야 합니다. 이 때 서버는 클라이언트가 연결을 맺기 전까지 기다립니다. 이러한 상태에 대한 설정이나 관리는 커널의 네트워크 드라이버가 알아서 해주기 때문에 따로 어플리케이션에서 변경하지 않습니다. TCP 어플리케이션은 통상적으로 리스너를 생성하고 listen()를 통해 소켓과 바인딩하고, accept()를 통해 establised 상태인 클라이언트 소켓을 받아서 연결을 맺습니다. 이 연결을 통해서 패킷을 주고 받고, 이후에는 close()로 연결을 종료합니다.


NIC에서 패킷을 받기 전에 네크워트 드라이버가 먼저 수신용 메모리 버퍼를 할당하는데, 이때 얼마나 많이 할당할지는 커널이 책임지고 관리합니다. NIC에서 패킷이 수신용 메모리 버퍼로 들어가더라도, 실제 CPU에서 처리가 되는 시점과는 차이가 발생할 수 있습니다. 패킷이 전송되는 속도가 처리속도보다 빠르면 언젠가부터 패킷이 손실되게 됩니다.


커널은 설정값을 통해서 동시에 ACK 신호를 받을 수 있는 소켓 수를 결정합니다. SYN backlog 큐의 사이즈가 128이라는 의미는 동시에 연결을 시도할 수 있는 클라이언트 수가 총 128개라는 뜻입니다.  클라이언트는 3-way handshake에서 연결을 완료할 필요가 없고 계속해서 연결을 요청할 수 있습니다. 만약 클라이언트가 ACK를 보내지 않고 계속해서 연결을 시도할 경우에는 SYN backlog가 128개를 초과하게 되고 버퍼에 공간이 없기 때문에 이후 연결은 실패하게 됩니다.


결국... 커널의 설정값을 적합하게 설정해줘야 한다는 뜻이죠..!


본 글은 이러한 커널의 네트워크 설정값을 튜닝하는 방법에 대한 기본적인 사항들에 대한 내용입니다. 기본 설정값이 도대체 뭐길래 튜닝을 하지 않으면 제대로 쓰지 못하는 건지 알아보도록 하겠습니다.




TCP/IP Socket과 3-way handshake


모든 TCP/IP 통신은 소위 말하는 3-way handshake를 통해 이루어집니다. 클라이언트와 서버의 TCP/IP 통신을 살펴보면 이해하기 쉽습니다. 서버는 특정 리스너 포트와 연결된 소켓(welcome socket)을 통해 연결을 기다립니다. 클라이언트가 서버에 연결을 시도할 때, 클라이언트는 이전에 사용되지 않은 임의의 포트 범위 중에서 포트를 선택합니다. 클라이언트는 해당 포트와 자신의 IP로 소켓을 만듭니다. 여기서 클라이언트의 IP는 서버와 통신이 가능한 네트워크 장비를 통해 할당된 IP여야 합니다.


서버 소켓 : 서버 IP + 포트
클라이언트 소켓 : 클라이언트 IP + 임의의 포트 범위에서 정한 포트


# 1

소켓을 생성한 클라이언트는 일련번호와 함께 SYN 패킷을 서버에 보내면서 TCP 연결을 시도합니다. 이 때 서버의 포트는 서버가 리스팅하고 있는 포트로 설정해야 합니다.


# 2

서버는 클라이언트가 보낸 패킷을 NIC rx ring으로부터 읽어 SYN 큐에 저장합니다. (SYN큐는 소켓이 생성될 당시 가지는 두 개의 큐 중 하나입니다.) NIC는 패킷이 도착했다고 인터럽트(interrupt)를 발생시키고, 인터럽트를 받은 서버는 SYN 큐로부터 패킷을 읽습니다. 서버는 해당 패킷이 SYN 패킷인지 확인한 후에 "SYN_RECV"상태의 연결을 생성합니다. 그리고 이 연결을 다시 SYN 큐에 저장합니다. 그리고 서버는 SYN와 함께 ACK 패킷(SYN+ACK)을 클라이언트에게 전송합니다.


# 3

클라이언트는 서버로부터 SYN+ACK를 받고 이를 정상적으로 받았다는 ACK 패킷을 다시 서버로 전송합니다. 이 때 클라이언트는 연결의 상태를 "ESTABLISHED"로 변경합니다. ACK 패킷을 받은 서버는 이전에 SYN 큐에 저장했던 연결을 제거한 후에 Accept 큐로 옮깁니다. 마지막으로 상태를 "SYN_RECV"에서 "ESTABLISHED"로 변경합니다.


어플리케이션은 다음 accept() 호출 시에 생성된 연결에서 클라이언트 소켓을 수신함으로써 클라이언트와 통신 할 수 있게 됩니다.


손으로 그려본 3-way handshake



SYN 큐와 Accept 큐 Deep Dive~~


위에서 보았던 SYN큐와 Accept큐에 대해서 자세히 살펴보겠습니다. 소켓은 어플리케이션 영역에서 생성이 가능하며, 생성 시에 연결을 위한 SYN큐와 Accept큐를 가집니다. SYN 큐는 클라이언트에서 보낸 SYN 큐를 저장하기 위한 큐입니다. 또한 SYN+ACK 패킷을 보내고 ACK를 받지 못한 경우 SYN+ACK패킷 전송을 재시도하는 역할을 합니다. 리눅스의 변수값 중에서는 net.ipv4.tcp_synack_retries로 설정할 수 있습니다.


$ sysctl net.ipv4.tcp_synack_retries
net.ipv4.tcp_synack_retries = 5



Accept 큐는 생성이 완료된 연결을 저장하는 큐입니다. 서버가 SYN를 받고, SYN+ACK를 보내고, 마지막으로 ACK를 받으면 기존에 SYN큐에 있던 SYN_RECV상태의 연결을 Accept 큐로 옮기고, ESTABLISHED로 상태를 변경합니다. 이렇게 ESTABLISHED된 소켓은 특정 프로세스가 Accept()를 호출하면 어플리케이션으로 전달됩니다.


이러한 SYN 패킷에 관련해서 리눅스에서 살펴보면 좋은 지표가 있는데, 그게 바로 아직 ACK를 보내지 않은 SYN의 카운트입니다. 이게 중요한 이유는 바로 SYN flood로 인해 장애가 발생할 수 있기 때문입니다. SYN flood란 ACK를 받지 못한 SYN패킷이 너무 많아서 SYN Backlog 큐 버퍼 사이즈를 채운 이후에 오는 패킷을 강제로 버리게 만드는 공격을 말합니다. 그러면 클라이언트에서는 접속을 할 수 없기 때문에 결국 장애가 발생합니다.


그래서 netstat 명령어를 통해서 Recv-Q와 Send-Q를 살펴볼 필요가 있습니다. netstat의 man 페이지에서 Recv-Q와 Send-Q 에 대한 설명을 찾아보면 아래와 같이 나옵니다.


| Recv-Q
Established: The count of bytes not copied by the user program connected to this
socket.  (소켓에 연결된 사용자 프로그램에서 아직 처리하지 못한 바이트 수)
Listening: Since Kernel 2.6.18 this column contains the current syn backlog.
(해당 소켓에 현재 쌓여있는 SYN backlog  수)

| Send-Q
Established: The count of bytes not acknowledged by the remote host.  
( 원격 호스트로부터 아직 ACK 신호를 받지 못한 패킷의 바이트 수 )
Listening: Since Kernel 2.6.18 this column contains the maximum size of the syn backlog.
( 커널 2.6.18 버전 이후부터 적용 - SYN backlog의 최대 크기


설명에서 보시는 것과 같이 두 지표는 상태에 따라서 의미하는 바가 달라집니다. 먼저 확인해보면 좋은 지표는 Listening하고 있는 포트와 연결된 소켓의 SYN backlog 최대 크기입니다(Send-Q). 이를 확인할 수 있는 명령어는 ss(socket statistics)입니다.


8888번 포트를 사용하는 자바 어플리케이션이 사용하는 소켓의 backlog 최대크기
$ ss -nl | grep <port>


현재 8888번 포트를 Listening하고 있는  소켓은 SYN backlog 사이즈가 최대 100으로 설정되어 있는 것을 보실 수 있습니다. 만약 소켓을 연결한 어플리케이션이 SYN 패킷을 쌓아놓고 ACK패킷을 받지 못하는 경우에는 100보다 초과되는 상황이 발생하고, 이는 장애로 이어질 가능성이 높습니다.




이제 커널을 한 번 건드려볼까요?


그러면 지금부터는 커널의 어떤 변수값이 네트워크 성능과 관련이 있는지 살펴보겠습니다. 먼저, 각 CPU 코어는 패킷이 NIC로부터 들어올 때 일종의 큐라고 볼 수 있는 ring buffer라는 공간에 패킷을 어느정도 모아놓을 수 있습니다. 이 때 net.core.netdev_max_backlog 값을 변경하여 큐에 저장할 수 있는 패킷의 수를 최대로 늘려주어야 순간적으로 들어오는 트래픽에서 패킷의 손실을 줄일 수 있습니다. 참고로 이 값은 CPU 코어당 값이고, 지나치게 많이 넣게 되면 buffer도 메모리기 때문에 메모리 관리에 영향을 줄 수 있습니다.


리눅스에서는 softnet_stat 파일을 통해 손실된 패킷이 발생하는지 확인하실 수 있습니다. 파일을 확인해보면 CPU 코어 수 만큼의 줄이 있고, 숫자가 컬럼별로 쓰여 있습니다. 각 컬럼은 개별적으로 의미가 있는 여기서는 앞 3개의 컬럼만 보도록 하겠습니다.(상세 설명 : https://insights-core.readthedocs.io/en/latest/shared_parsers_catalog/softnet_stat.html)


# cat /proc/net/softnet_stat
00153085 00000000 00000484  ...
00148546 00000000 00000466  ...
001126aa 00000000 00000354  ...

첫번째 컬럼은 CPU가 처리한 패킷의 수, 두번째 컬럼 손실된 패킷의 수, 세번째 컬럼 실제 패킷을 처리할 때 소모된 시간이 net.core.netdev_budget값을 초과한 횟수, 즉 주어진 시간동안 처리를 못한 경우를 나타냅니다. 만약 두번째 컬럼값이 증가한다면 이는 net.core.netdev_max_backlog 값을 변경해야한다는 신호입니다.


위에 3-way handshake에서 설명했던 것처럼, 서버가 SYN 패킷을 받아서 처리하게 되면 SYN_RECV상태로 연결이 생성되고, SYN backlog 큐에 저장됩니다. 여기서 SYN backlog 큐가 받을 수 있는 최대 사이즈를 결정하는 값이 net.ipv4.tcp_max_syn_backlog입니다. 기본값은 보통 128입니다.


서버의  tcp_max_syn_backlog 값이 10240로 크게 설정되어 있다.


netstat 명령어를 사용하면 SYN_RECV 상태인 연결이 있는 지 확인해 볼 수 있습니다.

$ netstat -an | grep SYN_RECV | wc -l

만약 이 값이 0이 아니고 계속해서 많아진다면, 패킷이 SYN_RECV 상태에서 계속 기다린다는 의미이기 때문에 다른 설정값 중에서 이런 현상을 초래하는 것이 있는지 살펴보아야 합니다.TCP연결 시 사용되는 소켓의 두 큐는 기본적으로 FIFO이기 때문에, 이 값이 계속 증가한다는 의미는 들어오는 속도가 나가는 속도보다 빠르다는 의미입니다.



그렇다면 대체 뭘 살펴봐야 할까..?


대표적으로 확인해볼 수 있는 것 중 하나가 SYN cookies입니다. 원래는 SYN 패킷을 받은 서버가 SYN+ACK 신호를 보내기 전에 연결을 생성해서 SYN backlog 큐에 저장합니다. 이 때 SYN cookies를 enable하면 먼저 SYN+ACK신호를 보내고 클라이언트로부터 ACK 신호를 받았을 때 비로소 연결을 생성합니다. 이렇게 되면 클라이언트가 정상적이지 못한 상황에서 SYN backlog 큐에 패킷이 쌓이는 것을 방지할 수 있습니다.


$ sysctl net.ipv4.tcp_syncookies


그런데 SYN cookies가 enable 된 상태에서 클라이언트로부터 오는 ACK패킷이 손실되면 문제가 발생할 수 있습니다. 서버는 이 ACK 패킷에 대한 손실을 모르고 하염없이 기다리는데, 클라이언트에서 바로 실제 데이터가 들어간 패킷을 보낼 수 있습니다. 이 때 하필 ACK패킷 이후의 첫번째 패킷이 손실되고 두번째 패킷이 도착하게 되면 서버는 이제서야 ACK 신호를 받았다고 생각하고 두번째 패킷부터 처리합니다. 즉, 첫번째 패킷은 영영 손실되기 때문에 문제가 발생합니다.



두번째로 확인할 수 있는 것은 위에서도 살짝 언급했던 SYN+ACK의 retry 횟수입니다. 서버가 SYN+ACK패킷을 보낸 후에 일정시간동안 ACK를 받지 못하면 다시 SYN+ACK 패킷을 보냅니다. 재시도할 때의 delay는 서버가 exponential backoff 알고리즘을 통해 설정합니다. 커널의 세팅 중에서는 net.ipv4.tcp_synack_retries 값이 영향을 주는데, 이 설정값의 기본값은 5입니다. 총 횟수가 5번이기 때문에 delay는 1초, 3초, 7초, 15초, 31초가 되고 총 63초가 지나면 타임아웃이 발생합니다. 다시 말하면 SYN을 받은 서버는 연결을 생성하고 SYN backlog 큐에 넣은다음 최대 63초동안이나 기다린다는 의미입니다. 이 때 SYN backlog 큐의 사이즈가 작으면 문제가 발생합니다. 기다리는 연결들로 큐가 금방 꽉차게 되고 1분 넘게 기다려서야 기존의 연결이 타임아웃이 나서 지워지기 때문에, 이후에 갑자기 대규모 트래픽이 발생하면 더이상 SYN backlog 큐에 자리가 없어서 장애가 발생하게 됩니다.


마지막으로 프록시 서버를 사용하는 경우에는 SYN의 retry 횟수도 눈여겨보아야 합니다. 어플리케이션 서버 입장에서 프록시 서버는 클라이언트입니다. 만약 프록시 서버가 SYN패킷을 보내놓고, ACK+SYN패킷을 기다리면서 SYN패킷을 계속해서 보내면 둘 간의 연결이 SYN 큐에 계속해서 쌓이기 때문에 정작 어플리케이션으로 들어오는 연결에 영향을 줄 수 있습니다. 이와 관련된 커널의 설정값은 net.ipv4.tcp_syn_retries이고 기본값은 5입니다.




그러면 Accept 큐에 대한 설정은...?


어플리케이션은 listen()을 통해서 연결을 맺을 때 backlog값을 파라미터로 넘겨줍니다. 위에서도 한 번 언급했듯이, 커널 2.6.18버전부터는 이 파라미터의 의미가 프로세스로부터 accept되기를 기다리는 완성된 연결 수의 최댓값을 의미합니다. 이와 관련된 커널의 설정값은 net.core.somaxconn입니다. 결국 어플리케이션은 두가지 방식으로 accept 큐의 사이즈를 정해서 생성할 수 있습니다. 첫번째는 listen함수 호출시에 backlog값을 전달하는 방법이고, 두번째는 커널의 net.core.somaxconn 값을 설정하는 방법입니다.


net.core.somaxconn의 기본값은 커널의 상수인 SOMAXCONN의 값과 같은 128입니다(5.4 버전부터는 4096). 보통의 어플리케이션은 listen()함수를 호출할 때 SOMAXCONN 값을 사용합니다. 그런데 nginx처럼 어플리케이션에서 자체적인 기본 값을 가지는 경우도 있습니다. 참고로 nginx의 기본값은 511입니다. 다만, 커널의 net.core.somaxconn 값이 128인 경우, 실제 적용되는 값은 둘 중에 작은 값을 기준으로 정해지기 때문에 결국 128로 정해집니다.


net.core.somaxconn 값도 10240으로 크게 잡혀있습니다.
참고로, 제가 사용한 서버의 설정은 지금 tcp_max_syn_backlog와 somaxconn 값 모두 10240으로 설정되어 있는데, 실제로 위에서 ss 명령어로 확인했던 8888번 포트를 리스닝하는 소켓의 Send-Q사이즈는 100으로 제한되어 있습니다. 이는 Spring boot를 사용하는 경우에는 기본적으로 tomcat을 내장 웹서버로 사용하는데, tomcat에서는 acceptCount 변수를 통해서 어플리케이션이 사용할 소켓의 backlog 값을 지정하실 수 있습니다. Apache Tomcat의 acceptCount 기본값이 100입니다.


Accept 큐 사이즈를 늘리게 되면, 대기하는 소켓의 연결 수가 많아지기 때문에 대규모 트래픽이 들어오는 경우에 통신이 원활할 수 있습니다. 하지만 accept 큐 사이즈가 커진만큼 실제로 연결을 가져다가 쓰는 어플리케이션 worker의 thread의 수도 늘려주어야 합니다. 만약 thread 수가 상대적으로 너무 작게 되면, 대기하고 있던 연결들이 thread가 accept할 때까지 하염없이 기다리다가 타임아웃이 발생할 수 있습니다.




이외에 바꿀게 더 있다?


모든 소켓 연결은 기본적으로 file descriptor를 사용합니다. 시스템이 할당할 수 있는 file descriptor의 최댓값은 커널 세팅 중에서 fs.file-max 값에서 확인하실 수 있습니다.

$ cat /proc/sys/fs/file-nr
864    0    65536


첫번째 값은 실제로 사용중인 file descriptor의 수, 두번째 값은 할당되었지만 사용하지 않고 있는 file descriptor의 수, 그리고 마지막 값이 바로 시스템이 할당할 수 있는 file descriptor의 최댓값입니다. 이 시스템에서는 최대 65536개의 file descriptor를 할당할 수 있습니다. 소켓 연결을 하기 위해서 반드시 할당해야 하는 요소이기 때문에 위에서 언급한 커널 설정값을 수정하면서 file descriptor의 수도 조정해주어야 좋은 성능을 낼 수 있습니다.


 File desciptor와 관련해서 또 하나 설정해야하는 것이 있는데, 바로 사용자 레벨에서의 limit입니다. 각 리눅스 사용자는 자신이 할당받을 수 있는 file descriptor의 수가 제한되어 있는데, 이 값은 system의 limits.conf(nofile)에 정의되어 있습니다. 또는 systemd를 통해 돌아가는 프로세스는 해당 프로세스의 systemd unit file에 정의할 수 있습니다.(LimitNOFILE)


$ ulimit -n
또는
$ cat /etc/security/limit.conf
(/etc/security/limits.d/ 아래에 있는 conf 파일에 있을 수도 있습니다.)


limit.conf 예시 (nproc에 대해서는 아래에 나옵니다)


자원을 늘려 놓았으니 쓸 놈도 늘려야겠죠?


File descriptor의 값을 늘렸으면, 이를 사용하는 worker의 수와 thread 수도 맞춰서 늘려주는 것이 좋습니다. 그래야 자원을 낭비하지 않고 쓸 수 있기 때문입니다. 각 프로세스는 여러개의 worker thread를 실행할 수 있는데, 총 생성할 수 있는 최대 thread의 수는 kernel.threads-max 값으로 정할 수 있습니다.


이 값은  /proc/sys/kernel/threads-max에 정의되어 있지만 sysctl 명령어로도 확인하실 수 있습니다.

kernel.threads_max 값


Thread 수의 최댓값도 사용자 제한값이 따로 존재합니다. 이는 각 사용자가 생성할 수 있는 thread를 제한하는데 아까 nofile과 같은 위치에 저장되어 있습니다.같은 파일에서 nproc이라는 설정값을 세팅해주면 됩니다.

nproc 값도 같이 설정되어 있는 것을 보실 수 있습니다.



아직도 끝나지 않았다, 기타 설정값!!


실제로 TCP 연결이 맺어진 소켓 상태를 netstat으로 확인하다보면 일부 소켓은 TIME_WAIT 이라고 뜨는 것을 볼 수 있습니다. TIME_WAIT 상태인 연결들은 클라이언트 쪽에서 연결을 종료하면서 보낸 FIN 패킷을 기다리고 있는 애들입니다. 만약 FIN 패킷이 유실되어서 안오는 경우라고 하면 해당 연결은 시스템의 자원만 잡아먹고 있는 꼴이 되어 버립니다. 이런 좀비같은 연결들이 많아지면 어플리케이션은 그만큼 thread를 낭비하게 되므로 추가적인 요청을 처리할 수 없는 상태가 발생할 수 있습니다. 이때 설정할 수 있는 커널의 설정값이 net.ipv4.tcp_fin_timeout입니다.


또한 TCP 연결 상태가 ESTABLISHED인 상태에서 Timer항목이 keepalive라고 되어 있는 것을 흔히 볼 수 있습니다. keepalive란 TCP 연결 활성화를 판단하기 전에 상대편이 살아있는지 확인하겠다는 의미입니다. 이 설정을 사용하는 이유는 TCP 연결 시에 거치는 3-way handshake이 자주 발생하면 성능이 떨어지기 때문에 기존에 맺어놓은 연결을 재사용하기 위함입니다.


커널의 설정값 중에서 keepalive와 관련된 설정들이 몇가지 있습니다.


1. net.ipv4.tcp_keepalive_time

얼마나 자주 keepalive 메시지를 보낼지를 결정합니다. 기본값은 2시간입니다.

2. net.ipv4.tcp_keepalive_probes

연결이 끊겼다고 판단하기 전에 keepalive 메세지를 보낼 횟수를 결정합니다. 기본값은 9회입니다.

3. net.ipv4.tcp_keepalive_intvl

keepalive_probes를 보낼 간격을 결정합니다. 기본값은 75초입니다.


위 설정값을 통해서 TCP 연결을 새로 맺지 않고 기존의 연결을 재사용할 수 있습니다. 예시로 DB와의 연결 정보를 살펴보면 keepalive를 사용하는 것을 확인하실 수 있습니다.

DB와의 연결이  keepalive로 세션을 유지하는 것

추가로, 조금 다른 이야기일 수 있는데, 특정 ip와의 연결상태를 보고 싶은 경우에는 netstat 커맨드를 사용하실 수 있습니다. 예시로, DB의 ip가 10.1.1.1 이라고 가정하면 아래와 같이 소켓 연결의 결과를 볼 수 있습니다.(참고로 DB의 ip를 모르는 경우 어플리케이션 서버에서 nslookup으로 찾으실 수 있습니다.) 이렇게 하면, 현재 시스템에서 실제 DB Connection이 얼마나 맺어지고 있는지 확인이 가능합니다.  




참고자료


1. Linux Kernel Tuning for High Performance Networking: High Volume Incoming Connections


2. SYN cookies ate my dog – breaking TCP on Linux


3. TCP/IP 네트워크 스택 이해하기 (네이버 D2)


4. SYN 패킷 처리 실제




## 잘못된 내용은 피드백주시면 더 좋은 글로 보답하겠습니다.

브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari