「これってもしかして・・・」
「私たちのレスポンス・・・」
「入れ替わってる〜!?」
Tomcat 7 の EOL は2021年3月31日までです。
2020年4月現在、あと1年を切りました。私のまわりでは Tomcat 7 つまり Servlet 3.0 向けに書かれたアプリケーションは未だ現役で、そろそろ本気を出してマイグレーションに取り組まなければいけません。
本気を出すためにも、Tomcat 7 でおきたセッション管理に関する怖い話を書いておきます。
レスポンスが混じる!
結論からいいますと、Tomcat 7 は最新の 7.0.103 であっても、大量のトラフィックを捌く環境において、AJP Connectorを使用してほぼ同時に処理している複数のリクエストのレスポンスが混ざってクライアントに送信されることがあります。
これが何を意味するかというと、あるユーザーに対して送信した Set-Cookie
が別のユーザーのリクエストのレスポンスにセットされたり、あるユーザー向けに送信された HTTP のストリームが、別のユーザーに送信されたりしてしまいます。
私が直面した問題は、Set-Cookie
が入れ替わり、他人のIDでログインした状態になってしまう!という事件でした。
原因
この原因は、Tomcat の仕組みにあります。
Tocmat は、AbstractProtocol#process(SocketWrapper, SocketStatus)
において、次々に到来するリクエストを Processor
を実装するクラス(たとえば AjpProcessor
とか Http11Processor
) のインスタンスに渡して順次処理していくというアーキテクチャーになっています。
この部分のコードは以下のようになっており、recycledProcessors
という ConcurrentLinkedQueue
を継承したキューで、前回のリクエストで処理が終わったProcessor
のインスタンスがキューに投入されることで回収されているため、キューから Processor
が取得できればそれを使いまわして次のリクエストの処理を行います。
if (processor == null) {
processor = recycledProcessors.poll();
}
if (processor == null) {
processor = createProcessor();
}
リクエストを処理するスレッドは、Tomcatが生成するスレッドプールのいずれかのスレッドが選択されるため、前回に処理されたスレッドと同じスレッドになるとは限りません。
残念なことに、Tomcatの各種 Processor
の実装や、その先でレスポンスを書き込む Response
の実装は、ざっと見た限りマルチスレッドを考慮したような同期コード(つまり synchronized)が正しく書かれている気配がありません。
Java の synchronized
は、複数のスレッド間で一つのスレッドのみがガードされたセクションを実行できるという意味の他に、CPUによるアウトオブオーダー実行をそのタイミングでキャンセルしたり、複数のCPUの間でそれぞれのCPUコアが持つキャッシュメモリやの内容を同期するという意味があります。
Tomcat のように適切に synchronized
が入っていない残念なコードだと、別のCPUコアで実行した結果がそのコアのキャッシュには存在していたとしても、別のコアが見るメインメモリには存在しないという状態が発生することも十分に考えられます。
そのため、前回(もしくはもっと前)に処理したレスポンスの結果がインスタンスのプロパティに残っていて、いきなり別のユーザーの Set-Cookie
が送信されてしまうということが発生してしまうのです。
ワークアラウンド
Tomcat にパッチをあてて凌いでいたのですが、頻度は減らせるものの完全には解決できませんでした。
ソースコードを確認していくと、Tomcatの設定に processorCache
というものがあり、recycledProcessors
のキューの長さを制限することがわかりました。
The protocol handler caches Processor objects to speed up performance. This setting dictates how many of these objects get cached. -1 means unlimited, default is 200. If not using Servlet 3.0 asynchronous processing, a good default is to use the same as the maxThreads setting. If using Servlet 3.0 asynchronous processing, a good default is to use the larger of maxThreads and the maximum number of expected concurrent requests (synchronous and asynchronous).
このように書いてありますが、Tomcat の Processor
インスタンスのキャッシュの仕組みはスレッドセーフではなく壊れています。
すべての Tomcat 7 環境における processorCache
の推奨値は 0 です。今すぐ変更してください!
例として AJP のコネクターの設定は以下のようになります。
<!-- Define an AJP 1.3 Connector on port 8009 -->
<Connector port="8009"
processorCache="0"
enableLookups="false" protocol="AJP/1.3" redirectPort="8443"
address="0.0.0.0" secretRequired="false"
maxThreads="200"
minSpareThreads="40"
maxSpareThreads="200"
backlog="100" connectionTimeout="120000" />
結論
大事なことなのでもう一度いいます。
「Tomcatはスレッドセーフでなく壊れています」今すぐにコネクターに以下の設定を入れてください。
processorCache="0"
一度アロケートしたメモリを使い回すような貧乏くさいプログラムをスレッドセーフに作るのは、豊かになった人類には難しすぎます。
現代のJVMにとってオブジェクトのアロケーションのコストは低く、GC性能も劇的に改善しています。あらゆるオブジェクトはイミュータブルであってほしい。
もはや害悪ですので、誰かTomcatを抜本的に直してほしいものです。