Linux Network Internal
이번 글에서는 커널이 로컬 포트를 선택하는 과정에 대해 살펴보겠습니다. 로컬 포트가 할당되는 과정을 살펴보면서 SO_REUSEADDR 옵션과, local port range , tw_reuse 파라미터가 어떻게 소스 코드에서 활용되는지 그리고 커널의 동작에 어떻게 영향을 주는지 이해하는데 도움이 되었으면 좋겠습니다.
잘못 정리된 부분이 있으면 언제든 말씀해 주세요~
TCP는 소켓 기반의 통신이며 소켓은 (Source IP, Source Port, Destination IP, Destination Port) 4개의 Tuple을 기준으로 생성이 됩니다. 이때 Source Port를 로컬 포트라 고도 부릅니다.
로컬 포트는 두 가지의 방식으로 바인딩이 되는데, 첫 번째는 특정 포트를 지정해서 바인딩하는 방법과 두 번째는 자동으로 바인딩될 포트를 선택하는 방법입니다.
특정 포트를 지정해서 바인딩하는 경우는 대게 서버 애플리케이션을 띄울 때 사용하는 방식입니다. nginx나 apache와 같은 웹 서버들이 특정 포트를 지정해서 바인딩하는 대표적인 예입니다. 사용자가 환경 설정 파일에 몇 번 포트로 바인딩하겠다고 지정을 하고 애플리케이션을 띄우면 커널은 그 요청을 받아서 해당 포트가 사용 가능한 포트인지 확인해서 애플리케이션에 사용해도 좋다, 혹은 사용할 수 없다 등의 메시지를 넘겨 줍니다.
자동으로 바인딩될 포트를 선택하는 방법은 클라이언트의 역할을 하게 될 때 사용하는 방식입니다. 특정 주소의 특정 포트로 접속해야 할 때 사용하며, 위에 언급한 4개의 Tuple 구조를 맞춰야 하기 때문에 커널에 어떤 포트를 사용하면 좋을지 포트 번호를 요청하게 되고, 커널은 적당한 포트를 골라서 사용하도록 넘겨 줍니다.
대부분 로컬 포트라고 하면 후자의 경우를 의미 하지만, 경우에 따라서는 전자의 경우가 사용될 수도 있습니다. 간략하게 두 가지 경우를 모두 다뤄 보겠습니다.
본격적인 설명에 앞서 우선 커널 소스 코드를 가볍게 만져 보겠습니다. 간단하게 주요 거점에 printk() 함수를 넣습니다. (커널 소스 코드를 다운하여서 컴파일하는 과정은 이 글의 범위를 벗어나기 때문에 생략합니다.)
아래는 printk()를 심어 놓은 파일과 대상 함수 명입니다.
net/ipv4/inet_connect_sock.c - inet_get_local_port_range(), inet_csk_get_port()
inet_hashtable.c - __inet_hash_connect(), __inet_check_established()
af_inet.c - inet_autobind()
tcp_ipv4.c - tcp_twsk_unique()
각 함수에서 중요하다고 생각되는 일부 값들과 호출 부분에 printk()를 걸어 두었습니다.
커널 컴파일한 후 컴파일된 커널로 재부팅합니다. 재부팅한 후 /var/log/messages에 설정한 printk()가 잘 찍히는 지 확인합니다.
curl 같은 명령으로 외부 페이지에 접근 요청을 하면 아마도 위와 같은 메시지가 남을 겁니다. inet_autobind() 함수가 호출되었었으며 inet_get_local_port_range(), __inet_hash_connect() 함수가 각각 호출되었음을 볼 수 있습니다.
그럼, 본격적으로 시작해 보겠습니다.
소켓을 만들고 원격지와 통신을 하기 위해서 로컬 포트를 할당 방법에는 크게 두 가지 방법이 있습니다. bind()를 호출한 후 connect()를 하는 방법과 connect()만 사용하는 방법입니다. 보통 특별한 경우가 아니라면 connect()만 사용해서 원격지와 통신을 준비합니다.
제가 두 가지 방법을 언급한 이유는 이 방법에 따라 서로 로컬 포트를 선택하는 함수가 달라지기 때문입니다.
bind()를 호출하는 경우에는 net/ipv4/inet_connection_sock.c에 있는 inet_csk_get_port() 함수를 호출하고 connect()만 사용하는 경우는 inet_hash_connect() 함수를 호출합니다.
두 함수는 비슷해 보이지만 아주 결정적인 차이를 가지고 있는데요, 바로 tw_reuse 파라미터에 대한 처리입니다.
아래는 테스트에 사용한 파이썬 스크립트입니다. 그리고 스크립트를 돌리기 전에 local_port_range 값을 32768 32769로 딱 두 개만 사용 가능하도록 세팅합니다.
import socket
import sys
SADDR = '172.16.33.136'
SPORT = 0
HOST = '110.76.141.122'
PORT = 80
server_address = (SADDR, int(SPORT))
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(server_address)
s.connect((HOST, PORT)
이 스크립트를 실행시키면 세 번째 실행시킬 때 재미난 일이 일어납니다.
tw_reuse가 켜져 있음에도 불구하고 Address already in use 에러가 발생합니다. netstat을 통해 보면 TIME_WAIT 상태의 소켓이 두 개가 있고, tw_reuse가 켜져 있기 때문에 당연히 재사용해서 나가야 할 텐데, 저런 에러를 뿌립니다.
printk()는 어떤 메시지를 찍었는지 확인해 볼까요?
여기도 재미있는 메시지가 잡혔는데요, 맨 처음 32769 포트를 선택하고, 이 포트는 사용한 적이 없으니까 사용하는 걸로 결정이 납니다. 그리고 connect() 함수를 호출해서 연결이 됩니다. 마지막엔 tcp_time_wait() 함수를 통해 TIME_WAIT 상태로 변경됩니다.
그리고 두 번째 호출했을 때 이번에도 랜덤에 의해 32769가 선택이 됩니다만, 32769는 사용된 적이 있고 TIME_WAIT 상태 이기 때문에 다른 포트를 다시 선택합니다. 여기가 이상하죠. 우리는 tw_reuse를 켰기 때문에 32769가 사용했던 안 했던 TIME_WAIT 상태이니까 바로 사용이 되어야 합니다. 헌데 사용하지 않고 바로 다음 포트를 찾게 됩니다. 다시 찾은 포트는 32768. 이 포트 역시 사용한 적이 없기 때문에 바로 사용이 됩니다.
세 번째 호출했을 때는 32768, 32769 모두 TIME_WAIT 상태이고, 어떤 이유에 선지 재사용이 안되기 때문에 할당 가능한 포트가 없다는 에러 메시지를 출력하게 됩니다.
위와 같이 bind() 함수 호출 시 재사용이 안되고 에러가 발생하는 원인은 inet_csk_get_port() 함수 때문입니다. 이 함수의 하단부를 보면 아래와 같은 로직이 있습니다.
sk->sk_reuse 가 켜져 있지 않으면 fastreuse라는 값을 0으로 만드는 건데요, sk->sk_reuse 값은 소켓 옵션 중 SO_REUSEADDR을 켜야만 1로 설정되는 값입니다. 이는, bind() 함수의 애초 목적이 서버 입장에서의 리스닝 포트를 소켓에 할당하기 위한 용도 이기 때문에 SO_REUSEADDR 이 설정되어야만 TIME_WAIT 소켓을 재사용할 수 있도록 설계되었기 때문입니다. 그래서 나가는 소켓을 위한 바인딩에는 우리가 원하는 대로 동작하지 않게 됩니다.
반대로 bind()를 하지 않고 connect()를 호출하도록 스크립트를 변경하고 돌리면 아무 문제없이 소켓이 생성되고 통신이 됩니다. tw_reuse가 켜져 있기 때문에 TIME_WAIT 소켓을 바로 재사용할 수 있기 때문입니다.
이때의 printk() 출력을 살펴볼까요?
connect()를 호출할 때 사용하는 __inet_hash_connect() 함수가 호출되는 것을 볼 수 있습니다. bind()를 하지 않고 바로 connect()를 호출하면 로컬 포트가 소켓에 연결되어 있지 않기 때문에 connect()에서 내부적으로 로컬 포트를 소켓에 연결하는 작업을 하며, 이때 __inet_hash_connect() 함수가 호출됩니다. 이 함수에서도 똑같이 랜덤 하게 로컬 포트를 선택한 후, 포트의 상태를 점검하는 로직이 있는데요, __inet_check_established() 함수가 그 역할을 합니다. 위에 화면에서 보이는 것처럼 TIME_WAIT 소켓임이 확인이 되고, twsk_unique() 함수를 호출해서 재사용해도 되는 소켓인지를 확인합니다.
이 함수에 아주 반가운 로직이 있는데요, 바로 tw_reuse 값이 세팅되어 있는지 아닌지를 체크하는 로직입니다.
여기서 타임스탬프와 tw_reuse 파라미터를 검사하고, 조건에 맞는 다면,
즉 타임스탬프가 더 크고 tw_reuse 파라미터가 1로 켜져 있다면 해당 소켓은 재사용할 수 있는 소켓으로 간주되고 1을 리턴해 줍니다.
이 과정을 통해서 connect() 함수는 TIME_WAIT 상태의 소켓을 재사용할 수 있게 됩니다.
커널이 로컬 포트를 선택하는 두 가지 방법에 대해 살펴보았습니다.
bind()를 통해서 로컬 포트를 선택하는 방법, connect()를 통해서 로컬 포트를 선택하는 방법, 이 두 가지를 살펴봤는데요, bind()를 통해 선택된 로컬 포트는 tw_reuse 옵션이 적용되지 않기 때문에 TIME_WAIT 상태의 소켓을 재사용할 수 없습니다. 나갈 때 사용하는 소켓에 대해서만 재사용이 불가능하며, 리스닝할 때 사용하는 소켓에는 SO_REUSEADDR을 켜면 재사용할 수 있습니다.
이는 bind() 함수의 목적 자체가 서버 입장에서의 소켓을 사용하기 위해 만들어졌기 때문입니다. 외부로 나가는 소켓을 만들기 위해서는 반드시 bind() 함수를 빼고 connect() 함수 만을 이용해서 만드시기 바랍니다.
사실 이미 대부분의 애플리케이션들은 connect() 함수만을 이용해서 나가는 소켓을 만들고 있습니다. 그래서 bind()를 사용하는 로직을 살펴보는 건 불필요할 수도 있지만,
나중에 tw_reuse를 켰음에도 불구하고 TIME_WAIT 소켓을 재사용하지 못하는 이슈가 발견된다면 문제를 해결하는데 도움이 될 수 있습니다.
긴 글 읽어 주셔서 감사합니다.