DNS
主线位置:连接主线的第一步。连接器把目标
Uri交给 hyper-util 的HttpConnector;它发现主机名不是 IP 字面量,就调 reqwest 塞给它的DynResolver,拿回一串SocketAddr,再按 Happy Eyeballs 去连。
flowchart TB
feat{"hickory_dns 开着?"} -- 否 --> gai["GaiResolver"]
feat -- 是 --> hick["HickoryDnsResolver"]
gai --> base["Arc dyn Resolve"]
hick --> base
custom["dns_resolver 自定义"] -. 替换 .-> base
base --> ovr{"有 resolve 覆盖?"}
ovr -- 是 --> wrap["DnsResolverWithOverrides"]
ovr -- 否 --> dyn["DynResolver"]
wrap --> dyn
dyn --> http["HttpConnector"]
dyn --> socks["SOCKS 本地解析"]
http --> he["Happy Eyeballs 与 TCP"]
源码在 src/dns/,四个文件共 312 行:resolve.rs(199 行,trait、DynResolver、覆盖)、gai.rs(32 行)、hickory.rs(69 行)、mod.rs(12 行)。真正的解析都在别处:getaddrinfo 那条路是 hyper-util 的 GaiResolver,另一条是 hickory-resolver。Happy Eyeballs 和按地址的 TCP 超时也在 hyper-util 里,hyper 卷的 HttpConnector 讲过(同为 hyper-util 0.1.21),本页只讲 reqwest 加了什么,和这些机制在 reqwest 的配置下表现成什么样。
实验平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,release 构建,所有客户端 .no_proxy()(原因见连接器)。卷里的实验模板打开了 hickory-dns feature,这一点下面会反复用到。实验不碰外网:只解析 localhost,其余名字全部用 resolve 覆盖或自定义 resolver 给出。
Resolve trait
/// Alias for an `Iterator` trait object over `SocketAddr`.
pub type Addrs = Box<dyn Iterator<Item = SocketAddr> + Send>;
/// Alias for the `Future` type returned by a DNS resolver.
pub type Resolving = Pin<Box<dyn Future<Output = Result<Addrs, BoxError>> + Send>>;
/// Trait for customizing DNS resolution in reqwest.
pub trait Resolve: Send + Sync {
/// Performs DNS resolution on a `Name`.
/// The return type is a future containing an iterator of `SocketAddr`.
///
/// It differs from `tower_service::Service<Name>` in several ways:
/// * It is assumed that `resolve` will always be ready to poll.
/// * It does not need a mutable reference to `self`.
/// * Since trait objects cannot make use of associated types, it requires
/// wrapping the returned `Future` and its contained `Iterator` with `Box`.
///
/// Explicitly specified port in the URL will override any port in the resolved `SocketAddr`s.
/// Otherwise, port `0` will be replaced by the conventional port for the given scheme (e.g. 80 for http).
fn resolve(&self, name: Name) -> Resolving;
}
hyper-util 的 resolver 是一个 Service<Name>(见 hyper 卷 HttpConnector 的「第二步:DNS」)。Resolve 是 reqwest 给用户的简化版,文档注释列了三处不同:没有 poll_ready,&self 而不是 &mut self,返回值全部装箱,所以能做成 Arc<dyn Resolve>。Name 包着 hyper-util 的 Name,只暴露 as_str()。
ClientBuilder::dns_resolver 接受任何 IntoResolve:实现了 Resolve 的 R、Arc<R>、Arc<dyn Resolve> 三种都行。这个 trait 是「不可命名」的(#[doc(hidden)] 的方法,只在 docs.rs 上导出),用户不能自己实现它。
选哪个 resolver
ClientBuilder::build 的开头:
let resolver = {
let mut resolver: Arc<dyn Resolve> = match config.hickory_dns {
false => Arc::new(GaiResolver::new()),
#[cfg(feature = "hickory-dns")]
true => Arc::new(HickoryDnsResolver::default()),
#[cfg(not(feature = "hickory-dns"))]
true => unreachable!("hickory-dns shouldn't be enabled unless the feature is"),
};
if let Some(dns_resolver) = config.dns_resolver {
resolver = dns_resolver;
}
if !config.dns_overrides.is_empty() {
resolver = Arc::new(DnsResolverWithOverrides::new(
resolver,
config.dns_overrides,
));
}
DynResolver::new(resolver)
};
三层,从里到外:
- 基础 resolver:
config.hickory_dns的默认值是cfg!(feature = "hickory-dns")。feature 打开就是默认,不用调hickory_dns(true)。cargo 的 feature 是叠加的,依赖树里任何一个 crate 打开了 reqwest 的hickory-dns,你的Client就换了 resolver。no_hickory_dns()在没有这个 feature 时也存在(是个空操作),文档注释写明了用途:「ensure aClientdoesn't use the hickory-dns async resolver even if another dependency were to enable the optionalhickory-dnsfeature」。 dns_resolver(r)整个替换掉第一层。- 有
resolve/resolve_to_addrs覆盖时,外面再包一层DnsResolverWithOverrides,所以覆盖对自定义 resolver 也生效。
结果装进 DynResolver,克隆给 HttpConnector::new_with_resolver;开了 socks feature 时同一个 resolver 还交给连接器,用于 socks4:// / socks5://(本地解析的两种)把目标主机名先解析成 IP(见 代理)。
DynResolver:适配,顺便打标签
DynResolver 把 Arc<dyn Resolve> 适配回 hyper-util 要的 Service<Name>:
impl Service<HyperName> for DynResolver {
type Response = Addrs;
type Error = BoxError;
type Future = Resolving;
fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, name: HyperName) -> Self::Future {
let resolving = self.resolver.resolve(Name(name));
// Tag resolution failures so `Error::is_dns` can recognize them once
Box::pin(async move { resolving.await.map_err(crate::error::dns) })
}
}
map_err(crate::error::dns) 把任何解析错误包进一个私有的 DnsError,Error::is_dns() 沿 source() 链找它。这是 0.13.5 新加的(#3057,「add Error::is_dns() to identify DNS resolution failures」)。之前 DNS 失败和「连接被拒」在 reqwest::Error 上都只是 is_connect(),要区分得自己去 source() 链里认 hyper-util 的错误文本。
一个总是失败的 resolver:
struct Fails;
impl Resolve for Fails {
fn resolve(&self, name: Name) -> Resolving {
let msg = format!("no such host: {}", name.as_str());
Box::pin(async move { Err(msg.into()) })
}
}
failing resolver, other name: error after 0ms is_dns=true is_connect=true: no such host: b.test
is_dns() 和 is_connect() 同时为真:DNS 错误在 hyper-util 里被包成 ConnectError,再成为 client error (Connect)。
默认:getaddrinfo 在 tokio 的阻塞线程池
GaiResolver 只是包了 hyper-util 的同名类型:
impl Resolve for GaiResolver {
fn resolve(&self, name: Name) -> Resolving {
let mut this = self.0.clone();
Box::pin(async move {
this.call(name.0)
.await
.map(|addrs| Box::new(addrs) as Addrs)
.map_err(|err| Box::new(err) as BoxError)
})
}
}
hyper-util 的 GaiResolver::call 用 tokio::task::spawn_blocking 调 to_socket_addrs(),也就是系统的 getaddrinfo。所以「默认 DNS 在哪个线程」的答案是:发请求的那个 tokio 运行时的阻塞线程池(阻塞线程池),不是 reqwest 自己的线程,也不是工作线程。
验证:用 current_thread 运行时(它没有工作线程,任何新线程都只能来自阻塞池),在 on_thread_start 里打印线程名,服务器同时监听 127.0.0.1 和 ::1 的同一端口,请求 http://localhost:<port>/:
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.on_thread_start(|| println!(" [thread started: {}]", std::thread::current().name().unwrap_or("?")))
.build()
.unwrap();
// ...
get("hickory (default with the feature)", Client::builder(), &local).await;
get("no_hickory_dns (getaddrinfo)", Client::builder().no_hickory_dns(), &local).await;
get("no_hickory_dns, second client", Client::builder().no_hickory_dns(), &local).await;
连跑 3 次,线程那一行每次都只出现一次、位置相同:
== localhost, server on 127.0.0.1 and ::1
hickory (default with the feature): 200 OK from [::1]:50252 after 5ms
[thread started: tokio-rt-worker]
no_hickory_dns (getaddrinfo): 200 OK from [::1]:50252 after 2ms
no_hickory_dns, second client: 200 OK from [::1]:50252 after 0ms
- 模板开着
hickory-dns,所以第一个Client::builder()用的是 hickory:没有起任何线程。 no_hickory_dns()切回 getaddrinfo:起了一个阻塞线程。- 第二个 getaddrinfo 客户端没有再起线程:阻塞池的线程空闲后会保留一段时间(默认 10 秒)供复用。
- 两条路都先连上
::1:macOS 的 getaddrinfo 对localhost先给 IPv6(hyper 卷实测[[::1]:0, 127.0.0.1:0]),hickory 的策略见下一节。
这也是 getaddrinfo 的代价所在:一次卡住的系统解析会一直占着一个阻塞线程,取消请求也停不下它(hyper 卷那一页和 tokio 卷的阻塞池都有实验)。
hickory-dns
HickoryDnsResolver 只有一个字段,Arc<OnceCell<TokioResolver>>。注释解释了为什么延迟构造:ClientBuilder::build 不一定在 tokio 运行时里调用,hickory 的 TokioResolver 却要。第一次 resolve 时才用 new_resolver 建:
fn new_resolver() -> Result<TokioResolver, NetError> {
let mut builder = TokioResolver::builder_tokio().unwrap_or_else(|err| {
log::debug!(
"hickory-dns: failed to load system DNS configuration; falling back to Google DNS: {:?}",
err
);
TokioResolver::builder_with_config(
ResolverConfig::udp_and_tcp(&GOOGLE),
TokioRuntimeProvider::default(),
)
});
builder.options_mut().ip_strategy = LookupIpStrategy::Ipv6AndIpv4;
builder.build()
}
builder_tokio()读系统配置。注释说的是/etc/resolv.conf,这只对 Linux 这类 Unix 成立;hickory-resolver 0.26.3 在 Apple 平台上编译的是system_conf/apple.rs,从 SystemConfiguration 的State:/Network/Global/DNS取全局 DNS 服务器。读失败时退到 Google 的公共 DNS(ResolverConfig::udp_and_tcp(&GOOGLE)),只打一条 debug 日志。这个退路是 0.13.3 加的(#2797,「fallback to hickory_resolver's default config if reading /etc/resolv.conf fails」);之前读失败时get_or_try_init把错误原样返回,每次解析都失败。退路的另一面是:读不到系统配置时,查询会发往 Google 的服务器,而不是本机配置的那个。- hickory 自己发 UDP/TCP 查询,全程异步,不占阻塞线程,上面的实验里也没有起线程。
localhost不发查询:hickory-resolver 0.26.3 的caching_client.rs按 RFC 6761 在本地直接回答 loopback 地址。 - hickory 只拿系统配置里的服务器地址,自己发查询,不经过系统的解析库。
hickory_dns的文档注释原话是「The hickory resolver does not work exactly the same, or on all the platforms that the default resolver does」。
ip_strategy = Ipv6AndIpv4:同时查 AAAA 和 A,IPv6 地址排前面。hyper-util 的 Happy Eyeballs 按列表第一个地址的协议族决定首选组(hyper 卷 HttpConnector 的「第三步」),所以这一行直接决定了先连 IPv6 还是 IPv4。
最早 reqwest 用 hickory 的默认策略 Ipv4ThenIpv6:只有查不到 IPv4 时才查 AAAA。#2378(「Enable happy eyeballs when using hickory-dns」,首次发布在 v0.12.6)的说明写道,这样「the address list hyper receives is almost always IPv4 only」,Happy Eyeballs 形同虚设,于是改成 Ipv4AndIpv6:两种都查,IPv4 在前。两年后 #3063(「use LookupIpStrategy::Ipv6AndIpv4 to prefer IPv6」,首次发布在 v0.13.5)只改了这一行,把 IPv6 挪到前面,CHANGELOG 记作「Fix hickory-dns feature to use Ipv6AndIpv4 strategy to prefer IPv6」。配合 hyper-util「第一个地址定首选族」的规则,开了 hickory-dns 的程序从 0.13.5 起对双栈主机先连 IPv6;上面的实验里,hickory 和本机 getaddrinfo 对 localhost 都先给出 ::1。
resolve 与 resolve_to_addrs
pub fn resolve_to_addrs(mut self, domain: &str, addrs: &[SocketAddr]) -> ClientBuilder {
self.config
.dns_overrides
.insert(domain.to_ascii_lowercase(), addrs.to_vec());
self
}
resolve 就是只给一个地址的 resolve_to_addrs。覆盖表在 build 时包成 DnsResolverWithOverrides:
impl Resolve for DnsResolverWithOverrides {
fn resolve(&self, name: Name) -> Resolving {
match self.overrides.get(name.as_str()) {
Some(dest) => {
let addrs: Addrs = Box::new(dest.clone().into_iter());
Box::pin(std::future::ready(Ok(addrs)))
}
None => self.dns_resolver.resolve(name),
}
}
}
- 精确匹配:一次
HashMap查找,没有通配符,覆盖了api.test不会影响www.api.test。键在插入时转小写,URL 的主机名经urlcrate 解析后本来就是小写,所以大小写不影响。 - 命中时根本不调下层 resolver,直接返回一个已完成的 future。
- 覆盖只发生在 DNS 这一步。TLS 的 SNI 和证书校验用的仍是 URL 里的主机名,所以「把
api.test指到本机的测试服务器,并按api.test校验它的证书」可以成立(TLS 配置的实验全是这样做的)。 - IP 字面量的 URL 不经过 resolver,覆盖也就无从生效。
端口:覆盖地址回到 hyper-util 后,经过和普通解析结果相同的 set_port。URL 写了端口时总用 URL 的;URL 没写时,只有地址里的端口是 0 才换成 scheme 的默认端口(80 / 443)。服务器在 <port>,几种组合:
let p = |x: u16| SocketAddr::from(([127, 0, 0, 1], x));
get("override port 0, URL with port", Client::builder().resolve("a.test", p(0)), &format!("http://a.test:{port}/")).await;
get("override with port, URL without port", Client::builder().resolve("a.test", p(port)), "http://a.test/").await;
get("override port 1, URL with port", Client::builder().resolve("a.test", p(1)), &format!("http://a.test:{port}/")).await;
get("override for A.TEST, URL a.test", Client::builder().resolve("A.TEST", p(port)), "http://a.test/").await;
get("override + failing resolver", Client::builder().dns_resolver(Fails).resolve("a.test", p(port)), "http://a.test/").await;
get("failing resolver, other name", Client::builder().dns_resolver(Fails).resolve("a.test", p(port)), "http://b.test/").await;
== resolve() overrides
override port 0, URL with port: 200 OK from 127.0.0.1:50252 after 0ms
override with port, URL without port: 200 OK from 127.0.0.1:50252 after 0ms
override port 1, URL with port: 200 OK from 127.0.0.1:50252 after 0ms
override for A.TEST, URL a.test: 200 OK from 127.0.0.1:50252 after 0ms
override + failing resolver: 200 OK from 127.0.0.1:50252 after 0ms
failing resolver, other name: error after 0ms is_dns=true is_connect=true: no such host: b.test
第二行:http://a.test/ 没写端口,却连到了 <port> 而不是 80,因为覆盖地址里的端口不是 0。第三行反过来,URL 的端口压过了覆盖里的 1。
问: 测试里把一个假域名指到本机,用 resolve 好,还是写一个 dns_resolver 好?
答: 只是固定几个名字,用 resolve:它是一张查找表,命中时连下层 resolver 都不调,也就不会有一次真实的 DNS 查询漏出去。dns_resolver 换掉的是全部名字的解析,适合「所有查询都走公司内网 DNS」或「解析结果要做过滤」这类需求;而且 resolve 的覆盖仍然叠在它上面,两者可以一起用。
Happy Eyeballs 和 connect_timeout 的平分
reqwest 没有调 set_happy_eyeballs_timeout,hyper-util 的默认值 300ms 生效。connect_timeout 则如连接器所说用在两处,其中 hyper-util 那一处按一组里的地址数平分。resolve_to_addrs 正好能精确控制地址列表。下面的 127.0.0.2 在 macOS 上走 lo0 但没有分配给任何接口,SYN 发出去没有回应,连接会一直挂着(hyper 卷用过同一个办法):
let black = SocketAddr::from(([127, 0, 0, 2], port));
let live6: SocketAddr = format!("[::1]:{port}").parse().unwrap();
let url = format!("http://dual.test:{port}/");
get("[black4, live6]", Client::builder().resolve_to_addrs("dual.test", &[black, live6]), &url).await;
get("[black4, live4], connect_timeout 1s", Client::builder().resolve_to_addrs("dual.test", &[black, p(port)]).connect_timeout(Duration::from_secs(1)), &url).await;
== resolve_to_addrs with a black-holed first address (127.0.0.2)
[black4, live6]: 200 OK from [::1]:50252 after 302ms
[black4, live4], connect_timeout 1s: 200 OK from 127.0.0.1:50252 after 503ms
[black4, live6]:第一个地址是 IPv4,首选组[127.0.0.2],备用组[::1]。首选挂住,300ms 后备用组开跑,立刻连上。没有设任何超时,也只多花了 300ms。[black4, live4]:两个地址同一族,没有备用组,只能顺序试。connect_timeout是 1 秒,hyper-util 给每个地址 500ms,第一个超时后才试第二个。没设connect_timeout的话,第一个地址要等操作系统的连接超时。
慢在 DNS 本身时,起作用的是外层那一处。一个要 2 秒才回答的 resolver(连接器一页的同一个实验程序):
== resolver that takes 2s
no timeouts: 200 OK after 2005ms
connect_timeout(300ms): after 303ms is_connect=true is_timeout=true is_dns=false
error sending request for url (http://slow.test:64705/) <- client error (Connect) <- operation timed out
hyper-util 的 connect_timeout 只管 TCP,管不到解析;reqwest 包在整个连接器外面的超时在 300ms 处把它截断。这时 is_dns() 是假:错误是超时,不是 resolver 返回的错误。
resolve_to_addrs 给四个 IPv4 地址,前三个是黑洞 127.0.0.2,最后一个是活的 127.0.0.1,connect_timeout 设 1 秒。请求成功还是超时?大约多久? get("[black4, black4, black4, live4], connect_timeout 1s", Client::builder().resolve_to_addrs("dual.test", &[black, black, black, p(port)]).connect_timeout(Duration::from_secs(1)), &url).await;
动脑筋答案
[black4, black4, black4, live4], connect_timeout 1s: 200 OK from 127.0.0.1:50252 after 760ms
连跑 3 次是 758ms 到 762ms。四个地址同属 IPv4,没有备用组;hyper-util 把 1 秒平分成每个地址 250ms,三个黑洞各耗 250ms,第四个立刻连上,总共约 750ms,赶在外层 1 秒超时之前。
同一个程序再加两行,黑洞增加到 5 个和 6 个:
5 x black4 + live4, connect_timeout 1s: 200 OK from 127.0.0.1:56522 after 849ms
6 x black4 + live4, connect_timeout 1s: 200 OK from 127.0.0.1:56522 after 876ms
N 个黑洞加一个活地址,耗时约 N/(N+1) 秒,总在 1 秒之内:平分保证了最后一个地址也有它那一份。平分的代价在另一头:地址越多,每个地址分到的时间越短,按代码推,一个能连上、但 TCP 握手要比这一份更久的地址会被判超时,转去试下一个。
小结
Resolve是 hyper-utilService<Name>的简化版:&self、无poll_ready、全装箱;DynResolver把它适配回去,并给错误打上DnsError标签,0.13.5 起is_dns()能认出 DNS 失败。- resolver 三层:getaddrinfo 或 hickory,
dns_resolver整体替换,resolve覆盖叠在最外;hickory-dnsfeature 一打开就是默认,no_hickory_dns()在任何 feature 组合下都能关掉它。 - 默认的 getaddrinfo 跑在当前 tokio 运行时的阻塞线程池里(实测
current_thread运行时上起了一个阻塞线程);hickory 全异步,不起线程,读不到系统配置时退到 Google DNS。 - 覆盖是精确匹配、键转小写、命中时不调下层 resolver;端口规则:URL 有端口用 URL 的,否则地址端口为 0 才用 scheme 默认端口;SNI 和证书校验仍按 URL 主机名。
- Happy Eyeballs 用 hyper-util 默认的 300ms,首选族由列表第一个地址决定,hickory 从 0.13.5 起 IPv6 在前;
connect_timeout在 hyper-util 里按地址平分,外层那一份还管 DNS 和 TLS。