同一名称下的多条 A 或 AAAA 记录提供一个地址集合。DNS 服务器可能轮换返回顺序,但收到集合后如何使用,由解析器和应用决定。普通地址记录不携带请求负载测量或应用健康结果;仅仅存在多个地址,不能保证均匀流量、自动故障转移,甚至不能保证不同实现看到相同的第一个地址。应将地址发现与后续选择分别理解。

坏掉的样子

DNS 回答分布与应用请求分布的分母不同。一个递归解析器可以用缓存回答许多客户端,一个应用也可能只查询一次,随后发出数千次请求。连接池会进一步强化这种差异。即便权威回答中的首个地址完美交替,实际服务器请求量仍可能严重不均。只统计 DNS 查询数量,不能证明用户业务已经按同样比例分配。

普通静态记录集中的端点失败后,其地址仍会保留,直到 DNS 数据被修改或另一个系统主动修改它。DNS 服务器不会仅因返回地址就知道应用健康状态。有的客户端连接失败后会尝试其他地址,有的会耗尽截止时间或者直接向调用方报错。是否重试、何时重试及哪些操作可重试,都应针对实际客户端和业务验证,不能默认由 DNS 完成。

把这种机制描述成保证公平的抛硬币,也不准确。顺序可能由配置决定、被随机化、被缓存保留,或者被地址选择策略重排。客户端可能选择首项、并发尝试,或复用先前结果。要解释一次分布观察,除了后端请求计数,还需要知道 DNS 配置、解析器行为、连接复用和工作负载;少了这些条件,无法判断不均衡出现在哪一层。

快速验证

将脚本保存为 check-dns-addresses.py,准备 Docker 后执行 python3 check-dns-addresses.py。固定环境使用 BIND 9.18.49,并明确把 rrset-order 设置为循环顺序。两个真实 HTTP 监听在隔离容器中绑定不同回环地址、使用相同临时端口,响应正文标识真正处理请求的端点。脚本不依赖公网域名,也不会访问现有业务服务器:

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,threading
class Handler(BaseHTTPRequestHandler):
    def log_message(self,*args):pass
    def do_GET(self):
        body=self.server.server_address[0].encode();self.server.hits+=1
        self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
def request(address,port):
    connection=http.client.HTTPConnection(address,port,timeout=.5)
    try:
        connection.request('GET','/');response=connection.getresponse();assert response.status==200;return response.read().decode()
    finally:connection.close()
with lab('pool 30 IN A 127.0.0.2\npool 30 IN A 127.0.0.3',extra='rrset-order { order cyclic; };') as (_,update,cache,queries):
    servers=[];threads=[]
    try:
        for address in ['127.0.0.2','127.0.0.3']:
            server=ThreadingHTTPServer((address,0 if not servers else servers[0].server_port),Handler);server.hits=0
            thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start();servers.append(server);threads.append(thread)
        port=servers[0].server_port;orders=[values(query('pool.lab.test.')) for _ in range(6)]
        assert all(set(order)=={'127.0.0.2','127.0.0.3'} for order in orders)
        assert {tuple(order) for order in orders}=={('127.0.0.2','127.0.0.3'),('127.0.0.3','127.0.0.2')}
        saved=values(query('pool.lab.test.'));selected=saved[0]
        hits=[request(selected,port) for _ in range(12)];assert hits==[selected]*12
        distribution={s.server_address[0]:s.hits for s in servers};assert sorted(distribution.values())==[0,12]
        victim=next(s for s in servers if s.server_address[0]==selected);victim.shutdown();victim.server_close()
        still_published=values(query('pool.lab.test.'));assert selected in still_published
        try:request(selected,port);raise AssertionError('stopped HTTP server accepted a connection')
        except OSError as error:failure=type(error).__name__
        alternate=next(a for a in saved if a!=selected);assert request(alternate,port)==alternate
        print(json.dumps({'cyclic_answer_orders':orders,'one_lookup_twelve_requests':distribution,'after_peer_stopped':still_published,
            'selected_address_error':failure,'explicit_alternate_succeeded':alternate},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)

对 pool.lab.test 进行六次直接查询,实测每次都返回 127.0.0.2 与 127.0.0.3,只是顺序交替。所有回答都包含完整地址集,断言同时检查成员与顺序变化。这证明的是当前权威服务器显式配置后的行为,不代表所有 DNS 服务器都必须轮换,也不保证递归缓存或最终应用会保持这个顺序。完整集合与首项顺序是不同检查点。

随后程序另外查询一次,保存列表,并用首个地址发送十二次 HTTP 请求。全部请求到达选中监听,分布为十二比零。每个请求都新建 HTTP 连接,因此这种不均衡甚至不需要连接池;只要在多个连接之间保留一次 DNS 选择,就足以让回答轮换与请求分配脱钩。响应正文和服务端计数共同确认了实际处理端点。

接着停止被选中的监听并关闭套接字。新的权威 DNS 查询仍包含其地址,因为区域没有修改。连接该端点时,实测得到 ConnectionRefusedError。程序随后明确选择保存列表里的另一个地址并成功取得响应。这证明仍有可用替代端点,但切换动作来自客户端代码;实验没有把显式重试伪装成 DNS 自动检测故障或自动迁移连接。

明确分配与失败处理契约

如果简单地址分布已经满足需求,应让所有公开端点都能服务该主机名和预期负载。既监控整体名称,也直接检查每个端点。通过一个地址成功,不能证明其他地址也健康,尤其客户端可能悄悄尝试替代地址,从调用方眼中隐藏第一次失败。监控应保留连接尝试和最终响应之间的区别,避免把被重试掩盖的问题长期留在地址集里。

若 DNS 服务商提供健康摘除,还需计算检测延迟与已经缓存的回答。依据健康状态修改 DNS 可以改善后续发现,却不会撤回旧回答或移动现有连接。按需求明确客户端重试策略和迁移重叠期。降低 TTL 改变缓存成本与刷新速度之间的取舍,但不会使 DNS 获得按应用请求调度的能力;故障恢复时间不能只由一个 TTL 数字推导。

需要逐请求路由、容量权重或有界故障转移时,应评估合适的负载均衡器以及实际健康检查和重试配置,同时承担其运维责任。比较依据应是可观察契约,而不是把 DNS 想成纯随机、把代理想成永不失败。应记录真正处理请求的端点,以及停止一个端点后用户看到的结果,这些数据才直接对应业务体验和选型要求。

名称、类别和类型相同的地址记录属于一个记录集,成员应使用一致 TTL。给其中各个地址设置不同寿命,并不是表达权重的可靠方式。需要权重时选择语义明确、实现支持的策略,并在真实解析器共享和连接复用条件下验证。只有把发现、选择、健康检测与重试的责任说清,才适合承诺流量会如何分散以及失败时会怎样恢复。

延伸阅读

来源