深入内核分析BindException异常原因

以云看科技 2024-09-15 05:44:34
一、前言

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

报错信息

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

image.png

二、Tomcat源码分析

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

三、OpenJDK源码分析

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

3.1、bind0 函数源码JNIEXPORT void JNICALLJava_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); }}

该函数主要做了两件事:

调用NET_Bind函数执行bind操作。调用handleSocketError函数处理bind操作返回的错误码。3.2、NET_Bind函数源码intNET_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的源码得出的,不够严谨。所以我们又进行了如下验证:

编写一个简单的SpringBoot程序并打成Jar包。基于线上相同的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) = 1816340 setsockopt(18, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 016340 setsockopt(18, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 016340 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) = 016340 listen(18, 100) = 016373 accept(18, <unfinished ...>...

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

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

3.4、handleSocketError函数源码jinthandleSocketError(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;}通过文件描述符fd找到对应的socket对象。将传入的address由用户态内存copy到内核态内存。调用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错误码的逻辑,下面会逐个分析。

当inet_bind_bucket_create函数返回为空时会返回EADDRINUSE错误码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函数源码中可以看出,只有当内存分配失败时才可能返回空,这种情况还是比较少见的基本可以排除这个原因。

当inet_use_bhash2_on_bind函数和inet_bhash2_addr_any_conflict函数都返回true会返回EADDRINUSE错误码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,所以排除这个可能性。

当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 信也科技基础架构研发专家,主要负责分布式对象存储的研发工作。

来源-微信公众号:拍码场

出处:https://mp.weixin.qq.com/s/YdLwN4rtNEyOpDYw6PUt4g

0 阅读:8