IT/AWS

AWS NLB로 gRPC 라우팅, ALPN 정책 한 줄을 안 넣으면 어떻게 깨지나

gfrog 2026. 5. 15. 21:43
반응형

ALB의 gRPC 지원은 꽤 알려져 있다. Target group protocol version을 GRPC로 바꾸고, health check 경로를 /grpc.health.v1.Health/Check로 잡으면 끝. 근데 사내에서 ALB를 안 쓰고 NLB로 가야 하는 상황이 생긴다. 클라이언트가 mTLS를 끝단까지 가져가야 하거나, ALB로는 못 받는 별난 트래픽이 섞여 있거나, 비용 문제거나.

이 글은 그 NLB + gRPC 조합에서 우리 팀이 며칠 헤맨 얘기를 정리한 거다. 결론부터 말하면 ALPN 정책 한 줄이 빠지면 TLS handshake는 되는데 gRPC만 안 된다. 로그도 별 게 안 남는다.

NLB가 gRPC를 "지원"한다는 말의 의미

NLB는 L4다. HTTP/2 프레임을 해석하지 않는다. 그래서 "gRPC를 지원한다"는 표현이 좀 헷갈리는데, 정확히는 TLS handshake에서 ALPN으로 h2를 협상해주고, 그 위로는 그대로 통과시킨다는 뜻이다. ALB처럼 메서드 단위로 라우팅하거나 status code 기반 health check를 해주지는 않는다.

그래서 NLB 앞단에서 gRPC가 동작하려면 두 가지가 같이 맞아야 한다.

  1. NLB listener의 ALPN policy가 h2를 협상하도록 설정돼 있을 것
  2. 백엔드(보통 envoy, nginx, 혹은 애플리케이션 자체)가 h2(또는 h2c)로 받을 준비가 돼 있을 것

둘 중 하나만 빠지면 증상이 미묘하게 다르게 나온다.

우리가 만난 증상

처음에 EKS에 올린 gRPC 서비스를 AWS Load Balancer Controller로 NLB에 연결했다. Service annotation은 이렇게:

service.beta.kubernetes.io/aws-load-balancer-type: external
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
service.beta.kubernetes.io/aws-load-balancer-listener-ssl-negotiation-policy: ELBSecurityPolicy-TLS13-1-2-2021-06

grpcurl로 외부에서 호출해봤다. TLS handshake는 성공. 그런데 RPC 호출은:

Failed to dial target host "api.example.com:443": context deadline exceeded

이게 묘한 게, curl로 HTTPS를 때리면 응답이 온다(백엔드가 h2c를 받게 돼 있어서 일반 HTTPS는 안 받지만 connection은 되는 식). 즉 L4까지는 멀쩡한데 gRPC client만 막힌다.

원인은 ALPN. 위 annotation에는 ALPN policy가 없다. 기본값은 None이다. NLB가 ALPN 협상을 안 해주면 클라이언트는 h2가 합의됐는지 확신할 수 없고, gRPC client(grpc-go, grpc-java 등)는 보통 h2 협상이 명시적으로 안 되면 INTERNAL: connection error로 떨어진다.

고친 방법

annotation 한 줄 추가:

service.beta.kubernetes.io/aws-load-balancer-alpn-policy: HTTP2Preferred

값은 HTTP2Preferred 또는 HTTP2Only를 쓴다. 차이는 이렇다.

  • HTTP2Preferred: 클라이언트가 h2를 제시하면 h2로, 아니면 http/1.1로 fallback. 같은 NLB로 HTTPS와 gRPC를 둘 다 받을 때 유용.
  • HTTP2Only: 무조건 h2. 클라이언트가 h2를 안 제시하면 handshake가 깨진다. gRPC 전용 NLB라면 이걸로.

우리는 gRPC 외에 다른 HTTPS 트래픽도 같은 NLB로 받고 있어서 HTTP2Preferred로 갔다. 이거 하나 넣고 다시 배포하니 grpcurl이 바로 떨어졌다.

Health check, 이게 또 별개

NLB는 L4라서 ALB처럼 /grpc.health.v1.Health/Check를 HTTP로 호출해주지 못한다. 옵션은 두 가지다.

# 옵션 1: TCP health check (기본)
service.beta.kubernetes.io/aws-load-balancer-healthcheck-protocol: TCP
service.beta.kubernetes.io/aws-load-balancer-healthcheck-port: "8080"

TCP는 단순히 포트가 열려있는지만 본다. 빠르고 안전한데, 애플리케이션이 살아있는지 정확히 알 수 없다.

# 옵션 2: HTTPS health check를 별도 경로로
service.beta.kubernetes.io/aws-load-balancer-healthcheck-protocol: HTTPS
service.beta.kubernetes.io/aws-load-balancer-healthcheck-path: /healthz
service.beta.kubernetes.io/aws-load-balancer-healthcheck-port: "8080"

이건 백엔드에 별도 HTTP/HTTPS health endpoint가 있어야 한다. gRPC만 노출하는 서비스라면 envoy를 사이드카로 띄워서 admin 포트의 /ready를 쓰거나, 애플리케이션에 healthz 핸들러를 같이 박는 식으로 풀었다.

우리 팀은 결국 옵션 2로 갔다. TCP health check는 프로세스가 좀비처럼 listen만 하고 실제 요청을 못 받는 상태를 못 잡았기 때문이다. 한 번 데였다.

트러블슈팅에 도움 됐던 명령들

ALPN 협상이 실제로 일어났는지는 openssl로 확인할 수 있다.

echo | openssl s_client -alpn h2 -connect api.example.com:443 2>/dev/null \
  | grep -i "ALPN"

ALPN policy가 None이면 No ALPN negotiated가 떨어진다. 정상이면 ALPN protocol: h2가 보인다.

gRPC 자체는 grpcurl로 확인.

grpcurl -insecure api.example.com:443 list
grpcurl -insecure api.example.com:443 grpc.health.v1.Health/Check

정리

NLB로 gRPC 받을 때 빼먹기 쉬운 거 세 가지를 다시 적자면:

첫째, ALPN policy. 이걸 안 넣으면 gRPC client만 골라서 깨진다. HTTP2Preferred가 무난하다.

둘째, health check 종류. TCP면 빠르지만 정확하지 않고, HTTPS면 정확하지만 endpoint를 따로 둬야 한다. 운영 환경에서는 HTTPS 쪽이 결국 마음 편하다.

셋째, 백엔드가 h2를 받게 돼 있는지. 애플리케이션 직결이면 보통 잘 맞는데, envoy/nginx 같은 프록시를 통하면 upstream protocol을 h2로 명시했는지 다시 확인할 것.

근데 한 가지 더 - 이렇게 NLB로 gRPC 풀어놓으면 메서드 단위로 라우팅하거나 weighted target으로 카나리하는 건 어려워진다. 그게 필요하면 결국 ALB로 돌아가거나, NLB 뒤에 envoy를 두는 패턴으로 간다. 우리는 후자로 가고 있는데, 이건 다음 글에서 정리해보려고 한다.

반응형