연결 하나가 느려지자 나머지가 전부 멈췄다 · 이 방식이 무너지는 자리 · 이론
이 방식이 무너지는 자리
한 줄 요약
한 프로세스가 감당하는 연결 수는 파일 서술자 상한과 연결당 메모리, 그리고 코어 하나가 정하며 세 가지 모두 재서 확인할 수 있습니다.
왜 이게 필요했나
다중화로 바꾸고 나면 연결 수가 갑자기 늘어도 버티는 것처럼 보입니다. 그래서 한계가 어디인지 묻지 않게 되고, 한계는 대개 가장 바쁜 날 처음 발견됩니다. 이 모듈은 그 한계를 세 가지로 나눠 미리 세어 봅니다. 세 가지 모두 추측이 아니라 명령 한 줄로 확인할 수 있는 값입니다.
어떻게 동작하나
첫째는 파일 서술자 상한입니다. [getrlimit(2)](https://man7.org/linux/man-pages/man2/getrlimit.2.html) 에 따르면 RLIMIT_NOFILE 은 그 프로세스가 열 수 있는 가장 큰 서술자 번호보다 1 큰 값을 뜻하며, 이를 넘기려는 시도는 EMFILE 로 실패합니다. 연결 하나가 서술자 하나이므로 이 값이 곧 동시 연결의 천장입니다. 자기 프로세스의 값은 ulimit -n 으로, 실제로 몇 개를 들고 있는지는 /proc/<pid>/fd 를 세어 볼 수 있습니다.
여기서 세어지는 것은 연결만이 아닙니다. 표준 입출력 세 개, 듣는 소켓 하나, 그리고 다중화 객체가 서술자를 쓰는 구현이라면 그것 하나가 더해집니다. 그래서 연결 200개를 붙여 놓고 센 값은 200이 아니라 그보다 조금 큽니다. 이 작은 차이가 중요한 이유는, 그 차이를 설명할 수 있어야 상한 가까이에서 무엇이 먼저 바닥나는지 예측할 수 있기 때문입니다.
둘째는 지켜보는 방식 자체의 한계입니다. 앞에서 본 대로 [select(2)](https://man7.org/linux/man-pages/man2/select.2.html) 는 glibc 구현에서 FD_SETSIZE 인 1024 보다 작은 번호만 다룰 수 있습니다. 서술자 상한을 아무리 올려도 select 를 쓰는 한 1023 번을 넘는 서술자는 지켜볼 수 없고, 문서는 그럴 때 poll 이나 epoll 을 쓰라고 적고 있습니다. 파이썬의 DefaultSelector 를 쓰면 이 선택을 플랫폼이 대신해 주지만, 어떤 구현이 골라졌는지는 확인해 둘 값어치가 있습니다.
셋째는 연결당 메모리입니다. 프로그램이 만든 상태만이 아니라 커널의 소켓 버퍼가 함께 듭니다. [tcp(7)](https://man7.org/linux/man-pages/man7/tcp.7.html) 은 소켓 버퍼 크기를 /proc/sys/net/ipv4/tcp_rmem 과 tcp_wmem, 또는 소켓별 SO_RCVBUF 와 SO_SNDBUF 로 정한다고 설명하면서, TCP 가 요청한 크기의 두 배를 실제로 할당하고 남는 공간을 관리용 구조에 쓴다고 적고 있습니다. 그래서 getsockopt 로 다시 읽은 값은 설정한 값과 같지 않습니다. 연결 하나가 차지하는 자리를 계산할 때 이 배수를 빼먹으면 예산이 절반으로 어긋납니다.
| 한계 | 어디서 정해지나 | 어떻게 확인하나 |
| --- | --- | --- |
| 동시 연결 수 | RLIMIT_NOFILE | ulimit -n, /proc/PID/fd 세기 |
| 지켜볼 수 있는 번호 | select 의 FD_SETSIZE | 문서에 1024, epoll 은 해당 없음 |
| 연결당 메모리 | 소켓 버퍼와 프로그램 상태 | tcp_rmem 과 실제 사용량 |
여기에 네 번째가 있습니다. 이벤트 루프는 실행 흐름이 하나라 코어 하나만 씁니다. 연결이 늘어 CPU 가 한 코어를 채우면, 서술자도 메모리도 남았는데 지연만 늘어납니다. 이때의 해법은 루프를 최적화하는 것이 아니라 프로세스를 늘려 코어를 더 쓰는 것입니다. 그래서 실제 서비스는 다중화와 다중 프로세스를 함께 씁니다. 이 코스는 그중 앞의 절반을 다룹니다.
현장에서 만나는 모습
같은 코드가 노트북에서는 되고 컨테이너에서는 안 되는 일이 자주 이 한계에서 생깁니다. 서술자 상한은 프로세스마다 붙는 값이라 실행 환경이 바뀌면 함께 바뀌고, 상한에 닿았을 때 나오는 EMFILE 은 대개 연결을 받는 자리가 아니라 엉뚱한 곳에서 먼저 터집니다. 파일을 열거나 로그를 회전하는 코드가 먼저 실패해서, 원인이 네트워크라는 사실이 늦게 드러납니다.
메모리 쪽 사고는 더 조용합니다. 연결 하나당 몇 킬로바이트는 작아 보이지만 수만 개가 되면 기가바이트 단위가 되고, 이 자리는 애플리케이션 힙이 아니라 커널 쪽이라 언어 런타임의 메모리 지표에는 잘 나타나지 않습니다. 그래서 프로세스의 상주 메모리는 평범한데 노드는 압박을 받는 상황이 만들어집니다.
그래서 용량 계획을 세울 때는 목표 연결 수를 먼저 정하고, 그 수에 서술자 상한과 연결당 자리를 각각 곱해 봅니다. 둘 중 먼저 걸리는 쪽이 그 서비스의 진짜 한계입니다. 숫자를 적어 두면 나중에 한계에 닿았을 때 무엇을 늘려야 하는지가 분명해지고, 무엇보다 한계에 닿기 전에 알 수 있습니다.
다음 실습에서 할 것
이제 직접 만들어 보겠습니다. 먼저 순차 서버로 줄서기를 재고, 같은 부하를 다중화 서버로 다시 받아 두 숫자를 견줍니다. 마지막에는 연결 200개를 붙여 놓고 서버 프로세스가 들고 있는 서술자를 세어 보고, 그 값이 왜 200이 아닌지 설명할 수 있어야 합니다. 실습의 채점기는 적어 낸 숫자를 그 자리에서 다시 재서 대조하므로, 그럴듯한 값을 적는 것으로는 통과하지 않습니다.