request 包中出现 DNS 解析超时的探究

事情的起因是这样的,公司使用自建 dns 服务器,但是有一个致命缺陷,不支持 ipv6 格式的地址解析,而 node 的 DNS 解析默认是同时请求 v4 和 v6 的地址的,这样会导致偶尔在解析 v6 地址的时候出现超时。

本文链接地址 https://blog.whyun.com/posts/request-dns/the-problem-of-dns-timeout-on-request-package/index.html ,转载请注明出处。

我们的程序中是使用的 request 这个包,查看了一下官方文档,请求 options 中并没有涉及跟 DNS 有关的配置,于是乎求教运维同事。运维同事告诉我在 docker run 的时候加参数 --sysctl net.ipv6.conf.all.disable_ipv6=1,试用了一下,并且写出来如下测试代码:

const dns = require('dns');
const domain = process.argv[2] || 'baidu.com';
const begin = Date.now();
dns.lookup(domain,function(err ,address , family) {
    console.log('耗时',Date.now() - begin, err ,address , family);
});

代码 1.1 DNS 查询测试代码

运行 代码 1.1 ,同时使用命令 tcpdump -i eth0 -n -s 500 port domain 来抓取 DNS 解析的数据包:

20:47:28.917563 IP 10.6.13.67.38050 > 10.7.11.219.domain: 40621+ A? baidu.com. (27)
20:47:28.917582 IP 10.6.13.67.38050 > 10.7.11.219.domain: 32393+ AAAA? baidu.com. (27)
20:47:28.921061 IP 10.7.11.219.domain > 10.6.13.67.38050: 40621 2/0/0 A 220.181.38.148, A 39.156.69.79 (59)
20:47:28.921114 IP 10.7.11.219.domain > 10.6.13.67.38050: 32393 0/1/0 (70)

从输出来看依然会请求 ipv6 的地址解析,所以当时我的判断是运维的配置是不生效的。

后来又有了些空闲的时间,所以研究了一下官方文档,看看是否有参数可以控制 http 请求的 DNS 协议版本,没有想到还真有,http.request 的 options 中可以设置 family 参数,可选值为 4 6, 即 ipv4 或者 ipv6,如果不指定这个参数,将同时使用 ipv4 和 ipv6。按理来说看到这里,我就应该死心了,如果不传这个参数,肯定会同时做 ipv4 和 ipv6 的地址解析,但是我还是抱着试试看的态度写下了如下测试代码:

var domain = process.argv[2] || 'baidu.com';
require('http').request('http://' + domain,function(res) {
    console.log(`STATUS: ${res.statusCode}`);
  console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
  res.setEncoding('utf8');
  res.on('data', (chunk) => {
    //console.log(`BODY: ${chunk}`);
  });
  res.on('end', () => {
    console.log('No more data in response.');
  });
}).end();

代码 1.2 http 请求测试

没有想到 代码 1.2 执行完成后竟然只做了 ipv4 的解析:

21:01:04.247650 IP 10.6.12.158.51977 > 10.7.11.219.domain: 21651+ A? docker.17zuoye.net. (36)
21:01:04.248316 IP 10.7.11.219.domain > 10.6.12.158.51977: 21651* 1/0/0 A 10.7.11.98 (52)
21:01:06.593429 IP 10.6.12.158.48479 > 10.7.11.219.domain: 10352+ A? baidu.com. (27)
21:01:06.596978 IP 10.7.11.219.domain > 10.6.12.158.48479: 10352 2/0/0 A 39.156.69.79, A 220.181.38.148 (59)

这就很神奇了,node 的 http 的代码封装中肯定做了什么!带着这个疑问,我阅读了 node 的源码,首先看 ClientRequest 的初始化代码中,连接初始化部分:

  // initiate connection
  if (this.agent) {
    this.agent.addRequest(this, options);
  } else {
    // No agent, default to Connection:close.
    this._last = true;
    this.shouldKeepAlive = false;
    if (typeof options.createConnection === 'function') {
      const newSocket = options.createConnection(options, oncreate);
      if (newSocket && !called) {
        called = true;
        this.onSocket(newSocket);
      } else {
        return;
      }
    } else {
      debug('CLIENT use net.createConnection', options);
      this.onSocket(net.createConnection(options));
    }
  }

