同一名称下的多条 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 服务器都必须轮换,也不保证递归缓存或最终应用会保持这个顺序。完整集合与首项顺序是不同检查点。
...