[소켓 프로그래밍] listen API의 backlog 무식하게 알아보기 (feat. gunicorn)

techy-yunong·2021년 5월 12일
0
post-thumbnail

서버를 위한 socket 프로그래밍을 할 때, listen API를 사용해 socket에 binding 된 host와 port로 들어오는 커넥션 요청을 받겠다고 선언할 수 있다. listen API의 첫번째 인자는 socketfd이며, socket의 file descriptor을 의미한다. 두 번째 인자는 backlog 인데, 이번 글에서는 이 backlog 인자가 정확히 어떤 역할을 하는지 알아보자.

정의

리눅스 메뉴얼의 listen API를 보면, backlog 인자에 대해 다음과 같이 정의되어 있다.

The backlog argument defines the maximum length to which the
queue of pending connections for sockfd may grow.

구글 변역기의 힘을 빌려보자면

무슨 말인지 모르겠다 (아직도 먼 기계학습...). 마음가는대로 해석해보면

socketfd의 커넥션 중, 허용 가능한 pending된 커넥션 큐의 최대 길이

정도가 될 것이다.

그러나 정의 만으로도 정확히 이해하기가 힘들었다. 그래서 무식하게 간단한 프로그램을 만들어 실험을 해보기로 결정했다.

에코 서버, 클라이언트 프로그램

필자는 python을 주로 사용해와서, python이 편하기 때문에 python으로 간단한 프로그램을 짜보았다.

에코 서버 프로그램

클라이언트에서 bytes를 보내면, 그 값을 그대로 돌려주는 간단한 서버 프로그램이다

# echo_server.py
import socket
import sys


if __name__ == '__main__':
    host = sys.argv[1]
    port = int(sys.argv[2])
    num_of_backlog = int(sys.argv[3])

    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        s.bind((host, port))
        s.listen(num_of_backlog)
        print(f'[INFO] listens to {host}:{port} with backlog {num_of_backlog}')
        while True:
            conn, addr = s.accept()
            with conn:
                print(f'[INFO] connected to {addr[0]}:{addr[1]}')
                while True:
                    data = conn.recv(1024)
                    if not data:
                        break
                    print(f'[INFO] received data: {data.decode("utf-8")}')

                    # echo to client
                    conn.sendall(data)
    finally:
        print('[INFO] socket closed')
        s.close()
  • python 3.8을 사용. f string을 사용했기 때문에 python 3.6 이상부터 작동할 것으로 예상
  • TCP socket을 사용하였다.
  • 쓰레드가 1개이기 때문에, 실제로 메세지를 주고 받을 수 있는 클라이언트는 동시에 1개 뿐이다.

실행은 다음과 같이 하면 된다.

python echo_server.py HOST PORT BACKLOG

Backlog를 테스트 해보기 위해서, BACKLOG를 인자로 넣었다.

에코 클라이언트 프로그램

이 프로그램은 입력값을 키보드로 부터 받아, 서버로 넘겨주는 간단한 클라이언트 프로그램이다.

# echo_client.py
import socket
import sys


if __name__ == '__main__':
    host = sys.argv[1]
    port = int(sys.argv[2])

    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        s.connect((host, port))
        print(f'[INFO] connected to {host}:{port}')
        while True:
            # make prompts
            print('>>', end=' ')

            # send input data to server
            input_data = input()
            s.sendall(input_data.encode(encoding='utf-8'))

            # receive data from server
            received_data = s.recv(1024)
            print(f'[INFO] received data: {received_data.decode("utf-8")}')
    finally:
        print('[INFO] socket closed')
        s.close()
  • python 3.8을 사용. f string을 사용했기 때문에 python 3.6 이상부터 작동할 것으로 예상
  • connection timeout 값은 60초이다. 즉, 60초 안에 TCP 커넥션을 맺지 못하면 timeout error가 발생한다.

실행은 다음과 같이 하면 된다.

실험

  • Mac OS Catalina iTerms2 터미널에서 실험하였다.
  • 서버는 한개만 실행한다. 실행 명령어는 python echo_server.py 127.0.0.1 10000 2 로 backlog 값은 2로 주었다.
  • 클라이언트 프로그램은 총 4개를 실행한다. 실행 명령어는 python echo_client.py 127.0.0.1 10000 이다. 각각 1번, 2번, 3번, 4번 클라이언트라 부르자.

상기 이미지를 보면, 4개의 클라이언트 프로그램을 실행했고, 실행 순서는 위에서 부터 차례로 실행했다. 3개까지는 커넥션이 잘 맺어졌고, 값을 입력할 수 있는 promps로 넘어간걸 볼 수 있다.

netstat 명령어를 사용해, 실제 연결된 커넥션을 봐보자. netstat -anlt | grep 10000 명령어를 치면 결과가 다음과 같이 나온다.

tcp4       0      0  127.0.0.1.60240        127.0.0.1.10000        SYN_SENT
tcp4       0      0  127.0.0.1.10000        127.0.0.1.60237        ESTABLISHED
tcp4       0      0  127.0.0.1.60237        127.0.0.1.10000        ESTABLISHED
tcp4       0      0  127.0.0.1.10000        127.0.0.1.60236        ESTABLISHED
tcp4       0      0  127.0.0.1.60236        127.0.0.1.10000        ESTABLISHED
tcp4       0      0  127.0.0.1.10000        127.0.0.1.60235        ESTABLISHED
tcp4       0      0  127.0.0.1.60235        127.0.0.1.10000        ESTABLISHED
tcp4       0      0  127.0.0.1.10000        *.*                    LISTEN

