理系学生日記

おまえはいつまで学生気分なのか

RestEasyとHttpComponents、そしてConnectionManager

RestEasy client を使用した HTTP 要求を行うと、TCP コネクションが長時間にわたり残存する問題が発生することがあります。 RestEasy の公式ドキュメントを読めば「HTTP コネクションは適切に release し、適切に close しなければならない」ということは分かるのですが、RestEasy にてどのように HTTP コネクションが管理されているのか良く分からなかったので、そのあたりを調べてみました。

RestEasy と HttpComponents

RestEasy が HTTP 要求を行う際、デフォルトでは、HttpComponents を内部で使用することになっています。 このあたりは、RestEasy における HTTP 要求を示すクラスである ClientRequest の実装を見れば分かりやすいでしょう。 何も意識しないと、以下のコンストラクタを使うことになりますが、ここではデフォルトの ClientExecutorgetDefaultExecutor() で生成しています。

   public ClientRequest(String uriTemplate)
   {
      this(uriTemplate, getDefaultExecutor());
   }

では、その実装はというと、以下のように、ApacheHttpClient4Executor を使用するようになっていることがわかります。

   private static String defaultExecutorClasss = "org.jboss.resteasy.client.core.executors.ApacheHttpClient4Executor";

   public static ClientExecutor getDefaultExecutor()
   {
      try
      {
         Class<?> clazz = Thread.currentThread().getContextClassLoader().loadClass(defaultExecutorClasss);
         return (ClientExecutor) clazz.newInstance();
      }
      catch (Exception e)
      {
         throw new RuntimeException(e);
      }
   }

あとはこの ApacheHttpClient4Executor の実装を見れば、httpClient に代表される httpComponents のクラスが山積みです。

httpComponent における HTTP コネクション管理

HTTP での通信を行う際は当然ながら HTTP コネクションを管理する必要があります。 httpComponent においては、この責務は ClientConnectionManager Interface の実装クラスが負うということになっています。具体的には、ClientConnectionManager

  • HTTP コネクションの作成
  • Keep Alive な HTTP コネクションの管理
  • 個々のスレッドに対してどの HTTP コネクションを使わせるのかの制御

を行うっていう Interface となり、その実装クラスとして、後述する BasicClientConnectionManagerPoolingClientConnectionManager なんてものが存在しています。

RestEasy のデフォルト

さて、RestEasy に戻りますが、何も意識しないで RestEasy を使用すると、ClientExecutor としては先述のように ApacheHttpClient4Executor が使用されます。 そして、このコンストラクタでは、以下のように httpClient として DefaultHttpClient が生成されます。

   public ApacheHttpClient4Executor()
   {
      this.httpClient = new DefaultHttpClient();
      this.createdHttpClient  = true;
      checkClientExceptionMapper();
   }

DefaultHttpClient は、HTTP コネクションを管理する ClientConnectionManager として、 BasicClientConnectionManager を使う実装になっています。 BasicClientConnectionManager は HTTP コネクションを 1 本だけ管理するという ConnectionManager であって、シングルスレッドのみで使われることを意図したものになっています。

これまでのまとめ

これまでの RestEasy の挙動を簡単にまとめると、

  1. ClientRequest を直接使用して HTTP 要求を行う際、(ApacheHttpClient4Executor が都度生成される結果として) 都度 httpClient が作成され、
  2. その中で、BasicClientConnectionManager が作られる

という振舞いになります。

実装1: BasicClientConnectionManager で close/release しない

