TT Lab
블로그 시작하기
배우기 러닝패스 코스

gRPC 서비스를 직접 세우고 운영한다

파드를 셋으로 늘렸는데 호출은 한 파드로만 갔다

TT Lab 에서 이어서 보기

목표

진짜 k3s 에서 gRPC 헬스 서비스와 kubelet 의 gRPC 프로브를 잇고, ClusterIP 로 붙은 클라이언트가 한 파드에만 호출을 보내는 현상을 재현한 뒤, 헤드리스 서비스와 클라이언트 쪽 round_robin, 클라이언트 헬스 체크, 서버의 연결 수명으로 차례로 풉니다.

왜 중요한가

gRPC 는 연결 하나를 오래 쓰며 그 위에 호출을 겹쳐 보냅니다. 쿠버네티스의 기본 Service 는 연결을 맺는 순간에만 파드를 고르므로, 파드를 늘려도 이미 붙은 클라이언트의 호출은 처음 고른 파드로만 갑니다. 대시보드에는 파드 셋 중 하나만 CPU 를 쓰는 그림으로 나타나고, 자동 확장은 늘린 파드를 놀립니다. 그리고 파드를 트래픽에서 빼는 일도 HTTP 와 다르게 흐릅니다. kubelet 의 readiness 가 Endpoints 에서 빼도 이미 연결된 클라이언트는 모르고, 클라이언트가 직접 헬스를 보거나 서버가 연결을 닫아 줘야 합니다. 이 실습은 그 차이를 파드 이름으로 셉니다.

