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

纵饮孤独 提交于 2020-05-07 08:50:02

近期在做一个 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)的初始化有四个步骤,从前到后分别是:

在第一种通过 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.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
复制代码

2.2. 实验二:

环境: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'
}
复制代码

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 值

参考资料


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;
}
...
复制代码
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!