代码 1.3 ClientRequest 类的连接初始化

http.request 没有加任何参数的情况,默认走到 this.onSocket(net.createConnection(options)); 这句话,然后看 net 包的代码,其中一端跟 DNS 相关的代码:

  if (dns === undefined) dns = require('dns');
  const dnsopts = {
    family: options.family,
    hints: options.hints || 0
  };

  if (process.platform !== 'win32' &&
      dnsopts.family !== 4 &&
      dnsopts.family !== 6 &&
      dnsopts.hints === 0) {
    dnsopts.hints = dns.ADDRCONFIG;
  }

  debug('connect: find host', host);
  debug('connect: dns options', dnsopts);
  self._host = host;
  const lookup = options.lookup || dns.lookup;

代码 1.4 net 包中 DNS 查询参数代码

然后我们再看 lookup 函数的源码:

// Easy DNS A/AAAA look up
// lookup(hostname, [options,] callback)
function lookup(hostname, options, callback) {
  var hints = 0;
  var family = -1;
  var all = false;
  var verbatim = false;

  // Parse arguments
  if (hostname && typeof hostname !== 'string') {
    throw new ERR_INVALID_ARG_TYPE('hostname', 'string', hostname);
  } else if (typeof options === 'function') {
    callback = options;
    family = 0;
  } else if (typeof callback !== 'function') {
    throw new ERR_INVALID_CALLBACK(callback);
  } else if (options !== null && typeof options === 'object') {
    hints = options.hints >>> 0;
    family = options.family >>> 0;
    all = options.all === true;
    verbatim = options.verbatim === true;

    validateHints(hints);
  } else {
    family = options >>> 0;
  }

  if (family !== 0 && family !== 4 && family !== 6)
    throw new ERR_INVALID_OPT_VALUE('family', family);

  if (!hostname) {
    emitInvalidHostnameWarning(hostname);
    if (all) {
      process.nextTick(callback, null, []);
    } else {
      process.nextTick(callback, null, null, family === 6 ? 6 : 4);
    }
    return {};
  }

  const matchedFamily = isIP(hostname);
  if (matchedFamily) {
    if (all) {
      process.nextTick(
        callback, null, [{ address: hostname, family: matchedFamily }]);
    } else {
      process.nextTick(callback, null, hostname, matchedFamily);
    }
    return {};
  }

  const req = new GetAddrInfoReqWrap();
  req.callback = callback;
  req.family = family;
  req.hostname = hostname;
  req.oncomplete = all ? onlookupall : onlookup;

  const err = cares.getaddrinfo(
    req, toASCII(hostname), family, hints, verbatim
  );
  if (err) {
    process.nextTick(callback, dnsException(err, 'getaddrinfo', hostname));
    return {};
  }
  return req;
}

代码 1.5 lookup 函数源码

通过代码 1.5 发现最终 DNS 查询是要调用 C++ 绑定类的,于是我又查看了 C++ 的代码:

void GetAddrInfo(const FunctionCallbackInfo<Value>& args) {
  Environment* env = Environment::GetCurrent(args);

  CHECK(args[0]->IsObject());
  CHECK(args[1]->IsString());
  CHECK(args[2]->IsInt32());
  CHECK(args[4]->IsBoolean());
  Local<Object> req_wrap_obj = args[0].As<Object>();
  node::Utf8Value hostname(env->isolate(), args[1]);

  int32_t flags = 0;
  if (args[3]->IsInt32()) {
    flags = args[3].As<Int32>()->Value();
  }

  int family;

  switch (args[2].As<Int32>()->Value()) {
    case 0:
      family = AF_UNSPEC;
      break;
    case 4:
      family = AF_INET;
      break;
    case 6:
      family = AF_INET6;
      break;
    default:
      CHECK(0 && "bad address family");
  }

  auto req_wrap = std::make_unique<GetAddrInfoReqWrap>(env,
                                                       req_wrap_obj,
                                                       args[4]->IsTrue());

  struct addrinfo hints;
  memset(&hints, 0, sizeof(hints));
  hints.ai_family = family;
  hints.ai_socktype = SOCK_STREAM;
  hints.ai_flags = flags;

  TRACE_EVENT_NESTABLE_ASYNC_BEGIN2(
      TRACING_CATEGORY_NODE2(dns, native), "lookup", req_wrap.get(),
      "hostname", TRACE_STR_COPY(*hostname),
      "family",
      family == AF_INET ? "ipv4" : family == AF_INET6 ? "ipv6" : "unspec");

  int err = req_wrap->Dispatch(uv_getaddrinfo,
                               AfterGetAddrInfo,
                               *hostname,
                               nullptr,
                               &hints);
  if (err == 0)
    // Release ownership of the pointer allowing the ownership to be transferred
    USE(req_wrap.release());

  args.GetReturnValue().Set(err);
}

