서버를 만드실때는 포트를 32768 이전으로 설정하세요.

최근에 아주 재미난(?) 일을 격었습니다. 서버를 시작시키는데, 아무리해도 서버가 뜨지 않는 것이었죠.
코드를 봐서는 아무런 문제가 없는데… 에러 로그는

“binding error: port is already used” 비슷한 오류가 발생하는 것이었습니다.

netstat 으로 LISTEN 포트만 찾아봐서는… 제가 사용하는 포트를 찾을 수도 없었죠.

그런데 netstat으로 포트를 더 찾아보니… 이상한 결과를 볼 수 있었습니다.

tcp        0      0 192.168.1.4:48121          192.168.1.4:6379         ESTABLISHED 15598/redis-server

여기서 제 서버에서 사용하고자 하던 포트를 48121 이라고 가정합니다. -_-(잉, 뭔가 가정이 이상하다구요. 그렇죠, 저도 뭔가 많이 이상합니다. 그러나 그것이 현실로 일어났습니다.)

그리고 다시 한번 제목을 보시죠.. 일단 포트에 대해서 잘 모르는 지식을 잠시 정리하자면 다음과 같습니다.

  • socket 이 바인딩하게 되는 포트는 udp/tcp 는 별개다. 즉 udp:53, tcp:53번 모두 바인딩 가능합니다. 실제로 DNS가 일반적으로 이렇게 하고 있죠.
  • socket 이 바인딩하게 되는 주소는 ip + port 다 즉 1.2.3.4 를 가지고 있는 서버는 127.0.0.1:48121 과 1.2.3.4:48121 를 따로 바인딩할 수 있다.

즉 다음과 같은 예제를 보면 에러가 발생해야 합니다.

# -*- coding: utf-8 -*-.

from flask import Flask, request, redirect, url_for, jsonify

app = Flask(__name__,static_folder='static', static_url_path='')

@app.route('/health_check.html')
def health():
    return "OK"

if __name__ == '__main__':
    app.run(host="127.0.0.1", port=20000)

두 개를 실행시키면 다음과 같이 오류가 발생합니다.

 * Running on http://127.0.0.1:20000/
Traceback (most recent call last):
  File "2.py", line 23, in <module>
    app.run(host="127.0.0.1", port=20000)
  File "/Users/charsyam/anaconda/lib/python2.7/site-packages/flask/app.py", line 772, in run
    run_simple(host, port, self, **options)
  File "/Users/charsyam/anaconda/lib/python2.7/site-packages/werkzeug/serving.py", line 710, in run_simple
    inner()
  File "/Users/charsyam/anaconda/lib/python2.7/site-packages/werkzeug/serving.py", line 692, in inner
    passthrough_errors, ssl_context).serve_forever()
  File "/Users/charsyam/anaconda/lib/python2.7/site-packages/werkzeug/serving.py", line 486, in make_server
    passthrough_errors, ssl_context)
  File "/Users/charsyam/anaconda/lib/python2.7/site-packages/werkzeug/serving.py", line 410, in __init__
    HTTPServer.__init__(self, (host, int(port)), handler)
  File "/Users/charsyam/anaconda/lib/python2.7/SocketServer.py", line 419, in __init__
    self.server_bind()
  File "/Users/charsyam/anaconda/lib/python2.7/BaseHTTPServer.py", line 108, in server_bind
    SocketServer.TCPServer.server_bind(self)
  File "/Users/charsyam/anaconda/lib/python2.7/SocketServer.py", line 430, in server_bind
    self.socket.bind(self.server_address)
  File "/Users/charsyam/anaconda/lib/python2.7/socket.py", line 224, in meth
    return getattr(self._sock,name)(*args)
socket.error: [Errno 48] Address already in use

이제 강제로 아이피를 설정해보겠습니다.

127.0.0.1, 192.168.1.4, 0.0.0.0 으로 세 개의 ip로 실행을 시켰습니다. 모두 20000 번으로 실행했습니다.

charsyam ~/kakao_home_affiliate (master*) $ python 1.py
 * Running on http://127.0.0.1:20000/

charsyam ~/kakao_home_affiliate (master*) $ python 2.py
 * Running on http://0.0.0.0:20000/

charsyam ~/kakao_home_affiliate (master*) $ python 3.py
 * Running on http://192.168.1.4:20000/

charsyam ~ $ telnet 0 20000 <-- 이건 실제로 local loopback으로 전달되므로 127.0.0.1 과 동일합니다.
Trying 0.0.0.0...
Connected to 0.
Escape character is '^]'.
GET /health_check.html HTTP/1.1

HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 2
Server: Werkzeug/0.9.4 Python/2.7.5
Date: Mon, 14 Apr 2014 14:48:43 GMT

OK

charsyam ~ $ telnet 127.0.0.1 20000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET /health_check.html HTTP/1.1

HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 2
Server: Werkzeug/0.9.4 Python/2.7.5
Date: Mon, 14 Apr 2014 14:48:53 GMT

OK

charsyam ~ $ telnet 192.168.1.4 20000
Trying 192.168.1.4...
Connected to 192.168.1.4.
Escape character is '^]'.
GET /health_check.html HTTP/1.1

HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 2
Server: Werkzeug/0.9.4 Python/2.7.5
Date: Mon, 14 Apr 2014 14:49:08 GMT

