- Spring Cloud Gateway에서 커스텀 로드밸런서 설정하기
- AbstractGatewayFilterFactory
- ServiceInstanceListSupplier
- ReactorServiceInstanceLoadBalancer
Spring Cloud Gateway에서는 한 서비스 인스턴스를 여러 개 띄웠을 때, application.yml에서 lb:// 설정을 통해 라운드 로빈 방식으로 로드밸런싱을 수행할 수 있다.
spring:
cloud:
gateway:
routes:
- id: product-service
uri: lb://product-service
predicates:
- Path=/products/**
로드밸런싱을 수행할 때 알고리즘을 라운드 로빈이 아닌 가중치 기반 로드밸런싱 등 다른 방식이 필요할 수도 있다. 이를 위해서 로드밸런서를 커스텀 하는 방법을 알아보자.
과제에서는 product 서비스 인스턴스를 두 개 띄우고, 가중치를 7:3으로 설정하도록 했다.
AbstractGatewayFilterFactory는 gateway를 통해서 요청이 들어 올 경우 요청 정보를 가공하거나 필터링하기 위해 사용하는 클래스이다.
유저의 요청(exchange)시, Gateway는 요청을 처리할 때 특정 서비스로 라우팅하는 Route 정보를 활용하는데, 이 부분을 내가 원하는 특정 서비스 정보로 변경시키는 방식이다.
AbstractGatewayFilterFactory를 상속하고, apply 메서드를 Override해서 사용한다.
exchange에 로드밸런싱할 인스턴스 정보가 Route 객체에 저장되어 있는데, 이 정보를 내가 원하는 인스턴스로 변경하면 된다.
라우팅할 인스턴스 정보는 DiscoveryClient에 저장되어 있고, 그 안에서 product 서비스 인스턴스들을 찾고 7:3으로 하나를 선택하여 새로운 Route객체에 담고 exchange에 덮어씌운다.
@Component
public class CustomFilter extends AbstractGatewayFilterFactory<Object> {
@Autowired
private DiscoveryClient discoveryClient;
private final Random random = new Random();
@Override
public GatewayFilter apply(Object config) {
return (exchange, chain) -> {
// 기존 로드밸런싱 된 라우팅 정보
Route originalRoute = (Route) exchange.getAttributes().get(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
// product 서비스에 대한 라우팅 정보가 아니면 기존 라우팅 정보를 그대로 사용
if (originalRoute == null || !"product".equals(originalRoute.getId())) {
return chain.filter(exchange);
}
// product 서비스에 대한 라우팅 정보가 있으면 product 서비스의 인스턴스 목록을 조회
List<ServiceInstance> serviceInstanceList = discoveryClient.getInstances("product");
// product 서비스의 인스턴스가 2개가 아니면 기존 라우팅 정보를 그대로 사용
if (serviceInstanceList.size() != 2) {
return chain.filter(exchange);
}
// product 서비스의 인스턴스 목록을 URI 문자열로 변환하여 오름차순으로 정렬
List<String> serviceUriStringList = serviceInstanceList.stream()
.map(thisServiceInstance -> thisServiceInstance.getUri().toString())
.sorted()
.toList();
// 랜덤 객체를 사용해서 라우팅을 7 : 3 비율로 나누어 선택
String selectedServiceUriString = serviceUriStringList.get(random.nextInt(10) < 7 ? 0 : 1);
// 선택된 서비스 인스턴스로 새 라우트 객체 생성
Route route = Route.async()
.id(originalRoute.getId())
.uri(selectedServiceUriString)
.order(originalRoute.getOrder())
.asyncPredicate(originalRoute.getPredicate())
.filters(originalRoute.getFilters())
.build();
// 새 라우트 객체를 exchange 객체에 저장
exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR, route);
return chain.filter(exchange);
};
}
}
random.nextInt(10) < 7 ? 0 : 1 를 통해 7:3 비율로 로드밸런싱을 수행한다.
random.nextInt(10)에서 10개의 숫자(0~9)가 생성되고, 7보다 낮은 숫자가 0~6으로 7개이므로 70프로 확률이다. 이 경우 0이 되어 인스턴스 목록 중 첫번째 인스턴스가 요청을 받게 되고, 그 외의 경우 1이 되어 인스턴스 목록 중 두번째 인스턴스가 요청을 받게 된다.
위 클래스를 구현한 후 application.yml의 filters 속성에 추가해 준다.
spring:
cloud:
gateway:
routes:
- id: product-service
uri: lb://product-service
predicates:
- Path=/products/**
filters:
- name: CustomHeaderGatewayFilterFactory
ServiceInstanceListSupplier는 요청과 관련된 서비스 인스턴스들의 정보를 로드밸런서에게 제공하는 클래스이다.
ServiceInstanceListSupplier 빌더의 withWeighted 메서드를 이용해서 가중치를 커스텀한다.
ProductLoadBalancerInfo 클래스는 Configuration 없이 두고, Bean을 설정할 때 @Scope("prototype")로 두어 Bean이 싱글톤이 아닌 서비스마다 적용이 되도록 설정해야 한다.
productLoadBalancerConfig 클래스에서 Configuration와 LoadBalancerClient를 이용해서 서비스가 product일 때만 작동하도록 설정한다.
만약 여러 서비스에 적용을 하려면 LoadBalancerClients를 이용해서 적용할 서비스를 추가해야 한다.
public class ProductLoadBalancerInfo {
@Bean
@Scope("prototype")
public ServiceInstanceListSupplier discoveryClientServiceInstanceListSupplier(
DiscoveryClient discoveryClient,
Environment environment,
ConfigurableApplicationContext context
) {
String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
List<ServiceInstance> sortedServiceInstanceList = discoveryClient.getInstances(name).stream()
.sorted(Comparator.comparing(thisServiceInstance -> thisServiceInstance.getUri().toString()))
.toList();
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withCaching()
.withWeighted(instance -> {
if (!"product".equals(name)) {
return 1;
}
if (sortedServiceInstanceList.size() != 2) {
return 1;
}
if (instance.getUri().toString().equals(sortedServiceInstanceList.get(0).getUri().toString())) {
return 7;
}
if (instance.getUri().toString().equals(sortedServiceInstanceList.get(1).getUri().toString())) {
return 3;
}
return 1;
})
.build(context);
}
}
@Configuration
@LoadBalancerClients(value = {
@LoadBalancerClient(name = "product", configuration = productLoadBalancerInfo.class),
// @LoadBalancerClient(name = "user", configuration = productLoadBalancerInfo.class)
})
public class ProductLoadBalancerConfig {}
다른 서비스에도 적용하고 싶다면 주석처리된 코드처럼 @LoadBalancerClient 어노테이션을 추가하고, .withWeighted() 내부의 람다 함수에도 적용해야 할 것이다.
ReactorServiceInstanceLoadBalancer는 gateway 서버에서 사용하는 로드밸런서 클래스이다.
이 클래스를 상속해 특정 서비스를 사용할 경우에만 기존의 로드밸런서 대신 사용하도록 설정하는 방식이다.
CustomLoadBalancer의 대부분은 RoundRobinLoadBalancer와 유사하게 구현했고, getInstanceResponse부분만 커스텀했다.
ServiceInstanceListSupplier의 경우와 유사하게, ProductLoadBalancerInfo 클래스는 @Configuration 없이 두고 ProductLoadBalancerConfig 클래스에서 @Configuration과 LoadBalancerClient를 이용해서 서비스가 product일 때만 작동하도록 설정한다.
이 경우에도 여러 서비스에 적용을 하려면 LoadBalancerClients를 이용해서 적용할 서비스를 추가해야 한다.
ublic class CustomLoadBalancer implements ReactorServiceInstanceLoadBalancer {
private final String serviceId;
private final SingletonSupplier<ServiceInstanceListSupplier> serviceInstanceListSingletonSupplier;
private final Random random = new Random();
public CustomLoadBalancer(ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider, String serviceId) {
this.serviceInstanceListSingletonSupplier = SingletonSupplier
.of(() -> serviceInstanceListSupplierProvider.getIfAvailable(NoopServiceInstanceListSupplier::new));
this.serviceId = serviceId;
}
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
ServiceInstanceListSupplier supplier = serviceInstanceListSingletonSupplier.obtain();
return supplier.get(request)
.next()
.map(serviceInstances -> processInstanceResponse(supplier, serviceInstances));
}
private Response<ServiceInstance> processInstanceResponse(ServiceInstanceListSupplier supplier,
List<ServiceInstance> serviceInstances) {
Response<ServiceInstance> serviceInstanceResponse = getInstanceResponse(serviceInstances);
if (supplier instanceof SelectedInstanceCallback && serviceInstanceResponse.hasServer()) {
((SelectedInstanceCallback) supplier).selectedServiceInstance(serviceInstanceResponse.getServer());
}
return serviceInstanceResponse;
}
private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> instances) {
if (instances.isEmpty()) {
return new EmptyResponse();
}
if (!"product".equals(serviceId)) {
return new DefaultResponse(instances.get(random.nextInt(instances.size())));
}
if (instances.size() != 2) {
return new DefaultResponse(instances.get(random.nextInt(instances.size())));
}
List<ServiceInstance> sortedproductServiceInstanceList = instances.stream()
.sorted(Comparator.comparing(thisServiceInstance -> thisServiceInstance.getUri().toString()))
.toList();
return new DefaultResponse(sortedproductServiceInstanceList.get(random.nextInt(10) < 7 ? 0 : 1));
}
public class ProductLoadBalancerInfo {
@Bean
public ReactorLoadBalancer<ServiceInstance> reactorServiceInstanceLoadBalancer(
Environment environment,
LoadBalancerClientFactory loadBalancerClientFactory) {
String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
return new CustomLoadBalancer(
loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class),
name
);
}
@Configuration
@LoadBalancerClients(value = {
@LoadBalancerClient(name = "product", configuration = ProductLoadBalancerInfo.class),
// @LoadBalancerClient(name = "user", configuration = ProductLoadBalancerInfo.class)
})
public class ProductLoadBalancerConfig {}
다른 서비스에도 적용하고 싶다면 주석처리된 코드처럼 @LoadBalancerClient 어노테이션을 추가하고, getInstanceResponse 메서드 내부의 구현에서 추가해야 할 것이다.