一次Commons-HttpClient的BindException排查


線上有個老應用,在流量增長的時候,HttpClient拋出了BindException。部分的StackTrace信息如下:

 java.net.BindException: Address already in use (Bind failed) at
 java.net.PlainSocketImpl.socketBind(Native Method) ~[?:1.8.0_162] at
 java.net.AbstractPlainSocketImpl.bind(AbstractPlainSocketImpl.java:387) ~[?:1.8.0_162] at
 java.net.Socket.bind(Socket.java:644) ~[?:1.8.0_162] at
 sun.reflect.GeneratedMethodAccessor289.invoke(Unknown Source) ~[?:?] at
 sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[?:1.8.0_162] at
 java.lang.reflect.Method.invoke(Method.java:498) ~[?:1.8.0_162] at
 org.apache.commons.httpclient.protocol.ReflectionSocketFactory.createSocket(ReflectionSocketFactory.java:139) ~[commons-httpclient-3.1.jar:?] at
 org.apache.commons.httpclient.protocol.DefaultProtocolSocketFactory.createSocket(DefaultProtocolSocketFactory.java:125) ~[commons-httpclient-3.1.jar:?] at
 org.apache.commons.httpclient.HttpConnection.open(HttpConnection.java:707) ~[commons-httpclient-3.1.jar:?] at
 org.apache.commons.httpclient.MultiThreadedHttpConnectionManager$HttpConnectionAdapter.open(MultiThreadedHttpConnectionManager.java:1361) ~[commons-httpclient-3.1.jar:?] at
 org.apache.commons.httpclient.HttpMethodDirector.executeWithRetry(HttpMethodDirector.java:387) ~[commons-httpclient-3.1.jar:?] at
 org.apache.commons.httpclient.HttpMethodDirector.executeMethod(HttpMethodDirector.java:171) ~[commons-httpclient-3.1.jar:?] at
 org.apache.commons.httpclient.HttpClient.executeMethod(HttpClient.java:397) ~[commons-httpclient-3.1.jar:?] at
 org.apache.commons.httpclient.HttpClient.executeMethod(HttpClient.java:323) ~[commons-httpclient-3.1.jar:?]`

Ephemeral Port Exhausted

先Google,很多人說是操作系統的臨時端口號耗盡了。倒也說得通,線上服務沒有連接池,流量一大,HttpClient每創建一個連接就會占用一個臨時端口號。

但我還是有疑問。

說疑問之前先簡單介紹下臨時端口號(Ephemeral Port)。

一個TCP連接由四元組標識:

   {source_ip, source_port, destination_ip, destination_port}

對於HttpClient來說,每次都是作為source創建TCP連接,也就是說destination_ip和destination_port是確定的,只需要調用系統調用connect,操作系統會自動分配source_ip和source_port。

這個分配過程不僅HttpClient的使用者不關心,HttpClient的開發者也不用關心。

不過臨時端口號對操作系統來說是有限的資源,有個范圍限制,同時創建的連接太多,就不夠用了。再創建連接,就會報錯。

比如下面這條nginx log,就是因為臨時端口號耗盡,Nginx無法創建到upstream的連接了:

2016/03/18 09:08:37 [crit] 1888#1888: *13 connect() to 10.2.2.77:8081 failed (99: Cannot assign requested address) while connecting to upstream, client: 10.2.2.42, server: , request: "GET / HTTP/1.1", upstream: "http://10.2.2.77:8081/", host: "10.2.2.77"

這個時候我的疑問來了。

如果原因是臨時端口號耗盡,HttpClient為什么會拋出BindException呢?作為創建TCP連接的source這一方,只需要系統調用connect,沒必要系統調用bind啊。

如果原因是臨時端口號耗盡,像上面nginx log那種錯誤提示才是合理的吧?

HttpClient 3.1

猜猜猜,猜不出來,只好去看看HttpClient的代碼。

老應用之老,不止年紀大,用的三方庫的版本也舊。HttpClient還是commons-httpclient-3.1.jar。

package org.apache.commons.httpclient.protocol;
public final class ReflectionSocketFactory:

    public static Socket createSocket(
        final String socketfactoryName,
        final String host,
        final int port,
        final InetAddress localAddress,
        final int localPort,
        int timeout)
     throws IOException, UnknownHostException, ConnectTimeoutException
    {
        if (REFLECTION_FAILED) {
            //This is known to have failed before. Do not try it again
            return null;
        }
        // This code uses reflection to essentially do the following:
        //
        //  SocketFactory socketFactory = Class.forName(socketfactoryName).getDefault();
        //  Socket socket = socketFactory.createSocket();
        //  SocketAddress localaddr = new InetSocketAddress(localAddress, localPort);
        //  SocketAddress remoteaddr = new InetSocketAddress(host, port);
        //  socket.bind(localaddr);
        //  socket.connect(remoteaddr, timeout);
        //  return socket;
        try {
            Class socketfactoryClass = Class.forName(socketfactoryName);
            Method method = socketfactoryClass.getMethod("getDefault", 
                new Class[] {});
            Object socketfactory = method.invoke(null, 
                new Object[] {});
            method = socketfactoryClass.getMethod("createSocket", 
                new Class[] {});
            Socket socket = (Socket) method.invoke(socketfactory, new Object[] {});

            if (INETSOCKETADDRESS_CONSTRUCTOR == null) {
                Class addressClass = Class.forName("java.net.InetSocketAddress");
                INETSOCKETADDRESS_CONSTRUCTOR = addressClass.getConstructor(
                    new Class[] { InetAddress.class, Integer.TYPE });
            }

            Object remoteaddr = INETSOCKETADDRESS_CONSTRUCTOR.newInstance(
                new Object[] { InetAddress.getByName(host), new Integer(port)});

            Object localaddr = INETSOCKETADDRESS_CONSTRUCTOR.newInstance(
                    new Object[] { localAddress, new Integer(localPort)});

            if (SOCKETCONNECT_METHOD == null) {
                SOCKETCONNECT_METHOD = Socket.class.getMethod("connect", 
                    new Class[] {Class.forName("java.net.SocketAddress"), Integer.TYPE});
            }

            if (SOCKETBIND_METHOD == null) {
                SOCKETBIND_METHOD = Socket.class.getMethod("bind", 
                    new Class[] {Class.forName("java.net.SocketAddress")});
            }
            SOCKETBIND_METHOD.invoke(socket, new Object[] { localaddr});
            SOCKETCONNECT_METHOD.invoke(socket, new Object[] { remoteaddr, new Integer(timeout)});
            return socket;
        }
        catch (InvocationTargetException e) {
            Throwable cause = e.getTargetException(); 
            if (SOCKETTIMEOUTEXCEPTION_CLASS == null) {
                try {
                    SOCKETTIMEOUTEXCEPTION_CLASS = Class.forName("java.net.SocketTimeoutException");
                } catch (ClassNotFoundException ex) {
                    // At this point this should never happen. Really.
                    REFLECTION_FAILED = true;
                    return null;
                }
            }
            if (SOCKETTIMEOUTEXCEPTION_CLASS.isInstance(cause)) {
                throw new ConnectTimeoutException(
                    "The host did not accept the connection within timeout of " 
                    + timeout + " ms", cause);
            }
            if (cause instanceof IOException) {
                throw (IOException)cause;
            }
            return null;
        }
        catch (Exception e) {
            REFLECTION_FAILED = true;
            return null;
        }
    }

重點是這兩句:

    SOCKETBIND_METHOD.invoke(socket, new Object[] { localaddr});
    SOCKETCONNECT_METHOD.invoke(socket, new Object[] { remoteaddr, new Integer(timeout)});

HttpClient在connect之前調用了bind,系統調用bind返回了EADDRINUSE錯誤:

    EADDRINUSE
        The given address is already in use.

然后是java.net.PlainSocketImpl.socketBind(Native Method)拋出了BindException。

這樣的話,的確,是臨時端口號耗盡,導致拋出了BindException,因為HttpClient在connect之前,先調用了bind。

只是,為什么要先bind呢?

Bind before Connect

connect之前先bind,是允許的,但並沒有什么好處,反而帶來極大的危害。

好吧,其實在特定情況下也可能有一點好處,這里先說危害,后面再說好處。

前面說了,臨時端口號是有限的資源,數量是有限制的。並且TCP連接是個四元組:

    {source_ip, source_port, destination_ip, destination_port}

如果我們直接調用connect,由操作系統來分配臨時端口號:

    connect(socket, destination_addr, sizeof destination_addr);

那么操作系統就為不同的destination_ip和destination_port,分別維護臨時端口號分配。

假設臨時端口號數量為N,那么每一個destination_ip和destination_port的組合,都能創建N個連接。

而如果connect之前先調用bind:

    bind(socket, source_addr, sizeof source_addr);
    connect(socket, destination_addr, sizeof destination_addr);

那已經bind過還沒釋放的source_port就不會再允許bind。臨時端口號就變成了不同destination之間共用的資源。

假設臨時端口號數量為N,那么所有destination_ip和destination_port的組合加起來,一共只能創建N個連接。

反應到HttpClient和java應用上,舉例來講:

如果你的java應用,既要使用HttpClient訪問百度,又要使用HttpClient訪問Google,還要使用HttpClient訪問Bing。你的操作系統臨時端口號數量限制為10000。

那么直接connect,百度、Google、Bing都能同時存在10000個連接,且互相之間無影響。

先bind后connect,百度、Google、Bing加起來一共只能創建10000個連接,且互相之間有影響,需要連接百度的流量大了,連接多了超過限制了,需要連接Google和Bing的也會失敗。

HttpClient 4.4

看到這里,原因已經清楚了。接下來去找了比較新的HttpCliet版本來看是否有改進。如下是HttpClient 4.4的創建連接相關代碼:

package org.apache.http.impl.pool;
public class BasicConnFactory implements ConnFactory<HttpHost, HttpClientConnection>:

    @Override
    public HttpClientConnection create(final HttpHost host) throws IOException {
        final String scheme = host.getSchemeName();
        Socket socket = null;
        if ("http".equalsIgnoreCase(scheme)) {
            socket = this.plainfactory != null ? this.plainfactory.createSocket() :
                    new Socket();
        } if ("https".equalsIgnoreCase(scheme)) {
            socket = (this.sslfactory != null ? this.sslfactory :
                    SSLSocketFactory.getDefault()).createSocket();
        }
        if (socket == null) {
            throw new IOException(scheme + " scheme is not supported");
        }
        final String hostname = host.getHostName();
        int port = host.getPort();
        if (port == -1) {
            if (host.getSchemeName().equalsIgnoreCase("http")) {
                port = 80;
            } else if (host.getSchemeName().equalsIgnoreCase("https")) {
                port = 443;
            }
        }
        socket.setSoTimeout(this.sconfig.getSoTimeout());
        if (this.sconfig.getSndBufSize() > 0) {
            socket.setSendBufferSize(this.sconfig.getSndBufSize());
        }
        if (this.sconfig.getRcvBufSize() > 0) {
            socket.setReceiveBufferSize(this.sconfig.getRcvBufSize());
        }
        socket.setTcpNoDelay(this.sconfig.isTcpNoDelay());
        final int linger = this.sconfig.getSoLinger();
        if (linger >= 0) {
            socket.setSoLinger(true, linger);
        }
        socket.setKeepAlive(this.sconfig.isSoKeepAlive());
        socket.connect(new InetSocketAddress(hostname, port), this.connectTimeout);
        return this.connFactory.createConnection(socket);
    }

果然,改掉了,沒有在connect之前先bind了。直接調用的connect:

    socket.connect(new InetSocketAddress(hostname, port), this.connectTimeout);

有條件還是要積極升級各種庫的版本啊。

連接池、熔斷降級

像這次這個老應用這種,對三方依賴占用的資源沒有限制,也沒有熔斷降級。確實還是太粗放了。

首先連接池必須有,連接復用提升效率,並且可以限制連接數,對客戶端對服務端都好。HttpClient本身就支持連接池。

另外對三方依賴要有熔斷降級,當一個依賴方出現問題或者相關流量大的時候,該降級降級,該熔斷熔斷,盡量的將影響控制到最小范圍。熔斷降級可以用hystrix。

Linux Ephemeral Port Range

就着這次問題排查,總結下臨時端口號相關知識。因為每個操作系統不同,這里主要介紹linux。

臨時端口號范圍:

# sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768   61000

假設我們的業務邏輯處理非常快網絡也好,一個連接從建立到關閉在1ms內,那么一個臨時端口號被分配到下次可以使用,只需要等待TCP連接的TIME_WAIT狀態結束即可。

TIME_WAIT狀態的持續時間定義內核代碼$KERNEL/include/net/tcp.h中:

#define TCP_TIMEWAIT_LEN (60*HZ)

以上皆為多數linux內核的默認值。

可以看到,默認臨時端口號共有61000-32768=28232個。一個端口號被使用后,最少需要60秒才能釋放。

也就是說,如果固定了source_ip、destination_ip、destination_port,每分鍾最多只能創建28232個連接,平均每秒(61000-32768)/60=470.5個。

幾百個,一個非常小的數值。對於流量大的業務,很容易出問題。更何況上面HttpClient先bind再connect。

如果想要改變這種情況,提高能夠同時創建的連接數量。有以下幾種辦法:

  • 調大net.ipv4.ip_local_port_range

這個范圍可以調大,但最大不能超過65536,最小不能超過1234

比如可以調成這樣:

sysctl net.ipv4.ip_local_port_range="1235 65000"

這個操作沒什么風險,可以適當調大。

  • 允許端口快速復用

也就是允許還處在TIME_WAIT狀態的TCP連接占用的本地端口,被其它TCP連接使用。系統默認是不允許的。

可以在系統層面配置net.ipv4.tcp_tw_reuse:

sysctl net.ipv4.tcp_tw_reuse=1

也可以為特定的socket設置SO_REUSEADDR選項。

不過TIME_WAIT狀態本身是有意義的,用來保證TCP連接的可靠性。允許復用TIME_WAIT狀態的連接占用的端口號,雖然資源利用率提供,但也可能帶來難以排查和解決的隱藏問題,需要慎重開啟相關配置。

誠如man ip(7)所述:

A TCP local socket address that has been bound is unavailable for some time after closing, unless the SO_REUSEADDR flag has been set. Care should be taken when using this flag as it makes TCP less reliable.

  • 使用多個source_ip

這個方案比較tricky,如前所述,固定了source_ip、destination_ip、destination_port,臨時端口號數量固定。

如果有多個source_ip,那么可用的臨時端口號數量可以成倍增長。

怎么用呢,需要利用系統調用bind的一個特性。如果在bind的時候,指定source_ip,但source_port設置為0,並且為socket設置IP_BIND_ADDRESS_NO_PORT選項。

tcp sockets before binding to a specific source ip with port 0 if you're going to use the socket for connect() rather then listen() this allows the kernel to delay allocating the source port until connect() time at which point it is much cheaper

這樣在bind的時候,系統不會分配端口號,而是等到connect時再分配,但又指定了source_ip。

想要用這個方案,就必須先bind再connect了。這就是前文所述,bind before connect有可能的好處。

這個方案不實用,大部分情況下,服務器只有一個可用ip,這個方案都是用不了的。即便能用,用起來也比較麻煩。

Reference

https://idea.popcount.org/2014-04-03-bind-before-connect/
https://www.nginx.com/blog/overcoming-ephemeral-port-exhaustion-nginx-plus/
https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux
https://github.com/torvalds/linux/blob/4ba9920e5e9c0e16b5ed24292d45322907bb9035/net/ipv4/inet_connection_sock.c#L118


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM