개요
on-premise 환경에서 java, spring 기반으로 MSA를 구축한다면 Eureka Server, client를 사용해 플랫폼 환경을 구성하는 것이 일반적이다. 그런 와중에 spring cloud reactive gateway를 사용한다면 서비스 디스커버리 기능 통합, 코드 없는 configuration properties 기반의 각종 유용한 설정들을 더 적은 수의 인스턴스(netty 기반 서버)로 운영할 수 있다는 기대감을 준다. 그런 기대감으로 회사 서버에서 실제로 운영해보니 치명적인 다가왔던 문제점들이 있었다.
문제점 1
첫 번째로 리액티브 게이트웨이 사용시 클라이언트 요청 -> 로드밸런서 -> 게이트웨이 서버로의 커넥션이 간헐적으로 CLOSE_WAIT로 빠지는 현상이 있었다.
이 문제는 검색해보니 어떤 컨텐츠에서 유사한 내용의 이슈에 대해 다뤘었고 게이트웨이 서버에서 FIN + ACK 신호를 로드밸런서에서 받지 않아 무한정으로 대기하는 현상같았다. 이 문제로 인해 서버의 가용 소켓 수가 점점 고갈되고 있었고 원인을 알지 못하면 주기적으로 리스타트를 하는 수 밖에 없었다. 해결방법으로는 netty 기반 서버에서 발생할 수 있는 문제로 톰캣과 다르게 기본적으로 설정돼있지 않은 server.netty.idle-timeout 설정으로 close_wait 커넥션을 종료시킬 수 있었다.
문제점2
두번째 문제점으로 또 다른 문제로 리액티브 게이트웨이 + 유레카 클라이언트 (로드밸런싱, 서비스 디스커버리) 조합에서 CLOSE_WAIT 커넥션이 쌓이는 현상이 있었다. 이것은 위 케이스보다는 빈도는 더 적지만 꾸준히 쌓이고 있다는 점에서 해결해야 할 문제점이었다. 해당 문제로는 깃헙 이슈에서 언급이 되었고 꽤 오랫동안 해결되지 않고 임시방편으로만 해결 방법이 나와 있었다. 추정 원인으로는 유레카 서버를 호출하기 위한 httpClient가 DefaultEurekaClientHttpRequestFactorySupplier 라는 객체 메소드에서 생성이 되는데 idle-timeout이 설정되지 않아 커넥션 close가 정상적으로 되지 않은 것이 쌓인것으로 보였다. 해당 문제는 깃헙 링크에서 사용자가 언급한 해결 방법으로 말한 다음 코드를 적용하면 되었다.
private final AtomicReference<CloseableHttpClient> ref = new AtomicReference<>();
@Bean
public EurekaClientHttpRequestFactorySupplier defaultEurekaClientHttpRequestFactorySupplier() {
return (sslContext, hostnameVerifier) -> {
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
HttpClientBuilder httpClientBuilder = HttpClients
.custom()
.evictExpiredConnections()
.evictIdleConnections(30L, TimeUnit.SECONDS);
if (sslContext != null) {
httpClientBuilder = httpClientBuilder.setSSLContext(sslContext);
}
if (hostnameVerifier != null) {
httpClientBuilder = httpClientBuilder.setSSLHostnameVerifier(hostnameVerifier);
}
if (ref.get() == null) {
ref.compareAndSet(null, httpClientBuilder.build());
}
requestFactory.setHttpClient(ref.get());
return requestFactory;
};
}
문제점3
마지막 문제점으로 위 스택에 컨피그 클라이언트(라우팅 정보 Refresh 용도)를 추가한 조합으로 사용할 때도 CLOSE_WAIT 커넥션이 쌓이는 문제가 있었다. 이해를 돕기 위해 다음 그림을 보자.
위 그림을 보면 가장 먼저 스케쥴러에 의해 유레카 클라이언트의 유레카 서버 인스턴스를 찾아오는 eureka client 동작이 실행된다.
그 때, 유레카 컨피그가 실행되는 이벤트가 발행되고 DiscoveryClient를 통한 컨피그 동기화를 위한 eureka config client의 컨피그 서버로의 api 호출이 발생한다. 유레카 컨피그 클라이언트에서도 자체적으로 호출을 위한 httpClient를 사용하는데 이 httpClient가 문제였었다.
해당 문제 소스를 보자.
public class EurekaConfigServerBootstrapper implements BootstrapRegistryInitializer {
@Override
public void initialize(BootstrapRegistry registry) {
if (!ClassUtils.isPresent("org.springframework.cloud.config.client.ConfigServerInstanceProvider", null)) {
return;
}
// It is important that we pass a lambda for the Function or else we will get a
// ClassNotFoundException when config is not on the classpath
registry.registerIfAbsent(ConfigServerInstanceProvider.Function.class, EurekaFunction::create);
}
private static Boolean getDiscoveryEnabled(Binder binder) {
return binder.bind(ConfigClientProperties.CONFIG_DISCOVERY_ENABLED, Boolean.class).orElse(false)
&& binder.bind("eureka.client.enabled", Boolean.class).orElse(true)
&& binder.bind("spring.cloud.discovery.enabled", Boolean.class).orElse(true);
}
final static class EurekaFunction implements ConfigServerInstanceProvider.Function {
private final BootstrapContext context;
static EurekaFunction create(BootstrapContext context) {
return new EurekaFunction(context);
}
private EurekaFunction(BootstrapContext context) {
this.context = context;
}
@Override
public List<ServiceInstance> apply(String serviceId, Binder binder, BindHandler bindHandler, Log log) {
if (binder == null || !getDiscoveryEnabled(binder)) {
return Collections.emptyList();
}
EurekaClientConfigBean config = binder.bind(EurekaClientConfigBean.PREFIX, EurekaClientConfigBean.class)
.orElseGet(EurekaClientConfigBean::new);
EurekaHttpClient httpClient = new RestTemplateTransportClientFactory(
context.getOrElse(TlsProperties.class, null),
context.getOrElse(EurekaClientHttpRequestFactorySupplier.class,
new DefaultEurekaClientHttpRequestFactorySupplier()))
.newClient(HostnameBasedUrlRandomizer.randomEndpoint(config, binder));
return new EurekaConfigServerInstanceProvider(httpClient, config).getInstances(serviceId);
}
@Override
public List<ServiceInstance> apply(String serviceId) {
// This should never be called now but is here for backward
// compatibility
return apply(serviceId, null, null, null);
}
}
}
이 클래스 외부에서 컨피그 서버 인스턴스를 가져오기 위해 Function.apply를 실행하게 되는데, DefaultEurekaClientHttpRequestFactorySupplier 코드에서 idle-timeout이 설정되지 않아 커넥션이 끊어지더라도 서버가 인식하지 못하면 커넥션이 살아있는 문제가 있었다.
두 번째 문제의 해결 방법에서는 유레카 클라이언트가 해당 Bean을 사용하기 때문에 해결이 되었지만, 이 경우에는 별도의 객체를 사용하고 있었기 때문에 처음에는 이 문제를 해결하기 위해 Supplier 객체에 두 번째 문제 해결 방법 내용을 적용하고 라이브러리를 빌드해 사용해보았다.
그랬더니 close_wait 커넥션은 사라졌지만 Thread-N 이름을 가진 스레드가 점점 쌓이는 문제가 발생했다.
디버깅으로 확인해보니 위 코드에서 EurekaHttpClient 객체를 생성할 때 부트스트랩 컨텍스트에 EurekaClientHttpRequestFactorySupplier가 있는지 확인하고, 없으면 DefaultEurekaClientHttpRequestFactorySupplier 객체를 새로 생성한다. 그래서 HttpClient를 가진 새로운 Supplier 객체가 매번 생성됐다. 게다가 idle-timeout을 적용하게 되면 http client에서 커넥션 사용 유무를 체크하기 위해 IdleConnectionEvictor 객체를 통해 새 스레드로 커넥션을 체크하게 된다. 이 스레드는 Supplier 객체가 매번 컨피그 서버 인스턴스를 조회할 때마다 생성되어 쌓이고 있었다. 이를 해결하기 위해 임시방편으로 EurekaFunction 객체의 상태값으로 해당 Supplier를 설정하고 컨텍스트에서 조회하는 구문을 지우고 해당 값을 사용하도록 바꾸었다.
위 문제점을 가진 소스들은 몇 년정도 된 이슈같지만 소스를 찾아보니 아직 해결하지 못한 버그인 모양이었다. 그래서 spring-cloud-netflix-eureka-client, spring-cloud-starter-gateway를 의존성으로 사용하는 프로젝트를 개발할 때 유레카 클라이언트, 컨피그 클라이언트를 사용하면 해당 이슈가 있다는 것을 인지하고, 같은 문제가 발생한다면 위 해결 방법을 사용해보도록 하자.
참고 자료
- https://medium.com/@avocadi/spring-cloud-gateway-and-connection-leak-5831293ef527
- https://github.com/spring-cloud/spring-cloud-netflix/issues/4103
'개발 > spring' 카테고리의 다른 글
[Java/Spring] JVM Warm up (0) | 2024.03.25 |
---|---|
[Spring] pojo 기반의 설정 객체 사용 방법 (0) | 2024.02.18 |
[spring] application event 사용해보기 (1) | 2024.02.05 |
[Spring/MongoDB] MongoDB에 Entity를 저장할 때 필요 없는 필드 제외하기 - 2 (0) | 2021.09.01 |
[Spring/MongoDB] MongoDB에 Entity를 저장할 때 필요 없는 필드 제외하기 (0) | 2021.06.22 |