LabHub

Tomcat & nginx 운영 · 커넥터와 스레드풀 튜닝 · 이론

maxThreads 200 이 실제로 무슨 뜻인가

LabHub 에서 이어서 보기

한 줄 요약

acceptCount·maxConnections·maxThreads 는 각각 다른 지점에서 다른 일을 하며, 느린 응답의 원인이 스레드 부족이 아닐 때 maxThreads 를 올리면 상황이 나빠진다.

왜 이 구분이 필요한가

세 값을 하나로 뭉뚱그리면 조치가 늘 같아진다 — 숫자를 올리는 것. 그런데 각각이 막히는 지점이 다르므로 증상도 다르다. acceptCount 를 넘기면 커널이 연결 자체를 거절해 Connection refused 가 나고, maxConnections 를 넘기면 연결은 되는데 응답이 안 오며, maxThreads 를 넘기면 요청이 큐에서 순서를 기다린다. 증상만 보고 어느 숫자를 만질지 고를 수 있어야 한다.

세 개의 숫자가 하는 일이 각각 다르다

성능 이슈가 생기면 누군가 반드시 이렇게 말한다. "maxThreads 를 올려 보죠."
그런데 대부분의 경우 그 조치는 아무것도 바꾸지 못하거나, 상황을 더 나쁘게 만든다.
왜인지 알려면 세 값이 각각 어느 지점에서 동작하는지 알아야 한다.

            [클라이언트 소켓]                  |                  v   (1) acceptCount        ← OS 소켓 백로그 큐. 여기까지 넘치면 Connection refused                  |                  v   (2) maxConnections     ← 톰캣이 동시에 '들고 있는' 커넥션 수                  |                  v   (3) maxThreads         ← 동시에 '처리 중인' 요청 수 (워커 스레드)                  |                  v              [애플리케이션]

Connection refused 가 찍혔다는 것은 "서버가 죽었다"가 아니라
"서버가 살아 있는데 받을 여력이 없었다"일 수 있다. 앞단 nginx 로그에
connect() failed (111: Connection refused) 가 보이면 백엔드 프로세스 사망뿐 아니라
백로그 포화도 후보에 올려야 한다.

maxThreads 를 올려도 안 되는 이유

요청 처리 시간의 대부분이 DB 대기라면, 스레드를 늘리는 것은
DB 앞에 줄 선 사람을 늘리는 일이다. 처리량은 그대로고 대기 시간만 늘어난다.
심하면 커넥션 풀 고갈로 전체가 멈춘다.

스레드 덤프를 떠서 이런 패턴이 보이면 스레드풀 문제가 아니다.

"http-nio-8080-exec-73" ... WAITING (parking)  at jdk.internal.misc.Unsafe.park  at com.zaxxer.hikari.pool.HikariPool.getConnection   ← DB 커넥션 풀 대기

이 덤프의 메시지는 명확하다: 문제는 WAS 스레드풀이 아니라 DB 커넥션 풀이다.
maxThreads 를 400 으로 올리면 대기 중인 스레드가 200개에서 400개로 늘 뿐이다.

근거 있게 산정하기 — 리틀의 법칙

감으로 정하지 말고 계산하자. 리틀의 법칙은 이렇게 쓴다.

동시 처리 수 = 목표 처리량(TPS) × 평균 응답시간(초)

목표가 초당 300건이고 평균 응답이 200ms 라면,

300 × 0.2 = 60

최소 60개 스레드가 동시에 일하고 있어야 300 TPS 가 나온다.
여기에 여유율(피크 대비, 보통 20~50%)을 곱한다. 30% 여유면 60 × 1.3 = 78.

CPU 바운드 작업이라면 다르다. 코어 수보다 많은 스레드는 컨텍스트 스위칭 비용만 늘린다.
경험칙은 대략 이렇다.

그리고 반드시 확인할 것 — maxThreads 를 정한 뒤에는 DB 커넥션 풀과의 관계를 본다.
WAS 스레드 200개가 모두 DB 를 쓰는데 풀이 20개면, 180개는 대기한다.
반대로 풀을 200개로 키우면 이번엔 DB 쪽 max_connections 가 터진다.
인스턴스가 여러 대면 곱하기가 된다.

서비스 A 12대 × 풀 20 = 240서비스 B  8대 × 풀 15 = 120배치 워커 4대 × 풀 5  =  20                합계   = 380DB max_connections     = 200   ← 롤링 배포로 인스턴스가 잠깐 두 배 되는 순간 터진다

이 계산을 안 해 본 시스템은 롤링 배포 중에
FATAL: sorry, too many clients already 로 무너진다. 더 나쁜 것은 그 오류가
헬스체크와 모니터링 에이전트까지 막는다는 점이다. 장애 순간에 관측 수단이 함께 사라진다.

Keep-Alive 는 양날이다

<Connector port="8080" protocol="HTTP/1.1"           connectionTimeout="20000"           keepAliveTimeout="5000"           maxKeepAliveRequests="100"           maxThreads="150" minSpareThreads="20"           maxConnections="2000" acceptCount="50" />

앞단에 nginx 가 있다면 nginx ↔ 톰캣 사이의 커넥션은 소수이고 재사용률이 높다.
그때는 keepAliveTimeout 을 넉넉히 주는 편이 낫다. 반대로 톰캣이 인터넷에
직접 노출돼 있으면 유휴 커넥션이 자원을 먹으므로 짧게 잡는다.
"정답 값"이 아니라 "앞단 구성에 따라 달라지는 값"이다.

공유 Executor

커넥터를 여러 개 두면 각자 스레드풀을 갖는다. 그러면 전체 스레드 수를 통제하기 어렵다.
이때 공유 Executor 를 쓴다.

<Executor name="tomcatThreadPool" namePrefix="labhub-exec-"          maxThreads="150" minSpareThreads="20"/><Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1" ... />

namePrefix 를 의미 있게 주는 것이 실무 팁이다. 스레드 덤프를 떴을 때
labhub-exec-37 처럼 나오면 이게 어느 커넥터의 스레드인지 바로 안다.
기본값 http-nio-8080-exec- 도 나쁘지 않지만, 커넥터가 여러 개일 때는 구분이 필요하다.

힙과 GC 로그는 튜닝 전에 켠다

CATALINA_OPTS="-Xms1g -Xmx1g \  -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs/dump \  -Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=10m"

이 세 줄을 오픈 전에 넣어 두면, 안정화 기간의 장애 분석 시간이 절반이 된다.

현장에서 만나는 모습

성능 회의에서 가장 자주 나오는 조치가 "maxThreads 를 올려 보죠" 다. 그리고 대개 아무것도 나아지지 않는다. 요청이 DB 응답을 기다리는 동안에도 스레드는 점유돼 있으므로, 병목이 DB 라면 스레드를 늘려도 기다리는 스레드만 늘어난다. 오히려 컨텍스트 스위칭과 힙 사용이 늘어 GC 정지가 길어진다.

그래서 순서가 있다. 먼저 스레드 덤프를 떠서 워커들이 무엇을 하고 있는지 본다. 대부분이 RUNNABLE 이면 CPU 가 부족한 것이고, 대부분이 소켓이나 락에서 WAITING 이면 스레드가 아니라 그 뒤가 병목이다. 후자에서 maxThreads 를 올리는 것은 대기실을 키우는 일이지 창구를 늘리는 일이 아니다.