Linux 系统如何处理名称解析

简单介绍

在 Linux 系统中, 绝大多数程序依赖系统库函数来完成名称解析, 整个解析过程包含多个操作, 有些操作信息在启动程序时确定, 有些操作信息则在程序运行时确认, Linux 也提供了一些网络函数(由 glibc 提供)来控制这些操作,下图所示为一个比较典型的应用程序, 域名解析及域名服务器之间的关系图:

dns_que1

程序在运行后通过 glibc 提供的网络函数(gethostbyname, getaddrinfo等)来调用解析器(resolver code, 比如 nsswitch 等), 解析器则读取一些配置文件(比如 /etc/nsswitch.conf, /etc/hosts, /etc/resolv.conf) 来决定使用什么域名服务器(nameserver)以什么选项(超时时间, 重试次数), 什么优先级(先 hosts 还是 先 dns) 对指定的域名进行处理. 当然也可以使用 dns 缓存类的工具加速请求的处理, 这些工具可以是 bind(当缓存用), nscd(由 glibc 提供), systemd-resolved(由 systemd 提供). 下面则简单介绍解析器提供的几个网络函数.

函数说明

glibc 的解析器(revolver code) 提供了下面两个函数实现名称到 ip 地址的解析, gethostbyname 函数以同步阻塞的方式提供服务, 没有超时等选项, 仅提供 IPv4 的解析. getaddrinfo 则没有这些限制, 同时支持 IPv4, IPv6, 也支持 IPv4 到 IPv6 的映射选项. 包含 Linux 在内的很多系统都已废弃 gethostbyname 函数, 使用 getaddrinfo 函数代替. 不过从现实的情况来看, 还是有很多程序或网络库使用 gethostbyname 进行服务.

gethostbyname

#include <netdb.h>
struct hostent *gethostbyname (const char *hostname);

        Returns: non-null pointer if OK,NULL on error with h_errno set

getaddrinfo

#include <netdb.h>
int getaddrinfo (const char *hostname, const char *service, const struct
                 addrinfo *hints, struct addrinfo **result) ;

        Returns: 0 if OK, nonzero on error

备注: 下文中用到的示例程序见:

hostercv.c
hostercv.go
hostercv.java
hostrecv.pl
hostrecv.py

resolv 解析

解析过程稍显复杂, 我们从一个正常的请求过程来分析整体的过程:

