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.conf

passwd:         files systemd
group:          files systemd
shadow:         files
gshadow:        files

hosts:          files dns myhostname
networks:       files

protocols:      db files
services:       db files
ethers:         db files
rpc:            db files

netgroup:       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();

  1. getaddrinfo() 访问NSS系统,并按照nsswitch.conf中指定的顺序进行;

  2. 如果首先指定了文件模块,则在/etc/hosts文件中查找名称;

  3. 如果启用了 dns 模块,NSS 将调用 libnss_dns.so,后者调用来自 libresolv 的函数;

  4. 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 或递归查询时的权威服务器。

  1. 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 地址发生变化时直接影响应用程序性能、可靠性和行为的关键机制。

相关推荐
郝学胜-神的一滴3 小时前
C++ STL(标准模板库)深度解析:从基础到实践
linux·服务器·开发语言·c++·算法
Q16849645153 小时前
红帽Linux复习-Vim
linux·运维·vim
羚羊角uou4 小时前
【Linux网络】Socket编程UDP
linux·服务器·网络
java_logo4 小时前
Docker 部署 MinerU 教程:打造你的本地 PDF 智能处理中心
linux·运维·人工智能·docker·ai·容器·aigc
硬核子牙5 小时前
gdb单步调试底层实现原理
linux
Dovis(誓平步青云)5 小时前
《剖析 Linux 文件系统:架构、原理与实战操作指南》
linux·运维·服务器
千百元5 小时前
centos查线程数
linux·运维·centos
---学无止境---5 小时前
Linux中基数树标签相关操作函数的实现
linux
优信电子5 小时前
ESP32 I2S音频总线学习笔记(二):I2S读取INMP441音频数据
单片机·嵌入式