단계

  1. 서비스와 헬스 프로토콜의 코드를 만든다: cd /root/grpcops/k8s && /opt/grpcops-venv/bin/python -m grpc_tools.protoc -I /opt/fixtures/grpcops/proto --python_out=app --grpc_python_out=app grpcops/v1/whoami.proto health.proto 로 /root/grpcops/k8s/app 에 grpcops/v1/whoami_pb2*.py 와 health_pb2*.py 를 만드세요. VM 의 gRPC 파이썬은 /opt/grpcops-venv/bin/python 입니다. /opt/fixtures/grpcops/proto/grpcops/v1/whoami.proto 와 /opt/fixtures/grpcops/proto/health.proto 를 열어 Who·Drain·Resume 과 Check·Watch 를 읽어 두세요.
  2. 헬스 서비스와 Drain 을 구현한다: /root/grpcops/k8s/app/server.py 를 쓰세요. 환경 변수 PORT(기본 50051)의 모든 주소([::]:PORT)에서 듣고, POD_NAME 환경 변수(없으면 local)를 이름으로 씁니다. WhoAmI.Who 는 WhoReply(pod=이름) 를 돌려줍니다. grpc.health.v1.Health 의 Check 는 서비스 이름 "" 와 "grpcops.v1.WhoAmI" 를 알고 처음에는 둘 다 SERVING 이며, 모르는 이름이면 NOT_FOUND 로 끝냅니다. Drain 은 grpcops.v1.WhoAmI 만 NOT_SERVING 으로, Resume 은 다시 SERVING 으로 바꾸고 DrainReply(pod, status=바뀐 상태 이름) 을 돌려줍니다. "" 는 바꾸지 않습니다. 파일을 python server.py 로 실행하면 서버가 떠야 합니다.
  3. Watch 로 상태가 바뀌는 순간을 알린다: Health.Watch 를 구현하세요. 처음에 지금 상태를 한 번 보내고, 상태가 바뀔 때마다 새 상태를 보냅니다. 모르는 서비스 이름이면 오류로 끝내지 말고 SERVICE_UNKNOWN 을 보낸 채 기다립니다. 호출이 끝나면(context.is_active() 가 거짓) 반복을 멈춥니다.
  4. gRPC 프로브를 단 디플로이먼트를 올린다: /root/grpcops/k8s/whoami.yaml 에 네임스페이스 grpcops 의 Deployment whoami(replicas 3, 라벨 app: whoami)와 Service whoami(ClusterIP, 포트 50051)를 쓰고 kubectl apply 하세요. 컨테이너는 python:3.12-slim 이미지로 python /app/server.py 를 실행하고, hostPath /root/grpcops/k8s/app 를 /app 에, /opt/grpcops-site 를 /site 에 걸며, 환경 변수 PYTHONPATH=/site 와 downward API 로 POD_NAME=metadata.name 을 줍니다. readinessProbe 는 grpc(port 50051, service grpcops.v1.WhoAmI)로 periodSeconds 2, failureThreshold 1 이고, livenessProbe 는 service 없는 grpc(port 50051)로 periodSeconds 5 입니다. kubectl -n grpcops rollout status deploy/whoami 로 셋이 Ready 가 될 때까지 기다립니다.
  5. Drain 한 파드가 트래픽에서만 빠지는 것을 본다: whoami 파드 하나의 IP 로 Drain 을 부르세요(kubectl -n grpcops exec deploy/client -- python 으로 클라이언트 파드 안에서 부릅니다). 몇 초 뒤 kubectl -n grpcops get endpointslices -l kubernetes.io/service-name=whoami -o yaml 에서 그 파드의 ready 가 false 인지, 그 파드의 restartCount 가 그대로인지 보고 /root/grpcops/k8s/drain.txt 에 POD=<파드 이름>, READY_ENDPOINTS=, RESTARTS=<그 파드의 재시작 수> 세 줄을 적으세요. 그다음 Resume 으로 되돌립니다. 채점기는 파일을 본 뒤 같은 일을 직접 한 번 더 해 봅니다.
  6. ClusterIP 로 붙은 채널은 한 파드에 묶인다: /root/grpcops/k8s/client.py 에 spread(target, n=60, config=None) 을 만드세요. 채널 하나를 열고(config 가 있으면 grpc.service_config 채널 옵션에 그 사전의 JSON 을 넣습니다) Who 를 timeout=3 으로 n 번 불러 {파드 이름: 받은 수} 사전을 돌려줍니다. 파일을 python client.py <대상> [rr|hc] 로 실행하면 그 결과를 JSON 으로 찍게 하세요. 그리고 kubectl -n grpcops exec deploy/client -- python /work/client.py whoami.grpcops.svc.cluster.local:50051 60 > /root/grpcops/k8s/pinned.json 로 결과를 남깁니다.
  7. 헤드리스 서비스와 round_robin 으로 나눈다: /root/grpcops/k8s/whoami-headless.yaml 에 Service whoami-headless(clusterIP: None, 같은 선택자, 포트 50051)를 쓰고 apply 하세요. 그리고 /root/grpcops/k8s/client.py 에 RR_CONFIG = {"loadBalancingConfig": [{"round_robin": {}}]} 을 두고 명령줄의 rr 이 그 설정을 쓰게 하세요. 채점기는 클라이언트 파드 안에서 spread("dns:///whoami-headless.grpcops.svc.cluster.local:50051", 60, RR_CONFIG) 를 부릅니다.
  8. 클라이언트가 Drain 한 파드를 곧바로 뺀다: /root/grpcops/k8s/client.py 에 HC_CONFIG 를 두세요. RR_CONFIG 에 "healthCheckConfig": {"serviceName": "grpcops.v1.WhoAmI"} 를 더한 사전이고, 명령줄의 hc 가 그것을 씁니다. 채점기는 HC_CONFIG 로 연 채널 하나로 세 파드를 확인한 뒤 한 파드를 Drain 하고 0.5초 안에 60호출을 보내, 그 파드가 하나도 받지 않는지 봅니다.
  9. 연결에 수명을 주어 ClusterIP 클라이언트도 퍼지게 한다: /root/grpcops/k8s/app/server.py 가 환경 변수 MAX_CONNECTION_AGE_MS 가 있으면 grpc.server 의 옵션 grpc.max_connection_age_ms 를 그 값으로, grpc.max_connection_age_grace_ms 를 5000 으로 걸게 하세요. 그리고 Deployment 에 MAX_CONNECTION_AGE_MS=1000 을 더해 apply 하고 rollout status 로 기다립니다. 채점기는 설정 없는 채널 하나로 ClusterIP 에 8초 동안 0.25초마다 Who 를 불러 두 파드 이상이 답하는지 봅니다.

참고

서비스와 헬스 프로토콜의 코드를 만든다

cd /root/grpcops/k8s && /opt/grpcops-venv/bin/python -m grpc_tools.protoc -I /opt/fixtures/grpcops/proto --python_out=app --grpc_python_out=app grpcops/v1/whoami.proto health.proto 로 /root/grpcops/k8s/app 에 grpcops/v1/whoami_pb2*.py 와 health_pb2*.py 를 만드세요. VM 의 gRPC 파이썬은 /opt/grpcops-venv/bin/python 입니다. /opt/fixtures/grpcops/proto/grpcops/v1/whoami.proto 와 /opt/fixtures/grpcops/proto/health.proto 를 열어 Who·Drain·Resume 과 Check·Watch 를 읽어 두세요.

/root/grpcops/k8s/app 폴더는 서버 파드에 그대로 걸립니다(hostPath). 파드 안에서는 /app 으로 보입니다. 그래서 서버 코드와 생성 코드를 같은 폴더에 둡니다.

헬스 서비스와 Drain 을 구현한다