代码 1.6 C++ 中 DNS 的查询代码

注意 代码 1.5 中的 family hints 最终会分别转化为 结构体变量 struct addrinfo hints 中的 ai_family 和 ai_flags。

最终这个结构体 hints 会层层传递到 libuv 中:

static void uv__getaddrinfo_work(struct uv__work* w) {
  uv_getaddrinfo_t* req;
  int err;

  req = container_of(w, uv_getaddrinfo_t, work_req);
  err = getaddrinfo(req->hostname, req->service, req->hints, &req->addrinfo);
  req->retcode = uv__getaddrinfo_translate_error(err);
}

代码 1.7 libuv 中的 dns 查询函数代码

注意到我们在 代码 1.4 中的 hints 参数,最终会作为 req->hints->ai_flags 参数,最终我在 man7 文档上找到了AI_ADDRCONFIG 的这个参数的说明:

If hints.ai_flags includes the AI_ADDRCONFIG flag, then IPv4
addresses are returned in the list pointed to by res only if the
local system has at least one IPv4 address configured, and IPv6
addresses are returned only if the local system has at least one IPv6
address configured.  The loopback address is not considered for this
case as valid as a configured address.  This flag is useful on, for
example, IPv4-only systems, to ensure that getaddrinfo() does not
return IPv6 socket addresses that would always fail in connect(2) or
bind(2).

大体意思是说,系统配置了 ipv4 才返回 ipv4的地址,系统配置了 ipv6 才返回 ipv6 的地址,而 docker 的启动参数 --sysctl net.ipv6.conf.all.disable_ipv6=1 等同于系统只支持 ipv4 的声明,所以操作系统函数 getaddrinfo 就只返回 ipv4 的地址。

重新验证这个问题,将代码 1.1 做改造:

const dns = require('dns');
const domain = process.argv[2] || 'baidu.com';
const begin = Date.now();
dns.lookup(domain,{hints:32},function(err ,address , family) {
    console.log('耗时',Date.now() - begin, err ,address , family);
});

代码 1.8 使用 ADDRCONFIG 参数做 DNS 查询

这里面之所以取值的 hints:32,是因为 AI_ADDRCONFIG 的值为32。通过设置环境变量 NODE_DEBUG=net 后启动 代码1.2 ,会发现 debug('connect: dns options', dnsopts); 打印的 hints 值为 32。

重新运行,发现果然只查询了 ipv4 的地址。

到此为止,其实可以算是圆满收官了,但是对于 request 包还是不死心,心想如果当前开源源码不支持,是否可以做一个 pull request 呢,于是我看了一下他们的官方源码,结果就发现了新大陆:

  var reqOptions = copy(self)
  delete reqOptions.auth

  debug('make request', self.uri.href)

  // node v6.8.0 now supports a `timeout` value in `http.request()`, but we
  // should delete it for now since we handle timeouts manually for better
  // consistency with node versions before v6.8.0
  delete reqOptions.timeout

  try {
    self.req = self.httpModule.request(reqOptions)
  } catch (err) {
    self.emit('error', err)
    return
  }

代码 1.9 request 源码片段

self.httpModule.request(reqOptions) 等同于 http.request(reqOptions) 或者 https.request(reqOptions),也就是说 http 模块的所有参数其实在 request 上也是适用的,但是 request 的官方文档却没有指出!

最圆满的方案出炉了,在调用 request 函数的时候,指定 family 为 4,即可屏蔽 ipv6 解析。