一、前言

前段时间公司内的站点发布时经常遇到Tomcat使用的8080端口被占用,导致启动报错BindException的情况。笔者参与了该问题的排查和修复,本文将深入Tomcat、OpenJDK、Linux内核等源码为大家讲解问题的原因以及排查过程。

报错信息

Caused by: java.net.BindException: Address already in use

深入内核分析BindException异常原因_错误码


二、Tomcat源码分析

起初我们通过Debug发现公司内使用的SpringBoot 1.5.x 版本的内置Tomcat没有默认设置reuse参数(如下图,soReuseAddress默认为null)。根据网上的相关资料,如果没有设置SO_REUSEADDR参数,就可能会与TIME_WAIT状态的Socket发生端口冲突。但服务端需要设置SO_REUSEADDR参数是网络编程的基础知识,Tomcat应该不会犯这么低级的错误,所以让我们带着这个疑问,逐步深入阅读源码进行求证。

深入内核分析BindException异常原因_sed_02


三、OpenJDK源码分析

通过上述错误堆栈一路追查到了 bind0 方法,该方法是 native 的在Java层面看不到源码,我们使用的JDK版本也不是开源的OpenJDK,但我们可以阅读OpenJDK的源码作为参考:

3.1、bind0 函数源码

JNIEXPORT void JNICALL
Java_sun_nio_ch_Net_bind0(JNIEnv *env, jclass clazz, jobject fdo, jboolean preferIPv6,
jboolean useExclBind, jobject iao, int port)
{
SOCKADDR sa;
int sa_len = SOCKADDR_LEN;
int rv = 0;

// 地址格式转换
if (NET_InetAddressToSockaddr(env, iao, port, (struct sockaddr *)&sa, &sa_len, preferIPv6) != 0) {
return;
}
// 1. 调用NET_Bind函数进行bind
rv = NET_Bind(fdval(env, fdo), (struct sockaddr *)&sa, sa_len);
if (rv != 0) {
// 2. 处理异常
handleSocketError(env, errno);
}
}

该函数主要做了两件事:

  1. 调用NET_Bind函数执行bind操作。
  2. 调用handleSocketError函数处理bind操作返回的错误码。

3.2、NET_Bind函数源码

int
NET_Bind(int fd, struct sockaddr *him, int len)
{
#if defined(__solaris__) && defined(AF_INET6)
int level = -1;
int exclbind = -1;
#endif
int rv;
int arg, alen;

#ifdef __linux__
/*
* ## get bugId for this issue - goes back to 1.2.2 port ##
* ## When IPv6 is enabled this will be an IPv4-mapped
* ## with family set to AF_INET6
*/
if (him->sa_family == AF_INET) {
struct sockaddr_in *sa = (struct sockaddr_in *)him;
if ((ntohl(sa->sin_addr.s_addr) & 0x7f0000ff) == 0x7f0000ff) {
errno = EADDRNOTAVAIL;
return -1;
}
}
#endif

#if defined(__solaris__)
/*
* Solaris has separate IPv4 and IPv6 port spaces so we
* use an exclusive bind when SO_REUSEADDR is not used to
* give the illusion of a unified port space.
* This also avoids problems with IPv6 sockets connecting
* to IPv4 mapped addresses whereby the socket conversion
* results in a late bind that fails because the
* corresponding IPv4 port is in use.
*/
alen = sizeof(arg);
if (useExclBind || getsockopt(fd, SOL_SOCKET, SO_REUSEADDR,
(char *)&arg, &alen) == 0) {
if (useExclBind || arg == 0) {
/*
* SO_REUSEADDR is disabled or sun.net.useExclusiveBind
* property is true so enable TCP_EXCLBIND or
* UDP_EXCLBIND
*/
alen = sizeof(arg);
if (getsockopt(fd, SOL_SOCKET, SO_TYPE, (char *)&arg,
&alen) == 0) {
if (arg == SOCK_STREAM) {
level = IPPROTO_TCP;
exclbind = TCP_EXCLBIND;
} else {
level = IPPROTO_UDP;
exclbind = UDP_EXCLBIND;
}
}

arg = 1;
// 调用setsockopt系统调用,设置参数
setsockopt(fd, level, exclbind, (char *)&arg,
sizeof(arg));
}
}

#endif
// 调用bind系统调用
rv = bind(fd, him, len);

#if defined(__solaris__) && defined(AF_INET6)
if (rv < 0) {
int en = errno;
/* Restore *_EXCLBIND if the bind fails */
if (exclbind != -1) {
int arg = 0;
setsockopt(fd, level, exclbind, (char *)&arg,
sizeof(arg));
}
errno = en;
}
#endif

return rv;
}

