0. 환경 설정
Threads 수 3000에 ramp-up 수 1을 1분 지속으로 테스트를 설정하고 헬스 체크 API에 적용하니 밑과 같은 에러를 만났습니다.
java.net.SocketException: Connection reset
at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:328)
at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:355)
at java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:808)
at java.base/java.net.Socket$SocketInputStream.read(Socket.java:966)
현재 프로젝트에서 쓰고 있는 톰캣의 쓰레드 수를 3000개로 늘려놓은 상태이고, 따로 DB를 거치지 않는 헬스 체크
API 였기 때문에, 이론상 모든 요청이 하나의 쓰레드를 점유하며 SPRING MVC 까지 와서 응답을 반환해야 했습니다. 하지만 에러율 46.14%
로 절반이 넘는 쓰레드가 요청을 실패 하였습니다. 저와 인프라팀이 이것에 대응하기 위해 생각하고, 실행 했던 방안은 다음과 같습니다.
1. WAS 대기 큐 (accept-count) 늘리기
요청이 한번에 들어와, WAS 쓰레드 점유에 대한 경쟁 상태가 생겨서 쓰레드 점유에 차질이 생기는 것이 원인이라 판단하였고, yaml 파일에서 다음과 같이 WAS의 요청들이 대기 하는 accept-count
라는 대기큐의 값을 늘려주었습니다.
server:
port: 8080
tomcat:
threads:
max: 3000 # 최대 스레드 개수 (기본값: 200)
min-spare: 500 # 최소 유지 스레드 개수 (기본값: 10)
accept-count: 10000 # 요청 대기 큐 크기 (기본값: 100)
connection-timeout: 30s # 클라이언트가 응답을 기다리는 최대 시간
max-connections: 10000 # 동시에 연결 가능한 최대 커넥션 수 (기본값: 8192)
하지만 이러한 설정 변경에도 불가하고, 여전히 스파이크 성 요청이 왔을 경우, 서버에서 모든 요청을 받지 못한 채, 위와 같은 SocketConnection Error
가 발생하였습니다.
2. 성능 테스트 툴 비교 및 클라이언트의 문제 상황 확인
저와 인프라팀은 더 이상 문제가 응용 계층인 WAS에서 있다고 판단하지 않았습니다. 이유는 다음과 같습니다.
SPRING-actuator
의존성을 추가하고,actuator
에서 제공하는 다음의 API를 통해,currentThreadsBusy(현재 처리 중인 요청 수)
,currentThreadCount(전체 생성된 스레드 수)
두가지 사항을 확인했습니다.curl http://localhost:8080/actuator/metrics/jvm.threads.states (runnable 상태의 쓰레드 확인) curl http://localhost:8080/actuator/metrics/jvm.threads.live (활성화된 쓰레드 확인)
- 일반
netstat
,eBPF
툴을 활용해, 운영체제 커널의 accpet 큐의 크기 확인 (yaml로 늘렸던 accept-count) sudo bpftrace -e 'kprobe/tcp_v4_syn_recv_sock { @[comm] = count(); }'
netstat -anp | grep :8080 | grep SYN_RECV | wc -l
이러한 상황을 거쳤지만, 전체 큐 사이즈와 Thread 수는 각각 만 개와 3000개로 정확히 형성되었지만, 3000개 이상이 한 번에 들어오는 스파이크 성 요청이 왔을 경우, 대기큐는 최대 2000개 밖에 활용하지 못하였고, 쓰레드 또한 200개 이상이 동시 활성화가 되지 않았습니다.
그래서 저와 인프라팀은 성능 테스트 툴로 사용하는 Jmeter가 실제로 요청을 제대로 보내지 못하는 상황이라고 생각했습니다. 실제로 제 로컬 컴퓨터에서의 Jmeter는 동일 요청에서 오류율이 최대 6.46% 였지만, 인프라 경훈님의 로컬 컴퓨터에서 전송 했을 시 오류율이 92.49%가 찍혀 있었기 때문입니다. 이후 Jmeter에 대한 튜닝을 진행하려 하다가, 전송 계층에서의 오류일 수 있겠다는 생각이 들어, 리눅스의 패킷 처리에 대한 학습 후 이를 적용하였습니다.
3. 운영체제의 전송 계층에서의 패킷 처리에 대하여 (Back-log overflow)
먼저 TCP 체계에서 서버와 클라이언트가 첫 연결을 하는 3-way handshaking 과정
에 대해 간단히 설명을 하고 가겠습니다.

