背景
腾讯云消息队列(Cloud Message Queue,CMQ)是一种分布式消息队列服务,它能够提供可靠的基于消息的异步通信机制,能够将分布式部署的不同应用(或同一应用的不同组件)之间的收发消息,存储在可靠有效的 CMQ 队列中,防止消息丢失。CMQ 支持多进程同时读写,收发互不干扰,无需各应用或组件始终处于运行状态。
可是有一天遇到一个问题,一个客户使用同样的消费者代码在三台CVM上面部署应用,其中有一台无法消费任何消息,运行报错,对于java这种Write once,run anywhere的语言来说,是很奇怪的,根据以往经验,这往往是环境问题。那么就需要我们仔细分析一下问题的根本原因。
问题排查原因及解决方案
我们先来看看报错的截图:
初步看来是RequestIdHelper这个类初始化失败,这种问题往往是静态代码块或者实例变量初始化异常造成。接着仔细查看异常堆栈,从中发现了问题,根源就是消费者静态代码块中用于获取ip地址构造RequestId的代码抛了异常,这句代码就是InetAddress.getLocalHost(),一句简单的代码,造成了严重的问题,整个消费者无法正常消费消息。
原因分析
为什么一句简单的InetAddress.getLocalHost()会抛出异常呢,我们分析下JDK的源代码,我们在源码中加注释分析:
代码语言:java复制 public static InetAddress getLocalHost() throws UnknownHostException {
SecurityManager security = System.getSecurityManager();
try {
// 1.获取hotname,这是个native方法,hotspot中实现非常简单,
直接系统调用gethostname,如果调用失败,那么获取硬编码值‘localhost’
String local = impl.getLocalHostName();
if (security != null) {
security.checkConnect(local, -1);
}
if (local.equals("localhost")) {
return impl.loopbackAddress();
}
InetAddress ret = null;
synchronized (cacheLock) {
long now = System.currentTimeMillis();
// 2. 如果命中cachedLocalHost,直接用缓存值
if (cachedLocalHost != null) {
if ((now - cacheTime) < maxCacheTime) // Less than 5s old?
ret = cachedLocalHost;
else
cachedLocalHost = null;
}
// we are calling getAddressesFromNameService directly
// to avoid getting localHost from cache
if (ret == null) {
InetAddress[] localAddrs;
try {
// 3. 下面的逻辑是去缓存或者系统调用获取地址
localAddrs =
InetAddress.getAddressesFromNameService(local, null);
} catch (UnknownHostException uhe) {
// Rethrow with a more informative error message.
UnknownHostException uhe2 =
new UnknownHostException(local ": "
uhe.getMessage());
uhe2.initCause(uhe);
throw uhe2;
}
cachedLocalHost = localAddrs[0];
cacheTime = now;
ret = localAddrs[0];
}
}
return ret;
} catch (java.lang.SecurityException e) {
return impl.loopbackAddress();
}
}
那么从上面代码来看,唯一出问题的可能就是在第三步InetAddress.getAddressesFromNameService,里面的逻辑无非就是从缓存中查找或者调用Inet4AddressImpl.lookupAllHostAddr。问题可能就出在这个lookupAllHostAddr方法。lookupAllHostAddr
代码如下:
代码语言:c复制/*
* Find an internet address for a given hostname. Note that this
* code only works for addresses of type INET. The translation
* of %d.%d.%d.%d to an address (int) occurs in java now, so the
* String "host" shouldn't *ever* be a %d.%d.%d.%d string
*
* Class: java_net_Inet4AddressImpl
* Method: lookupAllHostAddr
* Signature: (Ljava/lang/String;)[[B
*/
JNIEXPORT jobjectArray JNICALL
Java_java_net_Inet4AddressImpl_lookupAllHostAddr(JNIEnv *env, jobject this,
jstring host) {
...
...
// 系统调用
error = getaddrinfo(hostname, NULL, &hints, &res);
if (error) {
/* report error */
ThrowUnknownHostExceptionWithGaiError(env, hostname, error);
JNU_ReleaseStringPlatformChars(env, host, hostname);
return NULL;
} else {
....
}
...
}
上面去除一些无关逻辑,可以看到,查询hostname的ip地址的是一个系统调用方法getaddrinfo。如果查不到,那么就可能抛出异常。那么接着分析下这个getaddrinfo是如何执行的。下面写一段代码,准备使用strace分析分析。测试代码如下:
代码语言:javascript复制#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <string.h>
#include <stdio.h>
#include <arpa/inet.h>
int main(int argc, char **argv)
{
int ret = -1;
struct addrinfo *res;
struct addrinfo hint;
struct addrinfo *curr;
char ipstr[16];
if (argc != 2) {
printf("parameter errorn");
return -1;
}
bzero(&hint, sizeof(hint));
hint.ai_family = AF_INET;
hint.ai_socktype = SOCK_STREAM;
ret = getaddrinfo(argv[1], NULL, &hint, &res);
printf("getaddrinfo finishn");
if (ret != 0)
{
printf("getaddrinfo errorn");
return -1;
}
for (curr = res; curr != NULL; curr = curr->ai_next)
{
inet_ntop(AF_INET,&(((struct sockaddr_in *)(curr->ai_addr))->sin_addr), ipstr, 16);
printf("%sn", ipstr);
}
freeaddrinfo(res);
return 0;
}
测试代码中简单调用getaddrinfo方法,代码编写后,在腾讯云上申请一台CVM,系统环境为:Linux version 3.10.0-1062.18.1.el7.x86_64 (mockbuild@kbuilder.bsys.centos.org) (gcc version 4.8.5 20150623 (Red Hat 4.8.5-39) (GCC) ) #1 SMP Tue Mar 17 23:49:17 UTC 2020。然后执行 gcc testGetAddr.c -o testGetAddr,编译得到可执行程序。再执行strace ./testGetAddr {hostname},看到系统调用过程, 省略无关语句整理如下:
代码语言:javascript复制...
...
open("/lib64/libresolv.so.2", O_RDONLY|O_CLOEXEC) = 3
open("/etc/host.conf", O_RDONLY|O_CLOEXEC) = 3
open("/etc/resolv.conf", O_RDONLY|O_CLOEXEC) = 3
open("/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 3
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libnss_files.so.2", O_RDONLY|O_CLOEXEC) = 3
open("/etc/hosts", O_RDONLY|O_CLOEXEC) = 3
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
...
open("/lib64/libnss_dns.so.2", O_RDONLY|O_CLOEXEC) = 3
...
uname({sysname="Linux", nodename="efg", ...}) = 0
socket(AF_NETLINK, SOCK_RAW|SOCK_CLOEXEC|SOCK_NONBLOCK, NETLINK_ROUTE) = 3
setsockopt(3, SOL_SOCKET, SO_PASSCRED, [1], 4) = 0
setsockopt(3, SOL_NETLINK, 3, [1], 4) = 0
bind(3, {sa_family=AF_NETLINK, pid=0, groups=00000000}, 16) = 0
getsockname(3, {sa_family=AF_NETLINK, pid=8933, groups=00000000}, [12]) = 0
sendto(3, "x18x00x00x00x16x00x05x03x01x00x00x00x00x00x00x00x02x20x00x00x00x00x00x00", 24, 0, {sa_family=AF_NETLINK, pid=0, groups=00000000}, 16) = 24
clock_gettime(CLOCK_MONOTONIC, {63428, 128822240}) = 0
recvmsg(3, {msg_name(0)=NULL, msg_iov(1)=[{NULL, 0}], msg_controllen=56, [{cmsg_len=20, cmsg_level=SOL_NETLINK, cmsg_type=3}, {cmsg_len=28, cmsg_level=SOL_SOCKET, cmsg_type=SCM_CREDENTIALS, {pid=0, uid=0, gid=0}}], msg_flags=MSG_TRUNC}, MSG_PEEK|MSG_TRUNC) = 164
recvmsg(3, {msg_name(0)=NULL, msg_iov(1)=[{"x4cx00x00x00x14x00x02x00x01x00x00x00xe5x22x00x00x02x08x80xfex01x00x00x00x08x00x01x00x7fx00x00x01"..., 328}], msg_controllen=56, [{cmsg_len=20, cmsg_level=SOL_NETLINK, cmsg_type=3}, {cmsg_len=28, cmsg_level=SOL_SOCKET, cmsg_type=SCM_CREDENTIALS, {pid=0, uid=0, gid=0}}], msg_flags=0}, MSG_TRUNC) = 164
clock_gettime(CLOCK_MONOTONIC, {63428, 128967588}) = 0
...
上面系统调用主要保留了打开的文件,可以看到先调用libresolv.so.2动态链接库,再打开文件open("/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 3,用于判断从host文件还是从dns server获取地址。由于本机修改了hostname为"efg",以及没有在hosts文件中设置hostname的ip,通过host文件肯定是获取不到地址信息的。那么程序接下来就通过NETLINK的单播形式,给内核发送消息,并尝试得到地址。如果还是得不到的话,那么就会查看/etc/host.conf的内容,本机的是multi on,表示libresolv.so.2需要获取所有ip地址,解析器就会根据/etc/resolv.conf里面指定的所有nameserver获取地址,大概过程就是这样。
所以,InetAddress.getLocalHost实现的步骤总结如下:
* 是否设置好了hostname,如果没有设置,返回hostname为localhost,ip地址硬编码写死127.0.0.1的地址信息实体。
* 如果设置了hostname,那么看看java本地缓存有没有地址信息,如果有,返回地址信息,如果没有,则执行一些列检查逻辑,纠正错误逻辑(如特殊的hostname有它特定的处理),如果是个特殊的hostname,直接返回。
* 执行系统调用getaddrinfo,打开/etc/nsswitch.conf,判断先查host还是先从dns server查,本例子中是则根据名字在hosts文件中查找,找不到,先和内核空间进程通过单播形式通信,尝试获取,如果失败,则使用DNS客户端进行域名解析处理 * 打开文件/etc/services,查找服务 * 打开etc/host.conf 该配置文件为域名解析顺序配置文件,设定解析顺序方式 * 打开/etc/resolv.conf配置文件,该文件用于指定解析的DNS服务器,得dns server * 打开/etc/hosts 文件,查询主机名 * hosts中找不到记录,从nameserver进行主机名称解析。
那么一台机器找不到ip地址,就有可能是上面步骤出了问题。
解决方案/最佳实践
如何解决呢,最简单的办法就是在hosts文件做兜底方案,设置hostname的ip地址即可,或者干脆删除hostname也行,但是这种方案有三个问题,一个是ip地址可能会被瞎写,一个是不同系统设置不同,可能会导致像这次故障一样应用起不来,最后一种就是查询过程中有getnameinfo走dns解析,假如这里网络异常不但可能起不来,还可能使得应用启动缓慢(特别是云函数这种场景影响会非常大)
所以,正常情况下,我们一般不会直接使用InetAddress.getLocalHost,而是通过NetworkInterface
的方式获取ip,下面代码复制自 https://cloud.tencent.com/developer/article/1610919:
代码语言:javascript复制
public static InetAddress getLocalHostExactAddress() {
try {
InetAddress candidateAddress = null;
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
while (networkInterfaces.hasMoreElements()) {
NetworkInterface iface = networkInterfaces.nextElement();
// 该网卡接口下的ip会有多个,也需要一个个的遍历,找到自己所需要的
for (Enumeration<InetAddress> inetAddrs = iface.getInetAddresses(); inetAddrs.hasMoreElements(); ) {
InetAddress inetAddr = inetAddrs.nextElement();
// 排除loopback回环类型地址(不管是IPv4还是IPv6 只要是回环地址都会返回true)
if (!inetAddr.isLoopbackAddress()) {
if (inetAddr.isSiteLocalAddress()) {
// 如果是site-local地址,就是它了 就是我们要找的
// ~~~~~~~~~~~~~绝大部分情况下都会在此处返回你的ip地址值~~~~~~~~~~~~~
return inetAddr;
}
// 若不是site-local地址 那就记录下该地址当作候选
if (candidateAddress == null) {
candidateAddress = inetAddr;
}
}
}
}
// 如果出去loopback回环地之外无其它地址了,那就回退到原始方案吧
return candidateAddress == null ? InetAddress.getLocalHost() : candidateAddress;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}