发现 A 与 AAAA 记录,只提供可能的目的地,本身不会选出成功连接。地址排序、源地址选择、路由以及应用连接算法都会参与结果。RFC 6724 描述默认源地址和目的地址选择规则,应用仍可作自己的选择。如果程序只请求 IPv4,那么在任何双栈连接策略开始工作前,它就已经缩小了候选范围。解释行为时,应先确认实际调用方请求了什么。

坏掉的样子

地址偏好不是应用健康探测。某个 IPv6 目的地可能符合本地策略和路由条件,远端服务却已经停止,或者更后面的网络跳点会丢包,因此优先候选仍可能失败。如果假定操作系统已经测试了返回列表中每个端点,就会忽略连接截止时间和尝试其他地址的必要性。查询成功只能证明获得候选信息,不能代替服务通信结果。

RFC 6724 的规则是按顺序比较条件,并不是对网络成本或速度打一个总分。相关属性包括可用性、作用域、策略标签、优先顺序和匹配前缀,但它们不测量应用响应时间。目的地址选择还应与路由表最长前缀匹配区分:后者决定已选目的地的数据包如何发送。混用这两个阶段,会把主机偏好错误解释成已经知道整条路径延迟最低。

Happy Eyeballs 属于另一层,通过协调地址发现和错开连接尝试,减少某个地址族出问题时的等待。按顺序逐个尝试地址的客户端,并不自动实现该算法。立即拒绝连接与静默丢弃连接尝试,可能造成非常不同的等待时间,即使最后都能通过 IPv4 成功。不能只看最终用了另一个地址族,就把过程统称为相同的快速竞速机制。

快速验证

将脚本保存为 check-address-selection.py,准备 Docker 后执行 python3 check-address-selection.py。固定环境使用 glibc 2.36、Python 3.11,以及提供 DNS 的 BIND 9.18.49 和 dnsmasq 2.90。两个真实 HTTP 监听分别使用 IPv4 与 IPv6 回环,端口相同。实验没有公网路径,因此观察到的排序明确属于这个本地环境,不作为所有客户端的默认承诺:

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

@contextmanager
def named_process(root,config):
    root.mkdir(exist_ok=True);conf=root/'named.conf';log=root/'named.log';conf.write_text(config)
    run('named-checkconf',str(conf))
    with log.open('w') as output:
        process=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
        try:
            def ready():
                assert process.poll() is None,log.read_text()
                return re.search(r'\brunning\s*$',log.read_text(),re.M) is not None
            wait(ready);yield process,log
        finally:stop(process)
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import socket,threading
class Handler(BaseHTTPRequestHandler):
    def log_message(self,*args):pass
    def do_GET(self):
        body=self.server.server_address[0].encode();self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
class V6Server(ThreadingHTTPServer):address_family=socket.AF_INET6
with lab('dual 30 IN A 127.0.0.1\ndual 30 IN AAAA ::1') as (_,update,cache,queries):
    cache(port=53)
    Path('/etc/resolv.conf').write_text('nameserver 127.0.0.1\noptions timeout:1 attempts:1\n')
    nss=Path('/etc/nsswitch.conf');nss.write_text(re.sub(r'^hosts:.*$','hosts: dns',nss.read_text(),flags=re.M))
    servers=[];threads=[]
    try:
        for server_class,address in [(ThreadingHTTPServer,'127.0.0.1'),(V6Server,'::1')]:
            server=server_class((address,0 if not servers else servers[0].server_port),Handler)
            thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start();servers.append(server);threads.append(thread)
        port=servers[0].server_port;v6=servers[1]
        def candidates(family=socket.AF_UNSPEC):return socket.getaddrinfo('dual.lab.test',port,family,socket.SOCK_STREAM)
        addresses=candidates();assert {r[0] for r in addresses}=={socket.AF_INET,socket.AF_INET6}
        assert addresses[0][0]==socket.AF_INET6
        assert {r[0] for r in candidates(socket.AF_INET)}=={socket.AF_INET}
        assert {r[0] for r in candidates(socket.AF_INET6)}=={socket.AF_INET6}
        def request():
            with socket.create_connection(('dual.lab.test',port),timeout=1) as stream:
                chosen={'family':stream.family.name,'peer':stream.getpeername()[0],'source':stream.getsockname()[0]}
                stream.sendall(b'GET / HTTP/1.0\r\nHost: dual.lab.test\r\n\r\n');data=b''
                while True:
                    part=stream.recv(4096)
                    if not part:break
                    data+=part
                assert b' 200 ' in data.split(b'\r\n',1)[0] and data.endswith(chosen['peer'].encode());return chosen
        first=request();assert first['family']=='AF_INET6'
        v6.shutdown();v6.server_close();assert values(query('dual.lab.test.','AAAA'),'AAAA')==['::1']
        with socket.socket(socket.AF_INET6) as forced:
            forced.settimeout(1)
            try:forced.connect(('::1',port));raise AssertionError('closed IPv6 listener accepted a connection')
            except OSError as error:failure=type(error).__name__
        assert candidates()[0][0]==socket.AF_INET6
        second=request();assert second['family']=='AF_INET'
        print(json.dumps({'candidate_order':[{'family':socket.AddressFamily(r[0]).name,'address':r[4][0]} for r in addresses],
            'both_listeners_healthy':first,'forced_IPv6_after_stop':failure,'sequential_client_after_IPv6_stop':second,
            'AAAA_still_published':True},sort_keys=True))
    finally:
        for server in servers:server.shutdown();server.server_close()
        for thread in threads:thread.join(timeout=2)