通过上述源码可以看出,JDK封装的bind函数并不是简单的对bind系统调用的封装,里面会根据各种判断逻辑在bind系统调用之前先执行setsockopt系统调用设置socket参数。所以Tomcat没有设置SO_REUSEADDR参数,是因为JDK默认设置了

3.3、基于strace工具实验验证

由于我们使用的不是OpenJDK,而上述结论是基于OpenJDK的源码得出的,不够严谨。所以我们又进行了如下验证:

  1. 编写一个简单的SpringBoot程序并打成Jar包。
  2. 基于线上相同的JDK版本使用如下命令启动。

sudo strace -f -e socket,bind,setsockopt,getsockopt,listen,connect,accept -o strace.out java -jar test.jar

strace命令会打印这个Java程序运行时发起的系统调用的参数和返回值,输出如下:

...
16340 socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 18
16340 setsockopt(18, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0
16340 setsockopt(18, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
16340 bind(18, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
16340 listen(18, 100) = 0
16373 accept(18, <unfinished ...>
...

可以看到在执行bind系统调用前,先执行了setsockopt系统调用,将SO_REUSEADDR设置为了true。所以端口冲突并不是与TIME_WAIT状态的连接发生的冲突

排查到这我们有了新的思路:只要找到抛出BindException异常的代码逻辑,就可以找到端口冲突的原因。

3.4、handleSocketError函数源码

jint
handleSocketError(JNIEnv *env, jint errorValue)
{
char *xn;
switch (errorValue) {
case EINPROGRESS: /* Non-blocking connect */
return 0;
#ifdef EPROTO
case EPROTO:
xn = JNU_JAVANETPKG "ProtocolException";
break;
#endif
case ECONNREFUSED:
xn = JNU_JAVANETPKG "ConnectException";
break;
case ETIMEDOUT:
xn = JNU_JAVANETPKG "ConnectException";
break;
case EHOSTUNREACH:
xn = JNU_JAVANETPKG "NoRouteToHostException";
break;
case EADDRINUSE: /* Fall through */
case EADDRNOTAVAIL:
// 这里
xn = JNU_JAVANETPKG "BindException";
break;
default:
xn = JNU_JAVANETPKG "SocketException";
break;
}
errno = errorValue;
JNU_ThrowByNameWithLastError(env, xn, "NioSocketError");
return IOS_THROWN;
}

从handleSocketError函数源码可以看到只有当内核的bind系统调用返回的错误码等于EADDRINUSE或者EADDRNOTAVAIL时才会抛出BindException。那么接下来我们就去深入内核源码,找到bind系统调用返回这两个错误码的逻辑。

四、Kernel(6.3.9版本)源码分析

4.1、bind系统调用定义

// 定义了一个3个参数的系统调用 -- bind系统调用
SYSCALL_DEFINE3(bind, int, fd, struct sockaddr __user *, umyaddr, int, addrlen)
{
return __sys_bind(fd, umyaddr, addrlen);
}

// bind系统调用的代码逻辑入口
int __sys_bind(int fd, struct sockaddr __user *umyaddr, int addrlen)
{
struct socket *sock;
struct sockaddr_storage address;
int err, fput_needed;

// 1. 通过文件描述符fd,找到对应的socket对象
sock = sockfd_lookup_light(fd, &err, &fput_needed);
if (sock) {
// 2. 内存copy,将传入的address信息从用户态内存copy到内核态
err = move_addr_to_kernel(umyaddr, addrlen, &address);
if (!err) {
err = security_socket_bind(sock,
(struct sockaddr *)&address,
addrlen);
if (!err)
// 3. socket可能是网络socket或者unix socket,这里要到具体的实现类中查看内部实现逻辑
err = sock->ops->bind(sock,
(struct sockaddr *)
&address, addrlen);
}
fput_light(sock->file, fput_needed);
}
return err;
}

  1. 通过文件描述符fd找到对应的socket对象。
  2. 将传入的address由用户态内存copy到内核态内存。
  3. 调用sock->ops->bind函数(inet_bind)。不同的协议有不同的bind实现。

4.2、inet_bind函数

int inet_bind(struct socket *sock, struct sockaddr *uaddr, int addr_len)
{
struct sock *sk = sock->sk;
u32 flags = BIND_WITH_LOCK;
int err;

/* If the socket has its own bind function then use it. (RAW) */
if (sk->sk_prot->bind) {
return sk->sk_prot->bind(sk, uaddr, addr_len);
}
if (addr_len < sizeof(struct sockaddr_in))
return -EINVAL;

/* BPF prog is run before any checks are done so that if the prog
* changes context in a wrong way it will be caught.
*/
err = BPF_CGROUP_RUN_PROG_INET_BIND_LOCK(sk, uaddr,
CGROUP_INET4_BIND, &flags);
if (err)
return err;
// 核心逻辑在__inet_bind函数中
return __inet_bind(sk, uaddr, addr_len, flags);
}
EXPORT_SYMBOL(inet_bind);

int __inet_bind(struct sock *sk, struct sockaddr *uaddr, int addr_len,
u32 flags)
{
struct sockaddr_in *addr = (struct sockaddr_in *)uaddr;
struct inet_sock *inet = inet_sk(sk);
struct net *net = sock_net(sk);
unsigned short snum;
int chk_addr_ret;
u32 tb_id = RT_TABLE_LOCAL;
int err;

if (addr->sin_family != AF_INET) {
/* Compatibility games : accept AF_UNSPEC (mapped to AF_INET)
* only if s_addr is INADDR_ANY.
*/
err = -EAFNOSUPPORT;
if (addr->sin_family != AF_UNSPEC ||
addr->sin_addr.s_addr != htonl(INADDR_ANY))
goto out;
}

tb_id = l3mdev_fib_table_by_index(net, sk->sk_bound_dev_if) ? : tb_id;
chk_addr_ret = inet_addr_type_table(net, addr->sin_addr.s_addr, tb_id);

/* Not specified by any standard per-se, however it breaks too
* many applications when removed. It is unfortunate since
* allowing applications to make a non-local bind solves
* several problems with systems using dynamic addressing.
* (ie. your servers still start up even if your ISDN link
* is temporarily down)
*/
// EADDRNOTAVAIL错误码,当内核返回这个错误码时JDK会抛出BindException。当inet_addr_valid_or_nonlocal函数返回false时则会直接返回这个错误码
err = -EADDRNOTAVAIL;
if (!inet_addr_valid_or_nonlocal(net, inet, addr->sin_addr.s_addr,
chk_addr_ret))
goto out;
// socket的源端口(8080)
snum = ntohs(addr->sin_port);
err = -EACCES;
if (!(flags & BIND_NO_CAP_NET_BIND_SERVICE) &&
snum && inet_port_requires_bind_service(net, snum) &&
!ns_capable(net->user_ns, CAP_NET_BIND_SERVICE))
goto out;

/* We keep a pair of addresses. rcv_saddr is the one
* used by hash lookups, and saddr is used for transmit.
*
* In the BSD API these are the same except where it
* would be illegal to use them (multicast/broadcast) in
* which case the sending device address is used.
*/
if (flags & BIND_WITH_LOCK)
lock_sock(sk);

/* Check these errors (active socket, double bind). */
err = -EINVAL;
if (sk->sk_state != TCP_CLOSE || inet->inet_num)
goto out_release_sock;

inet->inet_rcv_saddr = inet->inet_saddr = addr->sin_addr.s_addr;
if (chk_addr_ret == RTN_MULTICAST || chk_addr_ret == RTN_BROADCAST)
inet->inet_saddr = 0; /* Use device */

// 服务端的源端口是主动设置上去的不是随机的所以这里源端口存在,所以会进入这个if
/* Make sure we are allowed to bind here. */
if (snum || !(inet->bind_address_no_port ||
(flags & BIND_FORCE_ADDRESS_NO_PORT))) {
// 这里会校验源端口是否冲突,需要看这个方法内部会不会返回那两个特殊的错误码
err = sk->sk_prot->get_port(sk, snum);
if (err) {
inet->inet_saddr = inet->inet_rcv_saddr = 0;
goto out_release_sock;
}
if (!(flags & BIND_FROM_BPF)) {
err = BPF_CGROUP_RUN_PROG_INET4_POST_BIND(sk);
if (err) {
inet->inet_saddr = inet->inet_rcv_saddr = 0;
if (sk->sk_prot->put_port)
sk->sk_prot->put_port(sk);
goto out_release_sock;
}
}
}

if (inet->inet_rcv_saddr)
sk->sk_userlocks |= SOCK_BINDADDR_LOCK;
if (snum)
sk->sk_userlocks |= SOCK_BINDPORT_LOCK;
inet->inet_sport = htons(inet->inet_num);
inet->inet_daddr = 0;
inet->inet_dport = 0;
sk_dst_reset(sk);
err = 0;
out_release_sock:
if (flags & BIND_WITH_LOCK)
release_sock(sk);
out:
return err;
}

在__inet_bind函数中我们遇到了EADDRNOTAVAIL,当inet_addr_valid_or_nonlocal函数返回false时就会返回这个错误码。下面来看下这个函数的源码,看下在这个场景中他是否会返回false。除此之外,sk->sk_prot->get_port函数用于校验端口号是否可用,该函数也可能会返回EADDRINUSE、EADDRNOTAVAIL错误码,需要重点排查。

4.3、inet_addr_valid_or_nonlocal函数源码

static inline bool inet_addr_valid_or_nonlocal(struct net *net,
struct inet_sock *inet,
__be32 addr,
int addr_type)
{
return inet_can_nonlocal_bind(net, inet) ||
我们是服务端的socket,addr默认等于 INADDR_ANY,这条判断只会为true
addr == htonl(INADDR_ANY) ||
addr_type == RTN_LOCAL ||
addr_type == RTN_MULTICAST ||
addr_type == RTN_BROADCAST;
}

该函数是由5个条件的或运算表达式组成的,在我们的场景中addr == htonl(INADDR_ANY)必定为true,所以该函数在我们的场景中只会返回true不会返回false。如果后面没有其他地方返回EADDRNOTAVAIL错误码,那么可以判断BindException不是因为内核返回了EADDRNOTAVAIL错误码导致的。接下来继续看sk->sk_prot->get_port函数(inet_csk_get_port)的逻辑。

4.4、inet_csk_get_port函数

int inet_csk_get_port(struct sock *sk, unsigned short snum)
{
struct inet_hashinfo *hinfo = tcp_or_dccp_get_hashinfo(sk);
由于我们之前设置过SO_REUSEADDR参数,并且此时还没有调用listen系统调用socket状态不等于TCP_LISTEN,所以reuse为true
bool reuse = sk->sk_reuse && sk->sk_state != TCP_LISTEN;
bool found_port = false, check_bind_conflict = true;
bool bhash_created = false, bhash2_created = false;
导致BindException的错误码出现了!
int ret = -EADDRINUSE, port = snum, l3mdev;
struct inet_bind_hashbucket *head, *head2;
struct inet_bind2_bucket *tb2 = NULL;
struct inet_bind_bucket *tb = NULL;
bool head2_lock_acquired = false;
struct net *net = sock_net(sk);

l3mdev = inet_sk_bound_l3mdev(sk);
如果没有指定端口,会进入这里随机端口。服务端socket不会进入这个if
if (!port) {
head = inet_csk_find_open_port(sk, &tb, &tb2, &head2, &port);
if (!head)
return ret;

head2_lock_acquired = true;

if (tb && tb2)
goto success;
found_port = true;
} else {
head = &hinfo->bhash[inet_bhashfn(net, port,
hinfo->bhash_size)];
spin_lock_bh(&head->lock);
inet_bind_bucket_for_each(tb, &head->chain)
if (inet_bind_bucket_match(tb, net, port, l3mdev))
break;
}

if (!tb) {
tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep, net,
head, port, l3mdev);
if (!tb)
当inet_bind_bucket_create函数返回为空时会返回EADDRINUSE错误码
goto fail_unlock;
bhash_created = true;
}

if (!found_port) {
if (!hlist_empty(&tb->owners)) {
if (sk->sk_reuse == SK_FORCE_REUSE ||
(tb->fastreuse > 0 && reuse) ||
sk_reuseport_match(tb, sk))
check_bind_conflict = false;
}
当inet_use_bhash2_on_bind函数和inet_bhash2_addr_any_conflict函数都返回true会返回EADDRINUSE错误码
if (check_bind_conflict && inet_use_bhash2_on_bind(sk)) {
if (inet_bhash2_addr_any_conflict(sk, port, l3mdev, true, true))
goto fail_unlock;
}

head2 = inet_bhashfn_portaddr(hinfo, sk, net, port);
spin_lock(&head2->lock);
head2_lock_acquired = true;
tb2 = inet_bind2_bucket_find(head2, net, port, l3mdev, sk);
}

if (!tb2) {
tb2 = inet_bind2_bucket_create(hinfo->bind2_bucket_cachep,
net, head2, port, l3mdev, sk);
if (!tb2)
与①相同,当inet_bind2_bucket_create返回空时返回EADDRINUSE错误码
goto fail_unlock;
bhash2_created = true;
}

if (!found_port && check_bind_conflict) {
当inet_csk_bind_conflict返回true时,返回EADDRINUSE错误码
if (inet_csk_bind_conflict(sk, tb, tb2, true, true))
goto fail_unlock;
}

success:
inet_csk_update_fastreuse(tb, sk);

if (!inet_csk(sk)->icsk_bind_hash)
inet_bind_hash(sk, tb, tb2, port);
WARN_ON(inet_csk(sk)->icsk_bind_hash != tb);
WARN_ON(inet_csk(sk)->icsk_bind2_hash != tb2);
ret = 0;

fail_unlock:
if (ret) {
if (bhash_created)
inet_bind_bucket_destroy(hinfo->bind_bucket_cachep, tb);
if (bhash2_created)
inet_bind2_bucket_destroy(hinfo->bind2_bucket_cachep,
tb2);
}
if (head2_lock_acquired)
spin_unlock(&head2->lock);
spin_unlock_bh(&head->lock);
return ret;
}

在inet_csk_get_port函数源码中有3处可能会返回EADDRINUSE错误码的逻辑,下面会逐个分析。

  1. 当inet_bind_bucket_create函数返回为空时会返回EADDRINUSE错误码
  2. inet_bind_bucket_create函数源码

struct inet_bind_bucket *inet_bind_bucket_create(struct kmem_cache *cachep,
struct net *net,
struct inet_bind_hashbucket *head,
const unsigned short snum,
int l3mdev)
{
分配内存
struct inet_bind_bucket *tb = kmem_cache_alloc(cachep, GFP_ATOMIC);
如果内存分配成功则初始化,否则直接返回空
if (tb) {
write_pnet(&tb->ib_net, net);
tb->l3mdev = l3mdev;
tb->port = snum;
tb->fastreuse = 0;
tb->fastreuseport = 0;
INIT_HLIST_HEAD(&tb->owners);
hlist_add_head(&tb->node, &head->chain);
}
return tb;
}

从inet_bind_bucket_create函数源码中可以看出,只有当内存分配失败时才可能返回空,这种情况还是比较少见的基本可以排除这个原因。

  1. 当inet_use_bhash2_on_bind函数和inet_bhash2_addr_any_conflict函数都返回true会返回EADDRINUSE错误码
  2. inet_use_bhash2_on_bind函数源码

static bool inet_use_bhash2_on_bind(const struct sock *sk)
{
#if IS_ENABLED(CONFIG_IPV6)
if (sk->sk_family == AF_INET6) {
int addr_type = ipv6_addr_type(&sk->sk_v6_rcv_saddr);

return addr_type != IPV6_ADDR_ANY &&
addr_type != IPV6_ADDR_MAPPED;
}
#endif
服务端socket的源地址默认等于INADDR_ANY,所以这里只会返回false
return sk->sk_rcv_saddr != htonl(INADDR_ANY);
}

inet_use_bhash2_on_bind函数在服务端socket的场景下只会返回false,所以排除这个可能性。

  1. 当inet_csk_bind_conflict返回true时,返回EADDRINUSE错误码

只剩这最后一种可能性了,从网上的资料可知inet_csk_bind_conflict函数是用来判断端口是否冲突的,当我们设置了SO_REUSEADDR参数之后,该函数还是返回端口冲突,那么就只能是在Tomcat启动之前有其他的Socket占用了8080端口导致的。 由于问题并不是必现的,所以一定不是其他的服务端socket也占用了8080端口导致的,只能是因为某些组件客户端在Tomcat启动之前进行初始化,发起了网络请求随机到了8080端口导致的(比如配置中心、MQ等都会在初始化时连接服务端)。在通过sysctl看了下系统配置,net.ipv4.ip_local_port_range的值被设置为了1024~65535,看到这里基本就可以确定是由于随机端口的范围设置不合理导致的,调整为10000~65535后问题得到解决。

五、总结

BindException是一个比较常见的异常,可能大多数程序员都遇到过,在遇到该问题时使用万能的重启大法基本都可以解决,所以也很少有人去排查定位该问题的根因。本文基于Tomcat、OpenJDK、Linux内核等源码由浅入深的分析了该异常可能出现的原因,希望对大家有所帮助。

作者简介

LGW 信也科技基础架构研发专家,主要负责分布式对象存储的研发工作。