본문 바로가기

카테고리 없음

[gRPC][java][k8s] client side load balancing 열심히 해보기

Problem) 왜 gRPC server 마다 부하가 다르지?

k8s 클러스터 환경에서 공통 로직을 grpc server 로 분리하여 구동하고 각기 다른 애플리케이션 client 들이 k8s service 로 묶인 endpoint 를 사용하여 grpc server 에게 요청을 보내고 있었습니다.
k8s service 로 묶여 있고 round-robin 으로 요청을 분배한다고 알고 있었기에 모든 grpc server 들이 동일한 부하를 받을 것이라 생각했습니다. 하지만, 각 grpc server 받는 부하는 달랐으며 client 와 맺은 connection 의 개수도 약간 다름을 metric 을 통해 확인했고 부하를 많이 받는 grpc server 에 연결되 애플리케이션 서버에서 지연이 발생하였습니다.
 

Why) connection 재사용

grpc 는 기본적으로 HTTP/2 프로토콜 상에서 동작하고 keepalive ping 을  통하여 connection 을 재사용할 수 있게 합니다. 또한, streaming RPC 를 사용하여 RPC 시작 과정 등을 반복하지 않도록 하며 HTTP/2 자체에서 multiplexing 을 지원하기 때문에 병렬로 요청을 보낼 수도 있습니다. gRPC Docs
위와 같은 문제가 발생한 것은, 이러한 grpc 의 특성으로 단일 HTTP 요청들이 k8s service 에 의해 connection-level 에서 자동으로 부하분산 되던 메커니즘을 타지 못하고 heavy load 애플리케이션 client 에 연결된 grpc server 는 다른 server 들보다 큰 부하를 감당하고 있던 것으로 추정하였습니다.

 
 
Image source: gRPC: Up and Running

What To Do) gRPC Load Balancing

grpc blog Load Balancing 글을 보면 아래와 같이 gRPC 에서 Load Balancing 을 소개하고 있습니다.

Proxy

Envoy 와 같은 grpc aware 한 proxy 서버L7 layer 의 LB 서버로 사용하여 client 는 proxy 서버를 보도록 하여 각 요청별로 부하분산 되도록 하는 방법입니다. reference
client 입장에서 이러한 backend 구조에 대해서 몰라도 되므로 간단하다는 장점이 있지만, LB 를 따로 관리해주어야 하고 LB 로 인한 latency 가 있을 수 있습니다.

Image source: https://svkrclg.medium.com/grpc-load-balancing-using-envoy-e8972214da2c

Client side 

client 자체적으로 load balancing 을 해주는 것으로 dns 로 부터 서버 목록을 받아오면 client 가 가진 prick first, round robin 등의 로직을 사용하여 요청을 분산해서 보내주는 방식입니다.
LB 의 latency 등이 extra hop 이 없어 빠르다는 장점이 있지만, client side 에서 이러한 LB 로직을 구현해야 하는 단점이 있습니다. 또한, server load 에 대한 고려 없이 요청을 client 입장에서만 부하분산 한다면, 원하는 결과가 나오지 않을 수도 있습니다.

Look aside

이해하기 어려웠던 방법인데요. look aside 방식은 client 에서는 단순한 round robin 과 같은 server selection 알고리즘만 가지고 있으며, 요청해도 되는 server 리스트는 load balancing 을 책임지는 독립적인 LB 를 통해서 받아오는 개념입니다. look aside LB 가 backend server 와 소통 및 health check 등의 책임을 가지고 있습니다.
기존에는 grpc 자체적으로 만든 grpclb 라이브러리를 사용하여 해당 방식을 사용했지만, 현재는 deprecated 되었고 xDS 프로토콜을 사용하도록 권장합니다. reference

 

xDS 프로토콜?

envoy 에서 동적 Proxy, LB 구성을 위해 만든 프로토콜로 현재는 cncf/xds 에서 data plane proxy 인 envoy 를 넘어서, service mesh, mobile client 등 다양한 클라이언트를 지원하고자 함. grpc 에서는 xDS API 를 제공하는 Istio Pilot, go-control-plane and java-control-plane다양한 플랫폼과의 호환을 위해 grpclb 를 deprecated 하고 xDS 를 선택

Client side load balancing in k8s

현재 문제가 된 grpc server 를 cluser 내에서만 사용합니다.
각 애플리케이션 client 에서 grpc client 구성을 번잡하게 하지 않아도 되도록 client 를 위한 service library 를 maven 을 통해 배포하여 사용하도록 합니다.
따라서 client 에 대한 적당한 통제권이 있고, 독립적인 LB 를 구성하여 관리 포인트를 넓히는 것은 비용이라고 생각되어 간단하게 코드 수정으로 문제를 해결하고자 하여 client side 를 사용하도록 하였습니다.

headless service

grpc client side LB 를 위해서는 DNS 를 통해 server 리스트를 받아와야 합니다.
k8s headless Service 는 clusterIP 를 None 으로 지정하면 생성할 수 있습니다. headless Service 로 DNS lookup 을 수행하게 되면 해당 Service 에 연결돼 모든 Pod 의 레코드를 반환해줍니다. 

apiVersion: v1
kind: Service
metadata:
  name: grpc-test-server
spec:
  clusterIP: None
  selector:
    app: grpc-test-server
  ports:
    - name: grpc
      port: 8080
      targetPort: 8080
kubectl exec dnsutils --  nslookup grpc-test-server
Server:         10.96.0.10
Address:        10.96.0.10#53

Name:   grpc-test-server.default.svc.cluster.local
Address: 10.244.0.147
Name:   grpc-test-server.default.svc.cluster.local
Address: 10.244.0.155

 
이제 grpc client channel 을 생성할 때, defaultLoadBalancingPolicy 로 LB 를 설정해주면 client LB 가 동작합니다. 