3개의 클라이언트는 TCP connection이 ESTABLISHED 된 상태이고, 하나의 클라이언트는 SYN_SENT 상태로 대기 중이다. 이 클라이언트가 4번 클라이언트인 것을 추측할 수 있다. 60초가 지난 후, 4번 클라이언트는 TimeoutError와 함께 종료된다. (4번 클라이언트는 이미지 캡처에서 아웃 시키겠다.)

[INFO] socket closed
Traceback (most recent call last):
  File "echo_client.py", line 11, in <module>
    s.connect((host, port))
TimeoutError: [Errno 60] Operation timed out

ESTABLISHED 인 1번, 2번, 3번의 클라이언트에 입력을 해보면, 실제로 작동하는 클라이언트는 1번 클라이언트고, 2번, 3번 클라이언트는 아무런 응답이 없다. 서버의 스레드가 하나여서, 실제로 서버 프로그램과 데이터를 주고 받을 수 있는 클라이언트는 1개인 것이다. 즉, 2번, 3번의 연결 상태는 ESTABLISHED 이미지만, listen 정의에서 언급된 큐에 대기중인 상태인 것을 알 수 있다.

1번 클라이언트를 강제 종료해보자 (Ctrl+C를 주어, KeyboardInterrupt를 발생시켰다). 대기하고 있던 2번 클라이언트가 서버로 부터 응답을 받게 된다.

이를 통해 backlog 정의에서 언급한 queue는 FIFO인것을 확인할 수 있다. (큐가 맞다!)

정리

만약 서버 프로그램이 동시에 응답할 수 있는 수가 3개라 가정하고, backlog 값을 5라 가정하자. 10개의 요청이 차례로 들어왔을 때, 상황을 그림을 그려보면 다음과 같다. (숫자는 요청을 의미하고, 가장 먼저 들어온 요청이 1번이다)

  • 1~3 요청을 보낸 클라이언트는 TCP connection이 established되고, 서버와 데이터를 주고 받을 수 있을 것이다.

  • listen backlog queue에 있는 4~8번 요청은 TCP connection은 established 되었지만, 서버와 데이터를 주고 받지는 못한다. 1~3번 요청 중 커넥션이 끊어지면, 4번 부터 차례로 데이터를 주고 받을 수 있도록 backlog에서 큐를 이동할 것이다.

  • tcp max sync backlog라는 곳에 저장된 9, 10번 요청은 SYNC_SENT 상태를 유지하게 된다. 실제로는 유지한다기 보다는 클라이언트에서 지속적으로 SYNC 요청을 시도하고, 서버 쪽에서는 계속 패킷을 drop 시킨다. tcp connection timeout이 값 이상으로 계속 시도하게되면, 클라이언트 쪽에서 커넥션에 대한 timeout을 발생키시고, 연결 시도를 중단한다.

  • 이 backlog는 OS 종류에 따라 없을 수도 있고, 다르게 동작할 수도 있다. 예를 들어 어떤 OS에서는 이 backlog가 존재하지 않고 바로 요청을 refuse 할수도 있다. 이 내용은 listen 매뉴얼에도 잘 나와있다.

  • tcp max sync backlog를 큐라 부르지 않는 이유는 순서가 보장이 안되기 때문이다. 실험해본 결과, 이 backlog에 대기중인 상태에서 listen의 backlog queue로 넘어갈 때, 요청한 순서대로 listen의 backlog 큐로 넘어가지 않았다. 단, mac OS에서 실험해보았기 때문에, mac OS에 국한된 작동일 수도 있다.

주의

  • linux 기준으로 listen의 backlog 값이 net.core.somaxconn 커널 파라미터 값보다 크다면, 실제 적용은 net.core.somaxconn 값이 된다.
  • listen의 backlog 인자값과 실제 backlog queue 사이즈는 OS에 따라 다를 수도 있다. backlog 인자값이 1로 지정하더라도, OS에 따라 실제 queue의 사이즈는 4로 적용될 수도 있음을 유의하자.

feat. gunicorn

gunicorn은 python의 WSGI 인터페이스를 지원하는 unix를 위한 http 서버이다. 설정값중에 backlog라는 값이 있다. gunicorn의 코드를 보았을 때, 이 backlog 값은 실제 listen API의 backlog 값과 동일한 것을 알 수 있었다. 다음 코드는 gunicorn 20.1.0의 gunicorn/sock.py 파일의 내용이다.

그렇다. gunicorn의 backlog 설정은, 말그대로 listen API의 backlog 인자와 동일하다.

마무리

사실 이 블로그는 gunicorn의 backlog 값이 어떤 역할을 하는지 궁금해서 시작되었다. 처음에는 gunicorn을 가지고 실험을 해보다, 결국 gunicorn의 코드를 까보게 되었고, listen API를 사용하는 것을 알고, listen API도 무식하게 backlog 인자를 테스트해보면서 여기까지 오게 되었다. 여러 OS 환경(특히 Window)에서 테스트하지 못했다는 점이 아쉽지만, 이 정도 정보면 실무에서 실제로 값을 설정할 때, 내가 뭔짓을 하고 있는지 아는데 부족하지 않을 것이다.

참고 자료

profile
좋아서 시작한 개발 지금은...

0개의 댓글