dnsmasq 可以向上游转发 DNS 查询,在有界缓存中保留支持的回答,也可以提供本地配置的名称。这些是不同的数据来源。重复得到相同回答,可能来自缓存命中,也可能来自本地配置。主机名与权威数据不一致、或者清缓存后仍然解析到原地址时,先确认来源十分重要;否则容易把有效配置误认为没有清干净的缓存。

坏掉的样子

启动 dnsmasq,不会自动让所有应用使用它。客户端必须把 DNS 查询交给预期监听,有的应用还会使用其他解析服务。同样,本地监听正常,也可能转发到错误上游或者读取了意外 hosts 配置。在增加缓存容量或重启服务前,应先确认真实查询路径。如果失败查询根本没到达这个进程,对它反复操作不会改变调用方看到的结果。

本地名称可以有意覆盖上游数据。清理转发缓存不会删除这个配置名称,因此反复清空也不能让上游地址获胜。应把本地记录当作有负责人、有用途的配置管理。即使回答的本地 TTL 为零,dnsmasq 仍可持续返回该值,因为它来自配置生成,不是靠保留某份转发回答维持。零 TTL 与删除本地配置并不是同一件事。

缓存容量和新鲜度解决不同问题。更大缓存可以保留更多合适条目,却不会延长条目的有效 TTL,也不会让上游故障期间所有名称都能查询成功。新名称与过期条目仍需要合适的解析路径。缓存大小也不直接代表可同时发送多少上游查询,更不能单独保证应用可用。应分别观察容量、寿命、并发与上游健康,而不是用一个参数代替所有约束。

快速验证

将脚本保存为 check-dnsmasq.py,准备 Docker 后执行 python3 check-dnsmasq.py。固定环境使用 dnsmasq 2.90 和 BIND 9.18.49。实验转发器忽略环境中的 resolv 和 hosts 文件,明确绑定回环监听,并只转发到自己的临时权威服务器。它不发布宿主端口,没有 DHCP 配置,结束后清理自己的实例,不影响现有服务:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,re,signal,subprocess,time
import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype

def run(*args,check=True):
    result=subprocess.run(args,capture_output=True,text=True,timeout=15)
    if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
    return result
def query(name,kind='A',port=1053):
    return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1)
def values(response,kind='A'):
    wanted=dns.rdatatype.from_text(kind)
    return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset]
def show(response):
    def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records]
    return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)}
def wait(predicate,timeout=15):
    deadline=time.monotonic()+timeout
    while time.monotonic()<deadline:
        try:
            if predicate():return
        except (OSError,dns.exception.Timeout):pass
        time.sleep(.05)
    raise AssertionError('DNS fixture condition did not become true')
def stop(process):
    if process.poll() is None:
        process.terminate()
        try:process.wait(timeout=5)
        except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)

