Featured image of post gRPC Load Balancing in Kubernetes

gRPC Load Balancing in Kubernetes

Overview

Kubernetes에서 gRPC를 사용하는 MSA기반의 솔루션이 있는데, 로드 밸런싱이 안 된다는 문제를 전달 받았다.
자세한 내용을 보기 전에, 우리에게 맞는 해결 방법을 위해 고민했던 내용을 남겨보고자 한다.

다시 언급하겠지만, 해결 방법은 3가지가 있다.

  1. ClientSide Load Balancing
  2. L7 Load Balancing
  3. Service Mesh

우선, 3번 Service mesh는 고려사항에서 제외하였다.

  1. 구축을 하더라도 우리 조직과 고객사에서 안정적으로 관리하기 위한 인력이 있는가?
  2. 우리가 서비스 운영을 하지 않고 있음ㅠㅠ
  3. 결론적으로 사이드 문제가 클 것으로 보임

처음에는, 1번 Client Side로 마음이 기울었었다.

  1. 프록시로 인한 레이턴시 없음
  2. 추가적인 리소스(pod)가 필요 없음
  3. 고객사에서는 K8s Headless Service로만 변경하면 끝

다시 한번 마음이 바뀌었는데, 2번 L7 LB가 최종 선택이었다.

  1. Client Side는 앱(client & server), 인프라 모두 수정이 필요하기에, 다른 동료가 파악하기 어려움.
  2. Client Side는 server에서 적절한 ‘MaxConnectionAge’을 찾아서 설정해야 함.
  3. 인프라에서만 수정하는게 추후 유연성이 높음 e.g. service mesh

사실, 자주 나오던 서비스 메시~!! 를 해보고 싶었지만, 이건 개인 프로젝트에서 하기로 생각하며.. 다음에 적용해보고 여기에 돌아오자.

Tricky Load Balancing

Client가 gRPC 서버로 Connection을 맺은 후, 서버의 cpu/memory 사용량이 증가하면서 auto scaling policy으로 새 pod가 생성된다. 그러나, 새 pod에 트래픽이 전달되지 않는 것을 볼 수 있는데, 이는 gRPC 글에서 얘기한 Multiplexing으로 인한 것이다.
이를 해결하기 위한 방법으로 크게 3가지 방법이 있다. 상황에 맞는 방식을 선택하는게 필요하며, 이번에는 1, 2번을 적용해보고 다음에 Service Mesh를 적용해보자. (성능은 proxyless가 가장 좋다)

  1. Client-side Load Balancing
  2. L7 Load Balancing
  3. Service Mesh
    • proxy
    • proxyless (xds)

Tricky Load Balancing

