線上有個老應用,在流量增長的時候,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