NodeJS 中 DNS 查询的坑 & DNS cache 分析

作者 : 开心源码 本文共11102个字,预计阅读时间需要28分钟 发布时间: 2022-05-13 共205人阅读

近期在做一个 DNS 服务器切换更新的演练中发现,我们在 NodeJS 中使用的 axios 以及默认的 dns.lookup 存在少量问题,会导致切换过程中的响应耗时从 ~80ms 上升至 ~3min,最终 nginx 层出现大量 502。

具体背景与分析参见《node中请求超时的少量坑》 ➡️

总结来说,NodeJS DNS 这块的“坑”可能有↓↓

  • 使用 http 模块发起请求(axios 也用的它),默认会使用 dns.lookup 来进行 DNS 查询,其底层调用了系统函数 getaddrinfogetaddrinfo 会同步阻塞,所以使用线程池来模拟异步,默认数量为 4。因而假如 DNS 查询时间过长且并发请求多,则会导致整体事件循环(Event Loop)出现推迟(阻塞)。
  • 假如使用 axios 来设置 timeout,在 0.19.0 之后实际会调用 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 查询与引入缓存的影响面较广,线上引入前需要慎重确认以下问题:

需要解答的疑问

假如使用 lookup-dns-cache 来替换默认的 dns.lookup,需要确认以下三个问题:

  1. 使用该 package 后,DNS 查询与缓存的具体实现细节是怎么的?
  2. 使用该 package 后能否与默认的 dns.lookup 方法一样,在 Linux 上也使用 resolv.conf 配置?
  3. 使用该 package 后,DNS 查询的 timeout 值如何控制?

下面基于 NodeJS v12.16.3 分别对这三个问题进行分析。

TL;DR

本文会从 NodeJS 源码(JS & C/C++)与底层依赖库源码上进行分析,觉得太长的可以直接看结论:

  1. lookup-dns-cache 在 JS 这一层做了防止重复请求和缓存两处优化
  2. lookup-dns-cache 最底层也使用了 resolv.conf 这个配置
  3. 使用 lookup-dns-cache 后无法控制 timeout 值

问题一:查询与缓存实现细节

lookup-dns-cache 整体代码量很少,DNS 查询相关功能都委托给了 dns.resolve* 方法。与 dns.lookup 不同,dns.resolve* 并不使用 getaddrinfo,并且是异步实现。

lookup-dns-cache 主要是在 dns.resolve* 之上提供了两个优化点:

  1. 避免额外的并行请求:对同一个 hostname 的并行查询,在查询请求未结束前,只会执行一次 dns.resolve*,其他放置在回调队列;
  2. DNS 查询结果的缓存:提供基于 TTL 的缓存能力。

1. 避免额外的并行请求

该处主要是用过 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 实例。

2. DNS 缓存

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 配置?


问题二:dns.resolve* 能否使用 resolv.conf 配置

1. 方法的源码分析

1.1. NodeJS 部分

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._handleChannelWrap 的一个实例。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。

1.2. c-ares 部分

在 c-ares 中具体配置(包括 dns server)的初始化有四个步骤,从前到后分别是:

  • 通过传参初始化配置:init_by_options
  • 通过环境变量初始化配置:init_by_environment
  • 通过 resolv conf 初始化:init_by_resolv_conf
  • 默认值填充:init_by_defaults

在第一种通过 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_ninitres_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。

2. 实际验证

经过上面的分析之后,可以再简单进行一下实际验证。下面是一段调用 dns.resolve(其余 resolve 方法同理)的代码:

const dns = require('dns');dns.resolve('www.acfun.cn', function (...args) {  console.log(...args);});

2.1. 试验一:

环境:CentOS Linux release 7.4.1708

运行输出:

$ node test.jsnull [ '172.18.201.64' ]

用 strace 看下它的调用链:

$ strace node test.js

内容比较多,下图只截取其中一部分,可以看到打开并读取了 resolv.conf。

image

strace 输出(第8行的 open 调用):

mprotect(0x43c0b904000, 503808, PROT_READ|PROT_EXEC) = 0read(21, "const dns = require('dns');\ndns."..., 102) = 102close(21)                               = 0mprotect(0x43c0b884000, 503808, PROT_READ|PROT_WRITE) = 0mprotect(0x43c0b904000, 503808, PROT_READ|PROT_WRITE) = 0mprotect(0x43c0b884000, 503808, PROT_READ|PROT_EXEC) = 0mprotect(0x43c0b904000, 503808, PROT_READ|PROT_EXEC) = 0open("/etc/resolv.conf", O_RDONLY)      = 21fstat(21, {st_mode=S_IFREG|0644, st_size=176, ...}) = 0mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4d5c7e6000read(21, "#nameserver 10.75.60.252\n#namese"..., 4096) = 176read(21, "", 4096)                      = 0close(21)                               = 0munmap(0x7f4d5c7e6000, 4096)            = 0open("/etc/nsswitch.conf", O_RDONLY)    = 21fstat(21, {st_mode=S_IFREG|0644, st_size=1746, ...}) = 0mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4d5c7e6000read(21, "#\n# /etc/nsswitch.conf\n#\n# An ex"..., 4096) = 1746read(21, "", 4096)                      = 0close(21)                               = 0munmap(0x7f4d5c7e6000, 4096)            = 0uname({sysname="Linux", nodename="hb2-acfuntest-ls004.aliyun", ...}) = 0open("/dev/urandom", O_RDONLY)          = 21fstat(21, {st_mode=S_IFCHR|0666, st_rdev=makedev(1, 9), ...}) = 0

2.2. 试验二:

环境:macOS 10.15.3

运行输出:

$ node test.jsnull [  '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.1nameserver 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'}

3. 结论

通过源码和测试,可以确定 dns.resolve 相关方法,在 Linux 依然会读取 resolv.conf 配置来设置本地 DNS 服务器。


问题三:关于 DNS 查询的 timeout

在 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 的时间。

综上

  1. lookup-dns-cache 在 JS 这一层做了防止重复请求和缓存两处优化
  2. lookup-dns-cache 最底层也使用了 resolv.conf 这个配置
  3. 使用 lookup-dns-cache 后无法控制 DNS 查询的 timeout 值

参考资料

  • NodeJS 官方文档
    • DNS
    • Dependencies
  • c-ares
  • man-pages: RESOLVER
  • How to fix nodejs DNS issues?
  • axios: difference in timeout behavior between versions 0.18.1 and 0.19.0-2

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;}...
说明
1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长!
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是摆设,本站源码仅提供给会员学习使用!
7. 如遇到加密压缩包,请使用360解压,如遇到无法解压的请联系管理员
开心源码网 » NodeJS 中 DNS 查询的坑 & DNS cache 分析

发表回复