/root/grpcops/k8s/app/server.py 를 쓰세요. 환경 변수 PORT(기본 50051)의 모든 주소([::]:PORT)에서 듣고, POD_NAME 환경 변수(없으면 local)를 이름으로 씁니다. WhoAmI.Who 는 WhoReply(pod=이름) 를 돌려줍니다. grpc.health.v1.Health 의 Check 는 서비스 이름 "" 와 "grpcops.v1.WhoAmI" 를 알고 처음에는 둘 다 SERVING 이며, 모르는 이름이면 NOT_FOUND 로 끝냅니다. Drain 은 grpcops.v1.WhoAmI 만 NOT_SERVING 으로, Resume 은 다시 SERVING 으로 바꾸고 DrainReply(pod, status=바뀐 상태 이름) 을 돌려줍니다. "" 는 바꾸지 않습니다. 파일을 python server.py 로 실행하면 서버가 떠야 합니다.

"" 는 「프로세스가 살아 있는가」, 서비스 이름은 「이 서비스가 트래픽을 받을 수 있는가」 입니다. 둘을 나눠 두면 트래픽에서만 빼고 프로세스는 살려 둘 수 있습니다. 상태 표는 여러 스레드가 읽고 쓰므로 threading.Condition 하나로 지킵니다.

Watch 로 상태가 바뀌는 순간을 알린다

Health.Watch 를 구현하세요. 처음에 지금 상태를 한 번 보내고, 상태가 바뀔 때마다 새 상태를 보냅니다. 모르는 서비스 이름이면 오류로 끝내지 말고 SERVICE_UNKNOWN 을 보낸 채 기다립니다. 호출이 끝나면(context.is_active() 가 거짓) 반복을 멈춥니다.

gRPC 클라이언트의 헬스 체크(8단계)가 이 Watch 스트림을 서버마다 하나씩 열어 둡니다. Check 를 주기적으로 부르는 것보다 빠르게 알 수 있습니다. 바뀔 때까지는 Condition.wait(timeout=1) 로 기다리고, 깨어날 때마다 is_active() 를 확인해야 상대가 떠난 스트림이 스레드를 붙잡고 있지 않습니다.

gRPC 프로브를 단 디플로이먼트를 올린다

/root/grpcops/k8s/whoami.yaml 에 네임스페이스 grpcops 의 Deployment whoami(replicas 3, 라벨 app: whoami)와 Service whoami(ClusterIP, 포트 50051)를 쓰고 kubectl apply 하세요. 컨테이너는 python:3.12-slim 이미지로 python /app/server.py 를 실행하고, hostPath /root/grpcops/k8s/app 를 /app 에, /opt/grpcops-site 를 /site 에 걸며, 환경 변수 PYTHONPATH=/site 와 downward API 로 POD_NAME=metadata.name 을 줍니다. readinessProbe 는 grpc(port 50051, service grpcops.v1.WhoAmI)로 periodSeconds 2, failureThreshold 1 이고, livenessProbe 는 service 없는 grpc(port 50051)로 periodSeconds 5 입니다. kubectl -n grpcops rollout status deploy/whoami 로 셋이 Ready 가 될 때까지 기다립니다.

kubelet 의 gRPC 프로브는 Health/Check 를 부르고 SERVING 이 아니면 실패로 셉니다. service 를 적지 않으면 "" 를 묻습니다. service 이름이 서버가 아는 이름과 한 글자라도 다르면 NOT_FOUND 가 돌아와 파드가 영영 Ready 가 되지 않습니다. 그때는 kubectl describe pod 의 Events 에 이유가 찍힙니다.

Drain 한 파드가 트래픽에서만 빠지는 것을 본다

whoami 파드 하나의 IP 로 Drain 을 부르세요(kubectl -n grpcops exec deploy/client -- python 으로 클라이언트 파드 안에서 부릅니다). 몇 초 뒤 kubectl -n grpcops get endpointslices -l kubernetes.io/service-name=whoami -o yaml 에서 그 파드의 ready 가 false 인지, 그 파드의 restartCount 가 그대로인지 보고 /root/grpcops/k8s/drain.txt 에 POD=<파드 이름>, READY_ENDPOINTS=, RESTARTS=<그 파드의 재시작 수> 세 줄을 적으세요. 그다음 Resume 으로 되돌립니다. 채점기는 파일을 본 뒤 같은 일을 직접 한 번 더 해 봅니다.

readiness 는 Endpoints 에서 빼는 것이고 liveness 는 컨테이너를 다시 띄우는 것입니다. 배포·점검 중인 파드를 빼야 할 때 프로세스를 죽이지 않고 readiness 만 떨어뜨리는 것이 Drain 의 쓰임입니다. liveness 에도 같은 서비스 이름을 걸면 Drain 한 파드가 15초 뒤 재시작되어 점검이 끊깁니다.

