이번 시간에는 WebSocket 서버 한 대가 받을 수 있는 동시 연결 수를 무엇이 정하는지, Docker 컨테이너에 띄운 Python WebSocket 서버로 측정하며 배워가는 시간입니다. 동시 접속 수를 막는 한도를 차례로 확인하고, 한도를 올린 뒤에는 연결을 1만 개까지 늘리며 연결당 메모리를 재 볼게요.
Docker Desktop은 리눅스 VM 안에서 컨테이너를 돌려요. 그래서 아래에 나오는 한도 값은 모두 컨테이너 안에서 읽은 값입니다.
실습 준비
이 글의 명령은 모두 실습 코드의 websocket-connection-limits/ 디렉터리에서 실행합니다.
Dockerfile: websockets와iproute2를 넣은 이미지compose.yaml: 서버ws-server, 부하 클라이언트ws-load,ws-load-slow,ws-load-port, 한도 비교용plain정의(ws-load-slow,ws-load-port,plain은 이 글에서는 쓰지 않아요)server.py: 연결을 들고 3초마다 연결 수와 메모리를 찍는 WebSocket 서버load.py: 연결을 정해진 속도로 만들고 실패를 원인별로 세는 부하 클라이언트per_conn.py: 서버 로그에서 단계별 연결당 메모리 계산
Python 3.12에 websockets 17.2를 설치하고, 소켓 상태를 보는 ss 명령이 들어 있는 iproute2도 넣었어요. Dockerfile은 이렇습니다.
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir websockets==17.2
compose.yaml에는 서버 하나와 부하 클라이언트 세 종류를 둬요.
ws-server: 서버의nofile한도와 메모리 상한을 환경 변수SERVER_NOFILE,SERVER_MEM으로 바꿀 수 있게 했어요. 기본값은 65536개와 1GB예요.memswap_limit을mem_limit과 같게 둬서 스왑을 쓰지 못하게 했습니다.ws-load: 클라이언트 프로세스도 연결마다 파일 디스크립터를 쓰기 때문에nofile을 늘 65536으로 둬요. 연결이 많을 때는 클라이언트 메모리도 모자랄 수 있어서 클라이언트를 두 개 띄워 연결을 나눕니다.
서버
서버 server.py는 연결을 받으면 환영 메시지를 하나 보내고, 연결이 닫힐 때까지 들고 있어요.
그리고 3초마다 로그를 한 줄 남겨요. 같은 3초 동안 닫힌 연결이 있으면 그 수와 close code도 따로 한 줄씩 남깁니다.
conns: 지금 붙어 있는 연결 수예요.fd:/proc/self/fd에 있는 항목 수, 곧 이 프로세스가 연 파일 디스크립터 수예요.rss:/proc/self/status의VmRSS예요.mem: 컨테이너 cgroup의memory.current예요. 컨테이너 안 프로세스의 메모리에 커널이 이 컨테이너 몫으로 쓴 메모리까지 더한 값이고,mem_limit과 비교되는 값이 바로 이거예요.anon,sock: cgroupmemory.stat의 두 항목이에요.anon은 프로세스가 할당한 익명 메모리이고,sock은 커널 소켓 버퍼, 곧 네트워크로 주고받는 데이터를 커널이 잠시 들고 있는 메모리예요.seq: 브로드캐스트를 켰을 때 지금까지 보낸 메시지 번호예요. 이 글에서는 브로드캐스트를 켜지 않아 늘 0이에요.
부하 클라이언트
load.py는 --target개의 연결을 초당 --rate개씩 만들어요. 접속은 10초 안에 끝나야 하고(open_timeout=10), 실패하면 원인별로 따로 셉니다.
errno 24(EMFILE): 클라이언트 프로세스가 파일 디스크립터를 다 썼다는 뜻이에요.errno 111(ECONNREFUSED): 서버 주소에서 그 포트로 연결을 기다리는 프로세스가 없다는 뜻이에요.timeout: 10초 안에 TCP 접속과 WebSocket 핸드셰이크가 끝나지 않았다는 뜻이에요.
시작할 때 자기 IP와 nofile 한도, 출발지 포트 범위, tcp_rmem 값을 찍고, 10초마다 성공(ok), 지금 열려 있는 연결(open), 실패 건수를 찍어요.
--steps를 주면 그 수에 닿을 때마다 --step-wait초 동안 멈춰요. --hold는 접속 시도가 다 끝난 뒤 연결을 들고 있을 시간이에요.
COMPOSE_PROJECT_NAME을 정해 두었으니, 이 셸에서 실행하는 docker compose 명령은 모두 wsconn 프로젝트를 대상으로 해요.
srv_up은 srv_up SERVER_NOFILE=1024처럼 환경 변수를 받아 서버를 새로 띄우고, srv_log는 서버 로그 전체를 보여 줘요. load는 부하 클라이언트 컨테이너 ws-load에서 load.py를 실행합니다.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
export COMPOSE_PROJECT_NAME=wsconn
srv_up() {
env "$@" docker compose up -d --force-recreate ws-server > /dev/null 2>&1
sleep 2
}
srv_log() { docker compose logs --no-log-prefix ws-server; }
load() { docker compose run --rm -T ws-load python load.py "$@"; }
docker compose build
연결 하나는 파일 디스크립터 하나를 쓴다
서버가 클라이언트 연결을 하나 받으면(accept) 커널은 그 연결을 가리키는 소켓을 만들고, 파일 디스크립터를 하나 새로 내줘요.
프로세스가 열 수 있는 파일 디스크립터 수는 RLIMIT_NOFILE이라는 자원 한도로 정해져요.
- soft 한도: 지금 실제로 적용되는 값이에요.
- hard 한도: soft 한도를 올릴 수 있는 최댓값이에요.
accept는 새 소켓에 줄 번호가 soft 한도 안에 남아 있지 않으면 EMFILE(errno 24, "Too many open files")로 실패합니다.
Docker에서는 docker run --ulimit nofile=<soft>:<hard>나 compose의 ulimits로 컨테이너의 한도를 정해요. 값을 주지 않으면 Docker 데몬에 설정된 기본값을 물려받고요.
연결 수를 늘리다 보면 아래 그림의 한도를 차례로 만나게 돼요. 칸마다 넘었을 때 보이는 증상이 다르니까, 증상을 보면 어느 한도에 걸렸는지 가릴 수 있어요.
실습 1: 한도에 닿으면
이번에는 서버의 nofile을 soft, hard 모두 1024로 낮추고, 클라이언트 하나가 초당 100개씩 1,500개까지 연결을 늘려 볼게요. 클라이언트의 한도는 65536 그대로예요. 시도가 모두 끝난 뒤에도 60초 동안 연결을 들고 있습니다.
서버 안의 소켓 상태는 연결을 들고 있는 동안 읽어야 해요. 그래서 클라이언트를 백그라운드로 띄우고, 25초 뒤(시도가 끝난 뒤)에 listen 소켓을, 그 뒤 30초 간격으로 연결들이 보낸 바이트 합계를 두 번 읽어 파일에 남겨요.
sent() {
docker compose exec -T ws-server sh -c \
"ss -tin state established '( sport = :8765 )' | grep -o 'bytes_sent:[0-9]*' | cut -d: -f2 | awk '{s+=\$1} END {print NR, s}'"
}
srv_up SERVER_NOFILE=1024
load --target 1500 --rate 100 --hold 60 > s2.log &
sleep 25
docker compose exec -T ws-server ss -ltn > ss.txt
sent > sent.txt
sleep 30
sent >> sent.txt
wait
cat s2.log
14:28:49 client ip=172.27.0.3 nofile soft=65536 hard=65536 port_range=32768-60999 tcp_rmem=4096/131072/6291456 target=1500 rate=100.0/s no_read=False
14:28:49 Sec-WebSocket-Extensions: permessage-deflate; server_max_window_bits=12; client_max_window_bits=12
14:28:59 progress ok=849 open=849 fail: 0
14:29:09 progress ok=1013 open=1013 fail: 0
14:29:17 all attempts done ok=1013 open=1013 fail: timeout=487
14:29:19 progress ok=1013 open=1013 fail: timeout=487
14:29:29 progress ok=1013 open=1013 fail: timeout=487
14:29:39 progress ok=1013 open=1013 fail: timeout=487
14:29:49 progress ok=1013 open=1013 fail: timeout=487
14:29:59 progress ok=1013 open=1013 fail: timeout=487
14:30:09 progress ok=1013 open=1013 fail: timeout=487
14:30:17 final ok=1013 open=1013 fail: timeout=487
- 클라이언트의
nofile은 65536이고, 실패 487건은 모두timeout이에요.errno 24는 한 건도 없으니 클라이언트 쪽 한도는 원인이 아니에요. - 성공한 1,013개는 시도가 끝난 뒤 60초 동안
open=1013을 유지했어요.
같은 시간 서버 로그를 볼게요.
srv_log | grep 'conns=' | sed -n '4,9p'
14:28:50 conns=23 fd=35 rss=27.1MiB mem=18.2MiB anon=16.2MiB sock=0.0MiB seq=0
14:28:53 conns=275 fd=287 rss=39.6MiB mem=32.4MiB anon=29.1MiB sock=0.0MiB seq=0
14:28:56 conns=532 fd=543 rss=52.4MiB mem=46.2MiB anon=42.0MiB sock=0.0MiB seq=0
14:28:59 conns=790 fd=801 rss=65.1MiB mem=60.3MiB anon=54.5MiB sock=0.0MiB seq=0
14:29:02 conns=1013 fd=1024 rss=76.2MiB mem=72.4MiB anon=65.7MiB sock=0.0MiB seq=0
14:29:05 conns=1013 fd=1024 rss=76.6MiB mem=72.7MiB anon=65.9MiB sock=0.0MiB seq=0
연결 수는 1,013개에서 멈췄고, 그때 파일 디스크립터는 1,024개로 한도에 정확히 닿았어요. 연결이 아닌 파일 디스크립터 11개(표준 입출력, 통계 파일, asyncio 내부 소켓, listen 소켓 등)가 같은 한도를 나눠 쓰고 있어서, 1,024개가 아닌 1,013개에서 멈춘 거예요.
접속이 거절되지 않고 타임아웃이 되는 이유
클라이언트의 연결 요청에 커널이 응답해 연결이 완성되면, 그 연결은 애플리케이션이 accept로 꺼내 갈 때까지 listen 소켓의 대기열에 머물러요.
대기열 길이(백로그)는 애플리케이션이 listen을 호출할 때 정하고, 커널은 이 값을 somaxconn으로 잘라요. asyncio의 create_server는 백로그 기본값이 100이고, websockets의 serve는 이 기본값을 그대로 씁니다.
한도에 닿은 서버 안에서 읽은 listen 소켓이에요.
cat ss.txt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 4096 127.0.0.11:44829 0.0.0.0:*
LISTEN 101 100 0.0.0.0:8765 0.0.0.0:*
listen 소켓에서 Send-Q는 대기열의 최대 길이이고, Recv-Q는 지금 대기열에서 accept를 기다리는 연결 수라고 보시면 됩니다.
Send-Q는 100이에요. asyncio 기본값 100과somaxconn4096 중 작은 값이죠.Recv-Q는 101로 대기열이 가득 찼어요. 서버가accept를 하지 못하니 TCP 연결은 커널에서 맺어진 채 대기열에 머물고, 클라이언트는 WebSocket 핸드셰이크 응답을 기다리다 10초 만에 포기해요.- 대기열이 가득 찬 뒤 들어온 연결 요청에는 서버 커널이 연결을 완성해 주지 않아요. 클라이언트 커널은 요청을 다시 보내며 기다리다가 역시 10초 제한에 걸립니다.
서버 포트에 아무도 없을 때 바로 돌아오는 Connection refused와는 증상이 달라요.
이미 붙은 연결은 그대로다
서버는 20초마다 연결마다 Ping을 보내고, 20초 안에 Pong이 오지 않으면 그 연결을 닫아요. 클라이언트도 기본값대로 20초마다 Ping을 보내고, 서버는 받은 Ping마다 Pong으로 답해요.
sent.txt에는 30초 간격으로 읽은 연결 수와, 서버의 연결들이 보낸 바이트 합계가 들어 있어요.
cat sent.txt
1013 336802
1013 357826
30초 동안 연결 1,013개에서 보낸 바이트가 21,024바이트, 연결당 20바이트 남짓 늘었어요. 이 서버는 환영 메시지 말고는 보내는 메시지가 없으니까, 늘어난 몫은 서버가 보낸 keepalive Ping과 클라이언트 Ping에 대한 Pong이에요.
서버 로그에는 클라이언트가 끝날 때까지 닫힌 연결이 하나도 없었으니 Pong도 제때 돌아온 거예요. 그러니 새 접속이 안 되는 것만 보고 서버가 죽었다고 판단하면 원인을 잘못 짚게 됩니다.
같은 실험을 네 번 했고, 네 번 모두 성공 1,013건, 타임아웃 487건, Recv-Q 101, Send-Q 100이었어요.
실습 2: 한도를 올리면 다음은 메모리다
이번에는 서버의 nofile을 65536으로 되돌리고, 연결 수를 1,000, 2,500, 5,000, 10,000개로 늘려 가며 메모리를 재 볼게요.
압축은 기본값(켬)이고 브로드캐스트는 하지 않아요. 클라이언트 두 개가 연결을 반씩 나눠 붙고, 단계마다 30초씩 멈춥니다.
srv_up
load --target 5000 --rate 150 --steps 500,1250,2500 --step-wait 30 --hold 20 > a.log &
load --target 5000 --rate 150 --steps 500,1250,2500 --step-wait 30 --hold 20 > b.log &
wait
서버 로그에서 단계마다 안정된 뒤의 줄을 골라 연결당 메모리를 계산해요. 연결이 0개일 때 값을 빼고 연결 수로 나눕니다. 계산하는 per_conn.py는 같은 이미지의 컨테이너에서 실행해요.
srv_log > server.txt
docker compose run --rm -T ws-load python per_conn.py server.txt
0개: rss=26.7MiB mem=17.7MiB
1000개 12:40:57: fd=1011 rss=76.1MiB mem=71.5MiB 연결당 rss 50.6KiB mem 55.1KiB
2500개 12:41:33: fd=2511 rss=152.5MiB mem=155.4MiB 연결당 rss 51.5KiB mem 56.4KiB
5000개 12:42:12: fd=5011 rss=280.7MiB mem=294.7MiB 연결당 rss 52.0KiB mem 56.7KiB
10000개 12:42:54: fd=10011 rss=534.6MiB mem=572.9MiB 연결당 rss 52.0KiB mem 56.9KiB
- 연결당
rss는 50.6~52.0KiB, 컨테이너 메모리(mem)로는 55.1~56.9KiB였어요. 세 번 반복한 값은 단계별로rss50.3~52.1KiB,mem55.1~56.9KiB 안에 있었어요. 연결 수가 열 배가 되는 동안 연결당 값이 거의 그대로였으니, 메모리는 연결 수에 비례해 늘어난 거예요. mem이rss보다 연결당 5KiB쯤 커요. 1만 개에서anon은 연결당 52.0KiB 늘었고mem은 56.9KiB 늘었어요. 이 회차의sock은 0이었으니 그 차이는 소켓 버퍼가 아니라, 커널이 연결마다 만드는 소켓 자료구조처럼 프로세스 메모리로는 잡히지 않는 커널 메모리로 보여요.
서버 한 대가 받을 수 있는 연결은 포트 번호가 16비트라서 65,535개라는 말이 있죠.
그런데 TCP 연결은 출발지 주소와 포트, 도착지 주소와 포트 네 값의 조합으로 구분돼요. 그래서 서버는 8765번 포트 하나로 클라이언트마다 다른 연결을 받아요.
6만 5천 개까지 올려 보지는 않았지만, 이 서버는 포트 하나로 연결 1만 개를 받았어요. 그때 한도를 정한 건 포트 번호가 아니라 nofile과 메모리였습니다.
실습 3: 메모리 상한을 낮추면 OOMKilled
연결당 57KiB라면 메모리 상한 384MB에서는 (384 - 17.7) × 1024 / 56.9, 곧 6,600개 안팎에서 상한에 닿을 거예요. 정말 그런지 서버의 mem_limit을 384m로 낮추고 1,000개씩 늘려 볼게요.
srv_up SERVER_MEM=384m
load --target 5000 --rate 150 --steps 500,1000,1500,2000,2500,3000,3500,4000,4500 --step-wait 15 --hold 20 > a.log &
load --target 5000 --rate 150 --steps 500,1000,1500,2000,2500,3000,3500,4000,4500 --step-wait 15 --hold 20 > b.log &
wait
srv_log | grep 'conns=' | tail -n 3
docker inspect wsconn-ws-server-1 --format 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}'
grep final a.log b.log
12:52:03 conns=6000 fd=6011 rss=332.4MiB mem=351.1MiB anon=321.8MiB sock=0.0MiB seq=0
12:52:06 conns=6000 fd=6011 rss=332.4MiB mem=351.1MiB anon=321.8MiB sock=0.0MiB seq=0
12:52:09 conns=6106 fd=6117 rss=336.8MiB mem=356.4MiB anon=326.2MiB sock=0.0MiB seq=0
OOMKilled=true ExitCode=137
a.log:12:53:39 final ok=3368 open=0 fail: InvalidMessage=4 errno 111=22 gaierror=1603 timeout=3
b.log:12:53:39 final ok=3358 open=0 fail: ConnectionResetError=1 InvalidMessage=4 errno 111=19 gaierror=1616 timeout=2
- 서버는
OOMKilled=true, 종료 코드 137로 끝났어요. 마지막 로그는 6,106개였고, 두 클라이언트가 성공한 연결은 합쳐서 6,726개예요. 마지막 로그를 남긴 뒤에도 3초가 안 되는 사이에 연결을 더 받다가 끝난 거죠. - 세 번 반복한 결과 성공한 연결은 6,726개, 6,750개, 6,750개였어요. 앞의 계산 6,600개와 3% 안쪽으로 맞아요.
- 서버가 끝나는 순간 열려 있던 연결은 모두 끊겼어요(
open=0). 그 뒤의 접속 시도는 핸드셰이크 도중 끊기거나(InvalidMessage,ConnectionResetError), 연결을 받을 프로세스가 없거나(errno 111), 컨테이너가 사라져ws-server라는 이름을 찾지 못해(gaierror) 실패했어요.
컨테이너 메모리 제한을 넘으면 커널이 프로세스를 끝내요(이 과정이 궁금하시면 컨테이너 OOM을 다룬 글을 참고해 주세요).
압축을 켠 이 서버가 연결 1만 개를 받으려면 컨테이너 메모리가 570MB 넘게 필요하고, 메시지가 오가면 더 필요해요.
재접속이 한꺼번에 몰리는 모양과 줄이는 방법이 궁금하시면 재접속을 다룬 글을, 서버를 여러 대로 늘렸을 때 메시지를 서버 사이에 넘겨주는 방법이 궁금하시면 서버를 여러 대로 늘리는 방법을 다룬 글을 참고해 주세요.
정리
범위: macOS Docker Desktop(Docker Engine 27.5.1, 리눅스 커널 6.12), Python 3.12, websockets 17.2, 서버 컨테이너 CPU 1개와 메모리 1GB(OOM 실험만 384MB), 각 측정은 3~4번 반복.
nofile1024에서 연결은 1,013개에 멈췄고 나머지 487개는 거절 응답 없이 10초 타임아웃으로 끝났으며, 이미 붙은 1,013개는 keepalive를 주고받으며 유지됐어요. 새 접속만 타임아웃으로 실패하면 서버가 죽었다고 보지 말고nofile과 listen 대기열(Recv-Q101,Send-Q100)부터 봐 주세요.- 연결 수를 1,000개에서 1만 개로 늘리는 동안 연결당 메모리는 거의 그대로였어요. 이 서버는 포트 하나로 연결 1만 개를 받았고, 그때 한도를 정한 건 포트 번호가 아니라
nofile과 메모리였습니다. - 384MB 상한에서 계산한 6,600개 안팎과 맞게 6,726~6,750개에서 OOMKilled(종료 코드 137)로 끝났고, 그 순간 열린 연결이 모두 끊겼어요. 메모리 상한은 연결당 메모리에 연결 수를 곱하고 메시지 버퍼 몫의 여유를 더해 잡아 주세요.
- 연결당 컨테이너 메모리는 압축(permessage-deflate)을 켠 기본값에서 55.1~56.9KiB였고, 이 글에 싣지 않은 추가 측정에서 압축을 끄면 20.8~20.9KiB였어요. 메시지가 작거나 이미 압축된 데이터라면 압축을 끄고, 연결당 메모리는 여러분 서버 코드로 직접 재 보세요.