해당 과정은 서버와 클라이언트가 본 요청을 주고 받기 전에 서로의 연결을 서로가 확신하기 위해 거치는
3-way handshaking 과정입니다. 여기서 서버가 클라이언트에게 SYN + ACK
요청을 보낸 후, 클라이언트로부터 마지막 ACK
요청을 기다릴 동안 즉 TCB (네트워크 스택에 저장된 TCP 블록)
가 half-open
상태일 때, 이 TCB는 SYN 큐
에 저장됩니다. 이후 ACK 요청이 클라이언트로부터 온 경우 (즉 TCB의 상태가 ESTABLISHED
일 때, ) 해당 TCB를 SYN 큐에서 꺼내 ACCEPT 큐
로 보냅니다. 이 두큐의 크기는 기본적으로 128로 저희가 2000개 3000개의 요청을 보내도, 그것 중 128개만 유효하게 WAS에게 전달되는 것입니다.
그러면 우리가 설정한 yaml의 accept-count는 왜 적용이 되지 않는가?
해당 accept-count
는 WAS 내의 listen BackLog의 크기를 설정하는 값입니다. 즉 Backlog의 큐로부터 한 번에 가져오는 TCB 데이터의 크기 입니다.
여기서 Java의 Server Sockert이 리눅스 커널을 내부 C 코드 함수 중 listen(fd, backlog)
를 부르게 되고, 해당 listen(fd, backlog)의 내부는 이렇게 되어 있습니다. 위의 listen(fd, backlog)
에서 backlog
란 java에서 설정한 "나 이정도까지 한 번에 받을 수 있어"라는 값의 accept-count
입니다.
int __sys_listen(int fd, int backlog)
{
...
if (backlog > somaxconn)
backlog = somaxconn;
...
}
somaxconn
은 커널 내부에 설정하는 실제 큐의 크기 입니다. 즉 backlog를 아무리 크게 설정해도, somaxconn의 값이 그보다 작으면 아무 소용이 없는 것입니다. 따라서 스파이크성 요청에 대한 완벽한 대응책을 위해서는, WAS 튜닝과 더불어 OS의 커널 튜닝도 필수 입니다.
따라서 현재 WAS 서버가 올라간 운영체제에 들어가 TCP Back-log의 크기를 확인하셔야 합니다. 저는 리눅스 서버임을 가정하고 다음 과정을 진행하겠습니다.
sysctl net.core.somaxconn
sysctl net.ipv4.tcp_max_syn_backlog
sysctl net.core.netdev_max_backlog
아마 기본 설정은 somaxconn의 값이 128로 되어 있을 텐데 이를 자신의 운영체제의 메모리 상황, 스펙에 맞게 조절하시기 바랍니다.
# 연결 요청 큐 크기 증가
sudo sysctl -w net.core.somaxconn=65535
# SYN 대기 큐 증가 (3-way handshake 동안 보류된 요청 큐)
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=65535
# NIC 수신 대기 큐 증가
sudo sysctl -w net.core.netdev_max_backlog=65535
4. 소결
이러한 과정을 거쳐, 저희 서버는 3000의 스파이크 성 테스트와 7000 이상의 부하 테스트를 견딜 수 있는 서버로 재탄생 하였습니다. 이후에는 저희 서버 중 제가 만든 API 25개와 팀원들의 API에 대해 통합 테스트를 진행해 보겠습니다.