Linux 中的 DNS 工作原理(一):从 getaddrinfo 到 resolv.conf
大家好!我是大聪明-PLUS!
当我们在浏览器中输入服务器名称或网站域名、执行 ping 命令或启动任何远程应用程序时,操作系统必须将这些名称转换为 IP 地址。这个过程称为域名解析。乍一看,它可能很简单,但其背后却隐藏着一个多层机制。
本文是系列文章的第一篇,专门探讨名称解析的底层架构。我们将讨论 Linux 内核中名称解析过程的构成、各种 C 库以及系统调用。
许多人都知道,Linux 中的名称解析过程不仅仅是一次“ DNS 调用”,而是一系列库、配置条目和调用,这些库、配置条目和调用取决于特定应用程序的实现、所使用的库类型和系统设置。
然而,工程师们仍然有一些疑问。例如,DNS 服务器地址更改后,应用程序是否需要重启?此外,为了诊断应用程序和系统中的错误、超时和其他名称解析问题,了解从 getaddrinfo() 到 resolv.conf 的整个流程至关重要。在本节中,我们将尝试逐层分解,并以简洁易懂的格式提供基础框架。
冰山一角
几乎所有现代 Linux 应用程序(从 curl 到 systemd)都使用标准 C 库(glibc 或 musl)中的getaddrinfo()函数。该函数的主要功能是根据设置和请求将域名转换为 IP 地址(A、AAAA 记录)。
它不仅执行 DNS 查询,还能处理其他类型的数据,例如服务名称。例如,它使用 /etc/services 将网络服务名称“http”转换为端口 80。这使得它成为一个多功能的网络应用工具。
getaddrinfo() 函数返回一个 addrinfo 结构列表,每个结构包含一个 IP 地址、套接字类型、协议和其他参数。这允许应用程序选择最合适的地址进行连接。
伪代码中使用 getaddrinfo() 的示例:
```
struct addrinfo hints, *res;
zero_memory(hints);
hints.ai_family = ANY_FAMILY;
hints.ai_socktype = TCP;err = getaddrinfo("example.com", "http", hints, &res);
if (err == 0) {for each addr in res:use(addr)freeaddrinfo(res);
} else {print(gai_strerror(err));
}```然而,getaddrinfo() 只是冰山一角。为了获取 IP 地址,它会调用系统配置数据中定义的一系列内部机制。其中之一就是 NSS(名称服务切换)。
NSS 是通过可加载模块(符合 glibc API 的动态库,例如 libnss_dns.so、libnss_files.so、libnss_myhostname.so 等)实现的。这些模块以插件的形式运行,由 glibc 库在运行时加载,负责特定的 IP 地址解析方法。用于名称解析的源顺序和集合在 /etc/nsswitch.conf 配置文件中指定。
nsswitch.conf内容示例:
# /etc/nsswitch.confpasswd: files systemd
group: files systemd
shadow: files
gshadow: fileshosts: files dns myhostname
networks: filesprotocols: db files
services: db files
ethers: db files
rpc: db filesnetgroup: nis例如,模块中包含“ hosts: files dns”的一行表示首先在本地 /etc/hosts 文件中查找匹配项,如果文件模块返回结果,则不会调用后续模块(例如 dns(执行 DNS 查找))。
因此,如果 nsswitch.conf 中的 hosts 行未提及 dns 模块,则包含访问 DNS 源设置的 resolv.conf 配置文件将被忽略,并且不会生成 DNS 查询。
NSS 还可以使用 mdns(用于 Zeroconf/Avahi)、nis(在旧系统中)和 myhostname 模块。
myhostname 模块是 systemd 的一部分,用于解析本地主机名。在 Alpine Linux 等极简系统上,它并不总是存在。
库
以下库是 Linux 生态系统的核心,为应用程序提供一组特定的功能,包括域名解析。
Glibc是 C 标准库最广泛使用的实现,它实现了诸如 getaddrinfo() 之类的高级函数。它与 NSS(名称服务交换机)交互以确定名称解析源(例如 /etc/hosts、DNS),并使用 libresolv 库执行 DNS 查询。
Glibc 可以使用 sendto 和 recvfrom 等系统调用通过 UDP 或 TCP 发送和接收 DNS 查询。它在大多数 Linux 发行版(Ubuntu、Debian、Fedora 等)中被广泛使用。
Musl是一个替代的标准 C 库,其设计考虑了极简主义、性能和 POSIX 兼容性。它用于 Alpine Linux 等轻量级发行版。
Musl 直接实现域名解析,无需使用 NSS。它读取 /etc/hosts 和 /etc/resolv.conf 文件并发送 DNS 查询,无需使用 libresolv 等外部库。然而,musl 对某些 resolv.conf 参数(例如轮换或复杂搜索)的支持有限。
Libresolv.so是 glibc 的一部分,实现低级 DNS 处理,执行诸如 res_query() 和 res_send() 之类的查询,但可以在某些应用程序中独立使用,例如 nslookup(允许直接执行 DNS 查询,绕过标准名称解析机制)。
当 NSS 指定 DNS 访问时,glibc 使用 Libresolv 执行 DNS 查询。它读取 /etc/resolv.conf 文件,生成 DNS 数据包,并通过 UDP 或 TCP 将其发送到指定的服务器。
值得注意的是,某些应用程序(例如用 Go 编写的应用程序)可能会完全绕过 glibc/musl 并使用自己的 DNS 解析器。
如何处理 resolv.conf
/etc/resolv.conf 文件包含基本的 DNS 客户端设置,即服务器列表、参数和搜索域。例如:
nameserver 192.168.1.1
search dev.local
options timeout:2 attempts:3如果有必要,Glibc 和 libresolv 会手动解析它。
要点和限制:
- rotate、ndots、timeout 和 attempt 等选项会影响请求的行为;
- rotate 选项用于从名称服务器列表中循环服务器,但 musl 不支持该选项;
- 搜索用于自动完成,例如,如果名称 db01 不是 FQDN,则搜索指令中的域将依次替换它。
需要注意的是,DHCP 客户端、NetworkManager 或 resolvconf 实用程序可能会动态修改 resolv.conf 文件,这在排除 DNS 问题时可能会造成混乱。我们将在以后的文章中讨论这个问题。
res_query() 做什么?
这是 libresolv 中的一个函数,在名称解析过程中内部调用。它手动构造一个 DNS 数据包并将其发送到 resolv.conf 中指定的 DNS 服务器。nslookup 等实用程序以及一些绕过 getaddrinfo() 的程序都会用到它。
该函数使用 res_send() 通过 UDP 发送 DNS 查询,并在必要时切换到 TCP,例如在接收到大于 512 字节的响应时。
重要提示:使用 res_query() 时,您不会从 /etc/hosts、NSS 或其他来源获取信息。这是一个纯 DNS 查询。因此,dig 或 nslookup 可能会返回一个结果,而 ping 或 curl 等则会返回完全不同的结果。
Res_query() 被视为已弃用函数,不建议使用。为了更方便、更安全地访问 DNS,最好使用 getaddrinfo() 或 c-ares 或 libdns 等库。
c-ares 是一个用于异步 DNS 查询的轻量级库,常用于高负载应用程序(例如 curl 和 Node.js)。
libunbound(来自 Unbound 项目)是一个更强大的库,具有 DNSSEC 支持和灵活的查询定制。
请求执行顺序和优先级
以下是Linux 上使用 glibc 和 NSS 的典型名称解析顺序:
1.应用程序调用getaddrinfo();
2. getaddrinfo() 访问NSS系统,并按照nsswitch.conf中指定的顺序进行;
3. 如果首先指定了文件模块,则在/etc/hosts文件中查找名称;
4. 如果启用了 dns 模块,NSS 将调用 libnss_dns.so,后者调用来自 libresolv 的函数;
5. libresolv通过res_query()生成DNS查询,并使用res_send()将其发送到resolv.conf中指定的DNS服务器地址,然后接收并返回IP地址。
Linux 中使用 glibc 进行名称解析的简化方案。此方案演示了基本路径,但也可以使用 NSS 中的其他源。源 (files/dns) 的顺序在 /etc/nsswitch.conf 中配置。现代系统也可能使用 DNS 缓存 (systemd-resolved、nscd)。
重要提示:如果在某个步骤中找到了名称(例如在主机中),则不会使用后续来源。
在带有 musl 的 Alpine Linux 等极简系统上,顺序可能会有所不同,因为 musl 不使用 NSS 而是通过读取 /etc/hosts 和 resolv.conf 本身直接实现 DNS 查询。
一些应用程序和语言(例如Go、Java、Node.js)可能会使用自己的DNS解析器,完全忽略系统设置。
作为示例,让我们分析一下 curl 实用程序的操作。
团队:
strace -f -e trace=network curl -s download.astralinux.ru > /dev/null
socket(AF_INET6, SOCK_DGRAM, IPPROTO_IP) = 3
socketpair(AF_UNIX, SOCK_STREAM, 0, [3, 4]) = 0
socketpair(AF_UNIX, SOCK_STREAM, 0, [5, 6]) = 0
strace: Process 283163 attached
[pid 283163] socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 7
[pid 283163] connect(7, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (Нет такого файла или каталога)
[pid 283163] socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 7
[pid 283163] connect(7, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (Нет такого файла или каталога)
[pid 283163] socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 7
[pid 283163] connect(7, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("172.24.31.107")}, 16) = 0
[pid 283163] sendmmsg(7, [{msg_hdr={msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\250\207\1\0\0\1\0\0\0\0\0\0\10download\nastralinux"..., iov_len=40}], msg_iovlen=1, msg_controllen=0, msg_flags=0}, msg_len=40}, {msg_hdr={msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\240\215\1\0\0\1\0\0\0\0\0\0\10download\nastralinux"..., iov_len=40}], msg_iovlen=1, msg_controllen=0, msg_flags=0}, msg_len=40}], 2, MSG_NOSIGNAL) = 2
[pid 283163] recvfrom(7, "\250\207\201\200\0\1\0\1\0\0\0\0\10download\nastralinux"..., 2048, 0, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("172.24.31.107")}, [28->16]) = 56
[pid 283163] recvfrom(7, "\240\215\201\200\0\1\0\0\0\1\0\0\10download\nastralinux"..., 65536, 0, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("172.24.31.107")}, [28->16]) = 114
[pid 283163] sendto(6, "\1", 1, MSG_NOSIGNAL, NULL, 0) = 1
[pid 283163] +++ exited with 0 +++
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 5
setsockopt(5, SOL_TCP, TCP_NODELAY, [1], 4) = 0
setsockopt(5, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0
setsockopt(5, SOL_TCP, TCP_KEEPIDLE, [60], 4) = 0
setsockopt(5, SOL_TCP, TCP_KEEPINTVL, [60], 4) = 0
connect(5, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("130.193.50.59")}, 16) = -1 EINPROGRESS (Операция выполняется в данный момент)
getsockopt(5, SOL_SOCKET, SO_ERROR, [0], [4]) = 0
getpeername(5, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("130.193.50.59")}, [128->16]) = 0
getsockname(5, {sa_family=AF_INET, sin_port=htons(48488), sin_addr=inet_addr("172.24.31.241")}, [128->16]) = 0
sendto(5, "GET / HTTP/1.1\r\nHost: download.a"..., 86, MSG_NOSIGNAL, NULL, 0) = 86
recvfrom(5, "HTTP/1.1 200 OK\r\nServer: nginx/1"..., 102400, 0, NULL, NULL) = 1617我们在这条 strace 中看到了什么?
1. 尝试使用 NSCD(名称服务缓存守护进程)
connect(..., "/var/run/nscd/socket", ...) = -1 ENOENT这意味着,glibc 首先会尝试使用 NSCD 中的名称缓存(如果 NSCD 正在运行)。如果系统中不存在该缓存,则请求将继续进行。
2. 调用socket()和connect()连接到DNS服务器
socket(AF_INET, SOCK_DGRAM|..., IPPROTO_IP) = 7
connect(7, ..., sin_addr=inet_addr("172.24.31.107")...)这将创建一个 UDP 套接字来联系 /etc/resolv.conf 中指定的 DNS 服务器。
3. 调用 sendmmsg() – 发送 DNS 查询
sendmmsg(7, [ { "download.astralinux.ru" }, { "download.astralinux.ru" } ], ...)名称解析请求发送到这里。
4. DNS响应
recvfrom(...) = 56
recvfrom(...) = 114现在 IP 地址已知了。
56 是包含 A 记录(IPv4 地址)的 DNS 响应的大小(以字节为单位)
114 - 附加数据的大小,例如 CNAME 或递归查询时的权威服务器。
5. IP 上的 TCP 连接
connect(5, ..., sin_addr=inet_addr("130.193.50.59"))这里 curl 本身与 getaddrinfo() 返回的 IP 地址建立了 TCP 连接。
因此,当我们调用 curl 时,我们无法直接看到 DNS 查询——它们是由 glibc 库在 getaddrinfo() 调用中执行的。但 strace 允许我们看到间接的迹象:
这些调用将包括尝试连接到 nscd、调用 DNS 服务器的 connect()、通过 sendmmsg() 发送 UDP 数据包,然后通过 IP 连接建立标准 TCP:
connect(7, {AF_INET, 172.24.31.107:53}) = 0
sendmmsg(7, [{ "download.astralinux.ru" }]) = 2
recvfrom(7, ...) = ...
connect(5, {130.193.50.59:80}) = 0需要注意的是,getaddrinfo() 的行为可能取决于 libc 的实现。例如,在 glibc 中,结果可能会被缓存,这会影响性能和数据新鲜度。
简要总结和要点
Linux 中的 DNS 查询不一定是向 DNS 服务器发出的请求。查询链可以包括主机、NSS、glibc 和其他来源。
NSS 和 nsswitch.conf 定义名称解析的顺序和来源。
glibc 使用 NSS 并可以缓存结果;musl直接实现 DNS 解析,但对 resolv.conf 选项的支持有限。
Resolv.conf控制解析器设置,但可以动态更改。
Getaddrinfo()是名称解析的主要接口,处理 DNS 和其他来源。
不同的编程语言(Go、Java、带有 dns.resolver 的 Python、Node.js)可能使用自己的 DNS 查询机制。
在下一节中,我们将概述DNS 记录缓存的工作原理——这是一种在 IP 地址发生变化时直接影响应用程序性能、可靠性和行为的关键机制。
