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

近期在作一個 DNS 服務器切換升級的演練中發現,咱們在 NodeJS 中使用的 axios 以及默認的 dns.lookup 存在一些問題,會致使切換過程當中的響應耗時從 ~80ms 上升至 ~3min,最終 nginx 層出現大量 502。html

具體背景與分析參見 《node中請求超時的一些坑》 ➡️

總結來講,NodeJS DNS 這塊的「坑」可能有↓↓node

  • 使用 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 查詢與引入緩存的影響面較廣,線上引入前須要慎重確認如下問題:linux

<!-- more -->ios

須要解答的疑問

若是使用 lookup-dns-cache 來替換默認的 dns.lookup,須要確認如下三個問題:nginx

  1. 使用該 package 後,DNS 查詢與緩存的具體實現細節是怎樣的?
  2. 使用該 package 後是否與默認的 dns.lookup 方法同樣,在 Linux 上也使用 resolv.conf 配置?
  3. 使用該 package 後,DNS 查詢的 timeout 值如何控制?

下面基於 NodeJS v12.16.3 分別對這三個問題進行分析。git

TL;DR

本文會從 NodeJS 源碼(JS & C/C++)與底層依賴庫源碼上進行分析,以爲太長的能夠直接看結論:github

  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,而且是異步實現。npm

lookup-dns-cache 主要是在 dns.resolve* 之上提供了兩個優化點:axios

  1. 避免額外的並行請求:對同一個 hostname 的並行查詢,在查詢請求未結束前,只會執行一次 dns.resolve*,其他放置在回調隊列;
  2. DNS 查詢結果的緩存:提供基於 TTL 的緩存能力。

1. 避免額外的並行請求

該處主要是用過 TasksManager 來實現。實現很簡單,發起 DNS 查詢時,用 Map 存儲當前正在進行查詢的 hostname,查詢結束後,從 Map 中刪除。具體調用則在 Lookup.js 的 _innerResolvec#

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;
}
...
相關文章
相關標籤/搜索