LabHub
Get started
배우기 러닝패스 코스

Real-Time Communication — WebSocket, gRPC Streaming and WebRTC

Build a connection pool and drain it

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

상한·대기 시간·폐기 규칙·유휴 한도를 갖춘 클라이언트 커넥션 풀을 직접 만들고, 요청 타임아웃이 없는 느린 호출이 풀을 말리는 장면과 되살아나는 장면을 같은 측정기로 잽니다.

왜 중요한가

HTTP 클라이언트, DB 드라이버, gRPC 채널은 모두 안에 풀을 갖고 있습니다. 풀이 마르면 서버는 한가한데 클라이언트의 모든 요청이 줄을 서고, 서버 지표만 보는 사람에게는 아무것도 보이지 않습니다. 원인은 대개 풀 설정이 아니라 풀 바깥의 규칙입니다 — 요청 타임아웃이 없거나, 타임아웃 난 연결을 돌려놓거나, 상대가 이미 닫은 연결을 다시 씁니다. 이 실습은 그 세 가지를 하나씩 재현하고 막습니다. 표준 라이브러리만 씁니다.

단계

  1. 상한이 있는 풀 — /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 은 수로 받지 않습니다.
  2. 빈자리가 없으면 정해진 만큼만 기다린다 — acquire() 를 고쳐, 연결이 모두 쓰이고 있고 더 만들 수도 없으면 acquire_timeout 초까지 기다리게 하세요. 그 사이에 누가 release 하면 기다리던 쪽이 곧바로 그 연결을 받고, 시간이 다 되면 PoolTimeout 을 냅니다. 기다리는 동안 CPU 를 쓰며 돌지 마세요.
  3. 상태를 모르는 연결은 돌려놓지 않는다 — Pool 에 release(conn, broken=False) 의 broken 을 더하고 connection() 컨텍스트 관리자를 추가하세요. broken=True 면 연결의 close() 를 부르고 풀에서 빼서, 다음 acquire 가 새 연결을 만들 수 있게 합니다. with pool.connection() as conn: 블록이 정상으로 끝나면 연결을 돌려놓고, 예외로 끝나면 broken 으로 처리한 뒤 그 예외를 그대로 다시 냅니다.
  4. 풀의 상태를 숫자로 내놓는다 — Pool 에 stats() 를 추가하세요. size(지금 살아 있는 연결 수), idle(쉬는 연결 수), in_use(빌려 간 연결 수), waiting(지금 acquire 에서 기다리는 호출 수), timeouts(지금까지 PoolTimeout 을 낸 횟수) 다섯 키의 dict 를 돌려줍니다.
  5. 타임아웃 난 연결에는 지난 응답이 남아 있다 — /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 인 풀로 느린 요청을 타임아웃시킨 뒤 곧바로 다음 요청을 보냅니다.
  6. 느린 호출 넷이 풀 전체를 붙잡는다 — /opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.py 를 돌리세요. 크기 4 인 여러분의 풀로, 5초 걸리는 요청 넷을 먼저 보낸 뒤 빠른 요청 스무 개를 보냅니다. 요청 타임아웃이 없을 때와 0.5초일 때 빠른 요청이 몇 개 성공했는지가 나옵니다. 출력의 starved_ok 와 bounded_ok 두 줄을 /root/rt/pool/report.txt 에 적으세요. 채점기가 다시 재서 대조합니다.
  7. 서버가 먼저 닫은 연결을 다시 쓰지 않는다 — Pool 의 idle_timeout 을 구현하세요. release 로 돌아온 시각을 기록해 두고, acquire 가 쉬는 연결을 꺼낼 때 쉰 시간이 idle_timeout 초를 넘은 것은 close() 하고 버린 뒤 다음 것을 봅니다. None 이면 버리지 않습니다. 채점기는 1초 동안 조용한 연결을 끊는 중계기 뒤에 서버를 두고, idle_timeout 0.5 인 풀로 요청 → 1.5초 쉼 → 요청을 보냅니다.

참고

상한이 있는 풀

/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초 쉼 → 요청을 보냅니다.

로드밸런서와 서버는 조용한 연결을 먼저 끊습니다. 클라이언트 풀은 그 사실을 다음에 그 연결로 보낼 때에야 압니다. 풀의 유휴 한도를 상대의 유휴 한도보다 짧게 두면 그 경주를 피할 수 있습니다.