ClusterIP 로 붙은 채널은 한 파드에 묶인다

/root/grpcops/k8s/client.py 에 spread(target, n=60, config=None) 을 만드세요. 채널 하나를 열고(config 가 있으면 grpc.service_config 채널 옵션에 그 사전의 JSON 을 넣습니다) Who 를 timeout=3 으로 n 번 불러 {파드 이름: 받은 수} 사전을 돌려줍니다. 파일을 python client.py <대상> [rr|hc] 로 실행하면 그 결과를 JSON 으로 찍게 하세요. 그리고 kubectl -n grpcops exec deploy/client -- python /work/client.py whoami.grpcops.svc.cluster.local:50051 60 > /root/grpcops/k8s/pinned.json 로 결과를 남깁니다.

ClusterIP 는 연결을 맺을 때 kube-proxy 가 파드 하나를 고릅니다. gRPC 채널은 연결 하나를 오래 쓰며 그 위에 호출을 몰아 보내므로 60호출이 한 파드로만 갑니다. 파드를 열 개로 늘려도 이미 맺은 연결은 그대로입니다. 클라이언트 파드 안에서 돌리는 이유는 서비스 DNS 이름이 클러스터 안에서만 풀리기 때문입니다.

헤드리스 서비스와 round_robin 으로 나눈다

/root/grpcops/k8s/whoami-headless.yaml 에 Service whoami-headless(clusterIP: None, 같은 선택자, 포트 50051)를 쓰고 apply 하세요. 그리고 /root/grpcops/k8s/client.py 에 RR_CONFIG = {"loadBalancingConfig": [{"round_robin": {}}]} 을 두고 명령줄의 rr 이 그 설정을 쓰게 하세요. 채점기는 클라이언트 파드 안에서 spread("dns:///whoami-headless.grpcops.svc.cluster.local:50051", 60, RR_CONFIG) 를 부릅니다.

헤드리스 서비스의 DNS 이름은 가상 IP 하나가 아니라 Ready 인 파드 IP 전부를 돌려줍니다. 그 주소마다 연결을 맺고 호출마다 돌아가며 고르는 것이 round_robin 입니다. 기본 정책(pick_first)은 주소가 여럿이어도 첫 연결 하나만 씁니다. 대상 앞의 dns:/// 는 gRPC 의 DNS 리졸버를 쓰라는 표시입니다.

클라이언트가 Drain 한 파드를 곧바로 뺀다

/root/grpcops/k8s/client.py 에 HC_CONFIG 를 두세요. RR_CONFIG 에 "healthCheckConfig": {"serviceName": "grpcops.v1.WhoAmI"} 를 더한 사전이고, 명령줄의 hc 가 그것을 씁니다. 채점기는 HC_CONFIG 로 연 채널 하나로 세 파드를 확인한 뒤 한 파드를 Drain 하고 0.5초 안에 60호출을 보내, 그 파드가 하나도 받지 않는지 봅니다.

kubelet 이 파드를 NotReady 로 바꾸고 DNS 가 그 주소를 빼도, 이미 연결을 맺어 둔 클라이언트는 연결이 끊기기 전까지 주소를 다시 묻지 않습니다. 클라이언트 헬스 체크는 그 틈을 메웁니다. 채널이 서버마다 Health/Watch 를 열어 NOT_SERVING 을 받는 순간 그 서버를 고르지 않습니다. 3단계의 Watch 가 없으면 이 설정은 아무 일도 하지 않습니다.

연결에 수명을 주어 ClusterIP 클라이언트도 퍼지게 한다

/root/grpcops/k8s/app/server.py 가 환경 변수 MAX_CONNECTION_AGE_MS 가 있으면 grpc.server 의 옵션 grpc.max_connection_age_ms 를 그 값으로, grpc.max_connection_age_grace_ms 를 5000 으로 걸게 하세요. 그리고 Deployment 에 MAX_CONNECTION_AGE_MS=1000 을 더해 apply 하고 rollout status 로 기다립니다. 채점기는 설정 없는 채널 하나로 ClusterIP 에 8초 동안 0.25초마다 Who 를 불러 두 파드 이상이 답하는지 봅니다.

클라이언트를 모두 고칠 수 없을 때 서버 쪽에서 할 수 있는 일입니다. 서버가 연결을 주기적으로 닫으면(GOAWAY) 클라이언트는 다시 연결하고, 그때 kube-proxy 가 파드를 다시 고릅니다. grace 동안은 진행 중인 호출이 끝날 시간을 줍니다. 1초는 실습에서 결과를 빨리 보려는 값이고, 운영에서는 수 분 단위로 둡니다.