たとえば以下のようなコードで 20 スレッドで HTTP 要求を行うと、HTTP コネクションが 20 本作成されるとともに、個々の HTTP コネクションが keep-alive のタイムアウト時間が経過するまで開放されない(5 秒間の間、20 本の ESTABLISHED な TCP コネクションが作成され、それが 5 秒後に CLOSE_WAIT に遷移し、当分の間残存する。

   public static void main(String... args) throws Exception {
        final int threadNum = 20;
        final String uri = "http://192.168.99.100:8080"; // Docker コンテナ上の Apache Listen ポート

        ExecutorService service = Executors.newFixedThreadPool(threadNum);
        for (int i = 0; i < threadNum; ++i) {
            service.submit(new GetRunnable(uri));
        }
        service.shutdown();

        // netstat 観測用の時間
        Thread.sleep(10000L);
    }

    public static class GetRunnable implements Runnable {

        private String uri;

        public GetRunnable(String uri) {
            this.uri = uri;
        }

        public void run() {
            try {
                new ClientRequest(uri).get();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

    }

実装2: PoolingClientConnectionManager で close/release しない

次に PoolingClientConnectionManager を使用してみる。この ConnectionManager は、その名前の通り、HTTP コネクションをプールとして管理する。 RestEasy が (正しくは httpComponent が)デフォルトで使用する BasicClientConnectionManager との違いは、BasicClientConnectionManager は個々の実行スレッドがそれぞれインスタンスを保持することを意図している一方で、PoolingClientConnectionManager はマルチスレッドで共有されることを(通常は)意図している点となる。 PoolingClientConnectionManager を使用するためには、次のように書けば良い。

   public static void main(String... args) throws Exception {
        final int threadNum = 20;
        final String uri = "http://192.168.99.100:8080";

        ClientConnectionManager connManager = new PoolingClientConnectionManager();
        HttpClient client = new DefaultHttpClient(connManager);

        ExecutorService service = Executors.newFixedThreadPool(threadNum);
        for (int i = 0; i < threadNum; ++i) {
            service.submit(new GetRunnable(uri, client));
        }

        service.shutdown();
    }

    public static class GetRunnable implements Runnable {

        private String uri;
        private HttpClient client;

        public GetRunnable(String uri, HttpClient client) {
            this.uri = uri;
            this.client = client;
        }

        public void run() {
            ClientExecutor executor = new ApacheHttpClient4Executor(client);
            try {
                new ClientRequest(uri, executor).get();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

    }

実際にこれを実行すると、非常に面白いことが起こる。 20 本のスレッドを同時実行しているはずなのに、Apache のアクセスログに記録されるのは 2 リクエストのみである。おい残りの 18 本はどこに行った。

192.168.99.1 - - [23/Apr/2016:13:03:52 +0000] "GET / HTTP/1.1" 200 12
192.168.99.1 - - [23/Apr/2016:13:03:52 +0000] "GET / HTTP/1.1" 200 12

また、この後で netstat を見ると、以下のように 2 本のコネクションが CLOSE_WAIT 状態のままとなっている。

tcp4       0      0  192.168.99.1.64268     192.168.99.100.8081    CLOSE_WAIT
tcp4       0      0  192.168.99.1.64267     192.168.99.100.8081    CLOSE_WAIT

種明かしをすると、以下の 2 つに起因して、残りの 18 スレッドは HTTP コネクション待ちの状態になっている。

  1. コネクションを適切に release していない
  2. PoolingClientConnectionManager のプール数の上限は、デフォルトだとトータルで 20 本、かつ、1 つの宛先ホスト毎に 2 本という制限がある

1 つ目の問題を解決するためには、ClientResponse#releaseConnection を呼び出せば良い。 releaseConnection は、HTTP コネクションをプールに返却するメソッドである。これにより HTTP コネクションが他のスレッドから使用できるようになるため、先ほどのような問題は解消される。

diff --git a/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java b/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java
index 34198f6..e54889a 100644
--- a/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java
+++ b/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java
@@ -9,6 +9,7 @@ import org.apache.http.impl.client.DefaultHttpClient;
 import org.apache.http.impl.conn.PoolingClientConnectionManager;
 import org.jboss.resteasy.client.ClientExecutor;
 import org.jboss.resteasy.client.ClientRequest;
+import org.jboss.resteasy.client.ClientResponse;
 import org.jboss.resteasy.client.core.executors.ApacheHttpClient4Executor;

 public class ResteasyClient {
@@ -41,10 +42,15 @@ public class ResteasyClient {

                public void run() {
                        ClientExecutor executor = new ApacheHttpClient4Executor(client);
+                       ClientResponse<?> response = null;
                        try {
-                               new ClientRequest(uri, executor).get();
+                               response = new ClientRequest(uri, executor).get();
                        } catch (Exception e) {
                                e.printStackTrace();
+                       } finally {
+                               if (response != null) {
+                                       response.releaseConnection();
+                               }
                        }
                }

2 つ目の問題に対しては、単純にプールの設定を変更してやれば良い。

diff --git a/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java b/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java
index e54889a..14ae85b 100644
--- a/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java
+++ b/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java
@@ -18,7 +18,9 @@ public class ResteasyClient {
                final int threadNum = 20;
                final String uri = "http://192.168.99.100:8081";

-               ClientConnectionManager connManager = new PoolingClientConnectionManager();
+               PoolingClientConnectionManager connManager = new PoolingClientConnectionManager();
+               connManager.setDefaultMaxPerRoute(10);
+               connManager.setMaxTotal(100);
                HttpClient client = new DefaultHttpClient(connManager);

                ExecutorService service = Executors.newFixedThreadPool(threadNum);

実装3: PoolingClientConnectionManager で close/release する

残りの問題は、CLOSE_WAIT が残り続ける問題なのだけれど、これは単純に ConnectionManager に対して HTTP コネクションの close を指示すれば良い。 これを行うのは ClientConnectionManager#shutdown メソッドである。 以下のように呼び出すことにより、TCP コネクションが CLOSE_WAIT 状態で残存することはなくなった。

diff --git a/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java b/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java
index 14811f9..b229cde 100644
--- a/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java
+++ b/src/main/java/com/kiririmode/blog/http/client/ResteasyClient.java
@@ -2,6 +2,7 @@ package com.kiririmode.blog.http.client;

 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;

 import org.apache.http.client.HttpClient;
 import org.apache.http.impl.client.DefaultHttpClient;
@@ -28,7 +29,9 @@ public class ResteasyClient {
                }

                service.shutdown();
-               Thread.sleep(10000L);
+               service.awaitTermination(10L, TimeUnit.SECONDS);
+
+               client.getConnectionManager().shutdown();
        }

        public static class GetRunnable implements Runnable {