@contextmanager
def lab(records,soa_ttl=8,minimum=4,extra=''):
    with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack:
        root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1
        def save(body):
            zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n')
            run('named-checkzone','lab.test',str(zone))
        save(records)
        conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none;
{extra} }};
controls {{}};
zone "lab.test" {{ type primary; file "{zone}"; }};
''')
        run('named-checkconf',str(conf))
        output=stack.enter_context(log.open('w'))
        authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
        stack.callback(stop,authority)
        def ready():
            assert authority.poll() is None,log.read_text()
            response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial
        wait(ready)
        def update(body):
            nonlocal serial
            serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready)
        def cache(port=1054,upstream=1053,size=150,options=()):
            logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w'))
            command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces',
                '--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream),
                '--cache-size='+str(size),'--log-queries','--log-facility=-',*options]
            process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process)
            def cache_ready():
                assert process.poll() is None,logfile.read_text()
                return query('lab.test.','SOA',port).rcode()==0
            wait(cache_ready);return process,logfile
        def queries(name,kind='A'):
            return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines())
        yield root,update,cache,queries
with lab('cached 20 IN A 127.0.0.2\nuncached 20 IN A 127.0.0.3\nlocal 20 IN A 127.0.0.4') as (_,update,cache,queries):
    daemon,log=cache(options=('--host-record=local.lab.test,127.0.0.9',))
    for _ in range(3):assert values(query('cached.lab.test.',port=1054))==['127.0.0.2']
    wait(lambda: queries('cached.lab.test')==1)
    cache(port=1055,size=0)
    for _ in range(3):assert values(query('uncached.lab.test.',port=1055))==['127.0.0.3']
    wait(lambda: queries('uncached.lab.test')==3)
    local=query('local.lab.test.',port=1054);assert values(local)==['127.0.0.9'] and local.answer[0].ttl==0
    assert queries('local.lab.test')==0
    authoritative=query('local.lab.test.');assert values(authoritative)==['127.0.0.4']
    wait(lambda: queries('local.lab.test')==1)
    update('cached 20 IN A 127.0.0.8\nuncached 20 IN A 127.0.0.3\nlocal 20 IN A 127.0.0.4')
    stale=query('cached.lab.test.',port=1054);assert values(stale)==['127.0.0.2'] and queries('cached.lab.test')==1
    previous=log.read_text().count('cleared cache');daemon.send_signal(signal.SIGHUP)
    wait(lambda: log.read_text().count('cleared cache')>previous)
    refreshed=query('cached.lab.test.',port=1054);assert values(refreshed)==['127.0.0.8']
    wait(lambda: queries('cached.lab.test')==2)
    retained=query('local.lab.test.',port=1054);assert values(retained)==['127.0.0.9']
    print(json.dumps({'three_lookups_upstream_counts':{'cache_enabled':1,'cache_disabled':3},'local_record':show(local),
        'same_name_authority':show(authoritative),'before_SIGHUP':show(stale),'after_SIGHUP':show(refreshed),
        'configured_local_record_retained':show(retained)},sort_keys=True))
"""
name='xbcatm-dns-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory);(root/'Dockerfile').write_text(recipe)
        built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr
        result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name,
            '/usr/bin/python3','-',input=program)
        assert result.returncode==0,result.stderr
        print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
    docker('rm','-f',name);docker('image','rm',name)

通过启用缓存的监听查询 cached.lab.test 三次,都返回 127.0.0.2,权威日志却只记录一次。第二个监听使用 cache-size=0,经它对另一个名称查询三次,就产生三次上游请求。这个对照直接验证缓存对转发工作量的影响,不用响应速度猜测是否命中,也不声称测得某个固定延迟改善。回环网络的快慢本身不足以证明数据来源。

第一个监听另有 host-record=local.lab.test,127.0.0.9。查询得到 127.0.0.9,TTL 为零,权威没有记录对应上游查询。直接向权威查询同名则得到 127.0.0.4,计数随之增加。这说明差异来自明确的本地覆盖;后一个正向对照也验证了计数器确实能记录上游流量,避免把始终为零的错误日志匹配当作可靠证据。

接着权威将 cached.lab.test 改为 127.0.0.8,缓存仍在剩余有效期内返回旧值 127.0.0.2。脚本只向这个特定 dnsmasq 进程发送 SIGHUP,并等待日志确认缓存已清除。下一次查询到达权威,返回 127.0.0.8,对应权威次数从一次增加到两次。信号、日志、回答和回源计数共同验证清理产生了实际效果。

同一次信号之后,local.lab.test 仍返回配置中的 127.0.0.9。转发缓存状态被清除,不代表显式本地记录消失。因此“刷新 DNS”不是完整排障说明:预期结果取决于哪个进程持有什么数据,以及该数据属于缓存、配置,还是另一个组件保存的状态。只有先识别来源,才能判断清理操作是否适用以及成功标准是什么。

有意识地管理监听与数据来源

一起检查有效配置、监听地址、上游目的地和客户端设置,避免两个本地解析器互相转发形成循环。本例去掉环境输入是为了隔离行为;真实安装可能有意读取 hosts、DHCP 租约或解析器管理软件生成的数据。应记录这些依赖,使自动重写和本地覆盖在排障时可见,而不是把它们隐藏在“系统 DNS”这个笼统名称之下。

按照 dnsmasq 手册,SIGHUP 会清缓存并重载特定数据来源,但通常不会重新读取主配置文件。应针对修改种类使用文档规定的服务操作,不能把清缓存成功当作监听、转发或其他启动配置编辑已经生效的证明。变更后还应从真实客户端路径检查目标行为,避免只验证进程仍在运行,就忽略新配置根本没有加载。

监听暴露应限制在预期客户端范围,尤其在可达网络上提供递归转发时。DHCP、DNSSEC 验证和加密上游传输都需要独立配置与验证,本实验没有证明这些能力。查询日志有助于诊断,也可能揭示客户端访问的名称,应根据环境选择范围和保留时间,而不是习惯性永久开启所有细节。日志用途应与实际排障需求对应。

最后,只有预期来源是权威时,才把缓存回答与该权威比较;本地配置名称应与它的配置比较。分别跟踪上游失败、缓存命中和条目周转,才能判断下一项有用修改涉及容量、寿命、上游服务还是本地命名规则。这样的观察能够解释差异的原因,而不是把每次不一致都归结为需要再清一遍缓存。

延伸阅读

来源

  • dnsmasq manual,转发、cache-size、host-record、本地 TTL 与 SIGHUP 行为,已核验 2026-09-12。
  • RFC 1035 domain names,DNS 资源记录与缓存寿命,已核验 2026-09-12。