实时通信 — WebSocket、gRPC 流式调用与 WebRTC
处理 gRPC 的截止时间、取消、流量控制、keepalive、重试与停机
한국어 원문으로 표시합니다.
목표
기한과 취소를 뒤 서비스로 전하는 중계 서버, 느린 소비자에게 맞춰 생산하는 스트리밍 서버, keepalive·재시도 정책을 선언한 채널, SIGTERM 에 우아하게 내려가는 서버를 만들고 서버 쪽 숫자로 확인합니다.
왜 중요한가
실시간 서비스는 여러 gRPC 호출이 사슬로 이어진 모양입니다. 사슬의 한 곳이 기한이나 취소를 끊으면 아무도 기다리지 않는 일에 자원이 쓰이고, 흐름 제어를 우회하면 느린 소비자 하나가 서버 메모리를 채우고, keepalive 가 어긋나면 조용한 스트림이 중간 장비에 잘리며, 재시도를 잘못 이해하면 스트림 결과를 두 번 받습니다. 배포할 때마다 스트림이 끊기는 서버도 흔합니다. 각각은 옵션 한 줄이나 콜백 하나로 막히지만, 그 한 줄을 모르면 원인을 찾는 데 며칠이 걸립니다.
단계
- 받은 기한을 뒤로 넘긴다 — 먼저 /opt/rt-lab/bin/python -m grpc_tools.protoc -I/opt/fixtures/rt/grpc --python_out=/root/rt/grpcc --grpc_python_out=/root/rt/grpcc /opt/fixtures/rt/grpc/meter.proto 로 코드를 만드세요. 그리고 /root/rt/grpcc/front.py 에 serve(port, backend) 를 만드세요. 127.0.0.1:port 에서 Meter 서비스를 듣되 Work 만 구현합니다. Work 는 같은 요청을 backend 주소의 Meter.Work 로 넘기면서, 자기 호출에 남은 기한(context.time_remaining())을 그대로 timeout 으로 겁니다. 기한이 없는 호출이면 timeout 을 걸지 않습니다. 뒤에서 오류가 나면 같은 상태 코드와 설명으로 context.abort 합니다.
- 앞이 취소되면 뒤도 취소한다 — Work 를 고쳐 뒤 호출을 stub.Work.future(...) 로 비동기로 걸고, context.add_callback 으로 자기 호출이 끝나거나 취소될 때 그 future 를 cancel 하게 하세요. 결과는 future.result() 로 기다립니다. 채점기는 100ms 짜리 30단계 일을 기한 10초로 건 뒤 0.4초에 취소하고, 2초 뒤 뒤 서비스가 몇 단계를 했는지 봅니다.
- 느린 소비자에게 맞춰 천천히 만든다 — /root/rt/grpcc/server.py 에 Meter 와 serve(port) 를 만드세요. Feed 는 request.n 개의 Chunk(seq, data) 를 보내며 data 는 request.size 바이트입니다. 만든 Chunk 수의 누계를 GetStats 가 Stats(feed_produced) 로 돌려줍니다. 채점기는 64KiB 짜리 3000개를 요청한 뒤 하나만 읽고 3초 멈춰, 그동안 서버가 몇 개를 만들었는지와 서버 메모리가 얼마나 늘었는지 봅니다.
- 유휴 타임아웃과 ping 정책 사이에서 간격을 고른다 — /root/rt/grpcc/client.py 에 make_channel(target) 을 만드세요. keepalive 옵션을 건 grpc.insecure_channel 을 돌려줍니다. 채점기는 3초 동안 조용한 연결을 끊는 중계기를 두고, 그 뒤에 1초보다 잦은 ping 을 GOAWAY(too_many_pings) 로 거절하는 서버를 둔 채, 7초 동안 아무 바이트도 오가지 않는 호출을 여러분의 채널로 보냅니다.
- 재시도는 채널 설정으로 선언한다 — /root/rt/grpcc/client.py 에 make_retry_channel(target) 을 추가하세요. grpc.service_config 옵션에 rt.Meter 서비스 전체의 retryPolicy 를 JSON 으로 넣습니다. maxAttempts 4, initialBackoff "0.1s", maxBackoff "1s", backoffMultiplier 2, retryableStatusCodes ["UNAVAILABLE"] 입니다. grpc.enable_retries 도 1 로 켭니다.
- 스트림은 첫 응답 뒤로는 다시 시도하지 않는다 — /root/rt/grpcc/client.py 에 collect(target, n, fail_at, fail_before) 를 추가하세요. make_retry_channel 로 채널을 열어 Countdown(n=n, interval_ms=10, fail_at=fail_at, fail_before=fail_before, fail_unavailable=True) 를 부르고, 받은 value 의 list 와 끝난 상태 코드 이름을 돌려줍니다. 애플리케이션에서 다시 부르지는 않습니다.
- 배포 중에도 하던 호출은 끝낸다 — /root/rt/grpcc/server.py 의 serve 에 SIGTERM 처리기를 달아, 신호를 받으면 server.stop(3) 을 불러 새 호출은 거절하고 진행 중인 호출에는 3초를 주게 하세요. Meter 에는 앞 실습처럼 n 부터 1 까지 보내는 Countdown 도 두세요. 채점기는 300ms 간격 4개짜리 Countdown 을 시작하고 0.3초에 SIGTERM 을 보낸 뒤, 그 스트림이 끝까지 오는지, 새 호출이 거절되는지, 프로세스가 끝나는지 봅니다.
참고
- 작업 폴더는 /root/rt/grpcc 입니다. mkdir -p /root/rt/grpcc 로 먼저 만드세요.
- 뒤 서비스와 클라이언트 단계의 상대는 기준 서버입니다. /opt/rt-lab/bin/python /opt/fixtures/rt/grpc/refserver.py --port 50052 --min-ping-ms 1000 으로 직접 띄워 볼 수 있습니다.
- 앞 서비스는 cd /root/rt/grpcc && /opt/rt-lab/bin/python -c "import front; front.serve(50061, '127.0.0.1:50052')" 처럼 띄웁니다. 채점기는 빈 포트를 골라 따로 띄웁니다.
- 흔한 실수 두 가지입니다. 기한이 없는 호출의 time_remaining() 을 그대로 넘기는 것, 그리고 스트림 도중에 끊긴 호출을 애플리케이션에서 처음부터 다시 불러 값을 두 번 받는 것입니다.
- 파이썬은 반드시 /opt/rt-lab/bin/python 으로 실행합니다. 이 실습의 라이브러리는 그 가상환경에만 들어 있고, 그냥 python3 로 돌리면 ModuleNotFoundError 가 납니다. alias rpy=/opt/rt-lab/bin/python 처럼 줄여 두면 편합니다.
- 실습 파드는 바깥으로 나가는 연결이 막혀 있습니다. 모든 통신은 같은 파드 안의 127.0.0.1 에서 일어나며, 설치나 다운로드는 필요 없습니다.
- 채점기는 코드를 별도 프로세스로 불러 실제 연결을 맺어 봅니다. 예시 파일은 함수 틀일 뿐이라 그대로 두면 통과하지 않습니다. 앞 단계에서 완성한 함수는 지우지 마세요.
- 실습 세션이 끝나면 /root 의 파일은 남지 않습니다. 필요한 코드는 끝내기 전에 따로 보관하세요.
받은 기한을 뒤로 넘긴다
먼저 /opt/rt-lab/bin/python -m grpc_tools.protoc -I/opt/fixtures/rt/grpc --python_out=/root/rt/grpcc --grpc_python_out=/root/rt/grpcc /opt/fixtures/rt/grpc/meter.proto 로 코드를 만드세요. 그리고 /root/rt/grpcc/front.py 에 serve(port, backend) 를 만드세요. 127.0.0.1:port 에서 Meter 서비스를 듣되 Work 만 구현합니다. Work 는 같은 요청을 backend 주소의 Meter.Work 로 넘기면서, 자기 호출에 남은 기한(context.time_remaining())을 그대로 timeout 으로 겁니다. 기한이 없는 호출이면 timeout 을 걸지 않습니다. 뒤에서 오류가 나면 같은 상태 코드와 설명으로 context.abort 합니다.
앞에서 0.6초 기한을 받았는데 뒤로는 기한 없이 부르면, 앞의 클라이언트가 포기한 뒤에도 뒤는 끝까지 일합니다. 남은 기한을 넘기는 것이 기한 전파입니다. 기한이 없는 호출에서 time_remaining() 은 아주 큰 값이 되므로 그대로 넘기지 마세요.
앞이 취소되면 뒤도 취소한다
Work 를 고쳐 뒤 호출을 stub.Work.future(...) 로 비동기로 걸고, context.add_callback 으로 자기 호출이 끝나거나 취소될 때 그 future 를 cancel 하게 하세요. 결과는 future.result() 로 기다립니다. 채점기는 100ms 짜리 30단계 일을 기한 10초로 건 뒤 0.4초에 취소하고, 2초 뒤 뒤 서비스가 몇 단계를 했는지 봅니다.
기한은 머리로 전해지지만 취소는 그렇지 않습니다. 클라이언트가 취소하면 앞 서버의 context 만 비활성이 되고, 앞 서버가 막혀 기다리는 뒤 호출은 모릅니다. 연결을 끊어 주는 것은 앞 서버의 몫입니다.
느린 소비자에게 맞춰 천천히 만든다
/root/rt/grpcc/server.py 에 Meter 와 serve(port) 를 만드세요. Feed 는 request.n 개의 Chunk(seq, data) 를 보내며 data 는 request.size 바이트입니다. 만든 Chunk 수의 누계를 GetStats 가 Stats(feed_produced) 로 돌려줍니다. 채점기는 64KiB 짜리 3000개를 요청한 뒤 하나만 읽고 3초 멈춰, 그동안 서버가 몇 개를 만들었는지와 서버 메모리가 얼마나 늘었는지 봅니다.
제너레이터에서 하나씩 yield 하면 gRPC 가 흐름 제어 창이 허락할 때만 다음 것을 꺼냅니다. 생산 스레드를 따로 두고 제한 없는 큐에 미리 채워 두면 그 창을 우회해 소비자 몫이 서버 메모리에 쌓입니다.
유휴 타임아웃과 ping 정책 사이에서 간격을 고른다
/root/rt/grpcc/client.py 에 make_channel(target) 을 만드세요. keepalive 옵션을 건 grpc.insecure_channel 을 돌려줍니다. 채점기는 3초 동안 조용한 연결을 끊는 중계기를 두고, 그 뒤에 1초보다 잦은 ping 을 GOAWAY(too_many_pings) 로 거절하는 서버를 둔 채, 7초 동안 아무 바이트도 오가지 않는 호출을 여러분의 채널로 보냅니다.
grpc.keepalive_time_ms 는 ping 간격, grpc.keepalive_timeout_ms 는 답을 기다릴 시간, grpc.keepalive_permit_without_calls 는 호출이 없을 때도 ping 할지, grpc.http2.max_pings_without_data 는 데이터 없이 보낼 ping 의 상한(0 이면 무제한)입니다. 너무 드물면 중계기가, 너무 잦으면 서버가 끊습니다.
재시도는 채널 설정으로 선언한다
/root/rt/grpcc/client.py 에 make_retry_channel(target) 을 추가하세요. grpc.service_config 옵션에 rt.Meter 서비스 전체의 retryPolicy 를 JSON 으로 넣습니다. maxAttempts 4, initialBackoff "0.1s", maxBackoff "1s", backoffMultiplier 2, retryableStatusCodes ["UNAVAILABLE"] 입니다. grpc.enable_retries 도 1 로 켭니다.
재시도를 애플리케이션 루프로 짜면 백오프·상한·어느 상태 코드를 재시도할지가 호출마다 흩어집니다. INVALID_ARGUMENT 처럼 다시 해도 같은 결과가 나올 오류는 재시도 목록에 넣지 않습니다.
스트림은 첫 응답 뒤로는 다시 시도하지 않는다
/root/rt/grpcc/client.py 에 collect(target, n, fail_at, fail_before) 를 추가하세요. make_retry_channel 로 채널을 열어 Countdown(n=n, interval_ms=10, fail_at=fail_at, fail_before=fail_before, fail_unavailable=True) 를 부르고, 받은 value 의 list 와 끝난 상태 코드 이름을 돌려줍니다. 애플리케이션에서 다시 부르지는 않습니다.
재시도 정책은 서버가 응답 머리나 첫 메시지를 보내기 전에 난 오류만 다시 시도합니다. 그 뒤로는 호출이 확정(committed)되어, 같은 UNAVAILABLE 이라도 그대로 올라옵니다. 이미 받은 값이 있는 스트림을 처음부터 다시 부르면 그 값을 두 번 받습니다.
배포 중에도 하던 호출은 끝낸다
/root/rt/grpcc/server.py 의 serve 에 SIGTERM 처리기를 달아, 신호를 받으면 server.stop(3) 을 불러 새 호출은 거절하고 진행 중인 호출에는 3초를 주게 하세요. Meter 에는 앞 실습처럼 n 부터 1 까지 보내는 Countdown 도 두세요. 채점기는 300ms 간격 4개짜리 Countdown 을 시작하고 0.3초에 SIGTERM 을 보낸 뒤, 그 스트림이 끝까지 오는지, 새 호출이 거절되는지, 프로세스가 끝나는지 봅니다.
쿠버네티스는 파드를 내릴 때 SIGTERM 을 보내고 terminationGracePeriodSeconds 뒤에 SIGKILL 을 보냅니다. 처리기가 없으면 파이썬은 SIGTERM 에 곧바로 죽고, 스트리밍 중이던 클라이언트는 모두 UNAVAILABLE 을 받습니다. 신호 처리기 안에서는 stop 을 부르기만 하고 기다리지 마세요.