이번 시간에는 Python gRPC 서버 세 대와 NGINX로 요청이 한 서버에 쏠리는 현상을 재현해 보고, 클라이언트 쪽 로드밸런싱(round_robin)과 서버가 연결 수명을 정하는 max_connection_age로 요청이 다시 나뉘는 과정을 Docker에서 측정하며 배워가는 시간입니다.
예를 들어서 gRPC 서버가 바쁘다는 알림을 받고 서버를 한 대에서 세 대로 늘렸다고 해볼게요. 앞에는 NGINX를 TCP 프록시로 두었어요. 그런데 서버마다 받은 요청 수를 세어 보면 300건이 모두 한 대로 가고, 나머지 두 대는 한 건도 받지 못합니다. 왜 그럴까요?
gRPC는 HTTP/2 연결 하나에 요청을 계속 실어 보내는데, TCP 연결 단위로 서버를 고르는 L4 프록시는 연결이 생길 때 한 번만 서버를 고르기 때문이에요.
실습 준비
이 글의 명령은 모두 실습 코드의 grpc-load-balancing/ 디렉터리에서 실행합니다.
Dockerfile: grpcio 1.84.0을 설치한 Python 3.12 이미지compose.yaml: 서버 세 대, NGINX, 클라이언트 구성hello.proto: RPC 하나짜리 서비스 정의server.py: 요청을 받으면 자기 IP를 돌려주는 gRPC 서버count.py: 요청을 보내고 서버별 처리 건수와 연결 주소를 찍는 클라이언트nginx.conf: NGINX를 TCP(L4) 프록시로 쓰는 설정
grpcio 1.84.0을 설치한 Python 3.12 이미지로 gRPC 서버 세 대와 클라이언트를 띄우고, 그 앞에 NGINX 1.30.5를 둬요. compose 파일 맨 위의 name이 프로젝트 이름이라, 아래 docker compose 명령은 모두 grpclb 프로젝트에서 실행됩니다.
compose.yaml은 아래 Dockerfile로 만든 이미지로 서버(deploy.replicas: 3, 각 128MB)와 클라이언트(256MB)를 띄우고, nginx:1.30.5-alpine(64MB)을 호스트 7210 포트에 열어요. 서버의 MAX_AGE_MS는 셸 환경 변수에서 받고 기본값은 0입니다. Dockerfile은 이렇습니다.
FROM python:3.12-slim
RUN pip install --no-cache-dir grpcio==1.84.0 grpcio-tools==1.84.0
서비스는 RPC 하나뿐이에요. 서버는 요청을 받으면 자기 IP 주소를 돌려주고, 클라이언트는 응답에 담긴 주소를 세어 어느 서버가 몇 건을 처리했는지 보여 줍니다. hello.proto는 서비스 Hello에 RPC Who(Empty) returns (Reply) 하나를 정의하고, Reply에는 문자열 필드 addr 하나만 둬요.
server.py는 50051 포트에서 스레드 8개로 요청을 받고, Who에 자기 컨테이너 IP를 돌려줘요. MAX_AGE_MS가 0이 아니면 grpc.max_connection_age_ms를 그 값으로, grpc.max_connection_age_grace_ms를 MAX_AGE_GRACE_MS(기본 5000)로 켜고, 시작할 때 주소와 옵션을 한 줄 찍습니다.
클라이언트 count.py는 인자로 접속 대상과 몇 가지 옵션을 받아요.
--policy: 로드밸런싱 정책 이름(pick_first,round_robin). 생략하면 gRPC 기본값을 씁니다.--n: 보낼 요청 수예요. 기본 300건이고, 요청은 하나씩 차례로 보냅니다.--seconds,--every: 정해진 시간 동안 0.05초 간격으로 계속 보내면서,--every초마다 누적 결과를 찍어요.
결과 마지막 줄의 연결:은 클라이언트 컨테이너에서 50051번 포트로 맺혀 있는 TCP 연결의 상대 주소예요.
NGINX는 stream 블록으로 TCP 연결을 그대로 넘기는 L4 프록시로 써요. HTTP 요청을 해석하지 않고 연결 단위로 서버를 고릅니다. nginx.conf는 이렇습니다.
worker_processes 1;
error_log /dev/stderr notice;
events {}
stream {
upstream grpc_servers {
server server:50051;
}
server {
listen 50051;
proxy_pass grpc_servers;
}
}
server:50051 한 줄만 썼지만, NGINX 문서에 따르면 이름이 여러 IP로 해석되면 서버 여러 대를 한 번에 정의한 것과 같아요. 이름은 설정을 읽을 때 한 번 해석합니다.
이미지를 빌드하고, proto 파일로 Python 코드를 만든 뒤 컨테이너를 띄워 볼게요.
docker compose build server
docker compose run --rm -T client python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. hello.proto
docker compose run --rm -T client python -c 'import grpc; print(grpc.__version__)'
docker compose up -d
docker compose logs --no-log-prefix server
1.84.0
172.28.0.4 listening, options=[]
172.28.0.2 listening, options=[]
172.28.0.3 listening, options=[]
deploy.replicas: 3으로 띄운 서버 세 대는 같은 서비스 이름 server를 써요. 그래서 Docker 내장 DNS에 이 이름을 물으면 세 주소가 모두 돌아옵니다.
먼저 명령어를 쉽게 쓸 수 있는 셸 함수 세 개를 만들어 놓을게요. count는 클라이언트를 실행하고, upstream은 NGINX 컨테이너 안에서 서버 쪽 50051번 포트로 맺힌 연결을 보여 줘요.
count_and_upstream은 클라이언트가 결과를 찍은 뒤 5초 동안 연결을 유지하게 해 두고, 그사이에 upstream을 실행합니다. Alpine 기반 NGINX 이미지에는 ss가 없어서 netstat을 썼어요.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
count() { docker compose run --rm -T client python count.py "$@"; }
upstream() {
docker compose exec -T nginx netstat -tn | awk '$6 == "ESTABLISHED" && $5 ~ /:50051$/ { print $4, "->", $5 }'
}
count_and_upstream() {
rm -f count.out; count "$@" --hold 5 > count.out &
until grep -q '연결:' count.out 2>/dev/null; do sleep 0.5; done
cat count.out; echo "--- NGINX -> 서버 연결"; upstream; wait
}
실습 1: HTTP/2 연결 하나에 요청이 실린다
gRPC는 HTTP/2 위에서 동작해요. HTTP/2는 TCP 연결 하나 안에 스트림을 여러 개 열 수 있고, 스트림마다 요청 하나와 그 응답이 오갑니다.
그래서 gRPC 채널은 서버 한 대에 연결 하나를 맺어 두고, 요청이 올 때마다 연결을 새로 만들지 않고 그 연결 안에 스트림을 하나 열어요. 클라이언트가 연 스트림은 1, 3, 5처럼 홀수 번호를 씁니다.
그럼 NGINX를 거쳐 300건을 보내고, 그동안 NGINX가 서버 쪽으로 맺은 연결을 볼게요.
count_and_upstream nginx:50051
172.28.0.2=300 errors=0
연결: 172.28.0.5
--- NGINX -> 서버 연결
172.28.0.5:35512 -> 172.28.0.2:50051
- 클라이언트에서 본 연결은 NGINX(
172.28.0.5)로 가는 연결 하나예요. - NGINX에서 서버로 간 연결도
172.28.0.2로 가는 하나뿐입니다. - 300건이 모두 이 연결 하나를 타고
172.28.0.2에서 처리됐어요.
NGINX stream 모듈의 기본 분배 방식은 라운드로빈이지만, 돌아가며 나누는 단위는 요청이 아니라 연결이에요. 클라이언트가 새 연결을 맺을 때 서버 한 대를 고르고, 그 연결이 끊길 때까지 같은 서버로 바이트를 넘깁니다.
실제 서비스에서는 클라이언트 프로세스가 오래 떠 있고 채널도 오래 유지하죠. 그래서 클라이언트 수가 서버 수보다 적으면 일부 서버만 일하게 돼요. Kubernetes의 ClusterIP Service도 kube-proxy가 연결 단위로 파드를 고르니까 같은 일이 생깁니다.
실습 2: 클라이언트가 직접 나누기, round_robin
gRPC 클라이언트는 이름 해석과 로드밸런싱을 스스로 할 수 있어요. 접속 대상을 dns:///server:50051처럼 쓰면 클라이언트가 DNS로 server의 주소 목록을 받아 로드밸런싱 정책에 넘깁니다. 여기서는 정책 두 가지를 비교해 볼게요.
pick_first: 기본 정책이에요. 받은 주소를 차례로 시도해 처음 연결된 한 곳만 씁니다. gRPC 문서도 이 정책은 실제로 부하를 나누지 않는다고 설명해요.round_robin: 받은 주소마다 연결(subchannel)을 맺고, 요청마다 연결된 서버를 돌아가며 골라요.
NGINX를 빼고 클라이언트가 서버들에 바로 붙게 한 뒤, round_robin과 기본 정책을 세 번씩 실행해 볼게요.
for i in 1 2 3; do count dns:///server:50051 --policy round_robin; done
for i in 1 2 3; do count dns:///server:50051; done
172.28.0.2=100 172.28.0.3=100 172.28.0.4=100 errors=0
연결: 172.28.0.2 172.28.0.3 172.28.0.4
172.28.0.2=100 172.28.0.3=100 172.28.0.4=100 errors=0
연결: 172.28.0.2 172.28.0.3 172.28.0.4
172.28.0.2=101 172.28.0.3=99 172.28.0.4=100 errors=0
연결: 172.28.0.2 172.28.0.3 172.28.0.4
172.28.0.4=300 errors=0
연결: 172.28.0.4
172.28.0.2=300 errors=0
연결: 172.28.0.2
172.28.0.4=300 errors=0
연결: 172.28.0.4
round_robin은 세 서버에 연결을 하나씩 맺고 100건 안팎으로 나눴어요. 세 번 중 한 번은 101, 99, 100으로 1건씩 차이가 났습니다. 첫 요청을 보낼 때 아직 연결이 다 맺어지지 않아 먼저 준비된 서버로 보낸 것으로 보여요.- 기본 정책(
pick_first)은 연결 하나로 300건을 모두 보냈어요. Docker DNS가 돌려주는 주소 순서가 매번 달라서 실행마다 다른 서버가 골라졌을 뿐, 한 번 실행하는 동안에는 한 서버만 썼습니다.
Kubernetes에서는 일반 Service 이름을 DNS로 물으면 Service의 가상 IP 하나만 돌아와서, round_robin이 나눌 주소가 하나뿐이에요. 클라이언트가 파드 주소를 직접 받으려면 clusterIP: None으로 만든 headless Service 이름을 써 주세요.
실습 3: max_connection_age로 새 서버까지 나누기
round_robin으로 보내는 중에 서버를 네 대로 늘리면 새 서버도 요청을 받을까요?
round_robin을 켰다고 클라이언트가 DNS를 주기적으로 다시 묻지는 않아요. 클라이언트가 이름을 다시 해석하는 때는 채널을 처음 만들 때와, 맺어 둔 연결이 끊기는 등 로드밸런싱 정책이 다시 해석해 달라고 요청할 때입니다.
세 연결이 모두 멀쩡하면 다시 물을 이유가 없으니, 새 서버의 주소는 클라이언트에 전달되지 않아요.
서버 쪽에서 연결 수명을 정해 두면, 수명이 다 된 연결을 서버가 정리하고 클라이언트는 다시 연결하면서 이름도 다시 해석해요. gRPC 제안서 A9(서버 쪽 연결 관리)에 정의된 설정이고, C 코어에서는 서버 옵션으로 넣습니다.
grpc.max_connection_age_ms: 연결이 유지될 수 있는 최대 시간이에요. 기본값은INT_MAX로, 꺼져 있다는 뜻입니다. 여러 연결이 동시에 끊기지 않도록 실제 값에는 ±10%의 무작위 편차가 붙어요.grpc.max_connection_age_grace_ms: 수명이 다 된 뒤 진행 중인 요청이 끝나기를 기다려 주는 시간이에요. 기본값은INT_MAX로, 진행 중인 요청이 끝날 때까지 기다립니다.
수명이 다 되면 서버는 HTTP/2 GOAWAY 프레임을 보내요. GOAWAY는 이 연결로 새 요청을 보내지 말라는 신호이고, 이미 진행 중인 요청은 grace 시간 안에서 끝까지 처리됩니다.
클라이언트는 새 요청을 다른 연결로 보내면서 끊긴 서버에 다시 연결하고, 이름 해석도 다시 요청해요.
서버를 MAX_AGE_MS=10000으로 다시 띄워 볼게요. 연결 수명 10초, grace 5초예요. 실습에서 빨리 보려고 짧게 잡은 값입니다.
클라이언트는 80초 동안 보내면서 5초마다 누적 결과를 찍고, 시작하고 25초 뒤에 서버를 한 대 추가합니다. 클라이언트가 찍는 시간은 컨테이너 기준이라 UTC예요. 서버를 늘린 시간도 UTC로 찍습니다.
export MAX_AGE_MS=10000
docker compose down
docker compose up -d
sleep 3
docker compose logs --no-log-prefix server
count dns:///server:50051 --policy round_robin --seconds 80 --every 5 &
sleep 25
echo "--- $(date -u +%T) scale"
docker compose up -d --scale server=4 --no-recreate server
wait
출력은 이렇습니다(발췌).
172.28.0.3 listening, options=[('grpc.max_connection_age_ms', 10000), ('grpc.max_connection_age_grace_ms', 5000)]
172.28.0.4 listening, options=[('grpc.max_connection_age_ms', 10000), ('grpc.max_connection_age_grace_ms', 5000)]
172.28.0.2 listening, options=[('grpc.max_connection_age_ms', 10000), ('grpc.max_connection_age_grace_ms', 5000)]
06:50:57 172.28.0.2=27 172.28.0.3=27 172.28.0.4=29 errors=0
06:51:02 172.28.0.2=50 172.28.0.3=51 172.28.0.4=51 errors=0
06:51:06 172.28.0.2=67 172.28.0.3=70 172.28.0.4=69 errors=0
--- 06:51:09 scale
06:51:11 172.28.0.2=96 172.28.0.3=98 172.28.0.4=97 errors=0
06:51:16 172.28.0.2=123 172.28.0.3=126 172.28.0.4=124 errors=0
06:51:21 172.28.0.2=142 172.28.0.3=145 172.28.0.4=143 errors=0
06:51:26 172.28.0.2=166 172.28.0.3=168 172.28.0.4=166 172.28.0.7=22 errors=0
06:51:31 172.28.0.2=185 172.28.0.3=185 172.28.0.4=184 172.28.0.7=41 errors=0
06:52:11 172.28.0.2=354 172.28.0.3=353 172.28.0.4=352 172.28.0.7=208 errors=0
연결: 172.28.0.2 172.28.0.3 172.28.0.4 172.28.0.7
- 06:51:09에 서버를 늘렸고, 06:51:21~06:51:26 사이에
172.28.0.7이 요청을 받기 시작했어요. 이후 5초마다 네 서버가 비슷한 수만큼 늘었습니다. - 연결이 10초마다 끊겼다 다시 맺혔지만 클라이언트 오류는 0건이에요.
그런데 연결은 10초마다 끊기는데, 새 서버가 들어오기까지 십수 초가 걸렸죠. 이유는 이름 해석 간격에 있어요.
채널 인자 grpc.dns_min_time_between_resolutions_ms가 DNS 해석 사이의 최소 간격이고, grpc 1.84 소스에 적힌 기본값은 30초예요. 연결이 끊길 때마다 재해석을 요청하더라도, 직전 해석에서 30초가 지나지 않았으면 30초가 될 때까지 미뤄집니다.
그러니 서버를 늘린 뒤 새 서버가 요청을 받기까지는 최대로 대략 연결 수명에 해석 간격 30초를 더한 만큼 걸린다고 보시면 됩니다.
연결 수명을 짧게 잡을수록 새 서버가 빨리 들어오지만, 그만큼 연결을 자주 다시 맺어요. TLS를 쓰면 연결마다 핸드셰이크 비용도 듭니다.
grace는 가장 오래 걸리는 정상 요청보다 길게 잡아야 진행 중인 요청이 끊기지 않아요. 요청마다 기다릴 최대 시간을 정하는 데드라인이 더 궁금하시면 gRPC 데드라인을 다룬 글을 참고해 주세요.
정리
범위: Python 3.12, grpcio 1.84.0, NGINX 1.30.5 stream 모듈(L4), 서버 세 대(늘리는 실험은 네 대), 요청 300건 또는 0.05초 간격 연속 전송.
- NGINX L4 프록시를 거친 채널 하나는 요청 300건을 모두 한 서버로 보냈고, NGINX에서 서버로 간 연결도 하나뿐이었습니다. L4 프록시 뒤에서는 서버를 늘려도 연결 수만큼만 나뉜다고 보세요.
dns:///server:50051에round_robin을 주자 세 서버가 100건 안팎씩 받았고, 정책을 주지 않은pick_first는 300건을 한 서버로 보냈습니다. 클라이언트에서 나누려면round_robin을 명시해 주세요.- 이 글에 싣지 않은 추가 측정에서는
round_robin만으로 서버를 네 대로 늘리자 새 서버가 1분이 지나도록 0건이었습니다. 서버 수가 바뀌는 환경이면 서버에grpc.max_connection_age_ms를 설정하세요. - 연결 수명 10초에서 06:51:09에 늘린 새 서버가 06:51:21~06:51:26 사이에 요청을 받기 시작했고, 연결이 10초마다 끊겨도 클라이언트 오류는 0건이었습니다. 새 서버가 들어오는 시간은 연결 수명에 재해석 간격 30초를 더해 잡고, grace는 가장 오래 걸리는 정상 요청보다 길게 잡으세요.