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

独立 A 和 AAAA 记录集的 TTL 分别为 17 和 29。文章实验通过 IPv4 查询 DNS,再分别观察 IPv6 回环 HTTP 200 与文档地址连接失败;NODATA 和反向 PTR 另行区分。
AAAA 发布的 IPv6 数据独立于 DNS 查询传输。文章实验另行验证本地 HTTP 可达性,不证明公网 IPv6 连通性或主机名 TLS。

坏掉的样子

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 不会自动发布相应反向记录。正向查询、反向命名、地址选择与应用通信彼此相关,但配置和可观察结果不同,应按照服务真正需要的契约逐项检查。

延伸阅读

来源