상태: 초안 (2026-10-17), 일부 실측. 기준 판 grpc/grpc-go v1.83.2, kubernetes v1.34.10, cilium v1.20.1, grpc/grpc v1.84.0 문서. 실측은 2026-10-17 03시대(KST)에 Prometheus·
kubectl get·Cilium 에이전트 안의cilium-dbg status를 읽기만 해서 얻었습니다. 부하 쏠림을 직접 재현하지는 않았습니다(미실측). 재현하려면 시험용 gRPC 서버 이미지와 디플로이먼트를 클러스터에 올려야 하는데, 이번 작업은 읽기만 하기로 했습니다. 필요한 것은 8절에 적었습니다.
목차
- 1. 직관: 택시 승강장
- 2. 그림
- 3. 왜 쏠리나
- 4. 해법 세 가지
- 5. 헬스 체크: grpc.health.v1
- 6. kubelet 의 gRPC 프로브
- 7. 한계
- 8. 작은 클러스터와의 연결
- 참고
1. 직관: 택시 승강장
Service(ClusterIP)는 택시 승강장의 배차원입니다. 손님(연결)이 오면 빈 택시(파드)를 하나 정해 줍니다. HTTP/1.1 클라이언트는 짧은 거리를 자주 타고 내리므로 배차원이 고르게 나눠 줄 기회가 많습니다. gRPC 클라이언트는 택시 한 대를 하루 종일 전세 냅니다. 그 택시 안에서 수천 번 이동(호출)해도 배차원은 처음 한 번만 관여했습니다. 택시를 열 대로 늘려도 이미 전세 낸 손님은 계속 첫 택시를 탑니다.
2. 그림
3. 왜 쏠리나
- kube-proxy 의 iptables·IPVS 든, Cilium 의 kube-proxy 대체든, ClusterIP 는 연결 단위로 백엔드를 고릅니다. Cilium 의 소켓 수준 부하 분산은
앱이
connect()를 부를 때 서비스 IP 를 백엔드 주소로 바꿔 소켓을 그 백엔드에 바로 묶습니다(kube-proxy free 문서). 작은 클러스터의 에이전트도KubeProxyReplacement: True였습니다(cilium-dbg status, 읽기). 구조는 Cilium 읽기에 있습니다. - gRPC 채널은 대상 하나에 연결 하나를 오래 유지하고, HTTP/2 스트림으로 호출을 동시에 실어 보냅니다(1편). gRPC 의 부하 분산 문서는 처음부터 분산을 연결이 아니라 호출 단위로 하도록 설계했다고 적습니다(load-balancing.md). 그런데 그 분산은 클라이언트 라이브러리 안의 LB 정책이 해야 하고, ClusterIP 는 주소 하나만 주므로 라이브러리가 나눌 대상이 없습니다.
- 기본 LB 정책
pick_first는 주소 목록의 첫 번째로 붙어 모든 호출을 거기로 보냅니다(같은 문서). 주소가 여럿이어도 기본값으로는 나누지 않습니다.
이 문제를 쿠버네티스 블로그가 2018년에 「gRPC Load Balancing on Kubernetes without Tears」로 정리한 적이 있습니다(Buoyant 의 글, kubernetes.io/blog).
4. 해법 세 가지
4.1 헤드리스 서비스 + 클라이언트 측 round_robin
clusterIP: None 인 헤드리스 서비스는 DNS 로 파드 IP 들을 그대로 돌려줍니다. 클라이언트가 dns:///scoreboard.tt.svc.cluster.local:50051 로 채널을
만들고 서비스 설정에서 round_robin 을 고르면, 채널이 파드마다 서브채널을 만들고 호출마다 돌아가며 보냅니다.
{"loadBalancingConfig": [{"round_robin": {}}]}
grpc-go 에서는 grpc.WithDefaultServiceConfig(...) 로 넣거나 DNS TXT 레코드로 받을 수 있습니다(쿠버네티스 DNS 는 TXT 를 주지 않으므로 보통 앞의 방법).
함정이 하나 있습니다. DNS 를 언제 다시 묻는가입니다. grpc-go 의 DNS 리졸버는 한 번 성공하면 주기적으로 다시 묻지 않고, 누군가
ResolveNow 를 부를 때까지 기다립니다(그래도 직전 질의로부터 30초는 지나야 함).
var nextResolutionTime time.Time
if err == nil {
// Success resolving, wait for the next ResolveNow. However, also wait 30
// seconds at the very least to prevent constantly re-resolving.
backoffIndex = 1
nextResolutionTime = internal.TimeNowFunc().Add(MinResolutionInterval)
select {
case <-d.ctx.Done():
return
case <-d.rn:
}
}
출처: grpc/grpc-go v1.83.2 internal/resolver/dns/dns_resolver.go 218~229행 발췌(watcher), Apache-2.0
ResolveNow 는 주로 연결이 끊기거나 연결에 실패할 때 불립니다(clientconn.go:1366·1515).
그래서 파드가 줄어들면(연결이 끊기니까) 금방 알아채지만, 늘어나면 아무도 모릅니다. 기존 파드가 멀쩡하면 연결이 끊길 일이 없기 때문입니다.
4.2 서버의 MaxConnectionAge 로 주기적으로 갈아타기
해결책은 서버가 연결에 수명을 주는 것입니다. grpc-go 서버의 keepalive.ServerParameters.MaxConnectionAge 를 정하면 그 시간(±10% 흔들림)이 지난
연결에 GOAWAY 를 보내고, MaxConnectionAgeGrace 뒤에 닫습니다(keepalive.go 의 주석). 기본값은 둘 다 무한입니다.
GOAWAY 를 받은 클라이언트는 진행 중인 호출은 끝내고 새 호출은 새 연결로 보내며, 연결이 끊긴 김에 DNS 를 다시 풉니다. ClusterIP 뒤에서도
새 연결을 열 때 배차가 다시 일어나므로 쏠림이 서서히 풀립니다. 흔들림이 있는 이유는 모든 클라이언트가 같은 순간에 몰려 재연결하지 않게 하려는 것입니다.
4.3 L7 프록시: Envoy, 메시, Cilium
HTTP/2 를 이해하는 프록시는 스트림(호출) 단위로 백엔드를 고를 수 있습니다. 사이드카 메시(Istio·Linkerd)나 게이트웨이가 이 일을 하고, 클라이언트 코드를 바꿀 필요가 없습니다. 대가는 홉 하나와 프록시의 CPU·메모리입니다.
Cilium 은 노드의 Envoy 로 gRPC 를 L7 에서 다룰 수 있습니다. gRPC 메서드가 POST /패키지.서비스/메서드 라는 점을 이용해 경로로 허용 목록을 만드는
네트워크 정책이 가능하고, 거부된 호출은 패킷을 버리는 대신 PERMISSION_DENIED 상태로 돌려줍니다(Cilium gRPC 정책 안내).
rules:
http:
- method: "POST"
path: "/tt.v1.Scoreboard/Hit"
작은 클러스터의 에이전트는 Proxy Status: OK … 0 redirects active 였습니다(cilium-dbg status). 지금은 L7 정책이 걸린 곳이 없어 gRPC 를
L7 에서 보는 경로가 쓰이지 않는다는 뜻입니다.
4.4 xDS: 프록시 없는 메시
gRPC 라이브러리가 직접 xDS(Envoy 의 설정 API)를 말하는 방식입니다(gRFC A27). 클라이언트는 xds:///서비스:포트 로 채널을 만들고, 제어
평면(예: istiod)에서 리스너·라우트·클러스터·엔드포인트를 받아 호출 단위로 분산합니다. Istio 는 이를 「proxyless gRPC」 로 제공하며, 에이전트가
부트스트랩 파일을 써 주고 xDS 를 대신 중계합니다(Istio 블로그 2021).
주의할 점은 gRPC 가 xDS 의 일부만 구현한다는 것입니다. 지원하지 않는 설정은 로그도 없이 무시하고, 지원하는 필드에 지원하지 않는 값이 오면 설정 전체를 거절(NACK)합니다(grpc_xds_features.md). Envoy 사이드카에서 되던 설정이 프록시 없는 gRPC 에서는 조용히 효과가 없을 수 있습니다.
작은 클러스터의 istiod 는 Envoy 프록시 3개와 xDS 로 연결되어 있었고(pilot_xds 3), 기동 이후 푸시를 EDS 44,798회, RDS 21,150회, CDS·LDS 각
21,102회 보냈습니다(sum by (type) (pilot_xds_pushes)). 엔드포인트(EDS)가 가장 자주 바뀌는 것은 파드가 오가기 때문입니다. 프록시 없는 gRPC
클라이언트는 없었습니다.
4.5 고르는 기준
| 방법 | 클라이언트 변경 | 새 파드 반영 | 추가 비용 | 맞는 경우 |
|---|---|---|---|---|
| ClusterIP 그대로 | 없음 | 새 연결부터 | 없음 | 클라이언트가 많고 각자 호출이 적을 때 |
| + 서버 MaxConnectionAge | 서버 설정 | 연결 수명마다 | 재연결 비용 | 위 상황에서 쏠림이 보일 때 |
| 헤드리스 + round_robin | 서비스 설정 | 연결 끊길 때(+수명) | 없음 | 클라이언트·서버를 모두 우리가 만들 때 |
| L7 프록시·메시 | 없음 | 즉시(xDS) | 홉, 프록시 자원 | 언어가 섞이고 정책·관측이 필요할 때 |
| 프록시 없는 xDS | 대상 문자열, 부트스트랩 | 즉시 | 제어 평면 | 홉을 줄이고 싶고 기능 제약을 감수할 때 |
5. 헬스 체크: grpc.health.v1
gRPC 에는 표준 헬스 서비스가 있습니다(health-checking.md).
Check(service)는 SERVING·NOT_SERVING 을 돌려줍니다. 서비스 이름이 빈 문자열이면 서버 전체 상태이고, 모르는 이름이면 NOT_FOUND 입니다. grpc-go 의health.Server.Check가 정확히 그렇게 동작하고, 새로 만들면 빈 이름이 SERVING 으로 들어 있습니다.Watch(service)는 서버 스트리밍으로 상태가 바뀔 때마다 알려 줍니다.List()는 등록된 모든 서비스의 상태를 한 번에 줍니다(gRFC A90). grpc-go 에는 1.83.2 에 이미 있고 proto 에도 있지만, grpc/grpc 의 문서에는 아직 없습니다(8편 기여 후보).- 클라이언트 측 헬스 체크(gRFC A17)를 켜면 채널이 서브채널마다
Watch를 열어 NOT_SERVING 인 백엔드로는 호출을 보내지 않습니다 (addrConn.startHealthCheck). round_robin 과 함께 쓰면 「살아는 있지만 받으면 안 되는」 파드를 피할 수 있습니다.
6. kubelet 의 gRPC 프로브
쿠버네티스는 1.27 부터 grpc 프로브가 안정판입니다. 포트와 선택적 서비스 이름을 주면 kubelet 이 grpc.health.v1.Health/Check 를 부릅니다.
문서가 적는 제약은 이렇습니다(프로브 설정 문서):
이름 붙은 포트를 쓸 수 없고, 기본은 평문 연결이며, TLS 는 최신 문서에 GRPCContainerProbeTLS 기능 게이트로 추가되어 있습니다(1.34 에는 없음, 인증서는 검증하지 않음).
kubelet 1.34.10 의 구현은 짧습니다.
opts := []grpc.DialOption{
grpc.WithUserAgent(fmt.Sprintf("kube-probe/%s.%s", v.Major, v.Minor)),
grpc.WithBlock(),
grpc.WithTransportCredentials(insecure.NewCredentials()), //credentials are currently not supported
...
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
conn, err := grpc.DialContext(ctx, addr, opts...)
...
resp, err := client.Check(metadata.NewOutgoingContext(ctx, make(metadata.MD)), &grpchealth.HealthCheckRequest{
Service: service,
})
출처: kubernetes v1.34.10 pkg/probe/grpc/grpc.go 56~90행 발췌(Probe), Apache-2.0
- 프로브 한 번에 채널 하나를 만들고 끝나면 닫습니다. 1편 6절의 「채널은 재사용」 원칙과 반대인데, 프로브는 「지금 새로 붙을 수 있는가」까지 보려는 것이라 그 편이 맞습니다. 대신 프로브 주기마다 TCP·HTTP/2 설정 교환이 한 번씩 생깁니다.
timeoutSeconds하나가 연결과 호출을 모두 덮습니다. 서버가 헬스 서비스를 구현하지 않았으면 UNIMPLEMENTED 를 받아 실패로 칩니다.- 쿠버네티스 main 은 같은 함수를
grpc.NewClient("passthrough:///"+addr, …)로 바꿨습니다.NewClient의 기본 리졸버가dns라서, 옛DialContext처럼 주소를 그대로 쓰려면passthrough를 붙여야 하기 때문입니다(1편 6절).
6.1 실측: 누가 gRPC 프로브를 쓰나
kubectl get pods -o json 의 프로브 종류를 세었습니다(사용자 업무 네임스페이스 하나는 세지 않음, 파드 271개).
| 프로브 종류 | 개수 |
|---|---|
| httpGet | 368 |
| exec | 38 |
| tcpSocket | 20 |
| grpc | 3 |
gRPC 프로브 3개는 모두 hubble-relay 의 시작·생존·준비 프로브입니다(포트 4222, 서비스 이름 빈 문자열, 주기는 생존·준비 10초, 시작 3초). 릴레이 로그에도 기동 때 4222 에 「gRPC health server」 를 연다고 남습니다. 결과 지표는 다음과 같았습니다.
sum by (probe_type, result) (prober_probe_total{namespace="kube-system", container="hubble-relay"})
| 프로브 | 성공 | 실패 |
|---|---|---|
| Liveness | 32,161 | 0 |
| Readiness | 32,162 | 0 |
| Startup | 1 | 8 |
- 시작 프로브가 8번 실패한 뒤 성공했습니다. 릴레이는 기동 직후 노드 목록을 주는
hubble-peer서비스에 연결이 거부되어 30초 뒤 다시 시도했고 (로그의connection refused, 6편), 그사이 헬스가 SERVING 이 아니었던 것으로 보입니다(추정). - 6시간 지연은 p50·p99 모두 첫 버킷(5ms) 안이었습니다. 프로브마다 새 연결을 여는 비용이 같은 노드 안에서는 이 정도라는 뜻입니다.
- 두 프로브가 10초마다 연결을 열고 닫으므로 이 파드 하나에 하루 약 17,280번의 짧은 TCP 연결이 생깁니다(계산).
7. 한계
- 쏠림 재현(미실측). 필요한 것: 시험용 gRPC 서버 이미지(예: grpc-go 의
examples/helloworld를 빌드한 것), 레플리카 3개 디플로이먼트와 ClusterIP·헤드리스 서비스 각 하나, 부하 발생기(ghz 등). 지표로 파드별grpc_server_handled_total을 비교하면 됩니다. - 작은 클러스터에서 gRPC 서버를 여러 레플리카로 돌리는 곳이 없었습니다(Argo CD repo-server 도 1개). 그래서 실제 쏠림 사례는 측정하지 못했습니다.
- Linkerd·Envoy 의 gRPC 재시도·타임아웃 정책, Gateway API 의 GRPCRoute 는 다루지 않았습니다.
8. 작은 클러스터와의 연결
- 직접 만든 gRPC 서버를 여러 레플리카로 띄운다면 서버에 MaxConnectionAge(예: 수 분)를 먼저 넣는 것이 가장 싼 출발점입니다. 클라이언트를 고칠 수 있으면 헤드리스 + round_robin 을 더합니다.
- 헬스 서비스를 등록하고 kubelet 의
grpc프로브를 쓰면 exec 프로브(grpc_health_probe바이너리) 없이 같은 일을 합니다. exec 프로브는 CRI 의ExecSync호출을 만들어 containerd 에 부담을 줍니다(4편 4.1절의 exec_sync 가 시간당 약 11,000번).
티티 한마디. 택시를 하루 종일 전세 내면 배차원이 아무리 똑똑해도 소용없어요. 가끔 내려서(MaxConnectionAge) 다시 줄을 서야 새 택시도 탈 수 있죠!
참고
모든 자료는 2026-10-17 에 열람했습니다.
- grpc/grpc v1.84.0:
doc/load-balancing.md,doc/health-checking.md,doc/grpc_xds_features.md,doc/naming.md - gRFC: A17 Client-side Health Checking, A27 xDS-Based Global Load Balancing, A90 Health Service List Method
- grpc/grpc-go v1.83.2:
internal/resolver/dns/dns_resolver.go,clientconn.go,health/server.go,keepalive/keepalive.go - kubernetes v1.34.10
pkg/probe/grpc/grpc.go, 같은 파일의 main - 쿠버네티스 문서 Configure Liveness, Readiness and Startup Probes, 블로그 gRPC Load Balancing on Kubernetes without Tears
- Cilium: Kubernetes Without kube-proxy, Securing gRPC
- Istio 블로그 gRPC Proxyless Service Mesh
- 인용 코드: grpc-go v1.83.2·kubernetes v1.34.10, Apache License 2.0.