主机名查询首先由具体程序发起。在 Linux 上,使用 glibc 名称服务接口的应用,可以依照 /etc/nsswitch.conf 中 hosts 项列出的服务查询;使用自有 DNS 客户端的程序则可能走另一条路径。并不存在所有应用都遵守的“先缓存、再 hosts、最后互联网”固定顺序。定位问题的起点是实际调用方,而不是凭操作系统名称假定完整流程。

坏掉的样子

当 files 位于 dns 前面时,hosts 文件条目可以覆盖正确的 DNS 回答。修改权威区域不会删除这个本地覆盖。直接 DNS 查询可能已经看到新地址,应用却仍使用本地文件中的旧值;这两项观察可以同时正确,因为它们实际回答的是不同来源的查询。仅凭 dig 正常就认定应用一定使用同一个地址,会漏掉最直接的配置原因。

NSS 的结果动作与服务顺序同样重要。在 files 后写 [NOTFOUND=return],表示文件服务没有找到条目时就停止。即使 DNS 中存在有效记录,也不会继续询问。把这类失败归咎于上游不可用,可能让人反复修改区域或者重启解析服务,却始终没有修正真正阻断查询的策略。应完整读取方括号规则,不能只摘出 files 和 dns 两个单词。

地址族筛选还会制造另一种差异。使用 AI_ADDRCONFIG 时,glibc 根据本地已配置的非回环地址决定返回哪些地址族;只有回环接口的容器因此可能不同于普通主机。两个工具即便都调用 libc,也未必请求相同地址族或标志。空结果不能立即解释为域名不存在,尤其在隔离实验、极简容器和刚启动的网络环境中,应先核对调用参数。

快速验证

将脚本保存为 check-resolution.py,准备 Docker 后执行 python3 check-resolution.py。脚本构建固定的 Debian 环境,使用 BIND 9.18.49、dnsmasq 2.90 和 glibc 2.36。可丢弃容器只有回环网络;hosts、NSS 与解析器配置修改全部发生在容器内部,不把宿主文件作为实验可写状态。每次创建独立实例,并在结束后清理自己的容器和镜像标签:

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('choice 30 IN A 127.0.0.2\ndns-only 30 IN A 127.0.0.3') as (_,update,cache,queries):
    cache(port=53)
    hosts=Path('/etc/hosts');hosts.write_text(hosts.read_text()+'\n127.0.0.9 choice.lab.test\n')
    Path('/etc/resolv.conf').write_text('nameserver 127.0.0.1\noptions timeout:1 attempts:1\n')
    nss=Path('/etc/nsswitch.conf');original=nss.read_text()
    def order(value):nss.write_text(re.sub(r'^hosts:.*$', 'hosts: '+value,original,flags=re.M))
    def application(name):
        result=run('/usr/bin/python3','-c',
            'import json,socket,sys;print(json.dumps(sorted({v[4][0] for v in socket.getaddrinfo(sys.argv[1],80,socket.AF_INET,socket.SOCK_STREAM)})))',name,check=False)
        return result.returncode,json.loads(result.stdout) if result.returncode==0 else None
    results={};order('files dns')
    assert application('choice.lab.test')==(0,['127.0.0.9'])
    filtered=run('getent','ahostsv4','choice.lab.test',check=False)
    assert filtered.returncode!=0
    libc=run('getent','-A','ahostsv4','choice.lab.test').stdout
    assert {line.split()[0] for line in libc.splitlines()}=={'127.0.0.9'}
    before=queries('choice.lab.test')
    direct=run('dig','@127.0.0.1','-p','1053','choice.lab.test.','A','+short').stdout.strip()
    wait(lambda: queries('choice.lab.test')==before+1)
    assert direct=='127.0.0.2';results['files_first']={'application':['127.0.0.9'],'getent_without_addrconfig':['127.0.0.9'],'dig':direct}
    results['loopback_only_getent_default_exit']=filtered.returncode
    order('dns files');assert application('choice.lab.test')==(0,['127.0.0.2']);results['dns_first']=['127.0.0.2']
    order('files [NOTFOUND=return] dns')
    before=queries('dns-only.lab.test');code,address=application('dns-only.lab.test')
    assert code!=0 and address is None and queries('dns-only.lab.test')==before
    assert values(query('dns-only.lab.test.'))==['127.0.0.3']
    wait(lambda: queries('dns-only.lab.test')==before+1)
    results['stop_after_files_miss']={'application_failed':True,'DNS_would_answer':['127.0.0.3']}
    print(json.dumps(results,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)

权威服务器把 choice.lab.test 发布为 127.0.0.2,容器 hosts 文件把同名映射为 127.0.0.9。在 hosts: files dns 下,新 Python 进程通过 getaddrinfo 请求 IPv4,得到文件中的地址;直接向权威服务器发出的 dig 查询则得到 DNS 地址。脚本同时核对权威查询日志增加,证明直接查询确实到达了所描述的服务器,而不是只比较两个事先写好的字符串。

这个仅有回环接口的环境里,默认 getent ahostsv4 以状态 2 退出;增加 -A 关闭地址配置筛选后,返回 127.0.0.9。实验保留该失败,避免把工具空输出默认为 DNS 缺记录。Python 调用明确指定地址族,且没有启用 AI_ADDRCONFIG,所以此前已能成功。这个结果解释的是具体调用条件,不意味着应在所有生产程序中统一关闭地址族筛选。

将 NSS 改为 dns files 后,应用返回 127.0.0.2。再改为 files [NOTFOUND=return] dns,查询仅在 DNS 中存在的 dns-only.lab.test 时应用失败,权威查询次数没有变化;随后直接 DNS 查询得到 127.0.0.3,计数也随之增加。这个正反对照把本地提前停止与服务器返回否定答案区分开,避免用没有经过验证的零计数证明查询没有发生。

沿失败进程的真实路径检查

首先确认应用使用的解析 API、地址族、标志与运行时缓存策略,再查看进程所在的文件系统和网络命名空间。容器可能拥有与宿主不同的 hosts 和 resolv 配置,浏览器也可能使用自己的加密 DNS 服务。宿主命令成功可以提供线索,却不能证明失败进程看到了相同配置。已经运行的进程是否重新读取配置,也应依据其实现判断。

对于 NSS 路径,读取完整 hosts 行、结果动作及实际可用模块;对于 DNS 路径,确认目标解析器、搜索域与查询类型。隔离搜索列表影响时使用完整域名。分别查询权威与递归解析器,保留响应状态、记录和剩余 TTL,不要把所有回答都压成一个地址。若存在本地缓存服务,再查看那个实际服务的状态,不能把所有“本地”组件当成同一层。

最后将名称查询与连接建立分开。任何来源返回的地址都可能不可达,已建立的连接也可能继续使用旧对端而根本不重新解析。修复应对应观察到的原因:更新意外的 hosts 条目、修正 NSS 动作、检查相关缓存,或者调查传输路径。清空不相关缓存既无法改变文件规则,也无法改变另一个解析实现的行为;操作是否有效,应通过同一个失败调用方重新验证。

延伸阅读

来源