DNS¶
主要作者
本文编写中
DNS 是网络最重要的组件之一。如果 DNS 出现问题,那么可能会以非预期的方式把其他的东西一起弄坏,甚至在不少「高可用」的场景下,DNS 故障也可能会让整个集群出现问题。以至于有人写俳句如下:
It’s not DNS
There’s no way it’s DNS
It was DNS.
以下分别介绍在 Linux 客户端和服务端,DNS 相关的配置和使用方法。
客户端¶
说到 DNS,你可能首先想到的是 /etc/resolv.conf 文件,可以像这样配置使用的 DNS 服务器:
事实上,上面的知识对解决一部分 DNS 问题已经足够了。但是很多时候事情没有那么简单:
- nsswitch.conf 是什么东西?
- 为什么我的 resolv.conf 写的是 127.0.0.53?
- 为什么 Alpine 容器的 DNS 行为好像不太一样?
为了解决这些疑难杂症,我们就需要完整了解 Linux 下 DNS 解析的相关组件。
C 库提供的 DNS 解析接口¶
在最早期的时候,C 运行时库提供 gethostbyname() 和 gethostbyaddr() 函数来进行 DNS 解析:
// 获取 example.com 解析的 IP
struct hostent host_1 = gethostbyname("example.com");
// 地址在 he->h_addr_list 列表中
// 获取 1.1.1.1 对应的域名
const char *ip_str = "1.1.1.1";
struct in_addr ip;
inet_aton(ip_str, &ip);
struct hostent host_2 = gethostbyaddr(&ip, sizeof(ip), AF_INET);
// 域名在 he->h_name 中
从 IP 反查域名
很多人对 DNS 的理解仅限于「从域名查 IP」(A 记录和 AAAA 记录),但 DNS 也支持「从 IP 查域名」(PTR 记录)。例如对上面 1.1.1.1 的域名的查询,可以先构造出 1.1.1.1.in-addr.arpa 这个域名,然后查询这个域名的 PTR 记录。
PTR 记录由 IP 的所有者(ISP/云服务商等)负责维护。
但是 gethostbyname 和 gethostbyaddr 这两个函数已经过时了——gethostbyname 不支持 IPv6(AAAA),而且这两个函数都不是线程安全的。因此现代 POSIX 标准引入了 getaddrinfo()(有时候也简称为 gai)和 getnameinfo() 函数来替代它们:
// 获取 example.com 解析的 IP
struct addrinfo hints, *res;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC; // IPv4 + IPv6
hints.ai_socktype = SOCK_STREAM;
int ret_1 = getaddrinfo("example.com", NULL, &hints, &res);
// 地址在 res 链表中
// 获取 1.1.1.1 对应的域名
struct sockaddr_in sa;
char hostname[NI_MAXHOST];
memset(&sa, 0, sizeof(sa));
sa.sin_family = AF_INET;
sa.sin_port = htons(53);
inet_pton(AF_INET, "1.1.1.1", &sa.sin_addr);
int ret_2 = getnameinfo((struct sockaddr *)&sa, sizeof(sa),
hostname, sizeof(hostname), NULL, 0, 0);
// 域名在 hostname 中
res_query
尽管 getaddrinfo() 可以解决不少问题,并且跨平台兼容性也不错,但是如果我们需要更底层的 DNS 查询功能(例如查询 A/AAAA 以外的记录)的时候,上面的 API 就不太够用了。而 libresolv(包含在 glibc 中)则提供了更底层的 res_nquery()/res_query() 等接口,便于需要直接构造 DNS 报文进行查询的程序使用。
musl 不支持 res_nquery(),但是支持 res_query()。
libresolv 在其他平台上可能有不同的行为,可参考:getaddrinfo sucks. everything else is much worse。
获取 C 库解析 API 的延迟
bcc 提供的基于 eBPF 的 gethostlatency 工具可以用来获取使用 C 运行时库的 DNS 解析延迟:
$ sudo gethostlatency
TIME PID COMM LATms HOST
02:59:28 10680 ThreadPoolForeg 166.831 main.vscode-cdn.net
有关 eBPF 的介绍,可参考问题调试部分。
不同的 C 运行时库对 DNS 会采取不同的解析方式。以下介绍 Linux 下最流行的两种 C 运行时库:glibc 和 musl。
glibc¶
glibc 会使用一套复杂的逻辑来决定如何解析用户提供的域名。其 getaddrinfo() 的内部实现调用了 gaih_inet() 函数执行实际的解析工作。简单来讲,这个函数会:
- 尝试从 nscd 缓存中获取结果(如果编译期启用了相关支持)
- 如果 nscd 缓存没有结果,那么就根据
/etc/nsswitch.conf文件中的配置,依次使用不同的 NSS(Name Service Switch)模块来解析域名
在 gaih_inet() 完成后,getaddrinfo() 会根据 RFC 3484(以及其继任者 RFC 6724)的规则,对返回的结果进行排序,然后返回给用户。
nscd¶
nscd(Name Service Cache Daemon)是 glibc 提供的用于缓存 DNS、用户信息等结果的服务。如果你在 Debian 下尝试对使用 glibc DNS 查询的程序 strace 的话,你会发现 glibc 会尝试连接 /var/run/nscd/socket:
socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 3
connect(3, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (No such file or directory)
close(3) = 0
socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 3
connect(3, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (No such file or directory)
close(3) = 0
尽管 Debian 的 glibc 仍然还有 nscd 的支持,但是其他一些发行版,例如 Fedora、Arch Linux 等都移除了 nscd 的支持,因为:
- nscd bug 较多,不太稳定。
- nscd 除了缓存 DNS 以外的部分(缓存用户信息等)已经被 sssd(System Security Services Daemon)代替了。
- nscd 强绑定了 glibc,并且不适用于容器化场景(你需要把
/var/run/nscd/socket给 bind mount 进容器,有些太疯狂了)。 - 本地运行的 DNS 缓存服务(例如 systemd-resolved、dnsmasq 等)已经可以很好地完成 DNS 缓存的功能。
因此这里也不推荐使用 nscd。
如果需要清理 nscd 的缓存,可以使用 nscd -i 命令。
NSS¶
NSS 模块是 glibc 提供的一套插件机制,用于从不同的数据源获取名称解析结果。相关模块的配置在 /etc/nsswitch.conf 文件中。glibc 会根据这个配置加载 NSS 模块(/lib/libnss_xxx.so,xxx 为模块名,如 files),然后调用模块中的接口来获取名称解析结果。
以下是 Debian 13 容器的默认配置:
passwd: files
group: files
shadow: files
gshadow: files
hosts: files dns
networks: files
protocols: db files
services: db files
ethers: db files
rpc: db files
netgroup: nis
这里与 DNS 相关的配置是 hosts 一行,以上配置表示:
files模块会解析/etc/hosts文件的内容,查看是否能够解析。- 如果
files模块没有解析出结果,那么就使用dns模块进行 DNS 查询(使用/etc/resolv.conf作为配置)。
另一种非常常见的配置是安装了 systemd-resolved 的场景。那么 hosts 可能会变成这样:
其中 myhostname 负责解析本机的主机名,resolve 模块则会通过 systemd-resolved 的 Unix socket(/run/systemd/resolve/io.systemd.Resolve)来进行解析(详情可阅读我们对 Varlink 的介绍)。
[!UNAVAIL=return] 表示,除非(!)resolve 模块不可用(例如 systemd-resolved 没有运行),否则就直接返回,不再继续使用后面的 dns 模块。这样设置下,如果 systemd-resolved 出现故障,那么系统仍然可以回退到直接使用 DNS 服务器进行解析。而如果只是域名不存在,那么就不会继续使用 dns 模块,避免了不必要的 DNS 查询。
可以使用 getent 测试 NSS 的解析结果,例如 getent hosts example.com、getent passwd 等。同时可以使用 -s 参数来指定使用的 NSS 模块,用于调试,例如:
就(一般来说)会返回空,因为其只会用 files 模块来解析 example.com,如果 /etc/hosts 中没有相关的记录,那么就不会有结果。
NSS 的返回状态
NSS 模块可能会返回以下几种状态:
SUCCESS:解析成功。NOTFOUND:没有找到对应的记录。UNAVAIL:模块(永久)不可用。TRYAGAIN:模块(暂时)不可用,可以重试。
默认配置相当于 [SUCCESS=return !SUCCESS=continue]。除了 return 和 continue 之外,还有 merge:
这样的话,如果某用户在本地(files)属于组 A,在 sssd(sss)中属于组 B,那么最终该用户就会同时属于组 A 和组 B。
为什么解析本机还需要 myhostname 模块?
一个约定俗成的做法是,将主机名放在 /etc/hostname 文件,而在 /etc/hosts 中添加相关的映射:
不过,如果 /etc/hosts 里面忘写了/忘改了对应的条目,那么就可能会出现非预期的行为。例如,如果忘记添加 localhost,那么有些程序就可能会因为无法解析 localhost 而出现问题。
systemd-hostnamed 服务则负责管理系统的主机名——静态的主机名(static hostname)仍然在 /etc/hostname 中,用户可读的主机名(pretty hostname,比如说 "Xiao Ming's Computer" 或者 "我的电脑" 这种有空格、特殊字符,甚至汉字的名字)等存储在 /etc/machine-info 中,同时其也会记录从网络(例如 DHCP)获取的主机名(transient hostname)。而 myhostname 模块就是 systemd-hostnamed 提供的 NSS 模块,确保系统主机名总是可以被正确解析,请看下面的例子:
$ getent -s myhostname hosts localhost
::1 localhost
$ # hostnamed 能获取网络接口的地址
$ getent -s myhostname hosts myhost
fd36:cccc:bbbb:aaaa:aaaa:aaaa:aaaa:aaaa myhost
2001:da8:d800:aaaa:aaaa:aaaa:aaaa:aaaa myhost
fe80::aaaa:aaaa:aaaa:aaaa:aaaa myhost
fe80::bbbb:bbbb:bbbb:bbbb:bbbb myhost
$ getent -s myhostname hosts 127.0.0.1
127.0.0.1 localhost
$ # hostnamed 中,127.0.0.2 对应主机名,127.0.0.1 对应 localhost
$ getent -s myhostname hosts 127.0.0.2
127.0.0.2 myhost
glibc、NSS 与静态链接
如果有尝试对访问网络(使用了 NSS 的)C 程序进行静态链接(-static)的话,那么你可能会看到:
/usr/bin/ld: /tmp/cchbUcHT.o: in function `main':
example.c:(.text+0x2a): warning: Using 'gethostbyname' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
这是因为 NSS 是动态加载(dlopen)的,如果静态链接之后扔到别的机器上,那么对应的 NSS 模块可能就不存在或者不兼容,从而导致程序无法运行。
地址排序与 gai.conf¶
glibc 的 getaddrinfo() 默认根据 RFC 3484 的规则对返回的结果进行排序,不过用户也可以在 /etc/gai.conf 文件中自定义排序规则。
RFC 3484 的排序包含两者:源地址选择(source address selection)和目的地址选择(destination address selection)。这里只涉及目的地址选择。目的地址选择具体的规则可以阅读 RFC 的第 6 节。其中需要了解的是 Policy Table(第 2.1 节),它是一个最长匹配的前缀表,对每个在表中的前缀定义了优先级(Precedence)和标签(Label),这些值会影响排序结果。gai.conf 配置的其实就是这个表。
简而言之,RFC 3484 规定的地址选择顺序是:
- 避免不可用的地址、避免不匹配的 scope、避免 deprecated 的地址;
- 优先选择具有相同 Label 的源地址和目的地址;
- 优先选择 Precedence 较高的目的地址(数值越大则优先级越高);
- 其他规则,如原生 IPv4 地址优先于 6to4 / Teredo、最长前缀匹配原则等。
最常见需要修改 gai.conf 的情况是希望优先使用 IPv4 地址。在进行目的地址选择时,IPv4 地址会映射到 ::ffff:0:0/96 前缀(例如 1.1.1.1 会映射到 ::ffff:101:101),而默认情况它的优先级是 10,比其他的 IPv6 地址都要低。因此如果希望优先使用 IPv4 地址,可以添加如下配置:
如果不希望 glibc 发送 AAAA 请求……
glibc 的 getaddrinfo() 在参数为 AF_UNSPEC 时,总是会同时发送 A 和 AAAA 请求(即使设置了 net.ipv6.conf.all.disable_ipv6)。同样,上面的 gai.conf 配置也无法阻止 glibc 发送 AAAA 请求(只是在获取到两者之后优先选择 IPv4)。
在 glibc 2.36 之后,glibc 提供了 no-aaaa 选项,可以在 resolv.conf 中添加。而对更老版本的机器,则可能需要考虑使用 nss-dns4only 这种第三方的 NSS 模块来实现。
resolv.conf¶
glibc 在实际发 DNS 请求前会读取 /etc/resolv.conf。其最多支持 MAXNS(默认为 3)个 nameserver 配置。如果配置了多个 nameserver,那么 glibc 会依次尝试这些服务器。
此外,一些可能有帮助的配置包括:
-
对查询非完整域名(例如主机名)的场景,glibc 会依次将
search列表中的域名附加到查询的域名后面进行查询。例如,假设配置了search example.com,那么查询myhost的时候,会优先搜索myhost.example.com。这个行为也可以被
options ndots:n配置项控制,默认值是 1,表示只有查询的域名中没有点的时候,才会使用search列表进行搜索。 -
默认情况下,glibc 会对每个
nameserver等待 5 秒(options timeout:n),尝试 2 次(options attempts:n)。所以如果你发现有什么东西刚好会卡住 5 秒或者 5 秒的倍数,那么检查一下 DNS 可能会有帮助,特别是在写了多个nameserver,而第一个nameserver有问题的情况下。 -
如果你配置了 systemd-resolved,那么
resolv.conf有可能会是这样:选项中的
edns0为 RFC 2671 中定义的扩展 DNS 功能,允许在 DNS 包中添加额外信息,包括能够接收的 UDP 响应的最大大小——以避免回退到 TCP 查询的额外网络开销;而trust-ad则与 DNSSEC 相关,表示信任 DNS 响应中与 DNSSEC 校验有关的 AD 标志。
musl¶
musl 追求简洁、可移植(一大好处是:静态链接变得极其方便),其和 glibc 在 DNS 解析方面有非常大的区别:
- musl 不使用 nscd、NSS,也不会读取
/etc/gai.conf。其固定使用/etc/hosts和/etc/resolv.conf作为解析的配置来源。 - 对于
/etc/resolv.conf中有多个nameserver的情况,musl 会并发请求(最多 3 个nameserver),并取首个返回的结果。这会导致网络压力增大,因此建议在这种情况下配置好本地的 DNS 缓存服务以缓解网络压力,减小 DNS 解析出错的可能。 - 在 musl 1.2.4(2023/5/1)之前,musl 不支持 TCP DNS 查询——这对 DNS 响应会超过 512 字节的场景是致命的。
其他技术区别的整理可参考:Functional differences from glibc。
resolvconf¶
在某些网络配置下,/etc/resolv.conf 可能会需要被多个程序修改,例如在接入网络时,DHCP 客户端会修改 resolv.conf 添加从 DHCP 服务器获取的 DNS 服务器,之后如果打开了 VPN,VPN 客户端也可能会修改 resolv.conf 添加 VPN 提供的 DNS 服务器,可以发现在这个模型下,/etc/resolv.conf 很容易就会被留在一个不正确的状态,导致 DNS 解析失败。
为了解决这种多个程序需要修改 /etc/resolv.conf 的场景,resolvconf 程序提供了一种解决途径:需要调整 DNS 的程序不修改 /etc/resolv.conf,而是调用 resolvconf 程序注册自己的 DNS 服务器信息,由 resolvconf 负责生成最终的 /etc/resolv.conf。以下是一个示意:
# 程序 1 在 eth0 接口上注册 DNS 服务器
echo <<EOF | resolvconf -a eth0
nameserver 192.168.1.1
EOF
# 程序 2 在 vpn0 接口上注册 DNS 服务器
echo <<EOF | resolvconf -a vpn0
nameserver 10.1.1.1
EOF
# 程序 2 退出
resolvconf -d vpn0
不过在目前的 Linux 系统中,resolvconf 已经不多见了:在桌面系统下,NetworkManager 管理整个系统的网络配置,同时也只有它会修改 /etc/resolv.conf;并且现在发行版的趋势是使用 systemd-resolved 来全权管理 DNS(NetworkManager 也可以调用 systemd-resolved 来设置 DNS)。同时 systemd-resolved 也提供了兼容 resolvconf 的接口。
DNS 缓存服务¶
可以注意到,glibc 设置了非常复杂的 DNS 解析逻辑,但是问题也是很明显的:
nsswitch.conf和gai.conf配置文件对容器场景难以适用- nscd 缓存服务不稳定且也不适合容器化
- 如果程序不使用 glibc 的 API 做 DNS 解析,那么这些配置就完全无效了(最典型的例子是使用 Go 语言在关闭了 cgo 的情况下编译的程序)
因此目前来讲,更推荐的做法是:在本地运行一个 DNS 缓存服务器,并且修改 /etc/resolv.conf 等配置将所有的 DNS 请求都发给这个缓存服务器,以统一整个系统的 DNS 解析行为。
systemd-resolved¶
systemd-resolved 是 systemd 的本地 DNS 解析服务,用于提供缓存、DNSSEC、DNS-over-TLS、mDNS/LLMNR 等功能。我们主要关注其缓存功能。目前,Ubuntu 默认使用 systemd-resolved,但是 Debian 不是。resolved 对系统中的应用提供了多种接口,包括其 DBus/Varlink 接口、NSS 模块接口(nss-resolve)以及一个内置的 DNS 服务器(127.0.0.53)——大部分的应用使用的都是后两者。
使用 resolvectl status 可以查看全局以及每个网络接口的 DNS 配置,resolvectl statistics 可以查看查询统计信息,resolvectl query 则可以直接查询 DNS 记录,用于调试。如果需要清空缓存,可以使用 resolvectl flush-caches。
/etc/resolv.conf 的配置¶
resolved 提供了以下几种 resolv.conf 文件,分别对应不同的模式:
/run/systemd/resolve/stub-resolv.conf:由 resolved 动态生成,指向 127.0.0.53,包含search域名搜索域。/run/systemd/resolve/resolv.conf:由 resolved 动态生成,包含实际的上游 DNS 服务器地址。/usr/lib/systemd/resolv.conf:安装systemd-resolved包后就有的文件,指向 127.0.0.53,不包含搜索域。
将 /etc/resolv.conf 软链接到上述文件即可指定对应的模式,一般来说使用的都是 stub 模式(第一种)。如果 /etc/resolv.conf 不是软链接,而且包含其他的 DNS 服务器,那么 resolved 会读取并设置为自己的上游。
缓存行为¶
默认情况下,resolved 会启用 DNS 缓存,根据 DNS 响应的 TTL 信息缓存数据,避免额外的网络开销。
TTL
每条 DNS 记录都有 TTL 时间(秒为单位),标志下游的服务器可以缓存该记录的时间。例如:
$ dig www.example.com
(省略)
;; ANSWER SECTION:
www.example.com. 237 IN A 104.18.26.120
www.example.com. 237 IN A 104.18.27.120
(省略)
这里的 237 就是获取到的 TTL 秒数。显然,如果 TTL 设置较长,那么每次更新之后,其他机器就可能需要更长的时间获取到修改后的记录;而如果 TTL 太短,那么就可能会给 DNS 服务器(特别是权威 DNS 服务器)带来较大的压力。
Negative cache 与 SOA 记录
为了减小网络开销,不仅成功的查询需要缓存,失败的也需要。这被称为 Negative cache。一些常见的情况:
- 某个域名只有 A 记录,但是没有 AAAA 记录(或者反过来)
- 在
search列表中有多个域名,或者ndots的值比较大,那么就可能会有多个查询尝试,其中一些查询会找不到记录
但是没有记录的话,客户端怎么知道要缓存多长时间呢?这个信息就由 SOA 记录来提供。每个 DNS zone 只有一个 SOA 记录,包含一些基础的参数,其中最后一个参数就是 Negative cache TTL。在查询到由某个 DNS zone 负责,但是不存在记录的域名时,DNS 服务器一般会返回 SOA 记录,类似如下:
$ dig AAAA ipv4.mirrors.ustc.edu.cn # IPv4-only 的域名,没有 AAAA
(省略)
;; AUTHORITY SECTION:
mirrors.ustc.edu.cn. 60 IN SOA ns-a.ustclug.org. lug.ustc.edu.cn. 2026022401 3600 600 604800 60
(省略)
这里的 negative cache TTL 就是 60 秒(一般来说,会取 SOA 本体的 TTL 和宣告的 negative TTL 的最小值)。
而如果 DNS 服务器本身查询失败(返回 SERVFAIL),那么也就没有 SOA 记录。resolved 对此默认缓存 10 秒。
Ubuntu 默认不启用 negative cache
与 systemd 上游设置不同,Ubuntu 的 systemd-resolved 包的 Cache 参数默认值为 no-negative,而不是 yes。
(TODO)
dnsmasq¶
dnsmasq 与 Docker 默认 bridge 网络的行为
Docker 的默认 bridge 网络不会使用其内置的 DNS 服务器,而是直接使用主机的 /etc/resolv.conf 配置放进容器中。假如 Docker 发现 nameserver 全都是本地地址,那么就会 fallback 到 8.8.8.8/8.8.4.4 上,绕过 dnsmasq 的缓存功能(Docker 对 systemd-resolved 做了特殊处理,可以从 /run/systemd/resolve/resolv.conf 获取到实际上游的 DNS 地址并设置,但是缓存也就失效了)。
因此,如果希望 Docker 容器使用到缓存功能,那么请考虑以下方法之一:
- 不使用默认 bridge 网络,使用
docker network create创建自定义的 bridge 网络。 - 让 dnsmasq 同时在
docker0上监听,并在/etc/resolv.conf中配置nameserver为docker0上的地址。
服务端¶
递归服务器¶
权威服务器¶
主流的权威 DNS 服务端软件包括 BIND、Knot DNS、PowerDNS 和 Unbound 等。
USTCLUG 的域名(即 ustclug.org,以及一些其他域名)使用 BIND 9 作为权威 DNS 服务器。