发现 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 只改变未来发现,缓存记录和已有连接仍可能继续存在。端点调整应一起规划这些寿命,而不是把改一个优先级或删一条记录当作立即切断所有旧流量。
将排序视为当前主机和进程的证据,不是全部客户端的保证。完整诊断应说明候选集合、本地选择策略、实际连接尝试以及最终成功对端。分开这些阶段,才能解释双栈行为,而不虚构“第一项必然健康”或“每个客户端都会竞速两个地址族”的承诺。实际实现与观察到的请求路径,应共同决定对外描述的连接行为。
延伸阅读
来源
- RFC 6724 default address selection,源地址与目的地址选择规则以及策略覆盖,已核验 2026-09-12。
- RFC 8305 Happy Eyeballs version 2,跨地址族错开启动连接尝试,已核验 2026-09-12。
- Python 3.11 socket documentation,getaddrinfo 地址族与 create_connection 尝试行为,已核验 2026-09-12。