ManagedChannel channel = Grpc.newChannelBuilder("grpc-test-server.default:8080", InsecureChannelCredentials.create())
            .defaultLoadBalancingPolicy("round_robin")
            .build();

 

Metric 확인

cluster 에 prometheus, grafana helm 을 설치하고 serviceMonitor 가 수집한 metrics 을 통해 정상 동작하는지 확인해보겠습니다.  (grpc server 는 손쉽게 metric 노출 및 connection metric 을 위해 armeria 로 감싸서 띄웠습니다.)
 
client 1대 / server 2대 인 상황에서
client LB 적용 전에는 server 1대로만 요청이 가는 것을 볼 수 있습니다.

client LB 적용 전

 
client LB 를 적용하고 나서는 2대 고루 요청이 들어가고 있습니다.

client LB 적용 후

Plus) Periodical refresh of DnsNameResolver for Scaling

만약 server 모두 받는 부하가 커져 Scale up 을 한다면, 이때도 LB 는 새로운 서버에게 고르게  부하분산을 해줄까요?

server scale up

 
새로 투입된 server 로는 요청이 가지 않는 것을 볼 수 있습니다.
이유를 서치하다가 grpc-java 에서 관련된 이슈를 찾을 수 있었습니다.

내용은 grpc 에서 사용하는 DnsNameResolver 가 connection 이 실패할 경우에만, re-resolve 하고 주기적으로 refresh 하지 않으므로 scale up 과 같이 단순 서버 추가는 인지하지 못한다는 글입니다. 해당 이슈는 PR 없이 Close 되었는데요. 단지 새로운 backend 를 알아내기 위해서 주기적으로 re-resolve 한다면 새로운 handshake 로 인한 CPU overhead 등 hack 이 될 수 있어서라고 하는 것 같습니다.
 
grpc 공식문서에서도 Custom Name Resolver 에 대한 이야기가 나옵니다. scale up / down 과 같이 backend server 에 reactive 하게 업데이트하길 원한다면, Custom Name Resolver 를 구현하여 적용할 수 있다고 합니다.

Plus) Custom Name Resolver

기존에 사용하던 DnsNameResolver 를 상속하고 주기적으로 re-resolve 하는 Custom Name Resolver 를 만들어 적용해보고 생각처럼 동작하는지 확인해보겠습니다.

public class CustomDnsNameResolver extends DnsNameResolver {

    private final ScheduledExecutorService scheduledExecutorService;
    private ScheduledFuture<?> refreshTask;

    public CustomDnsNameResolver(
            @Nullable String nsAuthority,
            String name,
            Args args,
            SharedResourceHolder.Resource<Executor> executorResource,
            Stopwatch stopwatch,
            boolean isAndroid) {
        super(nsAuthority, name, args, executorResource, stopwatch, isAndroid);
        this.scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();

        // Schedule the initial DNS resolution
        scheduleRefresh();
    }

    @Override
    public void refresh() {
         super.refresh();

        // Schedule the next refresh
        scheduleRefresh();
    }

    private void scheduleRefresh() {
        // Schedule the refresh task with your desired interval
        refreshTask = scheduledExecutorService.schedule(this::refresh, 1, TimeUnit.MINUTES);
    }

    @Override
    public void shutdown() {
        // Stop the scheduled refresh task
        if (refreshTask != null) {
            refreshTask.cancel(true);
        }

        super.shutdown();
    }
}

 
gpt 의 도움을 받아 계속해서 re-resolve 하는 CustomDnsNameResolver 를 만들고 Provider 로 감싸 등록해줍니다.

    NameResolverProvider customNameResolverProvider = new NameResolverProvider() {
      @Override
      protected boolean isAvailable() {
        return true;
      }

      // priority 가 높아야 같은 scheme 이라도 우선 적용
      @Override
      protected int priority() {
        return 6;
      }

      @Override
      public String getDefaultScheme() {
        return "dns";
      }

      @Override
      public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) {
        if ("dns".equals(targetUri.getScheme())) {
          String targetPath = Preconditions.checkNotNull(targetUri.getPath(), "targetPath");
          Preconditions.checkArgument(targetPath.startsWith("/"),
                  "the path component (%s) of the target (%s) must start with '/'", targetPath, targetUri);
          String name = targetPath.substring(1);
          return new CustomDnsNameResolver(
                  targetUri.getAuthority(),
                  name,
                  args,
                  GrpcUtil.SHARED_CHANNEL_EXECUTOR,
                  Stopwatch.createUnstarted(),
                  false);
        } else {
          return null;
        }
      }
    };

    NameResolverRegistry.getDefaultRegistry().register(customNameResolverProvider);

    ManagedChannel channel = Grpc.newChannelBuilder("dns://grpc-test-server.default:8080", InsecureChannelCredentials.create())
            .defaultLoadBalancingPolicy("round_robin")
            .build();

 
이제 server scale up 으로 새로 서버을 투입하면 해당 서버를 포함해서 부하분산해주는 것을 확인해볼 수 있었습니다.

 

참고

https://svkrclg.medium.com/grpc-load-balancing-using-envoy-e8972214da2c  
https://citymall.engineering/redefining-grpc-load-balancing-the-power-of-custom-dns-in-kubernetes-126ecc3cfb6c  https://majidfn.com/blog/grpc-load-balancing/   
https://grpc.io/blog/grpc-load-balancing
https://github.com/cncf/xds         
https://techdozo.dev/grpc-load-balancing-on-kubernetes-using-headless-service/
https://limm-jk.tistory.com/71