リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC
コネクションプールを作って枯渇させてみる
한국어 원문으로 표시합니다.
목표
상한·대기 시간·폐기 규칙·유휴 한도를 갖춘 클라이언트 커넥션 풀을 직접 만들고, 요청 타임아웃이 없는 느린 호출이 풀을 말리는 장면과 되살아나는 장면을 같은 측정기로 잽니다.
왜 중요한가
HTTP 클라이언트, DB 드라이버, gRPC 채널은 모두 안에 풀을 갖고 있습니다. 풀이 마르면 서버는 한가한데 클라이언트의 모든 요청이 줄을 서고, 서버 지표만 보는 사람에게는 아무것도 보이지 않습니다. 원인은 대개 풀 설정이 아니라 풀 바깥의 규칙입니다 — 요청 타임아웃이 없거나, 타임아웃 난 연결을 돌려놓거나, 상대가 이미 닫은 연결을 다시 씁니다. 이 실습은 그 세 가지를 하나씩 재현하고 막습니다. 표준 라이브러리만 씁니다.
단계
- 상한이 있는 풀 — /root/rt/pool/pool.py 에 PoolTimeout 예외와 Pool 클래스를 만드세요. Pool(factory, max_size, acquire_timeout, idle_timeout=None) 은 인자 없이 부르면 연결을 하나 만들어 주는 factory 를 받습니다. acquire() 는 쉬는 연결이 있으면 그것을, 없고 지금까지 만든 연결이 max_size 보다 적으면 factory 로 새로 만들어 돌려줍니다. release(conn) 은 연결을 쉬는 목록에 돌려놓습니다. max_size 가 1 이상의 int 가 아니거나 acquire_timeout 이 양수가 아니면 ValueError 이고, bool 은 수로 받지 않습니다.
- 빈자리가 없으면 정해진 만큼만 기다린다 — acquire() 를 고쳐, 연결이 모두 쓰이고 있고 더 만들 수도 없으면 acquire_timeout 초까지 기다리게 하세요. 그 사이에 누가 release 하면 기다리던 쪽이 곧바로 그 연결을 받고, 시간이 다 되면 PoolTimeout 을 냅니다. 기다리는 동안 CPU 를 쓰며 돌지 마세요.
- 상태를 모르는 연결은 돌려놓지 않는다 — Pool 에 release(conn, broken=False) 의 broken 을 더하고 connection() 컨텍스트 관리자를 추가하세요. broken=True 면 연결의 close() 를 부르고 풀에서 빼서, 다음 acquire 가 새 연결을 만들 수 있게 합니다. with pool.connection() as conn: 블록이 정상으로 끝나면 연결을 돌려놓고, 예외로 끝나면 broken 으로 처리한 뒤 그 예외를 그대로 다시 냅니다.
- 풀의 상태를 숫자로 내놓는다 — Pool 에 stats() 를 추가하세요. size(지금 살아 있는 연결 수), idle(쉬는 연결 수), in_use(빌려 간 연결 수), waiting(지금 acquire 에서 기다리는 호출 수), timeouts(지금까지 PoolTimeout 을 낸 횟수) 다섯 키의 dict 를 돌려줍니다.
- 타임아웃 난 연결에는 지난 응답이 남아 있다 — /root/rt/pool/pool.py 에 get(pool, path, timeout) 을 추가하세요. pool 에서 연결(소켓)을 빌려 settimeout(timeout) 을 걸고, /opt/fixtures/rt/rtnet.py 의 h1_get(sock, path) 로 HTTP/1.1 GET 을 보내 (상태, 본문) 을 돌려줍니다. 타임아웃을 포함해 어떤 예외든 나면 그 연결은 broken 으로 버리고 예외를 다시 냅니다. 채점기는 max_size 1 인 풀로 느린 요청을 타임아웃시킨 뒤 곧바로 다음 요청을 보냅니다.
- 느린 호출 넷이 풀 전체를 붙잡는다 — /opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.py 를 돌리세요. 크기 4 인 여러분의 풀로, 5초 걸리는 요청 넷을 먼저 보낸 뒤 빠른 요청 스무 개를 보냅니다. 요청 타임아웃이 없을 때와 0.5초일 때 빠른 요청이 몇 개 성공했는지가 나옵니다. 출력의 starved_ok 와 bounded_ok 두 줄을 /root/rt/pool/report.txt 에 적으세요. 채점기가 다시 재서 대조합니다.
- 서버가 먼저 닫은 연결을 다시 쓰지 않는다 — Pool 의 idle_timeout 을 구현하세요. release 로 돌아온 시각을 기록해 두고, acquire 가 쉬는 연결을 꺼낼 때 쉰 시간이 idle_timeout 초를 넘은 것은 close() 하고 버린 뒤 다음 것을 봅니다. None 이면 버리지 않습니다. 채점기는 1초 동안 조용한 연결을 끊는 중계기 뒤에 서버를 두고, idle_timeout 0.5 인 풀로 요청 → 1.5초 쉼 → 요청을 보냅니다.
참고
- 작업 폴더는 /root/rt/pool 입니다. mkdir -p /root/rt/pool 로 먼저 만드세요.
- 측정기는 /opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.py 이고, 여러분의 Pool 과 get 을 불러 씁니다. 서버는 측정기가 스스로 띄웁니다.
- 채점기는 스레드 여러 개로 acquire 를 동시에 부릅니다. 모든 상태 변경은 락 안에서 하세요.
- 흔한 실수 두 가지입니다. 타임아웃 난 소켓을 풀에 돌려놓아 다음 요청이 지난 응답을 읽는 것, 그리고 wait 에서 깨어난 뒤 조건을 다시 보지 않아 남의 연결을 가로채는 것입니다.
- 파이썬은 반드시 /opt/rt-lab/bin/python 으로 실행합니다. 이 실습의 라이브러리는 그 가상환경에만 들어 있고, 그냥 python3 로 돌리면 ModuleNotFoundError 가 납니다. alias rpy=/opt/rt-lab/bin/python 처럼 줄여 두면 편합니다.
- 실습 파드는 바깥으로 나가는 연결이 막혀 있습니다. 모든 통신은 같은 파드 안의 127.0.0.1 에서 일어나며, 설치나 다운로드는 필요 없습니다.
- 채점기는 코드를 별도 프로세스로 불러 실제 연결을 맺어 봅니다. 예시 파일은 함수 틀일 뿐이라 그대로 두면 통과하지 않습니다. 앞 단계에서 완성한 함수는 지우지 마세요.
- 실습 세션이 끝나면 /root 의 파일은 남지 않습니다. 필요한 코드는 끝내기 전에 따로 보관하세요.
상한이 있는 풀
/root/rt/pool/pool.py 에 PoolTimeout 예외와 Pool 클래스를 만드세요. Pool(factory, max_size, acquire_timeout, idle_timeout=None) 은 인자 없이 부르면 연결을 하나 만들어 주는 factory 를 받습니다. acquire() 는 쉬는 연결이 있으면 그것을, 없고 지금까지 만든 연결이 max_size 보다 적으면 factory 로 새로 만들어 돌려줍니다. release(conn) 은 연결을 쉬는 목록에 돌려놓습니다. max_size 가 1 이상의 int 가 아니거나 acquire_timeout 이 양수가 아니면 ValueError 이고, bool 은 수로 받지 않습니다.
쉬는 연결을 먼저 쓰는 것이 풀의 존재 이유입니다. 연결을 만드는 비용(TCP 핸드셰이크, TLS)을 요청마다 치르지 않으려는 것입니다. factory 가 몇 번 불렸는지를 채점기가 셉니다.
빈자리가 없으면 정해진 만큼만 기다린다
acquire() 를 고쳐, 연결이 모두 쓰이고 있고 더 만들 수도 없으면 acquire_timeout 초까지 기다리게 하세요. 그 사이에 누가 release 하면 기다리던 쪽이 곧바로 그 연결을 받고, 시간이 다 되면 PoolTimeout 을 냅니다. 기다리는 동안 CPU 를 쓰며 돌지 마세요.
threading.Condition 의 wait(timeout) 은 깨어난 이유를 알려 주지 않습니다. 깨어날 때마다 조건을 다시 보고, 남은 시간을 다시 계산해야 합니다. 마감 시각을 time.monotonic() 으로 한 번 정해 두면 계산이 단순해집니다.
상태를 모르는 연결은 돌려놓지 않는다
Pool 에 release(conn, broken=False) 의 broken 을 더하고 connection() 컨텍스트 관리자를 추가하세요. broken=True 면 연결의 close() 를 부르고 풀에서 빼서, 다음 acquire 가 새 연결을 만들 수 있게 합니다. with pool.connection() as conn: 블록이 정상으로 끝나면 연결을 돌려놓고, 예외로 끝나면 broken 으로 처리한 뒤 그 예외를 그대로 다시 냅니다.
예외가 난 순간 그 연결에 무엇이 남아 있는지 모릅니다. 응답의 뒷부분이 아직 소켓에 있을 수도 있습니다. 모르는 것은 버리는 것이 풀의 기본 규칙입니다. contextlib.contextmanager 를 쓰면 짧게 쓸 수 있습니다.
풀의 상태를 숫자로 내놓는다
Pool 에 stats() 를 추가하세요. size(지금 살아 있는 연결 수), idle(쉬는 연결 수), in_use(빌려 간 연결 수), waiting(지금 acquire 에서 기다리는 호출 수), timeouts(지금까지 PoolTimeout 을 낸 횟수) 다섯 키의 dict 를 돌려줍니다.
풀 고갈은 서버 지표에 보이지 않습니다. 서버는 한가하고 클라이언트만 줄을 서 있기 때문입니다. waiting 과 timeouts 가 그 줄을 보여 주는 유일한 숫자입니다. 기다리기 시작할 때 올리고 어떤 이유로든 기다림이 끝날 때 내리세요.
타임아웃 난 연결에는 지난 응답이 남아 있다
/root/rt/pool/pool.py 에 get(pool, path, timeout) 을 추가하세요. pool 에서 연결(소켓)을 빌려 settimeout(timeout) 을 걸고, /opt/fixtures/rt/rtnet.py 의 h1_get(sock, path) 로 HTTP/1.1 GET 을 보내 (상태, 본문) 을 돌려줍니다. 타임아웃을 포함해 어떤 예외든 나면 그 연결은 broken 으로 버리고 예외를 다시 냅니다. 채점기는 max_size 1 인 풀로 느린 요청을 타임아웃시킨 뒤 곧바로 다음 요청을 보냅니다.
타임아웃은 기다리기를 그만뒀다는 뜻이지 서버가 응답을 안 보낸다는 뜻이 아닙니다. 그 응답은 늦게라도 같은 소켓으로 도착합니다. 그 소켓을 풀에 돌려놓으면 다음 요청이 남의 응답을 읽습니다. sys.path 에 /opt/fixtures/rt 를 넣으면 rtnet 을 가져다 쓸 수 있습니다.
느린 호출 넷이 풀 전체를 붙잡는다
/opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.py 를 돌리세요. 크기 4 인 여러분의 풀로, 5초 걸리는 요청 넷을 먼저 보낸 뒤 빠른 요청 스무 개를 보냅니다. 요청 타임아웃이 없을 때와 0.5초일 때 빠른 요청이 몇 개 성공했는지가 나옵니다. 출력의 starved_ok 와 bounded_ok 두 줄을 /root/rt/pool/report.txt 에 적으세요. 채점기가 다시 재서 대조합니다.
타임아웃이 없는 호출은 풀의 자리를 무한정 차지합니다. acquire_timeout 은 기다리는 쪽을 구할 뿐 자리를 차지한 쪽을 끝내지 못합니다. 두 값이 왜 그렇게 나오는지 설명할 수 있어야 합니다.
서버가 먼저 닫은 연결을 다시 쓰지 않는다
Pool 의 idle_timeout 을 구현하세요. release 로 돌아온 시각을 기록해 두고, acquire 가 쉬는 연결을 꺼낼 때 쉰 시간이 idle_timeout 초를 넘은 것은 close() 하고 버린 뒤 다음 것을 봅니다. None 이면 버리지 않습니다. 채점기는 1초 동안 조용한 연결을 끊는 중계기 뒤에 서버를 두고, idle_timeout 0.5 인 풀로 요청 → 1.5초 쉼 → 요청을 보냅니다.
로드밸런서와 서버는 조용한 연결을 먼저 끊습니다. 클라이언트 풀은 그 사실을 다음에 그 연결로 보낼 때에야 압니다. 풀의 유휴 한도를 상대의 유휴 한도보다 짧게 두면 그 경주를 피할 수 있습니다.