近期在作一個 DNS 服務器切換升級的演練中發現,咱們在 NodeJS 中使用的 axios 以及默認的 dns.lookup
存在一些問題,會致使切換過程當中的響應耗時從 ~80ms 上升至 ~3min,最終 nginx 層出現大量 502。html
具體背景與分析參見 《node中請求超時的一些坑》 ➡️
總結來講,NodeJS DNS 這塊的「坑」可能有↓↓node
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 查詢與引入緩存的影響面較廣,線上引入前須要慎重確認如下問題:linux
<!-- more -->ios
若是使用 lookup-dns-cache 來替換默認的 dns.lookup
,須要確認如下三個問題:nginx
dns.lookup
方法同樣,在 Linux 上也使用 resolv.conf 配置?下面基於 NodeJS v12.16.3 分別對這三個問題進行分析。git
本文會從 NodeJS 源碼(JS & C/C++)與底層依賴庫源碼上進行分析,以爲太長的能夠直接看結論:github
lookup-dns-cache 總體代碼量不多,DNS 查詢相關功能都委託給了 dns.resolve*
方法。與 dns.lookup
不一樣,dns.resolve*
並不使用 getaddrinfo
,而且是異步實現。npm
lookup-dns-cache 主要是在 dns.resolve*
之上提供了兩個優化點:axios
dns.resolve*
,其他放置在回調隊列;該處主要是用過 TasksManager
來實現。實現很簡單,發起 DNS 查詢時,用 Map 存儲當前正在進行查詢的 hostname,查詢結束後,從 Map 中刪除。具體調用則在 Lookup.js 的 _innerResolve
中:c#
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; } ...