OK

모두 동작하는 걸 볼 수 있습니다.

charsyam ~/kakao_home_affiliate (master*) $ python 1.py
 * Running on http://127.0.0.1:20000/
127.0.0.1 - - [14/Apr/2014 23:48:43] "GET /health_check.html HTTP/1.1" 200 -
127.0.0.1 - - [14/Apr/2014 23:48:53] "GET /health_check.html HTTP/1.1" 200 -

charsyam ~/kakao_home_affiliate (master*) $ python 2.py
 * Running on http://0.0.0.0:20000/

charsyam ~/kakao_home_affiliate (master*) $ python 3.py
 * Running on http://192.168.1.4:20000/
192.168.1.4 - - [14/Apr/2014 23:49:08] "GET /health_check.html HTTP/1.1" 200 -

실제로 0.0.0.0 이 INADDR_ANY이지만 127.0.0.1 이나 192.168.1.4로 명시하면, 각각의 주소가 명시되므로 거기로 전달되게 됩니다.
(이러면… port 를 탈취하는 것도 가능할것 같네요. 실제로 테스트해보면 192.168.1.4를 kill 하면 그 다음부터는 0.0.0.0 으로 전달이 되네요.)

자자… 그러면… 이제 무슨 일이 있었던 걸까요?

정리하자면… 실제 서버가 Redis 서버에 연결되면서 하필이면 192.168.1.4:48121를 만들어서 실제 192.168.1.4:6379 에 붙어버린겁니다. 그러면서 서버는 48121포트를 할당하려고 하니 이미 bind 에러가 발생한것이죠.(흑흑흑, 정말 재수가 없었던겁니다.)

그럼… 이를 해결하는 방법이 있을까요? 뭔가 할당되는 정책이 있을까요? 이것을… 사실 local port 라고 합니다. 외부로 나가기 위해서 만들어지는 port 이죠.

이 값은 그럼 어떻게 설정될까요? 기본적으로 shell 에서 sysctl -A 를 해보면 거기서 다음과 같은 두 개의 값을 발견할 수 있습니다.

net.ipv4.ip_local_port_range = 32768	61000
net.ipv4.ip_local_reserved_ports =

ip_local_port_range 를 보면 기본적으로 저 값 안에서 32768에서 61000번 사이에서 할당이 하게 됩니다. 즉, 외부에서 접속하는 소켓의 포트가 위의 범위에서 할당되므로, 이 값을 피하는게 좋습니다. 위의 32768, 61000이 기본으로 설정되는 범위입니다. 즉 다음과 같은 port는 서버 설정시에 피하시는게 좋습니다.

그럼 실제로 커널 코드를 까보면 다음과 같습니다. 코드는 지루할테니 설렁설렁 보겠습니다. linux kernel 3.14를 기본으로 합니다.
먼저 해당 범위 값을 가져오는 함수는 inet_get_local_port_range 함수입니다.

void inet_get_local_port_range(struct net *net, int *low, int *high)
{
  unsigned int seq;

  do {
    seq = read_seqbegin(&net->ipv4.sysctl_local_ports.lock);

    *low = net->ipv4.sysctl_local_ports.range[0];
    *high = net->ipv4.sysctl_local_ports.range[1];
  } while (read_seqretry(&net->ipv4.sysctl_local_ports.lock, seq));
}

그리고 이 함수를 inet_csk_get_port 에서 호출합니다. 이 코드에서 실제로 local_port 가 결정납니다.

    do {
      if (inet_is_reserved_local_port(rover))
        goto next_nolock;
      head = &hashinfo->bhash[inet_bhashfn(net, rover,
          hashinfo->bhash_size)];
      spin_lock(&head->lock);
      inet_bind_bucket_for_each(tb, &head->chain)
        if (net_eq(ib_net(tb), net) && tb->port == rover) {
          if (((tb->fastreuse > 0 &&
                sk->sk_reuse &&
                sk->sk_state != TCP_LISTEN) ||
               (tb->fastreuseport > 0 &&
                sk->sk_reuseport &&
                uid_eq(tb->fastuid, uid))) &&
              (tb->num_owners < smallest_size || smallest_size == -1)) {
            smallest_size = tb->num_owners;
            smallest_rover = rover;
            if (atomic_read(&hashinfo->bsockets) > (high - low) + 1 &&
                !inet_csk(sk)->icsk_af_ops->bind_conflict(sk, tb, false)) {
              snum = smallest_rover;
              goto tb_found;
            }
          }
         if (!inet_csk(sk)->icsk_af_ops->bind_conflict(sk, tb, false)) {
            snum = rover;
            goto tb_found;
          }
          goto next;
        }
      break;
    next:
      spin_unlock(&head->lock);
    next_nolock:
      if (++rover > high)
        rover = low;
    } while (--remaining > 0);

뭔가 코드는 복잡하지만 inet_is_reserved_local_port 를 이용해서 예약된 local_port 는 피하는 것을 알 수 있습니다.
(udp는 또 udp_lib_get_port 에서 결정이 됩니다.)

물론 여기서 좀 더 깊게는 더 커널을 살펴봐야 겠지만… 대략 위의 형태로 동작한다는 것을 알 수 있습니다. 일단 이번 글은 여기서 끝!!!