近期在做一个 DNS 服务器切换升级的演练中发现,我们在 NodeJS 中使用的 axios 以及默认的 dns.lookup
存在一些问题,会导致切换过程中的响应耗时从 ~80ms 上升至 ~3min,最终 nginx 层出现大量 502。
具体背景与分析参见《node中请求超时的一些坑》 ➡️
总结来说,NodeJS DNS 这块的“坑”可能有↓↓
dns.lookup
来进行 DNS 查询,其底层调用了系统函数 getaddrinfo
。getaddrinfo
会同步阻塞,所以使用线程池来模拟异步,默认数量为 4。因此如果 DNS 查询时间过长且并发请求多,则会导致整体事件循环(Event Loop)出现延迟(阻塞)。Request#setTimeout
方法,该方法的超时时间不包括 DNS 查询。因此如果你将超时设为 3s,但是 DNS 查询由于 DNS 服务器未响应挂起了 5s(甚至更久),这种情况下你的请求是不会被超时释放的。随着请求的越来越多问题会被累积,造成雪崩。getaddrinfo
使用 resolv.conf 中 nameserver 配置作为本地 DNS 服务器,可以配置多个作为主从。但其并没有完备的探活等自动切换机制。主下掉后,仍然会从第一个开始尝试,超时后切换下一个。即使使用 Round Robin,理论上仍会有 1/N 的请求第一个命中超时节点(N 为 nameserver 的数量)。针对这种问题,在不去修改 NodeJS 底层(主要是 C/C++ 层)源码的情况下,在 JS 层引入 DNS 的缓存是一个轻量级的方案,会一定程度上规避这个问题(但也并不能完美解决)。因此,计划引入 lookup-dns-cache 作为优化方案。但更换 DNS 查询与引入缓存的影响面较广,线上引入前需要慎重确认以下问题:
<!-- more -->
如果使用 lookup-dns-cache 来替换默认的 dns.lookup
,需要确认以下三个问题:
dns.lookup
方法一样,在 Linux 上也使用 resolv.conf 配置?下面基于 NodeJS v12.16.3 分别对这三个问题进行分析。
本文会从 NodeJS 源码(JS & C/C++)与底层依赖库源码上进行分析,觉得太长的可以直接看结论:
lookup-dns-cache 整体代码量很少,DNS 查询相关功能都委托给了 dns.resolve*
方法。与 dns.lookup
不同,dns.resolve*
并不使用 getaddrinfo
,并且是异步实现。
lookup-dns-cache 主要是在 dns.resolve*
之上提供了两个优化点:
dns.resolve*
,其余放置在回调队列;该处主要是用过 TasksManager
来实现。实现很简单,发起 DNS 查询时,用 Map 存储当前正在进行查询的 hostname,查询结束后,从 Map 中删除。具体调用则在 Lookup.js 的 _innerResolve
中:
let task = this._tasksManager.find(key); if (task) { task.addResolvedCallback(callback); } else { task = new ResolveTask(hostname, ipVersion); this._tasksManager.add(key, task); task.on('addresses', addresses => { this._addressCache.set(key, addresses); }); task.on('done', () => { this._tasksManager.done(key); }); task.addResolvedCallback(callback); task.run(); }
其中的 key 是通过 ${hostname}_${ipVersion}
拼接而成(ipVersion:ipv4/ipv4)。可以看到,如果在 TasksManager
实例中找到 task,则只添加回调,否则就发起一个查询,即创建一个 ResolveTask
实例。
lookup-dns-cache 通过为 resolve* 方法设置 ttl: true
来让 DNS 查询结果返回 TTL 值。对于查询回来的结果会在当前时间基础上加上 TTL 来作为过期时间:
addresses.forEach(address => { address.family = this._ipVersion; address.expiredTime = Date.now() + address.ttl * 1000; });
当进行 DNS 查询前,会先查缓存,如果存在则直接返回。而在 AddressCache 中进行缓存查询时,如果判断当前时间超过过期时间,则不再返回缓存结果:
find(key) { if (!this._cache.has(key)) { return; } const addresses = this._cache.get(key); if (this._isExpired(addresses)) { return; } return addresses; }
这里可能会存在一个问题:如果查询的域名名称无限,由于缓存中仅判断是否过期,并无过期清理操作,因此过期缓存可能会一直占用内存而不释放。当然,由于普通业务项目中,域名查询的种类有限,并且基本会一直重复,因此并不会暴露该问题。
阅读 lookup-dns-cache 的源码可以知道,其进行 DNS 查询使用的是 NodeJS 提供的另一类方法 —— dns.resolve*
。因此引出了下一个问题,dns.resolve*
是否使用 resolv.conf 配置?
在 lib/dns.js
最后可以发现,dns 模块导出的相关 resolve 方法是通过
bindDefaultResolver(module.exports, getDefaultResolver());
这行绑定上去的。
而在 lib/internal/dns/utils.js
中会发现,getDefaultResolver
方法会返回一个 Resolver 实例。在这个模块里并没有各种 resolve 方法,而具体其上的 resolve 方法则还是在 lib/dns.js
中实现的:
... function resolver(bindingName) { function query(name, /* options, */ callback) { let options; if (arguments.length > 2) { options = callback; callback = arguments[2]; } validateString(name, 'name'); if (typeof callback !== 'function') { throw new ERR_INVALID_CALLBACK(callback); } const req = new QueryReqWrap(); req.bindingName = bindingName; req.callback = callback; req.hostname = name; req.oncomplete = onresolve; req.ttl = !!(options && options.ttl); const err = this._handle[bindingName](req, toASCII(name)); if (err) throw dnsException(err, bindingName, name); return req; } ObjectDefineProperty(query, 'name', { value: bindingName }); return query; } const resolveMap = ObjectCreate(null); Resolver.prototype.resolveAny = resolveMap.ANY = resolver('queryAny'); Resolver.prototype.resolve4 = resolveMap.A = resolver('queryA'); Resolver.prototype.resolve6 = resolveMap.AAAA = resolver('queryAaaa'); Resolver.prototype.resolveCname = resolveMap.CNAME = resolver('queryCname'); ...
而这里关于 DNS 查询调用的核心的方法就是 this._handle[bindingName](req, toASCII(name))
。如果我们再回到 lib/internal/dns/utils.js
这个定义 Resolver 类的地方就会发现:
... class Resolver { constructor() { this._handle = new ChannelWrap(); } ... } ...
this._handle
是 ChannelWrap
的一个实例。ChannelWrap
来自于对 c-ares 的内部绑定 —— cares_wrap.cc。
c-ares: This is an asynchronous resolver library. It is intended for applications which need to perform DNS queries without blocking, or need to perform multiple DNS queries in parallel.
按照官方文档的说法,c-ares 支持 resolv.conf。但为了保险起见,具体在 NodeJS 的调用中是否使用到,需要继续向下进一步确认。
拉到 cares_wrap.cc 的最后就可以看到针对 NodeJS 层的一些绑定代码,这里截取和 dns.resolve
相关部分:
... Local<FunctionTemplate> channel_wrap = env->NewFunctionTemplate(ChannelWrap::New); channel_wrap->InstanceTemplate()->SetInternalFieldCount(1); channel_wrap->Inherit(AsyncWrap::GetConstructorTemplate(env)); env->SetProtoMethod(channel_wrap, "queryAny", Query<QueryAnyWrap>); env->SetProtoMethod(channel_wrap, "queryA", Query<QueryAWrap>); env->SetProtoMethod(channel_wrap, "queryAaaa", Query<QueryAaaaWrap>); env->SetProtoMethod(channel_wrap, "queryCname", Query<QueryCnameWrap>); ... Local<String> channelWrapString = FIXED_ONE_BYTE_STRING(env->isolate(), "ChannelWrap"); channel_wrap->SetClassName(channelWrapString); target->Set(env->context(), channelWrapString, channel_wrap->GetFunction(context).ToLocalChecked()).Check(); ...
以上代码主要包括两个部分,在 C++ 层创建了 JS 的 ChannelWrap
类,同时设置相应的原型方法。因此,在 JS 层 new ChannelWrap()
基本上的调用链条为 ChannelWrap::New
--> ChannelWrap::ChannelWrap
--> ChannelWrap::Setup
。其中 Setup 阶段调用了 c-ares 的初始化配置方法:
void ChannelWrap::Setup() { ... /* We do the call to ares_init_option for caller. */ r = ares_init_options(&channel_, &options, ARES_OPT_FLAGS | ARES_OPT_SOCK_STATE_CB); ... }
注意这里的第三个参数,就是该方法的 opmask,会决定使用哪些 options。
在 c-ares 中具体配置(包括 dns server)的初始化有四个步骤,从前到后分别是:
在第一种通过 option 结构体传参中,ares 会通过 options->nservers
来获取 DNS 服务器配置。但同时,需要在操作掩码中设置 ARES_OPT_SERVERS
。而在 NodeJS 中值设置了 ARES_OPT_FLAGS | ARES_OPT_SOCK_STATE_CB
,因此不会设置 nservers。此外,init_by_options 中还会设置 resolvconf_path 的值,该值所指向的地址就是系统 resolv.conf 的地址:
/* Set path for resolv.conf file, if given. */ if ((optmask & ARES_OPT_RESOLVCONF) && !channel->resolvconf_path) { channel->resolvconf_path = ares_strdup(options->resolvconf_path); if (!channel->resolvconf_path && options->resolvconf_path) return ARES_ENOMEM; }
同样的,从上面节选的代码可以看出,NodeJS 调用中 optmask 并不包含 ARES_OPT_RESOLVCONF
,因此 channel->resolvconf_path
为空,而此处也会影响后续的 init_by_resolv_conf
方法。
从 ares_init_options
代码的流程控制来看,正常情况下,设置完传参和环境变量后,最终会走到 init_by_resolv_conf
中。init_by_resolv_conf
方法主要是用来解析和获取 nameservers,其中包含比较多平台相关的条件编译,我们可以关注两个条件分支:
#elif defined(CARES_USE_LIBRESOLV)
CARES_USE_LIBRESOLV
这个宏表示是否使用 resolv 这个库。
IF ((IOS OR APPLE) AND HAVE_LIBRESOLV) SET (CARES_USE_LIBRESOLV 1) ENDIF()
看起来似乎是在苹果系统下会启用。一旦使用这个库,条件分支里就会有两个重要的函数调用 —— res_ninit
和 res_getservers
。
从手册中可以看出,res_ninit
会读取 resolv.conf,
The res_ninit() and res_init() functions read the configuration files (see resolv.conf(5)) to get the default domain name and name server address(es).
因此在该分支中会使用 resolv.conf 文件。
再看另一条分支。最后条件分支(看起来应该是 Linux)部分的处理,其中会优先读取 resolv.conf 的配置地址,不存在则取预定义的宏变量:
/* Support path for resolvconf filename set by ares_init_options */ if(channel->resolvconf_path) { resolvconf_path = channel->resolvconf_path; } else { resolvconf_path = PATH_RESOLV_CONF; }
PATH_RESOLV_CONF
则定义在 ares_private.h
中:
#define PATH_RESOLV_CONF "/etc/resolv.conf"
channel->nservers
的设置也是通过读取文件中的 nameserver 配置项来添加的:
else if ((p = try_config(line, "nameserver", ';')) && channel->nservers == -1) status = config_nameserver(&servers, &nservers, p);
这里有个值得注意的地方,如果你具体去看,会发现并没有读取 timeout 配置,这个可能说明,如果使用 dns.resolve
,配置中的 timeout 变量并不会生效。
设置完成之后,当需要进行 DNS 查询时,最终会调用 ares_send.c 中的 ares_send
方法来发送查询请求。其中就会使用 channel->nservers
中的值来作为本地 DNS 查询服务器,其中 last_server 默认为 0:
/* Choose the server to send the query to. If rotation is enabled, keep track * of the next server we want to use. */ query->server = channel->last_server; if (channel->rotate == 1) channel->last_server = (channel->last_server + 1) % channel->nservers;
这里还有个细节,从代码上来看,可以通过控制 channel->rotate
的值为 1 来开启本地 DNS 查询服务器的 RoundRobin 策略。而从实现上来看,它是通过 options 和 opmask 来控制的,似乎不会因为 resolv.conf 配置多个 nameserver 而自动 rr?
综合上面的分析可知,在 NodeJS(v12.16.3)中,调用 dns.resolve*
相关方法,底层会调用 c-ares 这个库。根据 c-ares 的实现来分析,其最终会读取 resolv.conf
的 nameserver 设置本地 DNS,并用其进行查询。
P.S. c-ares 也依赖 glibc 的 resolv。
经过上面的分析之后,可以再简单进行一下实际验证。下面是一段调用 dns.resolve
(其他 resolve 方法同理)的代码:
const dns = require('dns'); dns.resolve('www.acfun.cn', function (...args) { console.log(...args); });
环境:CentOS Linux release 7.4.1708
运行输出:
$ node test.js null [ '172.18.201.64' ]
用 strace 看下它的调用链:
$ strace node test.js
内容比较多,下图只截取其中一部分,可以看到打开并读取了 resolv.conf。
strace 输出(第8行的 open 调用):
mprotect(0x43c0b904000, 503808, PROT_READ|PROT_EXEC) = 0 read(21, "const dns = require('dns');\ndns."..., 102) = 102 close(21) = 0 mprotect(0x43c0b884000, 503808, PROT_READ|PROT_WRITE) = 0 mprotect(0x43c0b904000, 503808, PROT_READ|PROT_WRITE) = 0 mprotect(0x43c0b884000, 503808, PROT_READ|PROT_EXEC) = 0 mprotect(0x43c0b904000, 503808, PROT_READ|PROT_EXEC) = 0 open("/etc/resolv.conf", O_RDONLY) = 21 fstat(21, {st_mode=S_IFREG|0644, st_size=176, ...}) = 0 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4d5c7e6000 read(21, "#nameserver 10.75.60.252\n#namese"..., 4096) = 176 read(21, "", 4096) = 0 close(21) = 0 munmap(0x7f4d5c7e6000, 4096) = 0 open("/etc/nsswitch.conf", O_RDONLY) = 21 fstat(21, {st_mode=S_IFREG|0644, st_size=1746, ...}) = 0 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4d5c7e6000 read(21, "#\n# /etc/nsswitch.conf\n#\n# An ex"..., 4096) = 1746 read(21, "", 4096) = 0 close(21) = 0 munmap(0x7f4d5c7e6000, 4096) = 0 uname({sysname="Linux", nodename="hb2-acfuntest-ls004.aliyun", ...}) = 0 open("/dev/urandom", O_RDONLY) = 21 fstat(21, {st_mode=S_IFCHR|0666, st_rdev=makedev(1, 9), ...}) = 0
环境:macOS 10.15.3
运行输出:
$ node test.js null [ '61.149.11.118', '111.206.4.103', '61.149.11.116', '61.149.11.117', '61.149.11.115', '61.149.11.113', '61.149.11.112', '111.206.4.98', '111.206.4.97', '61.149.11.119', '111.206.4.96', '61.149.11.114' ]
可以看到,域名被正常解析了。下面修改 /etc/resolv.conf
内容,将 nameserver 改为一个无法访问的 IP(前面三个被注释的是原 DNS server):
# # macOS Notice # # This file is not consulted for DNS hostname resolution, address # resolution, or the DNS query routing mechanism used by most # processes on this system. # # To view the DNS configuration used by this system, use: # scutil --dns # # SEE ALSO # dns-sd(1), scutil(8) # # This file is automatically generated. # #nameserver 172.18.1.166 #nameserver 192.168.43.27 #nameserver 192.168.1.1 nameserver 192.168.2.2
此时再执行,会触发超时错误:
Error: queryA ETIMEOUT www.acfun.cn at QueryReqWrap.onresolve [as oncomplete] (dns.js:202:19) { errno: 'ETIMEOUT', code: 'ETIMEOUT', syscall: 'queryA', hostname: 'www.acfun.cn' }
通过源码和测试,可以确定 dns.resolve 相关方法,在 Linux 仍然会读取 resolv.conf 配置来设置本地 DNS 服务器。
在 c-ares 部分有提到两个编译分支,在最后一个 else 中,并不会对 timeout 的值进行处理,因此会落到最后的默认赋值上(5s)
if (channel->timeout == -1) channel->timeout = DEFAULT_TIMEOUT;
DEFAULT_TIMEOUT
定义在这,为 5s
#define DEFAULT_TIMEOUT 5000 /* milliseconds */
而对于走到 CARES_USE_LIBRESOLV 分支的代码,则因为调用了 res_ninit
,可以在 __res_state
结构体中取到 retrans 值,该值会被用作 timeout 值:
if (channel->timeout == -1) channel->timeout = res.retrans * 1000;
c-ares 文档也有关于 timeout 的简单说明
按照之前分析来看,在生产环境(CentOS 7)中应该是属于第一种情况。由于 NodeJS 层没有暴露对应设置超时的入口,所以,如果替换为 lookup-dns-cache,则都会落到默认超时时间,无法控制 timeout 的时间。
NodeJS 官方文档
P.S. resolv 中设置 timeout(retrans)值目测是在这个地方
... else if (!strncmp (cp, "timeout:", sizeof ("timeout:") - 1)) { int i = atoi (cp + sizeof ("timeout:") - 1); if (i <= RES_MAXRETRANS) parser->template.retrans = i; else parser->template.retrans = RES_MAXRETRANS; } ...