サービスは最も小さいプールで止まる
一言でいうと
サービスは最も小さいプールで止まります。リクエスト処理スレッドが200個あっても、DBコネクションプールが10個で待機に上限がなければ、遅いクエリ1つが190個のスレッドをコネクションの前に並ばせます。プールのサイズと待ち時間の上限は、セットで決める必要があります。
なぜ必要なのか
前のモジュールの事件はロックでした。現場でより多く見るのはプール枯渇です。DBが一時的に遅くなりました。コネクションプール10個がすべて遅いクエリに掴まれました。後続のリクエストは、コネクションを無期限に待ちます。リクエスト処理スレッド200個がその待機にすべて入ると、ヘルスチェックのリクエストを処理するスレッドさえなくなります。DBが1分後に正常に戻っても、溜まったリクエストが全部はけるまでサービスは死んだままです。ロードバランサーはその間にこのインスタンスを外し、残ったインスタンスに負荷が偏って同じことが広がります。プールサイズの問題ではなく、待ちに上限がないことが問題です。
どう動くのか
TomcatのHTTPコネクターのドキュメントは、リクエストが入ってくる経路を3つの数値で説明しています。maxThreadsはリクエスト処理スレッドの最大数、つまり同時に処理できるリクエスト数で、デフォルトは200です。maxConnectionsはサーバーが受け付けて処理中として保持できる接続数で、この数に達すると、接続は受け付けるものの処理はせずに待たせます。acceptCountはmaxConnectionsまで埋まったときにOSがキューに溜めておく接続要求の数で、デフォルトは100です。このキューまで埋まると、OSが接続を拒否するかタイムアウトさせます。つまりリクエストは、スレッド→接続→OSキューの順に押し出されます。minSpareThreadsは常に生かしておくスレッド数で、デフォルトは10です。connectionTimeoutは接続を受け付けてからリクエスト行(request line)が届くまで待つミリ秒で、ドキュメントのデフォルトは60000ですが、ディストリビューションのserver.xmlは20000と書いています。keepAliveTimeoutは次のリクエストを待つ時間で、指定しなければconnectionTimeoutに従います。コネクターにexecutor属性で共有の<Executor>を設定すると、これらのスレッド属性は無視され、Executorの値が使われます(tc-threadpoolラボがその道筋です)。
スレッド名は診断の鍵です。コネクターの内部プールのスレッドはhttp-nio-8080-exec-Nという名前が付き、スレッドダンプでこの接頭辞を数えれば、今いくつ存在し、そのうちいくつがどこで待っているかがすぐにわかります。
DBコネクションプールは、JNDIデータソースのドキュメントにあるDBCP 2の例が基準です。context.xmlの<Resource type="javax.sql.DataSource" …>に、maxTotal(プールの最大コネクション数、-1なら無制限)、maxIdle(アイドルとして保持する最大数)、そして、maxWaitMillis(コネクションが空くまで待つ最大ミリ秒で、超えると例外を投げ、-1なら無期限に待ちます)を置きます。事件の原因がまさにこの-1です。maxWaitMillis="2000"なら2秒後に例外が出て、アプリケーションはそれを503に変えてスレッドを返せます。
同じ原理をJavaコードで見ると、Semaphore(N)がコネクションプールです。acquire()は無期限に待ち、tryAcquire(timeout, unit)は上限を設けます。ダンプでは、acquire()の待機はparking to wait for <…> (a java.util.concurrent.Semaphore$FairSync)と見えます。ラボのPoolDemoがこの形をそのまま再現します。処理スレッド4つ、コネクション2つ、クエリ3秒です。リクエスト6つを同時に送ると、2つは処理され、残りはコネクションの前に並びます。-Ddb.pool.timeout.ms=500を指定すると、500ms後に503が返ります。
管理ポートを別のスレッドにするのも設計です。PoolDemoの/statsは8087の別スレッドが応答するため、処理スレッドがすべて詰まっても状態を読み取れます。TomcatでJMXや別のコネクターを置く理由も同じです。止まったサービスに問い合わせる道は残しておきます。
Tomcatを複数運用する慣例が、紹介ドキュメントのCATALINA_HOMEとCATALINA_BASEです。HOMEはインストール本体(bin・lib)、BASEはインスタンスごとの設定・ログ・Webアプリ(conf・logs・temp・webapps・work)です。設定を変えるときにインストール本体には触れず、BASEだけを新しく作って起動すれば、実験用のインスタンスを本番の設定から分離できます。ラボではそのように起動します。
現場での姿
サイズを増やすだけの対応が最も多いです。maxThreadsを200から800に上げると、コネクションの前に並ぶスレッドが800個になります。より長く、より大きく止まります。プールは下流(DB)が耐えられる分だけにして、待ちに上限を設けて素早く失敗させるのが答えです。2つ目は、ヘルスチェックがDBを経由することです。DBが遅くなるとヘルスチェックも失敗し、正常なインスタンスまで外れます。ヘルスチェックはプールに触れない経路でなければなりません。3つ目は、タイムアウトを1つの層にしか置かないことです。コネクション待ち、クエリ実行、HTTPクライアント、ロードバランサーと、層ごとに上限が必要で、外側のほうが内側より長くなければなりません。
次のラボですること
PoolDemo.javaを起動して同時リクエストでコネクションプールを枯渇させ、ダンプでSemaphoreの待機を確認したあと、-Ddb.pool.timeout.ms=500で再起動して503が返ってくることを確認します。その後、CATALINA_BASEを/root/jvm/pool/tcに新しく作り、server.xmlの8080コネクターにmaxThreads・acceptCount・connectionTimeoutを、context.xmlにmaxTotal・maxIdle・maxWaitMillisを含むDataSourceを入れ、そのインスタンスを実際に起動して、http-nio-8080-exec-スレッドをダンプで数えます。