TT Lab
はじめる
学ぶ 学習パス コース

Tomcat & nginxの運用

maxThreads 200が実際に何を意味するのか

TT Labで続きを見る

一言でいうと

acceptCount・maxConnections・maxThreadsは、それぞれ別の地点で別の役割を果たしており、応答が遅い原因がスレッド不足でないときにmaxThreadsを上げると、状況が悪化します。

なぜこの区別が必要なのか

3つの値を1つにまとめてしまうと、対応がいつも同じになります。数字を上げることです。ところが、それぞれが詰まる地点が違うので、症状も違います。acceptCountを超えると、カーネルが接続そのものを拒否してConnection refusedが出て、maxConnectionsを超えると、接続はできるのに応答が来ず、maxThreadsを超えると、リクエストがキューで順番を待ちます。症状だけを見て、どの数字を触るかを選べる必要があります。

3つの数字の役割は、それぞれ違う

性能の課題が出ると、誰かが必ずこう言います。「maxThreadsを上げてみましょう。」ところが、大半の場合、その対応は何も変えられないか、状況をさらに悪化させます。なぜかを知るには、3つの値がそれぞれどの地点で動作するかを知る必要があります。

            [클라이언트 소켓]
                  |
                  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) × 평균 응답시간(초)

このコードブロックの韓国語は、同時処理数は、目標スループット(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
                합계   = 380
DB max_connections     = 200   ← 롤링 배포로 인스턴스가 잠깐 두 배 되는 순간 터진다

このコードブロックの韓国語は、サービスA 12台、サービスB 8台、バッチワーカー4台と、それぞれのプールサイズ、合計、そしてローリングデプロイでインスタンスが一時的に2倍になる瞬間に破綻する、という意味です。

この計算をしていないシステムは、ローリングデプロイの最中に、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 ↔ Tomcatの間のコネクションは少数で、再利用率が高いです。その場合は、keepAliveTimeoutを余裕を持って与えるほうがよいです。逆に、Tomcatがインターネットに直接公開されていると、アイドルのコネクションがリソースを消費するので、短く設定します。「正解の値」ではなく、「前段の構成によって変わる値」です。

共有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"

この3行をサービスイン前に入れておけば、安定化期間の障害分析の時間が半分になります。

現場での姿

性能会議で最もよく出る対応が、「maxThreadsを上げてみましょう」です。そして、たいてい何も良くなりません。リクエストがDBの応答を待っている間もスレッドは占有されているので、ボトルネックがDBなら、スレッドを増やしても待つスレッドが増えるだけです。むしろ、コンテキストスイッチとヒープ使用量が増えて、GCの停止が長くなります。

そのため、順序があります。まず、スレッドダンプを取って、ワーカーたちが何をしているかを見ます。大半がRUNNABLEならCPUが足りないのであり、大半がソケットやロックでWAITINGなら、スレッドではなく、その先がボトルネックです。後者でmaxThreadsを上げるのは、待合室を広げることであって、窓口を増やすことではありません。