(https://kubernetes.io/blog/2018/11/07/grpc-load-balancing-on-kubernetes-without-tears/)

Client-side Load Balancing

gRPC는 Client-side Load Balancing 기능을 제공하는데,pick_fist, round_robin 2가지 방식이 있다.
Defaultpick_first인데, 말 그대로 resolver에서 주소 리스트를 받아 첫 번째 주소로 연결을 시도하고, RPC들은 Channel을 통해 보내진다. 연결이 끊길 경우, backkoff로 다시 주소 리스트에서 연결 가능한 곳으로 connection을 맺게 된다.
round_robin은 resolver로부터 주소 리스트를 받아서, 각 주소에 대해 subchannel을 생성하고, 연결이 끊기면 다시 연결을 시도한다.
(https://github.com/grpc/grpc/blob/master/doc/load-balancing.md#load-balancing-policies)

아래에서 pick first, round robin 의 예시를 볼 수 있다.
(https://github.com/grpc/grpc-go/blob/master/examples/features/load_balancing/README.md)

this is pick first (from :10001)
this is pick first (from :10001)
this is pick first (from :10001)
...

this is round robin (from :10001)
this is round robin (from :10002)
this is round robin (from :10003)
...

위의 예시를 테스트해보면 Round-robin 설정으로 여러 서버에 로드밸런싱이 되는 것을 확인할 수 있다. 그러나 Kubenetes에서 추가로 필요한 작업들이 몇 가지 있다.

위의 예제에서는 서버 IP를 가지고 Custom resolver을 사용했지만, 나는 Kubernetes Service 도메인으로부터 Pods의 IP 주소들을 가져오려고 한다. gRPC의 기본 resolver를 사용할 경우, Default는 passthrough방식인데, 이는 Client가 Service 이름에서 여러 IP 주소를 얻을 수 없기에, dns scheme를 기본으로 설정해줘야 한다.

그럼, 계속 이어서 다음으로 필요한 내용들을 보자.
Kuberntes의 Service 레이어는 Client 요청을 L4 레벨 로드밸런싱으로 Service에 연결된 Pods 중 하나로 전달해준다.
Client에서 Lound-robin방식으로 설정한다 해도 이 서비스 레이어의 L4, persistent connection으로 인해, 한 Client의 요청은 하나의 Pod로만 전달되고, 다른 replica pods들은 트래픽을 받지 못하게 된다.

Kubernetes Service

(https://kubernetes.io/docs/tutorials/kubernetes-basics/expose/expose-intro/)

그럼, 어떻게 하면 될지 계속 살펴보자.
Kubernetes는 Headless ServiceService에 대한 단일 IP가 아닌, 각 포드 IP에 대한 다중 A 레코드를 생성할 수 있는데, 이를 통해 Client가 직접 replica Pods에 대한 주소 리스트를 받아서 로드밸런싱을 할 수 있다.

그런데 … 여기서 문제가 하나 더 있다. autoScaling 설정으로 사용자 트래픽이 증가하면서 생성된 새 Pods에는 요청이 전달되지 않는 것이다. 왜냐하면, Client가 Resolver로부터 주소 리스트를 받아서 subchannels를 생성한 후에, 서버로 연결이 끊어져 실패하기 전까지 새로운 주소로 연결을 시도하지 않기 때문이다. 그래서, MaxConnectionAge를 설정하여 일정시간이 지나면 재연결을 하는 방법이 있는데, 설정 값에 따라 pod가 새로 생성된 후 트래픽이 전달되기까지는 시간이 걸릴 것이다.

이제, 위 내용을 토대로 Kubernetes에서 Client Load Balancing 예시를 보겠다.

Client

resolver.SetDefaultScheme("dns")

conn, err := grpc.Dial(
    "<service-name><namespace>.svc.cluster.local",
	grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`),
    ...
)

Server

server := grpc.NewServer(
    grpc.KeepaliveParams(keepalive.ServerParameters{MaxConnectionAge: 1 * time.Minute, MaxConnectionAgeGrace: 30 * time.Second}),
    ...
)

Kubernetes

kind: Service
spec:
  clusterIP: None
...

L7 Load Balancer

gRPC는 TCP connection을 유지하기에, Kubernetes Service의 L4가 아닌 L7 Load Balancer가 따로 필요하다.
L7 로드밸런싱을 제공하는 여러 Load Balancer가 있는데, 그 중 Traefik으로 진행해본다.

Application Load Balancer

(https://kubernetes.io/blog/2018/11/07/grpc-load-balancing-on-kubernetes-without-tears/)

서비스에 대한 manifest 파일을 클러스터에 적용하고, 오토스케일링을 위해 metrics-server도 배포해준다.
그 다음, Traefik도 알맞게 설정 후 적용한다.
아래에서는, test-a, test-b, test-c gRPC 서버들이 있고, client에서 요청을 전달한다고 가정한다.

kubectl apply -f metrics-server.yaml
kubectl apply -f test-a.yaml -f test-b.yaml -f test-c.yaml

helm repo add traefik https://helm.traefik.io/traefik
helm repo update
helm search repo traefik/traefik --versions
helm show values traefik/traefik --version <version> traefik-values.yaml
# traefik-values 파일을 수정해서, 설정 오버라이딩
helm install traefik-l7-lb traefik/traefik --version <version> -n dongle -f traefik-values-override.yaml
kubectl apply -f traefik-route.yaml

test-a

kind: Service
metadata:
    name: test-a-service
    namespace: dongle
spec:
    type: ClusterIP
    selector:
        app.kubernetes.io/name: test-a
    ports:
        - protocol: TCP:
            port: 10001
            targetPort: 10001
...
---
kind: Deployment
metadata:
  name: test-a
  namespace: dongle
spec:
  replicas: 1
...
---
kind: HorizontalPodAutoscaler
metadata:
  name: test-a-hpa
  namespace: dongle
spec:
  scaleTargetRef:
    kind: Deployment
    name: test-a
  minReplicas: 1
  maxReplicas: 3
...

traefik-values-override

# https://github.dev/traefik/traefik-helm-chart/blob/master/traefik/templates/service.yaml
# https://github.com/traefik/traefik-helm-chart/blob/master/traefik/VALUES.md#values
# https://github.com/traefik/traefik-helm-chart/blob/master/traefik/VALUES.md

ports:
  test-a:
    port: 10001
    expose: true
    exposedPort: 10001
    protocol: TCP
  test-b:
    port: 10002
    expose: true
    exposedPort: 10002
    protocol: TCP
  test-c:
    port: 10003
    expose: true
    exposedPort: 10003
    protocol: TCP

service:
  type: LoadBalancer

traefik-route

# https://doc.traefik.io/traefik/routing/providers/kubernetes-crd/#kind-ingressroute
# https://doc.traefik.io/traefik/user-guides/grpc/

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: test-a-ingress-route
  namespace: dongle
spec:
  entryPoints:
    - test-a
  routes:
    - match: PathPrefix(`/`)
      kind: Rule
      services:
        - name: "test-a-service"
          port: 10001
          scheme: h2c

---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: test-b-ingress-route
  namespace: dongle
spec:
  entryPoints:
    - test-b
  routes:
    - match: PathPrefix(`/`)
      kind: Rule
      services:
        - name: "test-b-service"
          port: 10002
          scheme: h2c
---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: test-c-ingress-route
  namespace: dongle
spec:
  entryPoints:
    - test-c
  routes:
    - match: PathPrefix(`/`)
      kind: Rule
      services:
        - name: "test-b-service"
          port: 10003
          scheme: h2c