# strace -f -e open curl -I www.baidu.com
...
open("/lib64/libcurl.so.4", O_RDONLY|O_CLOEXEC) = 3
...
open("/lib64/libnss3.so", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libnssutil3.so", O_RDONLY|O_CLOEXEC) = 3
...
open("/lib64/libresolv.so.2", O_RDONLY|O_CLOEXEC) = 3
...
strace: Process 2398 attached
[pid  2398] open("/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 3
[pid  2398] open("/etc/host.conf", O_RDONLY|O_CLOEXEC) = 3
[pid  2398] open("/etc/resolv.conf", O_RDONLY|O_CLOEXEC) = 3
[pid  2398] open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
[pid  2398] open("/lib64/libnss_files.so.2", O_RDONLY|O_CLOEXEC) = 3
[pid  2398] open("/etc/hosts", O_RDONLY|O_CLOEXEC) = 3
[pid  2398] open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
[pid  2398] open("/lib64/libnss_mdns4_minimal.so.2", O_RDONLY|O_CLOEXEC) = 3
[pid  2398] open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
[pid  2398] open("/lib64/libnss_dns.so.2", O_RDONLY|O_CLOEXEC) = 3
[pid  2398] connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.12.17.21")}, 16) = 0
[pid  2398] open("/etc/gai.conf", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
[pid  2398] connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("220.181.111.37")}, 16) = 0
[pid  2398] +++ exited with 0 +++
HTTP/1.1 200 OK
Server: bfe/1.0.8.18
Date: Tue, 18 Dec 2018 09:32:19 GMT
Content-Type: text/html
Content-Length: 277
Last-Modified: Mon, 13 Jun 2016 02:50:08 GMT
Connection: Keep-Alive
ETag: "575e1f60-115"
Cache-Control: private, no-cache, no-store, proxy-revalidate, no-transform
Pragma: no-cache
Accept-Ranges: bytes

可以看到整个过程依次调用了 libnns3, libresolv 等动态库, 再访问 nsswitch.conf 以确定解析是先访问 hosts 文件还是先通过 dns server. host.conf 包含解析器的配置, 默认为 multi on, 表示 libresolv 会返回解析到的所有ip(单域名可能对应多条 A 记录). resolv.conf 包含指定的 nameserver, 解析器会连接这些 nameserver 获取域名对应的 A 记录. 后面的 /etc/hosts/etc/libnss_dns.so 则表示先访问 hosts 文件再发起 dns 到 nameserver 的请求. 如果我们绑定了域名 到 /etc/hosts, 则会忽略 /etc/libnss_dns.so 的步骤; 如果 nsswitch.conf 中 dns 优先, 则先通过 /etc/libnss_dns.so 请求, 找到记录则忽略 /etc/hosts 步骤. 之后连接 resolv.conf 指定的 dns server(10.12.17.21) 获取域名对应的 A 记录 220.181.111.37, 程序再连接该 A 记录得到网站的内容. 下面则详细介绍几个配置文件的作用.

/etc/nsswitch.conf

nsswitch.conf 为系统库和域名服务切换的配置文件, 支持的种类较多, 包含 group, hosts, networks, passwd 等, 绝大多数基于 GNU C 库的应用都会遵循该配置文件的设置以确定具体的信息以何种顺序获取. 以名称解析为例, 获取一个域名可以通过绑 hosts, dns, nis 以及自定义程序等多种方式获取, 不过我们可以在 nsswitch.conf 中控制优先使用哪种方式, 没有找到结果则使用下一种方式, 如下所示为默认的配置信息, 先文件(/etc/hosts) 再 dns 请求:

hosts:      files dns

备注: 修改 nsswitch.conf 中的选项顺序不影响已运行的程序的解析顺序.

/etc/hosts

hosts 文件使用的很普遍, 主要提供静态的名称查找. 本地测试等环境中会普遍使用此功能. 文件中的条目以如下格式存在:

IP_address canonical_hostname [aliases...]

备注: 修改此文件立即生效. 运行的程序也会受此影响, 长连接除外.

/etc/resolv.conf

resolv.conf 为解析器的配置文件, 可以为解析器配置相关的 dns server, 超时, 重试次数等选项. 推荐的配置参考如下:

options timeout:1 attempts:1
nameserver 10.0.2.2
nameserver 10.0.2.3
nameserver 10.0.2.4

上述的配置中, 我们制定每个 dns server 的超时时间为1, 失败 1 次即轮转下一个 dns server. 在网络连接不上情况下, 解析器先选取 10.0.2.2 作为 dns server, 如果 1s 内没有获取结果则轮转到 10.0.2.3, 以此类推, 如果三个 dns server 都没有成功则程序异常退出. 如果 attempts 设置为 2, 则每隔 dns server 会试 2 次, 如果都失败则转到下一个 dns. 如下所示:

# strace -f -e connect,poll curl -I www.baidu.com
strace: Process 3375 attached
...
[pid  3375] connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.0.2.2")}, 16) = 0
[pid  3375] poll([{fd=3, events=POLLIN}], 1, 1000 <unfinished ...>
[pid  3374] poll(NULL, 0, 1000 <unfinished ...>
[pid  3375] <... poll resumed> )        = 0 (Timeout)
...
[pid  3375] connect(4, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.0.2.3")}, 16) = 0
[pid  3375] poll([{fd=4, events=POLLIN}], 1, 1000 <unfinished ...>
[pid  3374] poll(NULL, 0, 1000 <unfinished ...>
[pid  3374] <... poll resumed> )        = 0 (Timeout)
...
[pid  3375] connect(5, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.0.2.4")}, 16) = 0
[pid  3375] poll([{fd=5, events=POLLIN}], 1, 1000 <unfinished ...>
[pid  3374] poll(NULL, 0, 1000 <unfinished ...>
[pid  3375] <... poll resumed> )        = 0 (Timeout)
[pid  3375] +++ exited with 0 +++
<... poll resumed> )                    = 0 (Timeout)
curl: (6) Could not resolve host: www.baidu.com; Unknown error

备注:

修改 resolv.conf 里的 dns server, 多数运行的程序不会立即生效. Centos 7 系统中, glibc-2.17-202 版本合并了官方 glibc-2.25.90-18 的功能, 增加了自动检测 resolv.conf 修改功能, 如下:

# rpm -q --changelog glibc-2.17-260
...
* Fri Sep 29 2017 Florian Weimer <fweimer@redhat.com> - 2.17-202
....
- Detect and apply /etc/resolv.conf changes in libresolv (#1432085)

其它 Linux 发行版的 glibc 也可能合并该功能. 低版本的 glibc 可以在以下两种情况会立即生效:

调用 res_init 函数

早期的很多程序在 gehostbyname 函数失败的时候会额外调用一次 res_init 函数以重新加载 /etc/resolv.conf 配置, 这种情况下修改的 resolv.conf 配置即可立即生效. 一些编程语言的网络库可能提供此功能或选项控制. 示例如下:

        if ((get = gethostbyname(argv[1])) == NULL) {  // get the host info
            herror("gethostbyname");
            res_init();
            continue;
        }

res_init 函数, 由 glibc 提供:

#include <netinet/in.h>
#include <arpa/nameser.h>
#include <resolv.h>

int res_init(void);

dns 缓存

一些 dns 缓存程序(nscd, systemd-resolved) 在运行时监听 resolv.conf 配置文件的修改,如果有变动则重新加载配置, 这样新的 dns server 就会生效. 下面小节会详细介绍 dns 缓存工具.

dns 缓存工具

在没有 dns 缓存的情况下, 正常名称解析的流程如下所示, 可以看到系统层是不做 dns 缓存的:

+-----------+     +----------------+     +----------+     +------------------+
|application| --> |network function| --> |glibc(nss)| --> |hosts/named server|
+-----------+     +----------------+     +----------+     +------------------+ 

启用了 dns 缓存工具后, 正常的名称解析大致如下:

+-----------+     +----------------+     +----------+     +----------+     +---------------+     +------------------+
|application| --> |network function| --> |glibc(nss)| --> |cache tool| --> |netwok function| --> |hosts/named server|
+-----------+     +----------------+     +----------+     +----------+     +---------------+     +------------------+

这里的 cache tool 相当于反向代理的角色, 应用程序将解析请求转给 cache tool, cache tool 则按照上述的流程进行名称解析, 如果缓存中包含待解析的条目则直接返回给程序, 反之则到 dns server 请求, 得到结果后返回给用户并加到缓存中.

目前常用的缓存工具包含以下几种:

nscd

该工具由 glibc 提供, /etc/nscd.conf 为其配置信息, 同 nsswitch, nscd 也支持 group, services, hosts, passwd 的缓存, 正式环境中推荐仅缓存 hosts 条目, 如下配置:

        enable-cache            hosts           yes
        positive-time-to-live   hosts           100      # 缓存中成功项的 TTL 值, 程序通过缓存的条目请求成功后可以多保留一会.
        negative-time-to-live   hosts           2        # 缓存中失败项的 TTL 值, 程序通过缓存的条目请求失败应该尽快剔除该条目.
        suggested-size          hosts           211
        check-files             hosts           yes      # 检查 hosts/resolv.conf 等文件的修改
        persistent              hosts           yes
        shared                  hosts           yes
        max-db-size             hosts           33554432 # 缓存的 db 文件最大 32M

如上所示, 开启 check-files 选项后, nscd 会监听 /etc/host, /etc/resolv.conf 的修改, 进而触发 nscd 重新加载配置文件:

[pid  5332] 17:57:25.629904 inotify_add_watch(3, "/etc/resolv.conf", IN_CLOSE_WRITE|IN_DELETE_SELF|IN_MOVE_SELF) = 4
[pid  5332] 17:57:25.630037 stat("/etc/resolv.conf", {st_mode=S_IFREG|0644, st_size=130, ...}) = 0
[pid  5332] 17:57:25.630150 open("/etc/resolv.conf", O_RDONLY|O_CLOEXEC) = 12

备注:

线上开启 nscd 前, 建议做好程序的测试, nscd 仅支持通过 glibc, c 标准机制运行的程序, 没有基于 glibc 运行的程序可能不支持 nscd. 另外一些 go, perl 等编程语言网络库的解析函数是单独实现的, 不会走 nscd 的 socket, 这种情况下程序可以进行名称解析, 但不会使用 nscd 缓存. 不过我们在测试环境中使用go, java 的常规网络库都可以正常连接 nscd 的 socket 进行请求; perl 语言使用 Net::DNS 模块, 不会使用 nscd 缓存; python 语言使用 python-dns 模块, 不会使用 nscd 缓存. python 和 perl 不使用模块的时候进行解析还是遵循上述的过程, 同时使用 nscd 缓存.

其它风险:

单独安装 nscd(deb, rpm)时, 其依赖的 glibc-{common,devel,headers} 可能会更新. 如果要使用 nscd, 最好在装机时就安装好.

systemd-resolved

systemd 也提供 dns 解析服务, 和 nscd 类似, 不过功能更加丰富, systemd-resolved 可以提供端口监听(默认 127.0.0.53:53, /etc/resolv.conf 需增加该 dns 地址), dbus 等方式对外服务, 也支持 DNSSEC 和 LLMNR 功能. 不过在实际的使用中, 低版本的 systemd 存在一些权限类的问题, 不如高版本的稳定, 下面以 Centos 7(Linux-3.10.0-862.14.4.el7.x86_64) 为例说明.

centos 7 的 systemd 为 219 版本, 在启动 systemd-resolved 服务的时候提示权限拒绝:

# /usr/lib/systemd/systemd-resolved               
Using system hostname 'dbinfo8'.
Failed to register name: Permission denied
Could not create manager: Permission denied

追踪来看, 启动的时候不允许设置本地 socket 的 buf, 以及连接 dbus 不支持一些协议:

11:27:26.585170 socket(AF_LOCAL, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 11
11:27:26.585479 setsockopt(11, SOL_SOCKET, SO_RCVBUFFORCE, [8388608], 4) = -1 EPERM (Operation not permitted)
11:27:26.585705 setsockopt(11, SOL_SOCKET, SO_SNDBUFFORCE, [8388608], 4) = -1 EPERM (Operation not permitted)
11:27:26.585905 connect(11, {sa_family=AF_LOCAL, sun_path="/var/run/dbus/system_bus_socket"}, 33) = 0
11:27:26.586093 getsockopt(11, SOL_SOCKET, SO_PEERSEC, 0x55b0f44d3a10, 0x7ffee693b7e4) = -1 ENOPROTOOPT (Protocol not available)

systemd 229 版本才开始完全支持 dbus 特性以鼓励开发者使用 dbus 而不是 dns server(53端口) 处理 dns 请求, 从这点来看在 centos 7 系统中不要使用该服务. 基于 Linux-4.x 系列的发行版的 systemd 都为 23x 版本, systemd-resolved 默认集成到 systemd 中. 该服务通过以下配置进行 dns 请求代理, 并监听 127.0.0.53:53 供本地程序作为 dns server 使用:

/run/systemd/resolve/resolv.conf

bind9, dnsmasq

bind9, dnsmasq 本身为 dns server 工具, 不过它们也提供 dns 缓存功能, 性能也足够好, 只需本地 /etc/resolv.conf 增加该 dns server 的信息即可. 实际使用中仅把 dns server 当缓存用的不多见.

consul

consul 类似上述 bind9, dnsmasq 工具, 不过提供 client 模式同步 server 端的 dns 条目. 其也自带故障转换功能, 一个 dns 条目有问题, 所有的请求都会转到能够正常服务的 dns 条目中.

java

java_doc 默认提供了 dns 缓存特性, jdk1.5 及之前, 默认都永久缓存, jdk 1.6 及之后受以下策略影响:

开启安全管理, 则永久缓存;
没有开启安全管理, 则缓存 30s;

是否开启安全管理查看以下信息, AllPermission 即为所有权限, 表示没有开启安全管理, jdk1.6 ~ 1.8 中默认都没有开启:

# /opt/jdk/jre/lib/security/java.policy
// Standard extensions get all permissions by default

grant codeBase "file:$/*" {
        permission java.security.AllPermission;
};

同样也需要注意以下两个选项:

# /opt/jdk/jre/lib/security/java.security

networkaddress.cache.ttl    # 由上述安全管理影响

networkaddress.cache.negative.ttl (default: 10) #缓存里的 ip 无效的时候多久剔除无效条目, 这个时间不要太长. 

如下所示, java 示例程序每 30s 请求一次 dns server, 在低于 glibc-2.17-202 的版本中修改 resolv.conf 不会生效, 高版本 glibc 中在 30s 周期完成后会重新加载 resolv.conf 信息:

# strace -f -e connect -tt /opt/jdk1.7.0_04/bin/java hostrecv www.baidu.com
[pid 55938] 13:33:18.375060 connect(4, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.12.17.21")}, 16) = 0
...
apidev.baidu.com/109.244.11.48
...
[pid 55938] 13:33:48.401914 connect(4, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.12.17.21")}, 16) = 0
...

在 30s 的缓存时间内, 修改 /etc/hosts 无效, 如下所示, 修改 hosts 后没有立即生效:

# strace -f -e connect,open,stat -tt /opt/jdk1.7.0_04/bin/java hostrecv www.baidu.com
...
[pid 57843] 13:36:04.186257 open("/etc/hosts", O_RDONLY|O_CLOEXEC) = 4
...
[pid 57843] 13:36:04.187994 connect(4, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.12.17.21")}, 16) = 0
apidev.baidu.com/109.244.11.47
...
[pid 57843] 13:36:34.214701 stat("/etc/resolv.conf", {st_mode=S_IFREG|0644, st_size=52, ...}) = 0
[pid 57843] 13:36:34.215079 open("/etc/hosts", O_RDONLY|O_CLOEXEC) = 4
apidev.baidu.com/10.12.17.28
...

这种策略并不会使得绑定 hosts 立即生效, 在实际的正式环境中需要注意这点. 一些 Java 容器, 比如 resin, tomcat 等可能会有自己的缓存策略, 具体信息可以查看对应的官方手册.

解析可能碰到的问题

了解上述的内容后, 正常的系统运行时, 尤其服务端调用服务端的场景中可能会碰到一些解析相关的问题:

网站连接超时

这种情况下 resolv.conf 中的 dns server 正常, 只是解析到的 A 记录可能因为网络等问题连接不上, 可以直接修改 hosts 记录使程序及时生效连接新的 ip 地址. 如果是长连接请求, 只能中断连接重新发起请求才会生效.

dns server 超时

dns server 超时的情况下, 很容易通过 strace curl 等方式判别, 如果 poll 调用超时, 在启用 nscd 等缓存或者程序及其依赖的网络库有 res_init 控制的情况下, 可以及时更换 resolv.conf 中的 nameserver 条目. 其它情况下包括修改 resolv.conf 不生效, 不能连接所有的 dns server 的时候, 都可以考虑绑 hosts 方式临时解决问题, 这种方式需要提前搜集好需要访问哪些域名.

并发请求过多

client 如果以短连接方式请求 server 端, 大量的 dns 也可能拖慢整个请求的过程. 这种情况可以考虑绑 hosts 缓解名称解析的压力, 如果请求的域名很多或不确定具体的域名, 建议开启 nscd 以支持 dns 缓存, 开启 nscd 前最好对程序做足够的测试. RHEL 7/Centos 7 系列不建议使用 systemd-resolved 服务.

多记录问题

有些域名存在多个 A 记录, 在 /etc/host.conf 启用 multi on 的情况下, 名称解析到的 ip 就会返回所有的 A 记录地址. 不过很多程序及其网络库在访问有问题的情况下会对解析到的 A 记录进行轮询访问. 在有单个 ip 访问有问题的情况可能会加重整个请求的超时时间, 建议程序设置好请求的连接超时时间. 以 curl(7.29.0) 为例, 在对端 ip 拒绝或丢包的情况下, 会对解析到的 A 记录做轮询请求, 这里设置连接超时时间为 5s:

# ./hostrecv baidu.com
Official name is: baidu.com
    IP addresses: 10.12.17.26 10.12.17.27 

在两个 A 记录拒绝连接的情况下, 很快返回连接拒绝:

# strace -f -tt -e connect,poll curl --connect-timeout 5 baidu.com
11:04:44.562428 connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("10.12.17.26")}, 16) = -1 EINPROGRESS (Operation now in progress)
11:04:44.562797 poll([{fd=3, events=POLLOUT|POLLWRNORM}], 1, 0) = 0 (Timeout)
...
11:04:44.564040 connect(4, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("10.12.17.27")}, 16) = -1 EINPROGRESS (Operation now in progress)
...
curl: (7) Failed connect to baidu.com:80; Connection refused

在 26,27 中 drop 所有 80 的包, 总共耗时接近 5s:

# strace -e connect,poll -tt curl --connect-timeout 5 baidu.com   
11:00:30.974243 connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("10.12.17.26")}, 16) = -1 EINPROGRESS (Operation now in progress)
11:00:30.974524 poll([{fd=3, events=POLLOUT|POLLWRNORM}], 1, 0) = 0 (Timeout)
...
11:00:33.467425 poll([{fd=3, events=POLLOUT|POLLWRNORM}], 1, 0) = 0 (Timeout)
11:00:33.467909 connect(4, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("10.12.17.27")}, 16) = -1 EINPROGRESS (Operation now in progress)
...
11:00:34.715443 poll([{fd=4, events=POLLOUT|POLLWRNORM}], 1, 0) = 0 (Timeout)
curl: (7) Failed connect to baidu.com:80; Operation now in progress

tcpdump 查看则更明显, tcp 三次握手没有完成, 进行了重试:

11:00:30.974364 IP 10.12.17.22.27490 > 10.12.17.26.80: Flags [S], ...
11:00:31.975390 IP 10.12.17.22.27490 > 10.12.17.26.80: Flags [S], ...
11:00:33.468005 IP 10.12.17.22.13030 > 10.12.17.27.80: Flags [S], ...
11:00:34.469362 IP 10.12.17.22.13030 > 10.12.17.27.80: Flags [S], ...

参考

rust-issues-41570
res_init
bugzilla-214538
systemd-resolved.service
resolved
dont-use-nscd
systemd-issues-47