Im Changsu
Too many open files
소켓 '파일'을 몰랐을 때의 위험

커버 이미지 출처: Stable diffusion “swimming pool lane pattern”

증상

Spring framework로 만든 웹 애플리케이션에서 비동기로 HTTP 요청하는 기능을 개발하고 있었다. 요구 사항을 위해 동시에 1,000개 이상의 요청을 보낼 때가 있는데, Too many open files 에러가 발생했다. 작업 PC(Ubuntu 22.04)에서 문제 없이 동작하던 프로그램이 IDC에 위치한 서버(CentOS 7)에서는 OutOfMemoryError가 발생하면서 동작하지 않았다.

java.lang.OutOfMemoryError: unable to create new native thread
...
java.util.concurrent.ExecutionException: com.markruler.RuntimeException: request error
...
Caused by: java.net.SocketException: Too many open files

SocketException인데 Too many open files? 이게 OOM? 이해되지 않았다.

문제를 정의하기 위해 먼저 이해부터 해야 했다.

분석

Too many open files

근본적인 원인이 되는 Too many open files는 프로세스에서 열려 있는 파일 디스크립터의 수가 시스템 제한을 초과하면 발생한다. 로컬 환경(Ubuntu 22.04)에서 먼저 테스트해봤다.

# 우선 별도의 세션을 연다.
bash

prlimit를 이용해 현재 프로세스의 파일 디스크립터 제한을 확인한다.

prlimit -n

기본적으로 4096이 설정되어 있었다.

RESOURCE DESCRIPTION              SOFT    HARD UNITS
NOFILE   max number of open files 4096 1048576 files

ulimit를 이용해 열 수 있는 파일 디스크립터 수를 제한한다.

ulimit -n 0

그리고 cat 명령어를 실행하면 Too many open files가 발생한다.

cat /etc/os-release
# bash: start_pipeline: pgrp pipe: Too many open files
# ls: error while loading shared libraries: libselinux.so.1: cannot open shared object file: Error 24

다시 나갔다가 새로운 세션을 연다. limit을 4로 설정하면 파일 내용이 정상적으로 출력된다. 하지만 에러가 발생한다.

ulimit -n 4
cat /etc/os-release
# bash: start_pipeline: pgrp pipe: Too many open files
# PRETTY_NAME="Ubuntu 22.04.2 LTS"
# ...

5로 설정하면 에러가 발생하지 않고 정상적으로 출력된다.

ulimit -n 5
cat /etc/os-release
# PRETTY_NAME="Ubuntu 22.04.2 LTS"
# ...

이유가 무엇일까?

파일 디스크립터 (File descriptor)

리눅스에서는 파일을 열면(open) 파일 디스크립터를 반환한다. 반환된 파일 디스크립터는 fdtable의 참조값을 나타내며, 파일을 읽고 쓰는데 사용된다.

// https://github.com/torvalds/linux/blob/v6.2/include/linux/sched.h#L1088
stuct task_struct {
    ...
  /* Filesystem information: */
  struct fs_struct    *fs;

  /* Open file information: */
  struct files_struct *files;
  ...
};
// https://github.com/torvalds/linux/blob/v6.2/include/linux/fdtable.h#L49
/*
 * Open file table structure
 */
struct files_struct {
  struct fdtable __rcu *fdt;
  ...
  struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};

정확히 fd를 어떻게 찾는지는 확인하지 않았다. 나중에 이 블로그 참고해서 공부하자.

fdtable의 0번 fd는 표준 입력(stdin), 1번 fd는 표준 출력(stdout). 2번 fd는 표준 에러(stderr)다. 3번 fd부터 어떤 작업을 수행하는 프로세스가 필요한 파일을 가리킨다. 그래서 ulimit -n 4로 설정하면 정상적으로 cat의 출력이 나오는 것이다.

다시 문제로 돌아가서 그럼 java.net.SocketException: Too many open files는 왜 발생했던 걸까?

Linux에서는 Socket도 파일로 취급한다. 그래서 소켓을 열 때마다 파일 디스크립터가 증가하고, 시스템 제한을 초과하면 Too many open files 에러가 발생하는 것이다.

문제가 발생했던 서버의 시스템 제한을 확인해봤다.

prlimit -n

SOFT 값이 1024로 1024개의 파일 디스크립터를 열 수 있다.

RESOURCE DESCRIPTION              SOFT    HARD UNITS
NOFILE   max number of open files 1024 1048576 files

이 제한을 늘리면 문제가 해결될 것 같았다. 그런데 다시 생각해보면 1024 만큼의 요청이 발생할 필요 없는 서버였다. 갑자기 요청이 늘어난 원인이 무엇일까?

혼자가 아닌 함께 개발할 때, 내가 사용하려는 인터페이스가 이미 팀 내에서 통용되어 사용되고 있다면 해당 소스 코드를 복사해서 사용하는 경우가 많았다. OkHttpClient도 그대로 복사해서 사용했었다.

OkHttpClient client = new OkHttpClient();

하지만 OkHttpClient를 생성자로 생성하면 OkHttp ConnectionPool 스레드가 생성된다. 파일 개수 제한이 4096인 로컬 환경에서 4,000개의 요청을 보내도록 테스트해봤다.

VisualVM을 사용해서 스레드를 확인해봤다.

visualvm-bad-okhttp-connectionpool

OkHttp ConnectionPool의 스레드가 4,000개가 채 못 되어 java.net.SocketException: Too many open files이 발생했다.

문제 정의

실제 문제는 불필요한 스레드가 과다 생성되어 발생한 것이다. 이 에러가 특히 위험한 이유는 시스템 제한을 초과했기 때문에 동일한 머신에 있는 다른 프로세스에도 영향을 준다는 것이다.

해결

OkHttp ConnectionPool을 재사용하기 위해 Spring Bean으로 등록했다. 이는 공식 문서에도 있는 내용이다.

OkHttpClients Should Be Shared
OkHttp performs best when you create a single OkHttpClient instance and reuse it for all of your HTTP calls. This is because each client holds its own connection pool and thread pools. Reusing connections and threads reduces latency and saves memory. Conversely, creating a client for each request wastes resources on idle pools.

import okhttp3.OkHttpClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OkHttpConfig {

    @Bean
    public OkHttpClient okHttpClient() {
        return new OkHttpClient();
    }

}
import okhttp3.OkHttpClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class MyHttpClient {

    private final OkHttpClient httpClient;

    @Autowired
    public MyHttpClient(OkHttpClient httpClient) {
        this.httpClient = httpClient;
    }

    // ...
}

다시 4,000개의 요청을 보내도록 테스트했다.

visualvm okhttpclient bean

더 이상 불필요하게 스레드가 늘어나지 않았고, 스레드를 새로 생성할 필요도 없으니 성능 또한 개선되었다. (평균 10초 → 3초)

시스템 제한 설정을 변경할 필요 없이 Too many open files 에러도 발생하지 않았다.