"""
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)

对 dual.lab.test 使用 AF_UNSPEC 调用 getaddrinfo,同时得到 ::1 和 127.0.0.1,实测 IPv6 在前。分别限定 AF_INET 或 AF_INET6 后,只返回相应地址族。这些调用确定当前进程的真实候选列表,而不是假定两次独立 A 与 AAAA 查询的输出天然组成统一顺序。地址族参数本身,就是连接选择之前的重要输入条件。

两个监听都健康时,Python 的 socket.create_connection 连接到 ::1。程序记录选中对端和本地源地址,再发送真实 HTTP 请求,检查监听回显的身份。这样将候选偏好与成功连接关联,同时保留源地址、目的地址和应用响应三个不同观察。不能只根据 DNS 首项,就宣称已经证明流量实际通过了那个地址族。

随后停止并关闭 IPv6 监听。DNS 仍发布 ::1,getaddrinfo 也仍把 IPv6 放在第一项;强制 IPv6 连接得到 ConnectionRefusedError。正常 Python 连接助手却能通过 127.0.0.1 成功,收到 IPv4 监听响应。按序尝试可以到达后面的可用候选,并不要求 DNS 排序随服务故障改变。真正完成选择的是客户端的连接处理过程。

本地路径上的拒绝立即发生。实验没有制造黑洞,没有测量互联网回退延迟,也没有执行完整 Happy Eyeballs 实现。它证明的是:服务可用性变化时,本地地址偏好可以不变,连接算法必须把候选列表转化成最终成功或失败。对于静默丢包、多个候选和不同截止时间,仍需依据实际客户端另外测量,不能外推这里的等待表现。

修改全局策略前先检查真实调用方

确认应用给 getaddrinfo 的地址族和标志、运行时是否缓存结果,以及它如何尝试候选项。有些应用采用不同解析库,或自己实现连接竞速。命令行工具重现查询,只有在相关选择与失败进程一致时才具有直接可比性。尤其不能用一个主动竞速的浏览器成功,覆盖另一个按序等待的后台客户端所遇到的延迟问题。

把选中的源地址、目的地址、连接时间和错误一起观察。DNS 成功后连接被拒绝,与沿路超时的调查方向不同。按实际服务身份和协议验证每个公开地址族。IPv4 端点健康,不证明 IPv6 端点具有相同路由、访问规则、证书或应用配置。监控只检查其中一条路径时,很容易遗漏另一类用户正在遭遇的问题。

部署确实需要策略变化时,应有意识地实施,并验证真实调用方效果。RFC 6724 允许管理策略,但全局改变偏好不能代替修复已经发布却损坏的服务。同样,删除 AAAA 只改变未来发现,缓存记录和已有连接仍可能继续存在。端点调整应一起规划这些寿命,而不是把改一个优先级或删一条记录当作立即切断所有旧流量。

将排序视为当前主机和进程的证据,不是全部客户端的保证。完整诊断应说明候选集合、本地选择策略、实际连接尝试以及最终成功对端。分开这些阶段,才能解释双栈行为,而不虚构“第一项必然健康”或“每个客户端都会竞速两个地址族”的承诺。实际实现与观察到的请求路径,应共同决定对外描述的连接行为。

延伸阅读

来源