TT Lab
요금제

한국어

시작하기
블로그

블로그

쿠버네티스에서의 gRPC: 한 파드로 쏠리는 이유와 헬스 체크

상태: 초안 (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. 직관: 택시 승강장

Service(ClusterIP)는 택시 승강장의 배차원입니다. 손님(연결)이 오면 빈 택시(파드)를 하나 정해 줍니다. HTTP/1.1 클라이언트는 짧은 거리를 자주 타고 내리므로 배차원이 고르게 나눠 줄 기회가 많습니다. gRPC 클라이언트는 택시 한 대를 하루 종일 전세 냅니다. 그 택시 안에서 수천 번 이동(호출)해도 배차원은 처음 한 번만 관여했습니다. 택시를 열 대로 늘려도 이미 전세 낸 손님은 계속 첫 택시를 탑니다.

2. 그림

연결 단위 분산과 호출 단위 분산 ① ClusterIP(L4): 연결을 처음 열 때 한 번 고른다 클라이언트 채널 1개 서비스 IP eBPF/iptables 파드 1 · 호출 전부 파드 2 · 0 파드 3 · 0 TCP 하나가 오래 살면 새 파드가 늘어도 그 연결은 그대로다. ② 헤드리스 + 클라이언트 LB(round_robin) 클라이언트 채널 DNS: 파드 IP 3개 서브채널 3개 호출마다 고름 파드 1 · 1/3 파드 2 · 1/3 파드 3 · 1/3 새 파드는 DNS 를 다시 풀어야 보인다(grpc-go 는 연결이 끊길 때 등). ③ L7 프록시(메시·게이트웨이·xDS) 클라이언트 Envoy 등 HTTP/2 스트림 단위 파드 1 파드 2 파드 3 xDS 를 쓰면 프록시 없이 gRPC 라이브러리가 직접 설정을 받는다. 점선 상자: 놀고 있는 파드. 굵은 실선: 실제 호출이 흐르는 길.
그림: 연결 단위 분산, 헤드리스와 클라이언트 LB, L7 프록시의 비교

3. 왜 쏠리나

이 문제를 쿠버네티스 블로그가 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).

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

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

7. 한계

8. 작은 클러스터와의 연결

티티 한마디. 택시를 하루 종일 전세 내면 배차원이 아무리 똑똑해도 소용없어요. 가끔 내려서(MaxConnectionAge) 다시 줄을 서야 새 택시도 탈 수 있죠!

참고

모든 자료는 2026-10-17 에 열람했습니다.

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다