一条 AAAA 资源记录保存一个一百二十八位 IPv6 地址,A 记录则保存 IPv4 地址。同一个名称可以只有其中一类、同时拥有两类,或者在每类中包含多个地址。A 与 AAAA 属于不同记录集,因此可以使用不同 TTL。发布 AAAA 让地址能够通过 DNS 被发现,却不会替你创建接口、路由、监听套接字或可用应用;这些条件需要在实际服务路径上成立。

坏掉的样子

AAAA 查询成功之后,连接仍可能失败。发布的地址可能已经停用,服务可能只监听 IPv4,网络也可能缺少可用 IPv6 路由。两种地址族的防火墙与 TLS 配置也可能不同。只通过 IPv4 做可用性检查,会漏掉这些问题,而双栈用户可能正沿着公开的 IPv6 路径遭遇延迟或错误。记录存在与服务可用是两个需要分别观察的事实。

Happy Eyeballs 通过协调地址发现和连接尝试,减少部分双栈连接延迟,但它属于客户端算法,并不是 DNS 对所有客户端的保证。具体回退行为、截止时间和应用协议仍会影响结果。某个浏览器在一次测试中迅速改用 A 记录成功,不能推出错误 AAAA 对所有调用方无害。尤其对于没有相同行为的库、设备或后台任务,应验证其真实连接策略。

缺少 AAAA 也应准确描述。权威服务器可以对只有 A 的名称返回成功 DNS 状态,却没有 AAAA 数据,这通常称为 NODATA;它不同于表示名称不存在的 NXDOMAIN。仅有 A 的服务也不是在所有 IPv6-only 网络中都绝对不可达:具备相应基础设施时,DNS64 可以合成 AAAA,再通过 NAT64 访问 IPv4 服务器。不能忽略翻译网络这一明确反例。

快速验证

将脚本保存为 check-aaaa.py,准备 Docker 后执行 python3 check-aaaa.py。固定环境使用 BIND 9.18.49 和 dnspython 2.3.0。隔离网络具有可工作的 IPv6 回环接口,但没有外部路由。程序在 ::1 上启动真实 HTTP 监听,分别执行 DNS 查询和 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
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import http.client,socket,threading
records='dual 17 IN A 127.0.0.1\ndual 29 IN AAAA ::1\nv4-only 17 IN A 127.0.0.1\nbroken 29 IN AAAA 2001:db8::1234'
class Handler(BaseHTTPRequestHandler):
    def log_message(self,*args):pass
    def do_GET(self):
        body=b'IPv6 response';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(records) as (_,update,cache,queries),V6Server(('::1',0),Handler) as server:
    thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
    try:
        a=query('dual.lab.test.','A');aaaa=query('dual.lab.test.','AAAA')
        assert values(a)==['127.0.0.1'] and values(aaaa,'AAAA')==['::1']
        assert a.answer[0].ttl==17 and aaaa.answer[0].ttl==29
        address=values(aaaa,'AAAA')[0];assert len(socket.inet_pton(socket.AF_INET6,address))==16
        client=http.client.HTTPConnection(address,server.server_port,timeout=2)
        client.request('GET','/');reply=client.getresponse();body=reply.read();client.close()
        assert reply.status==200 and body==b'IPv6 response'
        only_a=query('v4-only.lab.test.');assert values(only_a)==['127.0.0.1']
        missing=query('v4-only.lab.test.','AAAA');assert missing.rcode()==0 and missing.answer==[]
        assert len(missing.authority)==1 and missing.authority[0].rdtype==dns.rdatatype.SOA
        broken=query('broken.lab.test.','AAAA');assert values(broken,'AAAA')==['2001:db8::1234']
        unreachable=socket.socket(socket.AF_INET6);unreachable.settimeout(.3)
        try:unreachable.connect(('2001:db8::1234',server.server_port));raise AssertionError('documentation address unexpectedly reachable')
        except OSError as error:failure=type(error).__name__
        finally:unreachable.close()
        print(json.dumps({'A':show(a),'AAAA_over_IPv4_DNS':show(aaaa),'IPv6_HTTP_status':reply.status,
            'AAAA_wire_address_bytes':16,'A_only_AAAA_query':show(missing),'published_but_unreachable':{'answer':show(broken),'connect_error':failure}},sort_keys=True))
    finally:server.shutdown();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 的 A 回答为 127.0.0.1,TTL 为十七秒;AAAA 回答为 ::1,TTL 为二十九秒。两个 DNS 查询都发送到 127.0.0.1 上的 IPv4 DNS 套接字。通过这个套接字收到 AAAA,直接说明记录所描述的地址族与传输 DNS 问答的地址族可以不同。要查询 IPv6 地址,并不要求本次 DNS 数据报本身必须使用 IPv6。

程序将 AAAA 文本转换成打包形式,检查长度为十六字节,再使用返回的 IPv6 地址请求本地 HTTP 服务。实测得到状态 200 和预期正文。这项验证证明选定地址上的 IPv6 回环连接可用,而不把记录存在当作应用验证。A 与 AAAA 的 TTL 对照也检查了不同记录集可以独立配置,不能由其中一类回答推断另一类的寿命。

查询 v4-only.lab.test 的 AAAA 时,返回 NOERROR、空回答区和权威区中的 SOA;名称本身仍有 A 记录。另一个名称 broken.lab.test 成功返回文档地址 2001:db8::1234,随后连接却在隔离环境里失败。这个对照有意把正向 DNS 数据和传输成功分开,同时不会把测试流量发送给真实外部主机,也不把预期失败描述成 DNS 查询失败。

沿每个已发布地址族检查服务

发布 IPv6 前,先确认监听、路由、访问规则和应用行为。既使用正常主机名身份测试,也检查各个公开端点。裸地址 HTTP 成功不能验证基于名称的证书和虚拟主机路由。本例只验证本地 HTTP,不证明公共 IPv6 连通性,也没有评估某个客户端的 Happy Eyeballs 实现。报告结果应限定在实际执行的路径上,避免用局部成功覆盖其他配置责任。

监控和部署记录应同时覆盖两个记录集。删除 AAAA 会改变新的权威回答,却不会撤回客户端已经缓存的地址;现有连接还有自己的生命周期。迁移时按计划保留旧 IPv6 服务重叠期,并比较权威数据与受影响客户端使用的递归解析器。发现旧连接时,要先确认应用是否重新解析,不能把所有仍在访问旧端点的流量都归因于 DNS 缓存。

最后,AAAA 是正向地址数据,即使有些描述把它叫作 IPv6 指针。反向查询使用 ip6.arpa 下的 PTR 记录,名称来自地址的十六进制半字节倒序。发布正向 AAAA 不会自动发布相应反向记录。正向查询、反向命名、地址选择与应用通信彼此相关,但配置和可观察结果不同,应按照服务真正需要的契约逐项检查。

延伸阅读

来源