이 글은 Java/Spring Boot 기반 서비스를 운영하며 Thread 누수 문제를 분석하고 해결한 과정을 기록한 글입니다.
1. 문제 발생
Caused by: java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
at java.base/java.lang.Thread.start0(Native Method)
at java.base/java.lang.Thread.start(Thread.java:1526)
at java.base/java.util.Timer.<init>(Timer.java:188)
java.lang.OutOfMemoryError: unable to create native thread
위 에러는 Java에서 너무 많은 Thread를 생성하여 더 이상 Thread를 생성할 수 없을 때 발생하는 에러이다.
갑자기 API 호출이 계속해서 실패하여 로그를 확인해보니 위 에러가 발생하고 있었다.
이상했다. 여태 이상없이 잘 돌고 있던 서비스에서 갑자기 OOM이 간헐적으로 터졌다.
로드 밸런싱 처리도 되어있고 트래픽도 높지 않았다.
원인이 무엇일까?
2. 원인 분석
우선 Thread 생성 관련 OOM이 발생했다는 것은 Thread가 점진적으로 계속 생성만 되고 종료되지 않는다는 뜻이다.
정확한 원인 분석을 위해 VisualVM을 통해 활성 Thread를 모니터링 했다.
(문제가 발생했을 당시 Thread 덤프를 떠놓지 않아 정상적인 Thread 모니터링 이미지로 대체..)
I/O dispatcher Thread가 특정 간격으로 점진적으로 쌓이고 있는 것을 발견했다.
그렇다면 뭐 때문에 이렇게 Thread가 쌓이는지 다시 API 서버 로그를 확인해보니,
AWS Opensearch에서 데이터를 조회하는 API를 호출할 때 마다 Thread가 쌓이고 있는 것을 확인했다.
3. 증가하는 Thread 분석
점진적으로 쌓이던 Thread(I/O dispatcher)도 확인했고 Thread를 쌓이게 만들었던 원인(AWS Opensearch)도 발견했다.
그렇다면 이 둘은 무슨 관계가 있는걸까?
해당 프로젝트는 Opensearch-client를 사용했다.
아래와 같이 OpenSearchClient를 생성한 후 사용해야한다.
publci OpenSearchClient openSearchClient() {
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(openSearchId, openSearchPassword));
RestClient restClient = RestClient.builder(new HttpHost(openSearchHost, openSearchPort, "https"))
.setHttpClientConfigCallback(
httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
)
.build();
RestClientTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
return new OpenSearchClient(transport);
}
RestClinet를 생성할 때 HttpAsyncClientBuilder를 통해 CloseableHttpAsyncClient 추상 클래스를 구현 InternalHttpAsyncClient를 생성하여 사용하게 된다.
이 때, Connection 관리 및 네트워크 I/O 처리를 위해 IOReactor를 사용하게 되면서 I/O Dispatch Thread가 생성되게 된다.
httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
.setDefaultIOReactorConfig(IOReactorConfig.custom()
.setIoThreadCount(1)
.build())
위 처럼 IOReactor에서 사용할 Thread의 개수도 설정할 수 있다.
만약, 설정하지 않을 시 서버 CPU의 코어 수를 기반으로 I/O Thread가 생성된다.
//IOReactorConfig.class
public static int getDefaultMaxIoThreadCount() {
return DefaultMaxIoThreadCount > 0 ? DefaultMaxIoThreadCount : Runtime.getRuntime().availableProcessors();
}
점진적으로 쌓이던 Thread(I/O dispatcher)와 Thread를 쌓이게 만들었던 원인(AWS Opensearch)의 상관관계도 파악했다.
그렇다면 왜 에러가 발생한걸까?
4. 결론
생각보다 허무했고 어이가 없었다.
OpenSearchClient를 Bean으로 관리하지 않고 조회시 마다 Client를 새로 생성하며 발생한 문제였다.(너무 어처구니 없었다..)
심지어 IOReactor의 설정도 되어있지 않아 CPU 코어 수 만큼 Thread가 생성되고 있던 것이었다.
기존에는 잘 사용되지 않던 API가 갑자기 사용량이 늘면서 뒤늦게서야 문제를 파악하게 된 것이다.
OpenSearchClient를 사용할 땐 꼭 Bean으로 등록하거나 Client를 사용하고 나면 꼭 Close를 해주자..
opensearch._transport().close();