在搭建内网测试环境或做服务治理时,经常需要让程序优先使用自定义的hosts映射,而未被覆盖的域名则走正常DNS解析。Ruby标准库的Resolv虽然可用,但它是阻塞式设计,在高并发场景下性能吃力,而且要实现hosts覆盖逻辑得自己 hack 一番。Async::DNS基于async事件循环构建,天然支持非阻塞IO,配合async生态的并发原语,可以轻松实现自定义hosts文件优先匹配、多个上游DNS并行查询取最快结果等高级特性。本文将从零开始,完整实现一个生产可用的自定义DNS解析器。
为什么选择Async::DNS而不是标准库Resolv
Ruby内置的Resolv库在日常简单场景下够用,但它存在几个明显短板。第一,它是同步阻塞的,每次调用Resolv.getaddress都会阻塞当前线程直到响应返回或超时,如果要在并发环境下使用,只能靠多线程硬撑,线程切换开销和内存占用都会随并发量上升。第二,Resolv的可定制性有限,想插入一层本地hosts覆盖逻辑、或控制向哪些DNS服务器发起请求、如何处理超时重试,都需要继承并重写不少内部方法,代码侵入性强。第三,标准库对IPv6、DNSSEC等现代特性的支持不够完整。
Async::DNS则完全不同。它构建在async事件循环之上,所有网络IO均为非阻塞,单个线程内可以同时挂起成千上万个DNS查询,资源开销极低。它的Resolver类原生支持配置多个上游服务器、自定义超时时间和重试策略。更关键的是,解析流程是开放可扩展的,我们可以在查询链最前面插入自定义hosts查找逻辑,命中则直接返回,未命中再交给上游,这正是本文实现的基础。此外,async生态提供了Async::Barrier、Async::Condition等并发原语,让多上游并行查询的实现变得非常自然。
实现自定义hosts文件的加载与匹配
自定义hosts解析的核心思路是:在查询发往上游DNS之前,先在本地hosts记录中查找,命中就直接返回并附带一个较短的TTL,未命中则走上游解析。一个实用的hosts存储还应支持通配符域名,例如*.internal.ipipp.com可以匹配所有子域名,这在配置整个内网测试域时非常方便。
下面是hosts文件的加载与匹配实现,包含精确匹配、通配符匹配和线程安全的热更新支持:
require 'set'
class HostsStore
def initialize(path)
@path = path
@exact = {} # 精确匹配表: hostname => ip
@wildcards = [] # 通配符规则: [正则, ip]
@lock = Mutex.new
@mtime = nil
reload
end
# 解析hosts文件,每行格式: IP 域名1 域名2 ...
def reload
return unless File.exist?(@path)
mtime = File.mtime(@path)
return if @mtime == mtime # 文件未变化,跳过重载
exact = {}
wildcards = []
File.readlines(@path, chomp: true).each do |line|
line = line.strip
next if line.empty? || line.start_with?('#')
parts = line.split(/\s+/)
ip = parts[0]
parts[1..].each do |host|
if host.include?('*')
# 将 *.example.ipipp.com 转为正则 ^[^.]+\.example\.ipipp\.com$
pattern = host.downcase.gsub(/\./, '\\.')
pattern = pattern.gsub('*', '[^.]+')
wildcards << [/\\A#{pattern}\\z/, ip]
else
exact[host.downcase] = ip
end
end
end
@lock.synchronize do
@exact = exact
@wildcards = wildcards
@mtime = mtime
end
end
# 查询域名,返回ip或nil
def lookup(hostname)
reload # 惰性检测文件变化,hosts文件很小,开销可忽略
name = hostname.downcase.sub(/\.\z/, '') # 去掉末尾的点
@lock.synchronize do
return @exact[name] if @exact.key?(name)
@wildcards.each do |regex, ip|
return ip if regex.match?(name)
end
end
nil
end
end这段代码有几个细节值得注意。域名统一做了小写转换并去掉末尾的点,因为DNS规范允许域名以点结尾,而hosts文件中通常不带。通配符转正则时,只允许*匹配单段内容(即不含点),这样*.internal.ipipp.com不会错误匹配a.b.internal.ipipp.com,行为与常见DNS通配符语义一致。热更新采用惰性检测策略,每次查询时检查文件修改时间,避免额外的后台线程。
接下来把这个HostsStore接入Async::DNS的解析流程,形成分层解析器:
require 'async/dns'
class LayeredResolver
def initialize(hosts_path, servers)
@hosts = HostsStore.new(hosts_path)
@upstream = Async::DNS::Resolver.new(servers.map { |h, p| [[:udp, h, p], [:tcp, h, p]] })
end
# 先查自定义hosts,未命中再查上游
def addresses_for(hostname)
if (ip = @hosts.lookup(hostname))
return [Resolv::IPv4.create(ip)]
end
@upstream.addresses_for(hostname)
end
end
Async do |task|
servers = [['8.8.8.8', 53], ['223.5.5.5', 53]]
resolver = LayeredResolver.new('./custom_hosts', servers)
addrs = resolver.addresses_for('api.internal.ipipp.com')
puts "hosts命中: #{addrs.first}"
endhosts来源的记录建议返回较短的TTL(比如5到30秒),这样修改hosts文件后最多等一个TTL周期就能生效。上面的示例为简洁直接返回无过期概念的结果,实际项目中可以配合后文的缓存层补充TTL语义。
多上游DNS并行查询与性能对比
Async::DNS的Resolver接受多个服务器地址,但默认行为是按顺序尝试,第一个超时后才切换到下一个。如果某个上游服务器响应慢或宕机,每次解析都要白白等一个超时周期。更优的策略是同时向所有上游发起查询,谁先返回就用谁的结果。在传统线程模型里这需要开多个线程再加锁竞争第一个结果,而在async事件循环里,实现非常简洁。
利用Async::Condition和Async::Barrier实现先到先得:
require 'async'
require 'async/dns'
class RacingResolver
Result = Struct.new(:addresses, :error)
def initialize(servers, timeout: 2.0)
@resolvers = servers.map do |host, port|
Async::DNS::Resolver.new([[[:udp, host, port]]])
end
@timeout = timeout
end
# 并行查询所有上游,返回第一个成功结果
def addresses_for(hostname)
condition = Async::Condition.new
barrier = Async::Barrier.new
winner = nil
@resolvers.each do |resolver|
barrier.async(parent: barrier) do
begin
addrs = resolver.addresses_for(hostname)
unless addrs.empty? || winner
winner = addrs
condition.signal
end
rescue StandardError => e
# 单个上游失败不影响整体,记录即可
warn "上游查询失败: #{e.class}: #{e.message}"
end
end
end
# 等待第一个成功信号或整体超时
task = Async::Task.current
waiter = task.async { condition.wait }
begin
waiter.wait_with_timeout(@timeout)
ensure
barrier.stop # 取消所有未完成的查询任务
end
winner or raise Async::DNS::ResolutionFailure,
"所有上游均未在 #{@timeout}s 内返回 #{hostname}"
end
end
servers = [['8.8.8.8', 53], ['1.1.1.1', 53], ['223.5.5.5', 53]]
Async do |task|
resolver = RacingResolver.new(servers)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
addrs = resolver.addresses_for('www.example.ipipp.com')
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
puts "解析结果: #{addrs.first},耗时 #{elapsed.round(3)}s"
end并行查询的收益在弱网环境或某个上游不稳定时最为明显。假设三个上游中有一台宕机,串行模式下最坏要等第一个超时(比如3秒)才能轮询到健康节点;并行模式下整体耗时约等于最快健康节点的响应时间,通常在几十到几百毫秒。代价则是每次解析的查询量放大N倍,对DNS服务器有一定压力。如果解析量大,可以折中处理:只并行查询两个最稳定的上游,或者在首轮串行失败后再升级为并行重试。
另一个值得做的优化是给并行结果套一层带TTL的本地缓存,减少重复查询:
class TTLCache
def initialize
@store = {}
@lock = Mutex.new
end
def fetch(hostname, ttl)
@lock.synchronize do
entry = @store[hostname]
return entry[:value] if entry && entry[:expires_at] > Time.now
end
value = yield
@lock.synchronize do
@store[hostname] = { value: value, expires_at: Time.now + ttl }
end
value
end
end
# 使用示例:缓存60秒
cache = TTLCache.new
addrs = cache.fetch(hostname, 60) { resolver.addresses_for(hostname) }异常处理与生产环境注意事项
生产环境必须处理三类异常:上游全部失败、域名不存在、以及查询整体超时。上面的RacingResolver已经演示了整体超时控制和ResolutionFailure的抛出方式,调用方应捕获该异常并做降级,比如回退到系统默认解析或返回缓存中的旧值(stale-while-error策略)。单个上游的瞬时故障则在内部消化,只记日志不上抛,保证解析主流程不被个别节点拖垮。
还需要注意UDP包大小问题。当DNS响应较大(比如开启了DNSSEC)时,上游会返回截断标志并要求TCP重试,因此给Async::DNS::Resolver配置服务器时建议同时声明udp和tcp两种协议。另外,如果解析器运行在容器环境,容器内的/etc/resolv.conf往往指向宿主或虚拟网关,自建解析器时应显式指定公共DNS(如223.5.5.5、1.1.1.1)或内部DNS服务地址,不要依赖环境默认值。
综合来看,一个健壮的Ruby DNS解析器应该是清晰的三层结构:最前面是TTL缓存层,其次是自定义hosts覆盖层,最后是并行上游查询层,外围再包上统一的超时、异常捕获和日志。Async::DNS加上async事件循环,让这套结构在单线程内就能支撑高并发解析请求,无论是做内网开发测试工具,还是嵌入到爬虫、网关类服务中,都是一套切实可行的方案。
Ruby Async::DNS自定义hosts解析并行DNS查询修改时间:2026-08-31 09:03:33